Files
MLBApp/mlbTVOS/Views/GameCardView.swift
Trey t 588b42ffed Fix tvOS memory crash: cap highlights to 50, replace blurs with gradients
The app was crashing from memory pressure on tvOS. Three causes fixed:

1. Feed was rendering all 418 highlights at once — capped to 50 items.

2. FeaturedGameCard had 3 blur effects (radius 80-120) on large circles
   for team color glow — replaced with a single LinearGradient. Same
   visual effect, fraction of the GPU memory.

3. BroadcastBackground had 3 blurred circles (radius 120-140, 680-900px)
   rendering on every screen — replaced with RadialGradients which are
   composited by the GPU natively without offscreen render passes.

Also fixed iOS build: replaced tvOS-only font refs (tvSectionTitle,
tvBody) with cross-platform equivalents in DashboardView fallback state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:44:25 -05:00

329 lines
12 KiB
Swift

import SwiftUI
struct GameCardView: View {
let game: Game
let onSelect: () -> Void
@Environment(GamesViewModel.self) private var viewModel
private var inMultiView: Bool {
game.broadcasts.contains(where: { broadcast in
viewModel.activeStreams.contains(where: { $0.id == broadcast.id })
})
}
private var awayColor: Color { TeamAssets.color(for: game.awayTeam.code) }
private var homeColor: Color { TeamAssets.color(for: game.homeTeam.code) }
var body: some View {
Button(action: onSelect) {
VStack(alignment: .leading, spacing: cardSpacing) {
headerRow
matchupBlock
footerBlock
}
.frame(maxWidth: .infinity, minHeight: cardHeight, alignment: .topLeading)
.padding(cardPad)
.background(cardBackground)
.overlay(cardBorder)
.clipShape(RoundedRectangle(cornerRadius: cardRadius, style: .continuous))
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
}
.platformCardStyle()
}
private var headerRow: some View {
HStack(alignment: .center, spacing: 12) {
statusPill
Spacer(minLength: 8)
if inMultiView {
chip(title: "In Multi-View", tint: DS.Colors.positive)
}
if game.hasStreams {
chip(title: "\(game.broadcasts.count) feed\(game.broadcasts.count == 1 ? "" : "s")", tint: DS.Colors.interactive)
}
}
}
private var matchupBlock: some View {
VStack(spacing: 16) {
teamRow(team: game.awayTeam, isLeading: isWinning(away: true))
teamRow(team: game.homeTeam, isLeading: isWinning(away: false))
}
}
@ViewBuilder
private var footerBlock: some View {
VStack(alignment: .leading, spacing: 14) {
switch game.status {
case .live:
liveFooter
case .final_:
finalFooter
case .scheduled:
scheduledFooter
case .unknown:
unknownFooter
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 2)
}
private func teamRow(team: TeamInfo, isLeading: Bool) -> some View {
HStack(spacing: 14) {
TeamLogoView(team: team, size: logoSize)
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 10) {
Text(team.code)
.font(codeFont)
.foregroundStyle(.white)
if let record = team.record {
Text(record)
.font(metaFont)
.foregroundStyle(DS.Colors.textSecondary)
}
}
Text(team.displayName)
.font(nameFont)
.foregroundStyle(DS.Colors.textSecondary)
.lineLimit(1)
if let summary = team.standingSummary {
Text(summary)
.font(metaFont)
.foregroundStyle(DS.Colors.textTertiary)
.lineLimit(1)
}
}
Spacer(minLength: 8)
Text(team.score.map(String.init) ?? "")
.font(scoreFont)
.foregroundStyle(isLeading ? .white : DS.Colors.textSecondary)
.monospacedDigit()
}
}
@ViewBuilder
private var liveFooter: some View {
if let linescore = game.linescore, !game.status.isScheduled {
HStack(alignment: .bottom, spacing: 16) {
VStack(alignment: .leading, spacing: 10) {
Text(game.currentInningDisplay ?? "Live")
.font(footerTitleFont)
.foregroundStyle(DS.Colors.live)
if let awayRuns = linescore.teams?.away?.runs,
let homeRuns = linescore.teams?.home?.runs,
let awayHits = linescore.teams?.away?.hits,
let homeHits = linescore.teams?.home?.hits {
HStack(spacing: 10) {
footerMetric(label: game.awayTeam.code, value: "\(awayRuns)R \(awayHits)H")
footerMetric(label: game.homeTeam.code, value: "\(homeRuns)R \(homeHits)H")
}
}
}
Spacer(minLength: 12)
DiamondView(
balls: linescore.balls ?? 0,
strikes: linescore.strikes ?? 0,
outs: linescore.outs ?? 0
)
}
MiniLinescoreView(
linescore: linescore,
awayCode: game.awayTeam.code,
homeCode: game.homeTeam.code
)
} else {
Text("Live update available")
.font(footerBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
}
}
private var finalFooter: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Final")
.font(footerTitleFont)
.foregroundStyle(DS.Colors.positive)
Text(game.scoreDisplay ?? "Game complete")
.font(footerValueFont)
.foregroundStyle(.white)
if let venue = game.venue {
Text(venue)
.font(footerBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
}
}
}
private var scheduledFooter: some View {
VStack(alignment: .leading, spacing: 10) {
Text(game.startTime ?? game.status.label)
.font(footerTitleFont)
.foregroundStyle(DS.Colors.warning)
Text(game.pitchers ?? "Probable pitchers pending")
.font(footerBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
.lineLimit(2)
if let venue = game.venue {
Text(venue)
.font(metaFont)
.foregroundStyle(DS.Colors.textTertiary)
.lineLimit(1)
}
}
}
private var unknownFooter: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Awaiting update")
.font(footerTitleFont)
.foregroundStyle(DS.Colors.textSecondary)
if let venue = game.venue {
Text(venue)
.font(footerBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
}
}
}
private func footerMetric(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(metaFont)
.foregroundStyle(DS.Colors.textTertiary)
Text(value)
.font(footerValueFont)
.foregroundStyle(.white)
.monospacedDigit()
}
}
private func chip(title: String, tint: Color) -> some View {
Text(title)
.font(chipFont)
.foregroundStyle(tint)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
Capsule()
.fill(tint.opacity(0.12))
.overlay {
Capsule()
.strokeBorder(tint.opacity(0.22), lineWidth: 1)
}
)
}
@ViewBuilder
private var statusPill: some View {
switch game.status {
case .live(let inning):
chip(title: inning?.uppercased() ?? "LIVE", tint: DS.Colors.live)
case .scheduled(let time):
chip(title: time.uppercased(), tint: DS.Colors.warning)
case .final_:
chip(title: "FINAL", tint: DS.Colors.positive)
case .unknown:
chip(title: "PENDING", tint: DS.Colors.textTertiary)
}
}
private var cardBackground: some View {
RoundedRectangle(cornerRadius: cardRadius, style: .continuous)
.fill(
LinearGradient(
colors: [
DS.Colors.panelFill,
DS.Colors.panelFillMuted,
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay(alignment: .top) {
Rectangle()
.fill(
LinearGradient(
colors: [awayColor, homeColor],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(height: 5)
.clipShape(
RoundedRectangle(cornerRadius: cardRadius, style: .continuous)
)
}
}
private var cardBorder: some View {
RoundedRectangle(cornerRadius: cardRadius, style: .continuous)
.strokeBorder(borderColor, lineWidth: borderWidth)
}
private func isWinning(away: Bool) -> Bool {
guard let awayScore = game.awayTeam.score, let homeScore = game.homeTeam.score else {
return false
}
return away ? awayScore > homeScore : homeScore > awayScore
}
private var borderColor: Color {
if inMultiView { return DS.Colors.positive.opacity(0.46) }
if game.isLive { return DS.Colors.live.opacity(0.34) }
return DS.Colors.panelStroke
}
private var borderWidth: CGFloat {
inMultiView || game.isLive ? 1.6 : 1
}
#if os(tvOS)
private var cardHeight: CGFloat { 270 }
private var cardRadius: CGFloat { 28 }
private var cardPad: CGFloat { 24 }
private var cardSpacing: CGFloat { 18 }
private var logoSize: CGFloat { 46 }
private var codeFont: Font { .system(size: 24, weight: .black, design: .rounded) }
private var nameFont: Font { .system(size: 22, weight: .bold, design: .rounded) }
private var metaFont: Font { .system(size: 15, weight: .bold, design: .rounded) }
private var scoreFont: Font { .system(size: 38, weight: .black, design: .rounded).monospacedDigit() }
private var chipFont: Font { .system(size: 13, weight: .black, design: .rounded) }
private var footerTitleFont: Font { .system(size: 18, weight: .black, design: .rounded) }
private var footerValueFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
private var footerBodyFont: Font { .system(size: 16, weight: .semibold) }
#else
private var cardHeight: CGFloat { 200 }
private var cardRadius: CGFloat { 20 }
private var cardPad: CGFloat { 18 }
private var cardSpacing: CGFloat { 14 }
private var logoSize: CGFloat { 34 }
private var codeFont: Font { .system(size: 18, weight: .black, design: .rounded) }
private var nameFont: Font { .system(size: 16, weight: .bold, design: .rounded) }
private var metaFont: Font { .system(size: 11, weight: .bold, design: .rounded) }
private var scoreFont: Font { .system(size: 28, weight: .black, design: .rounded).monospacedDigit() }
private var chipFont: Font { .system(size: 10, weight: .black, design: .rounded) }
private var footerTitleFont: Font { .system(size: 13, weight: .black, design: .rounded) }
private var footerValueFont: Font { .system(size: 13, weight: .bold, design: .rounded) }
private var footerBodyFont: Font { .system(size: 12, weight: .semibold) }
#endif
}