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>
This commit is contained in:
Trey t
2026-04-12 16:44:25 -05:00
parent 870fbcb844
commit 588b42ffed
12 changed files with 2004 additions and 744 deletions

View File

@@ -3,11 +3,12 @@ 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: { bc in
viewModel.activeStreams.contains(where: { $0.id == bc.id })
game.broadcasts.contains(where: { broadcast in
viewModel.activeStreams.contains(where: { $0.id == broadcast.id })
})
}
@@ -16,160 +17,312 @@ struct GameCardView: View {
var body: some View {
Button(action: onSelect) {
VStack(spacing: 0) {
// Team color accent bar
HStack(spacing: 0) {
Rectangle().fill(awayColor)
Rectangle().fill(homeColor)
}
.frame(height: 4)
VStack(spacing: rowGap) {
teamRow(team: game.awayTeam, isWinning: isWinning(away: true))
teamRow(team: game.homeTeam, isWinning: isWinning(away: false))
}
.padding(.horizontal, cardPadH)
.padding(.top, cardPadV)
Spacer(minLength: 6)
// Footer: status + linescore
HStack {
statusPill
Spacer()
if let linescore = game.linescore, !game.status.isScheduled {
MiniLinescoreView(
linescore: linescore,
awayCode: game.awayTeam.code,
homeCode: game.homeTeam.code
)
}
}
.padding(.horizontal, cardPadH)
.padding(.bottom, cardPadV)
VStack(alignment: .leading, spacing: cardSpacing) {
headerRow
matchupBlock
footerBlock
}
.frame(maxWidth: .infinity, minHeight: cardHeight, alignment: .topLeading)
.background(DS.Colors.panelFill)
.padding(cardPad)
.background(cardBackground)
.overlay(cardBorder)
.clipShape(RoundedRectangle(cornerRadius: cardRadius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: cardRadius, style: .continuous)
.strokeBorder(borderColor, lineWidth: borderWidth)
)
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
}
.platformCardStyle()
}
@ViewBuilder
private func teamRow(team: TeamInfo, isWinning: Bool) -> some View {
HStack(spacing: teamSpacing) {
TeamLogoView(team: team, size: logoSize)
private var headerRow: some View {
HStack(alignment: .center, spacing: 12) {
statusPill
Spacer(minLength: 8)
Text(team.code)
.font(codeFont)
.foregroundStyle(DS.Colors.textPrimary)
.frame(width: codeWidth, alignment: .leading)
Text(team.displayName)
.font(nameFont)
.foregroundStyle(DS.Colors.textSecondary)
.lineLimit(1)
Spacer(minLength: 4)
if let record = team.record {
Text(record)
.font(recordFont)
.foregroundStyle(DS.Colors.textTertiary)
if inMultiView {
chip(title: "In Multi-View", tint: DS.Colors.positive)
}
if let streak = team.streak {
Text(streak)
.font(recordFont)
.foregroundStyle(streak.hasPrefix("W") ? DS.Colors.positive : DS.Colors.live)
}
if !game.status.isScheduled, let score = team.score {
Text("\(score)")
.font(scoreFont)
.foregroundStyle(isWinning ? DS.Colors.textPrimary : DS.Colors.textTertiary)
.frame(width: scoreWidth, alignment: .trailing)
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):
HStack(spacing: 6) {
Circle().fill(DS.Colors.live).frame(width: 8, height: 8)
Text(inning ?? "LIVE")
.font(statusFont)
.foregroundStyle(DS.Colors.live)
}
chip(title: inning?.uppercased() ?? "LIVE", tint: DS.Colors.live)
case .scheduled(let time):
Text(time)
.font(statusFont)
.foregroundStyle(DS.Colors.textSecondary)
chip(title: time.uppercased(), tint: DS.Colors.warning)
case .final_:
Text("FINAL")
.font(statusFont)
.foregroundStyle(DS.Colors.textTertiary)
chip(title: "FINAL", tint: DS.Colors.positive)
case .unknown:
EmptyView()
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 a = game.awayTeam.score, let h = game.homeTeam.score else { return false }
return away ? a > h : h > a
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.5) }
if game.isLive { return DS.Colors.live.opacity(0.3) }
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 ? 2 : 0.5
inMultiView || game.isLive ? 1.6 : 1
}
#if os(tvOS)
private var cardHeight: CGFloat { 200 }
private var cardRadius: CGFloat { 22 }
private var cardPadH: CGFloat { 22 }
private var cardPadV: CGFloat { 16 }
private var rowGap: CGFloat { 10 }
private var logoSize: CGFloat { 44 }
private var teamSpacing: CGFloat { 14 }
private var codeWidth: CGFloat { 60 }
private var scoreWidth: CGFloat { 40 }
private var codeFont: Font { .system(size: 26, weight: .black, design: .rounded) }
private var nameFont: Font { .system(size: 22, weight: .semibold) }
private var scoreFont: Font { .system(size: 30, weight: .black, design: .rounded).monospacedDigit() }
private var recordFont: Font { .system(size: 20, weight: .bold, design: .monospaced) }
private var statusFont: Font { .system(size: 22, weight: .bold, design: .rounded) }
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 { 150 }
private var cardRadius: CGFloat { 18 }
private var cardPadH: CGFloat { 16 }
private var cardPadV: CGFloat { 12 }
private var rowGap: CGFloat { 8 }
private var logoSize: CGFloat { 32 }
private var teamSpacing: CGFloat { 10 }
private var codeWidth: CGFloat { 44 }
private var scoreWidth: CGFloat { 30 }
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: 14, weight: .semibold) }
private var scoreFont: Font { .system(size: 22, weight: .black, design: .rounded).monospacedDigit() }
private var recordFont: Font { .system(size: 13, weight: .bold, design: .monospaced) }
private var statusFont: Font { .system(size: 14, weight: .bold, 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
}