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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user