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

@@ -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)
}
}