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,77 +3,223 @@ import SwiftUI
struct CategoryPillBar: View {
@Binding var selected: AppSection
var streamCount: Int = 0
var totalGames: Int = 0
var liveGames: Int = 0
@Namespace private var selectionNamespace
private var primarySections: [AppSection] {
AppSection.allCases.filter { $0 != .settings }
}
var body: some View {
HStack(spacing: pillSpacing) {
ForEach(AppSection.allCases) { section in
if section == .multiView {
// Multi-View as a separate icon button with badge
Button {
selected = .multiView
} label: {
HStack(spacing: 6) {
Image(systemName: "rectangle.split.2x2.fill")
.font(.system(size: iconSize, weight: .semibold))
if streamCount > 0 {
Text("\(streamCount)")
.font(pillFont.weight(.black))
}
}
.foregroundStyle(selected == .multiView ? .white : DS.Colors.textTertiary)
.padding(.horizontal, pillPadH)
.padding(.vertical, pillPadV)
.background(
Capsule().fill(selected == .multiView ? DS.Colors.interactive : .clear)
)
}
.platformCardStyle()
} else if section == .settings {
Spacer()
Button {
selected = .settings
} label: {
Image(systemName: "gearshape.fill")
.font(.system(size: iconSize, weight: .semibold))
.foregroundStyle(selected == .settings ? .white : DS.Colors.textTertiary)
.padding(.horizontal, pillPadH)
.padding(.vertical, pillPadV)
.background(
Capsule().fill(selected == .settings ? DS.Colors.interactive : .clear)
)
}
.platformCardStyle()
} else {
Button {
selected = section
} label: {
Text(section.title)
.font(pillFont)
.foregroundStyle(selected == section ? .white : DS.Colors.textTertiary)
.padding(.horizontal, pillPadH)
.padding(.vertical, pillPadV)
.background(
Capsule().fill(selected == section ? DS.Colors.interactive : .clear)
)
}
.platformCardStyle()
}
}
ViewThatFits {
expandedBar
compactBar
}
}
private var expandedBar: some View {
HStack(spacing: 24) {
brandLockup
HStack(spacing: 10) {
ForEach(primarySections) { section in
sectionButton(section)
}
}
Spacer(minLength: 12)
HStack(spacing: 10) {
statusChip(value: "\(liveGames)", label: "Live", systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live)
statusChip(value: "\(totalGames)", label: "Games", systemImage: "sportscourt", tint: DS.Colors.media)
statusChip(value: "\(streamCount)", label: "Tiles", systemImage: "rectangle.split.2x2", tint: DS.Colors.positive)
settingsButton
}
}
.padding(.horizontal, containerPadH)
.padding(.vertical, containerPadV)
.background(shellBackground)
}
private var compactBar: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 16) {
brandLockup
Spacer()
settingsButton
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(primarySections) { section in
sectionButton(section)
}
statusChip(value: "\(liveGames)", label: "Live", systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live)
statusChip(value: "\(totalGames)", label: "Games", systemImage: "sportscourt", tint: DS.Colors.media)
statusChip(value: "\(streamCount)", label: "Tiles", systemImage: "rectangle.split.2x2", tint: DS.Colors.positive)
}
}
.scrollClipDisabled()
}
.padding(.horizontal, containerPadH)
.padding(.vertical, containerPadV)
.background(shellBackground)
}
private var brandLockup: some View {
VStack(alignment: .leading, spacing: 3) {
Text("MLB")
.font(brandPrimaryFont)
.foregroundStyle(DS.Colors.textPrimary)
Text("CONTROL ROOM")
.font(brandSecondaryFont)
.foregroundStyle(DS.Colors.textTertiary)
.tracking(brandTracking)
}
}
private func sectionButton(_ section: AppSection) -> some View {
Button {
withAnimation(.spring(response: 0.36, dampingFraction: 0.82)) {
selected = section
}
} label: {
HStack(spacing: 10) {
Image(systemName: section.systemImage)
.font(.system(size: iconSize, weight: .bold))
Text(section.title)
.font(pillFont)
}
.foregroundStyle(selected == section ? Color.black.opacity(0.86) : DS.Colors.textSecondary)
.padding(.horizontal, pillPadH)
.padding(.vertical, pillPadV)
.background {
if selected == section {
Capsule()
.fill(
LinearGradient(
colors: [
DS.Colors.interactive,
DS.Colors.warning,
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.matchedGeometryEffect(id: "selected-section", in: selectionNamespace)
} else {
Capsule()
.fill(DS.Colors.panelFillMuted)
}
}
}
.platformCardStyle()
}
private var settingsButton: some View {
Button {
selected = .settings
} label: {
Image(systemName: "gearshape.fill")
.font(.system(size: iconSize, weight: .bold))
.foregroundStyle(selected == .settings ? Color.black.opacity(0.86) : DS.Colors.textSecondary)
.padding(.horizontal, pillPadH)
.padding(.vertical, pillPadV)
.background {
if selected == .settings {
Capsule()
.fill(
LinearGradient(
colors: [
DS.Colors.interactive,
DS.Colors.warning,
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
} else {
Capsule()
.fill(DS.Colors.panelFillMuted)
}
}
}
.platformCardStyle()
}
private func statusChip(value: String, label: String, systemImage: String, tint: Color) -> some View {
HStack(spacing: 9) {
Image(systemName: systemImage)
.font(.system(size: statIconSize, weight: .bold))
.foregroundStyle(tint)
Text(value)
.font(statValueFont)
.foregroundStyle(DS.Colors.textPrimary)
.monospacedDigit()
Text(label)
.font(statLabelFont)
.foregroundStyle(DS.Colors.textTertiary)
}
.padding(.horizontal, statPadH)
.padding(.vertical, statPadV)
.background(
Capsule()
.fill(DS.Colors.panelFillMuted)
.overlay {
Capsule()
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
}
)
}
private var shellBackground: some View {
RoundedRectangle(cornerRadius: shellRadius, style: .continuous)
.fill(DS.Colors.navFill)
.overlay {
RoundedRectangle(cornerRadius: shellRadius, style: .continuous)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
}
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
}
#if os(tvOS)
private var pillSpacing: CGFloat { 8 }
private var pillPadH: CGFloat { 28 }
private var pillPadV: CGFloat { 14 }
private var pillFont: Font { .system(size: 24, weight: .bold, design: .rounded) }
private var iconSize: CGFloat { 22 }
private var shellRadius: CGFloat { 28 }
private var containerPadH: CGFloat { 28 }
private var containerPadV: CGFloat { 22 }
private var pillPadH: CGFloat { 24 }
private var pillPadV: CGFloat { 16 }
private var statPadH: CGFloat { 18 }
private var statPadV: CGFloat { 14 }
private var pillFont: Font { .system(size: 23, weight: .bold, design: .rounded) }
private var statValueFont: Font { .system(size: 22, weight: .black, design: .rounded) }
private var statLabelFont: Font { .system(size: 16, weight: .bold, design: .rounded) }
private var brandPrimaryFont: Font { .system(size: 34, weight: .black, design: .rounded) }
private var brandSecondaryFont: Font { .system(size: 14, weight: .black, design: .rounded) }
private var brandTracking: CGFloat { 2.6 }
private var iconSize: CGFloat { 20 }
private var statIconSize: CGFloat { 13 }
#else
private var pillSpacing: CGFloat { 4 }
private var shellRadius: CGFloat { 22 }
private var containerPadH: CGFloat { 18 }
private var containerPadV: CGFloat { 16 }
private var pillPadH: CGFloat { 18 }
private var pillPadV: CGFloat { 10 }
private var statPadH: CGFloat { 12 }
private var statPadV: CGFloat { 10 }
private var pillFont: Font { .system(size: 15, weight: .bold, design: .rounded) }
private var iconSize: CGFloat { 16 }
private var statValueFont: Font { .system(size: 15, weight: .black, design: .rounded) }
private var statLabelFont: Font { .system(size: 11, weight: .bold, design: .rounded) }
private var brandPrimaryFont: Font { .system(size: 20, weight: .black, design: .rounded) }
private var brandSecondaryFont: Font { .system(size: 10, weight: .black, design: .rounded) }
private var brandTracking: CGFloat { 1.8 }
private var iconSize: CGFloat { 15 }
private var statIconSize: CGFloat { 11 }
#endif
}
@@ -88,11 +234,21 @@ enum AppSection: String, CaseIterable, Identifiable {
var title: String {
switch self {
case .today: "Today"
case .intel: "Intel"
case .today: "Dashboard"
case .intel: "League"
case .highlights: "Highlights"
case .multiView: "Multi-View"
case .settings: "Settings"
}
}
var systemImage: String {
switch self {
case .today: "rectangle.3.group.fill"
case .intel: "chart.xyaxis.line"
case .highlights: "play.rectangle.on.rectangle.fill"
case .multiView: "rectangle.split.2x2.fill"
case .settings: "gearshape.fill"
}
}
}

View File

@@ -32,9 +32,9 @@ struct DataPanel<Content: View>: View {
if let code = teamAccentCode {
RoundedRectangle(cornerRadius: 1.5)
.fill(TeamAssets.color(for: code))
.frame(width: 3)
.padding(.vertical, 6)
.padding(.leading, 4)
.frame(width: 4)
.padding(.vertical, 8)
.padding(.leading, 6)
}
VStack(alignment: .leading, spacing: 0) {
@@ -43,15 +43,24 @@ struct DataPanel<Content: View>: View {
.frame(maxWidth: .infinity, alignment: .leading)
.padding(density.padding)
}
.background(
RoundedRectangle(cornerRadius: density.cornerRadius)
.fill(DS.Colors.panelFill)
.background {
RoundedRectangle(cornerRadius: density.cornerRadius, style: .continuous)
.fill(
LinearGradient(
colors: [
DS.Colors.panelFill,
DS.Colors.panelFillMuted,
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay {
RoundedRectangle(cornerRadius: density.cornerRadius, style: .continuous)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
}
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
)
.overlay(
RoundedRectangle(cornerRadius: density.cornerRadius)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5)
)
}
}
}

View File

@@ -1,52 +1,46 @@
import SwiftUI
// MARK: - The Dugout Design System Warm Light Theme
enum DS {
// MARK: - Colors
enum Colors {
static let background = Color(red: 0.96, green: 0.95, blue: 0.94)
static let panelFill = Color.white
static let panelStroke = Color.black.opacity(0.06)
static let background = Color(red: 0.03, green: 0.05, blue: 0.10)
static let backgroundElevated = Color(red: 0.06, green: 0.10, blue: 0.18)
static let navFill = Color(red: 0.07, green: 0.10, blue: 0.18).opacity(0.94)
static let panelFill = Color(red: 0.08, green: 0.11, blue: 0.18).opacity(0.94)
static let panelFillMuted = Color(red: 0.10, green: 0.14, blue: 0.23).opacity(0.84)
static let panelStroke = Color.white.opacity(0.09)
static let live = Color(red: 0.92, green: 0.18, blue: 0.18)
static let positive = Color(red: 0.15, green: 0.68, blue: 0.32)
static let warning = Color(red: 0.92, green: 0.50, blue: 0.10)
static let interactive = Color(red: 0.95, green: 0.45, blue: 0.15) // warm orange
static let media = Color(red: 0.50, green: 0.30, blue: 0.80)
static let live = Color(red: 0.94, green: 0.25, blue: 0.28)
static let positive = Color(red: 0.24, green: 0.86, blue: 0.63)
static let warning = Color(red: 0.98, green: 0.76, blue: 0.24)
static let interactive = Color(red: 1.00, green: 0.75, blue: 0.20)
static let media = Color(red: 0.35, green: 0.78, blue: 0.95)
static let textPrimary = Color(red: 0.12, green: 0.12, blue: 0.14)
static let textSecondary = Color(red: 0.12, green: 0.12, blue: 0.14).opacity(0.6)
static let textTertiary = Color(red: 0.12, green: 0.12, blue: 0.14).opacity(0.4)
static let textQuaternary = Color(red: 0.12, green: 0.12, blue: 0.14).opacity(0.2)
static let textPrimary = Color.white.opacity(0.96)
static let textSecondary = Color.white.opacity(0.76)
static let textTertiary = Color.white.opacity(0.50)
static let textQuaternary = Color.white.opacity(0.24)
// For use on dark/image backgrounds (hero, overlays)
static let onDarkPrimary = Color.white
static let onDarkSecondary = Color.white.opacity(0.7)
static let onDarkTertiary = Color.white.opacity(0.45)
static let onDarkPrimary = textPrimary
static let onDarkSecondary = textSecondary
static let onDarkTertiary = textTertiary
}
// MARK: - Shadows
enum Shadows {
static let card = Color.black.opacity(0.06)
static let cardRadius: CGFloat = 16
static let cardY: CGFloat = 4
static let cardLifted = Color.black.opacity(0.12)
static let cardLiftedRadius: CGFloat = 24
static let cardLiftedY: CGFloat = 8
static let card = Color.black.opacity(0.30)
static let cardRadius: CGFloat = 28
static let cardY: CGFloat = 14
static let cardLifted = Color.black.opacity(0.44)
static let cardLiftedRadius: CGFloat = 42
static let cardLiftedY: CGFloat = 18
}
// MARK: - Typography
enum Fonts {
static let heroScore = Font.system(size: 72, weight: .black, design: .rounded).monospacedDigit()
static let largeScore = Font.system(size: 42, weight: .black, design: .rounded).monospacedDigit()
static let score = Font.system(size: 28, weight: .black, design: .rounded).monospacedDigit()
static let scoreCompact = Font.system(size: 22, weight: .bold, design: .rounded).monospacedDigit()
static let sectionTitle = Font.system(size: 28, weight: .bold, design: .rounded)
static let sectionTitle = Font.system(size: 30, weight: .bold, design: .rounded)
static let cardTitle = Font.system(size: 20, weight: .bold, design: .rounded)
static let cardTitleCompact = Font.system(size: 17, weight: .bold, design: .rounded)
@@ -56,26 +50,23 @@ enum DS {
static let bodySmall = Font.system(size: 13, weight: .medium)
static let caption = Font.system(size: 11, weight: .bold, design: .rounded)
// tvOS scaled variants 22px minimum for readability at 10ft
#if os(tvOS)
static let tvHeroScore = Font.system(size: 96, weight: .black, design: .rounded).monospacedDigit()
static let tvSectionTitle = Font.system(size: 38, weight: .bold, design: .rounded)
static let tvHeroScore = Font.system(size: 94, weight: .black, design: .rounded).monospacedDigit()
static let tvSectionTitle = Font.system(size: 40, weight: .bold, design: .rounded)
static let tvCardTitle = Font.system(size: 28, weight: .bold, design: .rounded)
static let tvScore = Font.system(size: 36, weight: .black, design: .rounded).monospacedDigit()
static let tvDataValue = Font.system(size: 24, weight: .bold, design: .rounded).monospacedDigit()
static let tvBody = Font.system(size: 24, weight: .medium)
static let tvCaption = Font.system(size: 22, weight: .bold, design: .rounded)
static let tvBody = Font.system(size: 22, weight: .medium)
static let tvCaption = Font.system(size: 20, weight: .bold, design: .rounded)
#endif
}
// MARK: - Spacing
enum Spacing {
#if os(tvOS)
static let panelPadCompact: CGFloat = 18
static let panelPadStandard: CGFloat = 24
static let panelPadFeatured: CGFloat = 32
static let sectionGap: CGFloat = 40
static let sectionGap: CGFloat = 42
static let cardGap: CGFloat = 20
static let itemGap: CGFloat = 12
static let edgeInset: CGFloat = 50
@@ -90,16 +81,82 @@ enum DS {
#endif
}
// MARK: - Radii
enum Radii {
static let compact: CGFloat = 14
static let standard: CGFloat = 18
static let featured: CGFloat = 22
static let compact: CGFloat = 16
static let standard: CGFloat = 24
static let featured: CGFloat = 30
}
}
// MARK: - Data Label Style
struct BroadcastBackground: View {
var body: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.03, green: 0.05, blue: 0.10),
Color(red: 0.04, green: 0.08, blue: 0.16),
Color(red: 0.02, green: 0.04, blue: 0.09),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
// Subtle color washes radial gradients instead of blurred circles for performance
RadialGradient(
colors: [Color(red: 0.00, green: 0.46, blue: 0.72).opacity(0.12), .clear],
center: UnitPoint(x: 0.1, y: 0.15),
startRadius: 50,
endRadius: 500
)
RadialGradient(
colors: [DS.Colors.interactive.opacity(0.10), .clear],
center: UnitPoint(x: 0.85, y: 0.15),
startRadius: 50,
endRadius: 450
)
RadialGradient(
colors: [DS.Colors.live.opacity(0.06), .clear],
center: UnitPoint(x: 0.8, y: 0.85),
startRadius: 50,
endRadius: 400
)
BroadcastGridOverlay()
.opacity(0.30)
}
}
}
private struct BroadcastGridOverlay: View {
var body: some View {
GeometryReader { proxy in
let size = proxy.size
Path { path in
let verticalSpacing: CGFloat = 110
let horizontalSpacing: CGFloat = 90
var x: CGFloat = 0
while x <= size.width {
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x, y: size.height))
x += verticalSpacing
}
var y: CGFloat = 0
while y <= size.height {
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: size.width, y: y))
y += horizontalSpacing
}
}
.stroke(Color.white.opacity(0.05), lineWidth: 1)
}
.allowsHitTesting(false)
}
}
struct DataLabelStyle: ViewModifier {
func body(content: Content) -> some View {

View File

@@ -19,7 +19,7 @@ struct TVFocusButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.97 : isFocused ? 1.04 : 1.0)
.scaleEffect(configuration.isPressed ? 0.98 : isFocused ? 1.035 : 1.0)
.opacity(configuration.isPressed ? 0.85 : 1.0)
.shadow(
color: isFocused ? DS.Shadows.cardLifted : .clear,
@@ -27,9 +27,13 @@ struct TVFocusButtonStyle: ButtonStyle {
y: isFocused ? DS.Shadows.cardLiftedY : 0
)
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(DS.Colors.interactive.opacity(isFocused ? 0.5 : 0), lineWidth: 2.5)
RoundedRectangle(cornerRadius: 26, style: .continuous)
.strokeBorder(DS.Colors.interactive.opacity(isFocused ? 0.72 : 0), lineWidth: 3)
)
.overlay {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(DS.Colors.interactive.opacity(isFocused ? 0.08 : 0))
}
.animation(.easeInOut(duration: 0.2), value: isFocused)
.animation(.easeOut(duration: 0.12), value: configuration.isPressed)
}

View File

@@ -19,10 +19,10 @@ struct ScoresTickerView: View {
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.black.opacity(0.72))
.fill(DS.Colors.navFill)
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
}
)
.accessibilityHidden(true)