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:
@@ -84,70 +84,87 @@ struct DashboardView: View {
|
||||
|
||||
private var shelfCardWidth: CGFloat {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact ? 340 : 480
|
||||
horizontalSizeClass == .compact ? 340 : 500
|
||||
#else
|
||||
640
|
||||
540
|
||||
#endif
|
||||
}
|
||||
|
||||
private var controlRailWidth: CGFloat {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact ? 0 : 360
|
||||
#else
|
||||
420
|
||||
#endif
|
||||
}
|
||||
|
||||
private var usesStackedHeroLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
private var radarGames: [Game] {
|
||||
if !viewModel.liveGames.isEmpty {
|
||||
return Array(viewModel.liveGames.prefix(4))
|
||||
}
|
||||
if !viewModel.scheduledGames.isEmpty {
|
||||
return Array(viewModel.scheduledGames.prefix(4))
|
||||
}
|
||||
return Array(viewModel.finalGames.prefix(4))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: contentSpacing) {
|
||||
headerSection
|
||||
|
||||
if viewModel.isLoading {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView("Loading games...")
|
||||
.font(.title3)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 80)
|
||||
loadingState
|
||||
} else if let error = viewModel.errorMessage, viewModel.games.isEmpty {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 50))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(error)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Retry") {
|
||||
Task { await viewModel.loadGames() }
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 80)
|
||||
errorState(error)
|
||||
} else {
|
||||
// Hero featured game
|
||||
if let featured = viewModel.featuredGame {
|
||||
FeaturedGameCard(game: featured) {
|
||||
selectedGame = featured
|
||||
}
|
||||
}
|
||||
overviewStrip
|
||||
heroAndControlSection
|
||||
|
||||
if !viewModel.liveGames.isEmpty {
|
||||
gameShelf(title: "Live", icon: "antenna.radiowaves.left.and.right", games: viewModel.liveGames, excludeId: viewModel.featuredGame?.id)
|
||||
gameShelf(
|
||||
title: "Live Board",
|
||||
subtitle: "Open games with inning state, records, and stream availability.",
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
accent: DS.Colors.live,
|
||||
games: viewModel.liveGames,
|
||||
excludeId: viewModel.featuredGame?.id
|
||||
)
|
||||
}
|
||||
if !viewModel.scheduledGames.isEmpty {
|
||||
gameShelf(title: "Upcoming", icon: "calendar", games: viewModel.scheduledGames, excludeId: viewModel.featuredGame?.id)
|
||||
gameShelf(
|
||||
title: "Upcoming Windows",
|
||||
subtitle: "Probables, first pitch, and watch-ready cards for the rest of the slate.",
|
||||
icon: "calendar",
|
||||
accent: DS.Colors.warning,
|
||||
games: viewModel.scheduledGames,
|
||||
excludeId: viewModel.featuredGame?.id
|
||||
)
|
||||
}
|
||||
if !viewModel.finalGames.isEmpty {
|
||||
gameShelf(title: "Final", icon: "checkmark.circle", games: viewModel.finalGames, excludeId: viewModel.featuredGame?.id)
|
||||
gameShelf(
|
||||
title: "Completed Games",
|
||||
subtitle: "Finished scoreboards ready for replays, box scores, and highlights.",
|
||||
icon: "checkmark.circle",
|
||||
accent: DS.Colors.positive,
|
||||
games: viewModel.finalGames,
|
||||
excludeId: viewModel.featuredGame?.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
featuredChannelsSection
|
||||
|
||||
if !viewModel.activeStreams.isEmpty {
|
||||
multiViewStatus
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.vertical, verticalPadding)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
.onAppear {
|
||||
logDashboard("DashboardView appeared")
|
||||
viewModel.startAutoRefresh()
|
||||
@@ -340,232 +357,632 @@ struct DashboardView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Game Shelf (Horizontal)
|
||||
private var loadingState: some View {
|
||||
VStack(spacing: 18) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.3)
|
||||
Text("Loading the daily board")
|
||||
.font(sectionTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
Text("Scores, streams, and matchup context are on the way.")
|
||||
.font(sectionBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 120)
|
||||
.background(surfaceCardBackground())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func gameShelf(title: String, icon: String, games: [Game], excludeId: String?) -> some View {
|
||||
let filtered = games.filter { $0.id != excludeId }
|
||||
if !filtered.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Label(title, systemImage: icon)
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
private func errorState(_ error: String) -> some View {
|
||||
VStack(spacing: 18) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 44, weight: .bold))
|
||||
.foregroundStyle(DS.Colors.warning)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: 30) {
|
||||
ForEach(filtered) { game in
|
||||
GameCardView(game: game) {
|
||||
selectedGame = game
|
||||
}
|
||||
.frame(width: shelfCardWidth)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.scrollClipDisabled()
|
||||
Text(error)
|
||||
.font(sectionTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Button("Reload Board") {
|
||||
Task { await viewModel.loadGames() }
|
||||
}
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(DS.Colors.interactive)
|
||||
)
|
||||
.foregroundStyle(Color.black.opacity(0.82))
|
||||
.platformCardStyle()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 110)
|
||||
.background(surfaceCardBackground())
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
ViewThatFits {
|
||||
HStack(alignment: .bottom, spacing: 28) {
|
||||
headerCopy
|
||||
Spacer(minLength: 16)
|
||||
dateNavigator
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 22) {
|
||||
headerCopy
|
||||
dateNavigator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
private var headerCopy: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Daily Control Room")
|
||||
.font(headerTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
@ViewBuilder
|
||||
private var headerSection: some View {
|
||||
HStack(alignment: .center) {
|
||||
// Date navigation — compact inline
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await viewModel.goToPreviousDay() }
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: dateNavIconSize, weight: .semibold))
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
}
|
||||
Text("A broadcast-grade slate view with live radar, featured watch windows, and fast access to every stream.")
|
||||
.font(headerBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var dateNavigator: some View {
|
||||
HStack(spacing: 12) {
|
||||
navigatorButton(systemImage: "chevron.left") {
|
||||
Task { await viewModel.goToPreviousDay() }
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(viewModel.isToday ? "Today" : "Archive Day")
|
||||
.font(dateLabelFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
|
||||
Text(viewModel.displayDateString)
|
||||
.font(dateFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
|
||||
navigatorButton(systemImage: "chevron.right") {
|
||||
Task { await viewModel.goToNextDay() }
|
||||
}
|
||||
|
||||
if !viewModel.isToday {
|
||||
Button {
|
||||
Task { await viewModel.goToNextDay() }
|
||||
Task { await viewModel.goToToday() }
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: dateNavIconSize, weight: .semibold))
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
Text("Jump to Today")
|
||||
.font(todayBtnFont)
|
||||
.foregroundStyle(Color.black.opacity(0.84))
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(DS.Colors.interactive)
|
||||
)
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 18)
|
||||
.background(surfaceCardBackground(radius: 26))
|
||||
}
|
||||
|
||||
if !viewModel.isToday {
|
||||
Button {
|
||||
Task { await viewModel.goToToday() }
|
||||
} label: {
|
||||
Text("Today")
|
||||
.font(todayBtnFont)
|
||||
.foregroundStyle(DS.Colors.interactive)
|
||||
private func navigatorButton(systemImage: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: dateNavIconSize, weight: .bold))
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(DS.Colors.panelFillMuted)
|
||||
)
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
private var overviewStrip: some View {
|
||||
ViewThatFits {
|
||||
HStack(spacing: 18) {
|
||||
metricTile(value: "\(viewModel.liveGames.count)", label: "Live Games", detail: liveStatusDetail, systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live)
|
||||
metricTile(value: "\(viewModel.scheduledGames.count)", label: "Upcoming", detail: "First pitches still ahead", systemImage: "calendar", tint: DS.Colors.warning)
|
||||
metricTile(value: "\(viewModel.finalGames.count)", label: "Final", detail: "Completed scoreboards", systemImage: "flag.checkered.2.crossed", tint: DS.Colors.positive)
|
||||
metricTile(value: "\(viewModel.activeStreams.count)", label: "Stream Tiles", detail: activeAudioDetail, systemImage: "rectangle.split.2x2", tint: DS.Colors.media)
|
||||
}
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 18) {
|
||||
metricTile(value: "\(viewModel.liveGames.count)", label: "Live Games", detail: liveStatusDetail, systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live)
|
||||
metricTile(value: "\(viewModel.scheduledGames.count)", label: "Upcoming", detail: "First pitches still ahead", systemImage: "calendar", tint: DS.Colors.warning)
|
||||
metricTile(value: "\(viewModel.finalGames.count)", label: "Final", detail: "Completed scoreboards", systemImage: "flag.checkered.2.crossed", tint: DS.Colors.positive)
|
||||
metricTile(value: "\(viewModel.activeStreams.count)", label: "Stream Tiles", detail: activeAudioDetail, systemImage: "rectangle.split.2x2", tint: DS.Colors.media)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.scrollClipDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
private var liveStatusDetail: String {
|
||||
if viewModel.liveGames.isEmpty {
|
||||
return "No live first pitch yet"
|
||||
}
|
||||
return "\(viewModel.liveGames.count) games active now"
|
||||
}
|
||||
|
||||
private var activeAudioDetail: String {
|
||||
if let activeAudio = viewModel.activeAudioStream {
|
||||
return "Audio: \(activeAudio.game.awayTeam.code) @ \(activeAudio.game.homeTeam.code)"
|
||||
}
|
||||
if isPiPActive {
|
||||
return "Picture in Picture active"
|
||||
}
|
||||
return "Quadbox ready"
|
||||
}
|
||||
|
||||
private func metricTile(value: String, label: String, detail: String, systemImage: String, tint: Color) -> some View {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(value)
|
||||
.font(metricValueFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.monospacedDigit()
|
||||
|
||||
Text(label)
|
||||
.font(metricLabelFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
|
||||
Text(detail)
|
||||
.font(metricDetailFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 18)
|
||||
.background(surfaceCardBackground(radius: 26))
|
||||
}
|
||||
|
||||
private var heroAndControlSection: some View {
|
||||
Group {
|
||||
if usesStackedHeroLayout {
|
||||
VStack(alignment: .leading, spacing: 22) {
|
||||
featuredHero
|
||||
controlRail
|
||||
}
|
||||
} else {
|
||||
HStack(alignment: .top, spacing: 24) {
|
||||
featuredHero
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
controlRail
|
||||
.frame(width: controlRailWidth, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var featuredHero: some View {
|
||||
Group {
|
||||
if let featured = viewModel.featuredGame {
|
||||
FeaturedGameCard(game: featured) {
|
||||
selectedGame = featured
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("No featured matchup")
|
||||
.font(DS.Fonts.sectionTitle)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
Text("As soon as the slate populates, the best watch window appears here with scores, context, and stream access.")
|
||||
.font(DS.Fonts.body)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 320, alignment: .leading)
|
||||
.padding(32)
|
||||
.background(surfaceCardBackground(radius: 34))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var controlRail: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
liveRadarPanel
|
||||
featuredChannelsSection
|
||||
multiViewStatus
|
||||
}
|
||||
}
|
||||
|
||||
private var liveRadarPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Live Radar")
|
||||
.font(railTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Text(radarSubtitle)
|
||||
.font(railBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if radarGames.isEmpty {
|
||||
Text("No games loaded yet.")
|
||||
.font(railBodyFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(radarGames) { game in
|
||||
radarRow(game)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background(surfaceCardBackground())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
private var radarSubtitle: String {
|
||||
if !viewModel.liveGames.isEmpty {
|
||||
return "Fast board for the most active windows."
|
||||
}
|
||||
if !viewModel.scheduledGames.isEmpty {
|
||||
return "No live action yet. Upcoming first pitches are next."
|
||||
}
|
||||
return "Completed slate snapshots."
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Text("\(viewModel.games.count) games")
|
||||
.font(metaCountFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
private func radarRow(_ game: Game) -> some View {
|
||||
Button {
|
||||
selectedGame = game
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("\(game.awayTeam.code) @ \(game.homeTeam.code)")
|
||||
.font(radarRowTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
if !viewModel.liveGames.isEmpty {
|
||||
HStack(spacing: 5) {
|
||||
Circle().fill(DS.Colors.live).frame(width: 7, height: 7)
|
||||
Text("\(viewModel.liveGames.count) live")
|
||||
.font(metaCountFont)
|
||||
.foregroundStyle(DS.Colors.live)
|
||||
Text(radarDetail(for: game))
|
||||
.font(radarRowBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
Text(radarStatus(for: game))
|
||||
.font(radarRowStatusFont)
|
||||
.foregroundStyle(radarTint(for: game))
|
||||
|
||||
if let score = game.scoreDisplay {
|
||||
Text(score)
|
||||
.font(radarRowScoreFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(DS.Colors.panelFillMuted)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
)
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
private func radarDetail(for game: Game) -> String {
|
||||
if game.isLive {
|
||||
return game.venue ?? "Live board ready"
|
||||
}
|
||||
if let pitchers = game.pitchers, !pitchers.isEmpty {
|
||||
return pitchers
|
||||
}
|
||||
return game.venue ?? "Open matchup details"
|
||||
}
|
||||
|
||||
private func radarStatus(for game: Game) -> String {
|
||||
switch game.status {
|
||||
case .live(let inning):
|
||||
return inning?.uppercased() ?? "LIVE"
|
||||
case .scheduled(let time):
|
||||
return time.uppercased()
|
||||
case .final_:
|
||||
return "FINAL"
|
||||
case .unknown:
|
||||
return "PENDING"
|
||||
}
|
||||
}
|
||||
|
||||
private func radarTint(for game: Game) -> Color {
|
||||
if game.isLive { return DS.Colors.live }
|
||||
if game.isFinal { return DS.Colors.positive }
|
||||
return DS.Colors.warning
|
||||
}
|
||||
|
||||
private func gameShelf(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
icon: String,
|
||||
accent: Color,
|
||||
games: [Game],
|
||||
excludeId: String?
|
||||
) -> some View {
|
||||
let filtered = games.filter { $0.id != excludeId }
|
||||
return Group {
|
||||
if !filtered.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack(alignment: .bottom, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label(title, systemImage: icon)
|
||||
.font(sectionTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Text(subtitle)
|
||||
.font(sectionBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Circle()
|
||||
.fill(accent)
|
||||
.frame(width: 10, height: 10)
|
||||
}
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHStack(spacing: 24) {
|
||||
ForEach(filtered) { game in
|
||||
GameCardView(game: game) {
|
||||
selectedGame = game
|
||||
}
|
||||
.frame(width: shelfCardWidth)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.scrollClipDisabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private var dateFont: Font { .system(size: 32, weight: .bold) }
|
||||
private var dateNavIconSize: CGFloat { 22 }
|
||||
private var todayBtnFont: Font { .system(size: 22, weight: .bold, design: .rounded) }
|
||||
private var metaCountFont: Font { .system(size: 22, weight: .medium) }
|
||||
private var headerTitleFont: Font { .system(size: 50, weight: .black, design: .rounded) }
|
||||
private var headerBodyFont: Font { .system(size: 22, weight: .medium) }
|
||||
private var dateLabelFont: Font { .system(size: 14, weight: .black, design: .rounded) }
|
||||
private var dateFont: Font { .system(size: 26, weight: .bold, design: .rounded) }
|
||||
private var dateNavIconSize: CGFloat { 20 }
|
||||
private var todayBtnFont: Font { .system(size: 18, weight: .black, design: .rounded) }
|
||||
private var metricValueFont: Font { .system(size: 34, weight: .black, design: .rounded) }
|
||||
private var metricLabelFont: Font { .system(size: 19, weight: .bold, design: .rounded) }
|
||||
private var metricDetailFont: Font { .system(size: 16, weight: .semibold) }
|
||||
private var railTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) }
|
||||
private var railBodyFont: Font { .system(size: 17, weight: .medium) }
|
||||
private var radarRowTitleFont: Font { .system(size: 19, weight: .black, design: .rounded) }
|
||||
private var radarRowBodyFont: Font { .system(size: 15, weight: .semibold) }
|
||||
private var radarRowStatusFont: Font { .system(size: 14, weight: .black, design: .rounded) }
|
||||
private var radarRowScoreFont: Font { .system(size: 20, weight: .black, design: .rounded).monospacedDigit() }
|
||||
private var sectionTitleFont: Font { .system(size: 30, weight: .black, design: .rounded) }
|
||||
private var sectionBodyFont: Font { .system(size: 17, weight: .medium) }
|
||||
#else
|
||||
private var dateFont: Font { .system(size: 22, weight: .bold) }
|
||||
private var dateNavIconSize: CGFloat { 16 }
|
||||
private var todayBtnFont: Font { .system(size: 15, weight: .bold, design: .rounded) }
|
||||
private var metaCountFont: Font { .system(size: 14, weight: .medium) }
|
||||
private var headerTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) }
|
||||
private var headerBodyFont: Font { .system(size: 15, weight: .medium) }
|
||||
private var dateLabelFont: Font { .system(size: 10, weight: .black, design: .rounded) }
|
||||
private var dateFont: Font { .system(size: 17, weight: .bold, design: .rounded) }
|
||||
private var dateNavIconSize: CGFloat { 15 }
|
||||
private var todayBtnFont: Font { .system(size: 13, weight: .black, design: .rounded) }
|
||||
private var metricValueFont: Font { .system(size: 22, weight: .black, design: .rounded) }
|
||||
private var metricLabelFont: Font { .system(size: 13, weight: .bold, design: .rounded) }
|
||||
private var metricDetailFont: Font { .system(size: 11, weight: .semibold) }
|
||||
private var railTitleFont: Font { .system(size: 20, weight: .black, design: .rounded) }
|
||||
private var railBodyFont: Font { .system(size: 12, weight: .medium) }
|
||||
private var radarRowTitleFont: Font { .system(size: 14, weight: .black, design: .rounded) }
|
||||
private var radarRowBodyFont: Font { .system(size: 11, weight: .semibold) }
|
||||
private var radarRowStatusFont: Font { .system(size: 10, weight: .black, design: .rounded) }
|
||||
private var radarRowScoreFont: Font { .system(size: 14, weight: .black, design: .rounded).monospacedDigit() }
|
||||
private var sectionTitleFont: Font { .system(size: 22, weight: .black, design: .rounded) }
|
||||
private var sectionBodyFont: Font { .system(size: 12, weight: .medium) }
|
||||
#endif
|
||||
|
||||
// MARK: - Featured Channels
|
||||
|
||||
@ViewBuilder
|
||||
private var featuredChannelsSection: some View {
|
||||
ViewThatFits {
|
||||
HStack(alignment: .top, spacing: 24) {
|
||||
mlbNetworkCard
|
||||
.frame(maxWidth: .infinity)
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Quick Channels")
|
||||
.font(railTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
nsfwVideosCard
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
mlbNetworkCard
|
||||
nsfwVideosCard
|
||||
}
|
||||
mlbNetworkCard
|
||||
nsfwVideosCard
|
||||
}
|
||||
.padding(24)
|
||||
.background(surfaceCardBackground())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mlbNetworkCard: some View {
|
||||
let added = viewModel.activeStreams.contains(where: { $0.id == "MLBN" })
|
||||
Button {
|
||||
return channelCard(
|
||||
title: "MLB Network",
|
||||
subtitle: "League-wide coverage, whip-around cuts, analysis, and highlights.",
|
||||
systemImage: "tv.fill",
|
||||
tint: .blue,
|
||||
status: added ? "Pinned to Multi-View" : "Open Channel"
|
||||
) {
|
||||
showMLBNetworkSheet = true
|
||||
} label: {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: "tv.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.blue)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(.blue.opacity(0.2))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("MLB Network")
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
Text("Live coverage, analysis & highlights")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if added {
|
||||
Label("In Multi-View", systemImage: "checkmark.circle.fill")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(DS.Colors.positive)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 108, alignment: .leading)
|
||||
.padding(24)
|
||||
.background(DS.Colors.panelFill)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var nsfwVideosCard: some View {
|
||||
let added = viewModel.activeStreams.contains(where: { $0.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID })
|
||||
Button {
|
||||
return channelCard(
|
||||
title: SpecialPlaybackChannelConfig.werkoutNSFWTitle,
|
||||
subtitle: SpecialPlaybackChannelConfig.werkoutNSFWSubtitle,
|
||||
systemImage: "play.rectangle.fill",
|
||||
tint: .pink,
|
||||
status: added ? "Pinned to Multi-View" : "Private feed access"
|
||||
) {
|
||||
showWerkoutNSFWSheet = true
|
||||
} label: {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: "play.rectangle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.pink)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(.pink.opacity(0.2))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(SpecialPlaybackChannelConfig.werkoutNSFWTitle)
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
Text(SpecialPlaybackChannelConfig.werkoutNSFWSubtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
private var multiViewStatus: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Multi-View Status")
|
||||
.font(railTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Spacer()
|
||||
Text("Current grid state, active audio focus, and ready-to-open tiles.")
|
||||
.font(railBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
|
||||
if added {
|
||||
Label("In Multi-View", systemImage: "checkmark.circle.fill")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(DS.Colors.positive)
|
||||
} else {
|
||||
Label("Open", systemImage: "play.fill")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.pink)
|
||||
HStack(spacing: 14) {
|
||||
metricBadge(value: "\(viewModel.activeStreams.count)/4", label: "Tiles")
|
||||
metricBadge(value: viewModel.multiViewLayoutMode.title, label: "Layout")
|
||||
metricBadge(value: viewModel.activeAudioStream == nil ? "Muted" : "Live", label: "Audio")
|
||||
}
|
||||
|
||||
if viewModel.activeStreams.isEmpty {
|
||||
Text("No active tiles yet. Add any game feed to build the quadbox.")
|
||||
.font(railBodyFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
} else {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
||||
ForEach(viewModel.activeStreams) { stream in
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(viewModel.activeAudioStream?.id == stream.id ? DS.Colors.interactive : DS.Colors.positive)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(stream.label)
|
||||
.font(radarRowBodyFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(DS.Colors.panelFillMuted)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 108, alignment: .leading)
|
||||
.padding(24)
|
||||
.background(DS.Colors.panelFill)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
|
||||
}
|
||||
.padding(24)
|
||||
.background(surfaceCardBackground())
|
||||
}
|
||||
|
||||
private func channelCard(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
systemImage: String,
|
||||
tint: Color,
|
||||
status: String,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 58, height: 58)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(tint.opacity(0.16))
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(title)
|
||||
.font(radarRowTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Text(subtitle)
|
||||
.font(radarRowBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Text(status)
|
||||
.font(radarRowStatusFont)
|
||||
.foregroundStyle(tint)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.fill(DS.Colors.panelFillMuted)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
)
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
// MARK: - Multi-View Status
|
||||
|
||||
@ViewBuilder
|
||||
private var multiViewStatus: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Label("Multi-View", systemImage: "rectangle.split.2x2")
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ForEach(viewModel.activeStreams) { stream in
|
||||
HStack(spacing: 8) {
|
||||
Circle().fill(DS.Colors.positive).frame(width: 8, height: 8)
|
||||
Text(stream.label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(DS.Colors.panelFill)
|
||||
.clipShape(Capsule())
|
||||
.shadow(color: DS.Shadows.card, radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
private func metricBadge(value: String, label: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(value)
|
||||
.font(radarRowTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
Text(label)
|
||||
.font(radarRowBodyFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(DS.Colors.panelFillMuted)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func surfaceCardBackground(radius: CGFloat = 28) -> some View {
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
DS.Colors.panelFill,
|
||||
DS.Colors.panelFillMuted,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user