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>
255 lines
9.2 KiB
Swift
255 lines
9.2 KiB
Swift
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 {
|
|
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 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 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 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
|
|
}
|
|
|
|
enum AppSection: String, CaseIterable, Identifiable {
|
|
case today
|
|
case intel
|
|
case highlights
|
|
case multiView
|
|
case settings
|
|
|
|
var id: String { rawValue }
|
|
|
|
var title: String {
|
|
switch self {
|
|
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"
|
|
}
|
|
}
|
|
}
|