Files
MLBApp/mlbTVOS/Views/DashboardView.swift
Trey t 310d857c7f Fix horizontal overflow: shrink control rail, hero padding, title font
Everything was clipping off the left edge because the hero card + Live
Radar sidebar + padding exceeded screen width. Reduced control rail from
420px to 340px, hero internal padding from 48px to 40px, detail panel
from 360px to 300px, title font from 52pt to 44pt, hero height from
560px to 500px.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:52:45 -05:00

1361 lines
52 KiB
Swift

import SwiftUI
import OSLog
private let dashboardLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "Dashboard")
private func logDashboard(_ message: String) {
dashboardLogger.debug("\(message, privacy: .public)")
print("[Dashboard] \(message)")
}
enum SpecialPlaybackChannelConfig {
static let werkoutNSFWStreamID = "WKNSFW"
static let werkoutNSFWTitle = "Werkout NSFW"
static let werkoutNSFWSubtitle = "Authenticated OF media feed"
static let werkoutNSFWFeedURLString = "https://ofapp.treytartt.com/api/media?users=tabatachang,werkout,kayla-lauren,ray-mattos,josie-hamming-2,dani-speegle-2&type=video"
static let werkoutNSFWCookie = "ofapp_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc3NDY0MjEyNiwiZXhwIjoxNzc3MjM0MTI2fQ.RdYGvyBPbR6tmK0kaEVhSnIVW6TZlm3Ef42Lxpvl_oQ"
static let werkoutNSFWTeamCode = "WK"
static var werkoutNSFWHeaders: [String: String] {
[
"Cookie": werkoutNSFWCookie,
]
}
static var werkoutNSFWFeedURL: URL {
URL(string: werkoutNSFWFeedURLString)!
}
static var werkoutNSFWBroadcast: Broadcast {
Broadcast(
id: werkoutNSFWStreamID,
teamCode: werkoutNSFWTeamCode,
name: werkoutNSFWTitle,
mediaId: "",
streamURL: werkoutNSFWFeedURLString
)
}
static var werkoutNSFWGame: Game {
Game(
id: werkoutNSFWStreamID,
awayTeam: TeamInfo(code: werkoutNSFWTeamCode, name: werkoutNSFWTitle, score: nil),
homeTeam: TeamInfo(code: werkoutNSFWTeamCode, name: werkoutNSFWTitle, score: nil),
status: .live(nil), gameType: nil, startTime: nil, venue: nil,
pitchers: nil, gamePk: nil, gameDate: "",
broadcasts: [], isBlackedOut: false
)
}
}
struct DashboardView: View {
@Environment(GamesViewModel.self) private var viewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var selectedGame: Game?
@State private var fullScreenBroadcast: BroadcastSelection?
@State private var pendingFullScreenBroadcast: BroadcastSelection?
@State private var showMLBNetworkSheet = false
@State private var showWerkoutNSFWSheet = false
@State private var isPiPActive = false
private var horizontalPadding: CGFloat {
#if os(iOS)
horizontalSizeClass == .compact ? 20 : 32
#else
60
#endif
}
private var verticalPadding: CGFloat {
#if os(iOS)
horizontalSizeClass == .compact ? 24 : 32
#else
40
#endif
}
private var contentSpacing: CGFloat {
#if os(iOS)
horizontalSizeClass == .compact ? 32 : 42
#else
50
#endif
}
private var shelfCardWidth: CGFloat {
#if os(iOS)
horizontalSizeClass == .compact ? 340 : 500
#else
540
#endif
}
private var controlRailWidth: CGFloat {
#if os(iOS)
horizontalSizeClass == .compact ? 0 : 300
#else
340
#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 {
loadingState
} else if let error = viewModel.errorMessage, viewModel.games.isEmpty {
errorState(error)
} else {
overviewStrip
heroAndControlSection
if !viewModel.liveGames.isEmpty {
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 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: "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
)
}
}
}
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
}
.scrollIndicators(.hidden)
.onAppear {
logDashboard("DashboardView appeared")
viewModel.startAutoRefresh()
Task { await viewModel.loadGames() }
}
.onDisappear {
logDashboard("DashboardView disappeared")
viewModel.stopAutoRefresh()
}
.sheet(item: $selectedGame, onDismiss: presentPendingFullScreenBroadcast) { game in
StreamOptionsSheet(game: game) { broadcast in
logDashboard("Queued fullscreen broadcast from game sheet broadcastId=\(broadcast.broadcast.id) gameId=\(broadcast.game.id)")
pendingFullScreenBroadcast = broadcast
selectedGame = nil
}
}
.fullScreenCover(item: $fullScreenBroadcast, content: fullScreenPlaybackScreen)
.onChange(of: fullScreenBroadcast?.id) { _, newValue in
logDashboard("fullScreenBroadcast changed newValue=\(newValue ?? "nil")")
}
.sheet(isPresented: $showMLBNetworkSheet, onDismiss: presentPendingFullScreenBroadcast) {
MLBNetworkSheet(
onWatchFullScreen: {
logDashboard("Queued fullscreen broadcast from MLB Network sheet")
showMLBNetworkSheet = false
pendingFullScreenBroadcast = mlbNetworkBroadcastSelection
}
)
}
.sheet(isPresented: $showWerkoutNSFWSheet, onDismiss: presentPendingFullScreenBroadcast) {
WerkoutNSFWSheet(
onWatchFullScreen: {
logDashboard("Queued fullscreen broadcast from Werkout NSFW sheet")
showWerkoutNSFWSheet = false
pendingFullScreenBroadcast = nsfwVideosBroadcastSelection
}
)
}
}
private func presentPendingFullScreenBroadcast() {
guard selectedGame == nil, !showMLBNetworkSheet, !showWerkoutNSFWSheet else {
logDashboard("Skipped pending fullscreen presentation because another sheet is still active")
return
}
guard let pendingFullScreenBroadcast else { return }
logDashboard(
"Presenting pending fullscreen broadcast broadcastId=\(pendingFullScreenBroadcast.broadcast.id) gameId=\(pendingFullScreenBroadcast.game.id)"
)
self.pendingFullScreenBroadcast = nil
DispatchQueue.main.async {
fullScreenBroadcast = pendingFullScreenBroadcast
}
}
private func presentFullScreenBroadcast(_ selection: BroadcastSelection) {
if selectedGame == nil, !showMLBNetworkSheet, !showWerkoutNSFWSheet {
fullScreenBroadcast = selection
} else {
pendingFullScreenBroadcast = selection
}
}
@ViewBuilder
private func fullScreenPlaybackScreen(selection: BroadcastSelection) -> some View {
SingleStreamPlaybackScreen(
resolveSource: {
await resolveFullScreenSource(for: selection)
},
resolveNextSource: nextFullScreenSourceResolver(for: selection),
tickerGames: tickerGames(for: selection),
game: selection.game,
onPiPActiveChanged: { active in
isPiPActive = active
}
)
.ignoresSafeArea()
.onAppear {
logDashboard("fullScreenCover content mounted broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
}
}
private func resolveFullScreenSource(for selection: BroadcastSelection) async -> SingleStreamPlaybackSource? {
logDashboard("resolveSource closure invoked broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
if let directSource = selection.directSource {
return directSource
}
if selection.broadcast.id == "MLBN" {
let url = await viewModel.buildEventStreamURL(event: "MLBN")
return SingleStreamPlaybackSource(url: url)
}
if selection.broadcast.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID {
guard let url = await viewModel.resolveAuthenticatedVideoFeedURL(
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
) else {
return nil
}
return SingleStreamPlaybackSource(
url: url,
httpHeaders: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
forceMuteAudio: true
)
}
let stream = ActiveStream(
id: selection.broadcast.id,
game: selection.game,
label: selection.broadcast.displayLabel,
mediaId: selection.broadcast.mediaId,
streamURLString: selection.broadcast.streamURL
)
guard let url = await viewModel.resolveStreamURL(
for: stream,
resolutionOverride: viewModel.defaultResolution,
preserveServerResolutionWhenBest: false
) else { return nil }
return SingleStreamPlaybackSource(url: url)
}
private func resolveNextFullScreenSource(
for selection: BroadcastSelection,
currentURL: URL?
) async -> SingleStreamPlaybackSource? {
guard selection.broadcast.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else {
return nil
}
var nextURL: URL?
for attempt in 1...3 {
nextURL = await viewModel.resolveAuthenticatedVideoFeedURL(
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
excluding: currentURL
)
if nextURL != nil { break }
logDashboard("resolveNextFullScreenSource retry attempt=\(attempt)/3")
if attempt < 3 {
try? await Task.sleep(nanoseconds: UInt64(attempt) * 500_000_000)
}
}
guard let nextURL else {
return nil
}
return SingleStreamPlaybackSource(
url: nextURL,
httpHeaders: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
forceMuteAudio: true
)
}
private func nextFullScreenSourceResolver(
for selection: BroadcastSelection
) -> (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? {
guard selection.broadcast.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else {
return nil
}
return { currentURL in
await resolveNextFullScreenSource(for: selection, currentURL: currentURL)
}
}
private func tickerGames(for selection: BroadcastSelection) -> [Game] {
selection.broadcast.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID ? [] : viewModel.games
}
private var mlbNetworkBroadcastSelection: BroadcastSelection {
let bc = Broadcast(
id: "MLBN",
teamCode: "MLBN",
name: "MLB Network",
mediaId: "",
streamURL: ""
)
let game = Game(
id: "MLBN",
awayTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
homeTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
status: .live(nil), gameType: nil, startTime: nil, venue: nil,
pitchers: nil, gamePk: nil, gameDate: "",
broadcasts: [], isBlackedOut: false
)
return BroadcastSelection(broadcast: bc, game: game)
}
private var nsfwVideosBroadcastSelection: BroadcastSelection {
return BroadcastSelection(
broadcast: SpecialPlaybackChannelConfig.werkoutNSFWBroadcast,
game: SpecialPlaybackChannelConfig.werkoutNSFWGame
)
}
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())
}
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)
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
}
}
}
private var headerCopy: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Daily Control Room")
.font(headerTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
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.goToToday() }
} label: {
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))
}
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())
}
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."
}
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)
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 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 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
private var featuredChannelsSection: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Quick Channels")
.font(railTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
mlbNetworkCard
nsfwVideosCard
}
.padding(24)
.background(surfaceCardBackground())
}
private var mlbNetworkCard: some View {
let added = viewModel.activeStreams.contains(where: { $0.id == "MLBN" })
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
}
}
private var nsfwVideosCard: some View {
let added = viewModel.activeStreams.contains(where: { $0.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID })
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
}
}
private var multiViewStatus: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Multi-View Status")
.font(railTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
Text("Current grid state, active audio focus, and ready-to-open tiles.")
.font(railBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
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)
}
)
}
}
}
}
.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()
}
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)
}
}
struct BroadcastSelection: Identifiable {
let id: String
let broadcast: Broadcast
let game: Game
let directSource: SingleStreamPlaybackSource?
init(
broadcast: Broadcast,
game: Game,
directSource: SingleStreamPlaybackSource? = nil
) {
self.id = broadcast.id
self.broadcast = broadcast
self.game = game
self.directSource = directSource
}
}
struct WerkoutNSFWSheet: View {
var onWatchFullScreen: () -> Void
@Environment(GamesViewModel.self) private var viewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.dismiss) private var dismiss
@State private var isResolvingMultiViewSource = false
@State private var multiViewErrorMessage: String?
private var added: Bool {
viewModel.activeStreams.contains(where: { $0.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID })
}
private var canToggleIntoMultiview: Bool {
added || viewModel.activeStreams.count < 4
}
private var usesStackedLayout: Bool {
#if os(iOS)
horizontalSizeClass == .compact
#else
false
#endif
}
private var outerHorizontalPadding: CGFloat {
usesStackedLayout ? 20 : 94
}
private var outerVerticalPadding: CGFloat {
usesStackedLayout ? 24 : 70
}
var body: some View {
ZStack {
sheetBackground
.ignoresSafeArea()
ViewThatFits {
HStack(alignment: .top, spacing: 32) {
overviewColumn
.frame(maxWidth: .infinity, alignment: .leading)
actionColumn
.frame(width: 360, alignment: .leading)
}
VStack(alignment: .leading, spacing: 24) {
overviewColumn
actionColumn
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(38)
.background(
RoundedRectangle(cornerRadius: 34, style: .continuous)
.fill(.black.opacity(0.46))
.overlay {
RoundedRectangle(cornerRadius: 34, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
)
.padding(.horizontal, outerHorizontalPadding)
.padding(.vertical, outerVerticalPadding)
}
}
@ViewBuilder
private var overviewColumn: some View {
VStack(alignment: .leading, spacing: 28) {
VStack(alignment: .leading, spacing: 18) {
HStack(spacing: 12) {
statusPill(title: "PRIVATE FEED", color: .pink)
if added {
statusPill(title: "IN MULTI-VIEW", color: .green)
}
}
HStack(alignment: .center, spacing: 22) {
ZStack {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(.pink.opacity(0.16))
.frame(width: 110, height: 110)
Image(systemName: "play.rectangle.fill")
.font(.system(size: 46, weight: .bold))
.foregroundStyle(
LinearGradient(
colors: [.white, .pink.opacity(0.88)],
startPoint: .top,
endPoint: .bottom
)
)
}
VStack(alignment: .leading, spacing: 10) {
Text(SpecialPlaybackChannelConfig.werkoutNSFWTitle)
.font(.system(size: 46, weight: .black, design: .rounded))
.foregroundStyle(.white)
Text("Launch the authenticated Werkout video feed full screen or drop it into an open Multi-View tile.")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(.white.opacity(0.72))
.fixedSize(horizontal: false, vertical: true)
}
}
}
HStack(spacing: 14) {
featurePill(title: "Authenticated", systemImage: "lock.fill")
featurePill(title: "Private Media", systemImage: "video.fill")
featurePill(title: "Direct Playback", systemImage: "play.fill")
}
VStack(alignment: .leading, spacing: 14) {
Text("Notes")
.font(.system(size: 16, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.48))
.kerning(1.2)
VStack(alignment: .leading, spacing: 14) {
overviewLine(
icon: "lock.shield.fill",
title: "Token-gated playback",
detail: "This feed is played with an Authorization header instead of a public URL."
)
overviewLine(
icon: "square.grid.2x2.fill",
title: "Multi-View compatible",
detail: "You can pin this feed into the active grid and route audio to it like other channel streams."
)
overviewLine(
icon: "exclamationmark.triangle.fill",
title: "Backend auth still matters",
detail: "If the server rejects the token, playback will fail in both full screen and Multi-View."
)
}
}
.padding(24)
.background(panelBackground)
if let multiViewErrorMessage {
Label(multiViewErrorMessage, systemImage: "exclamationmark.triangle.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.red)
.padding(.horizontal, 18)
.padding(.vertical, 16)
.background(panelBackground)
}
if !canToggleIntoMultiview {
Label(
"Multi-View is full. Remove a stream before adding Werkout NSFW.",
systemImage: "rectangle.split.2x2.fill"
)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.orange)
.padding(.horizontal, 18)
.padding(.vertical, 16)
.background(panelBackground)
}
}
}
@ViewBuilder
private var actionColumn: some View {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 8) {
Text("Actions")
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("Play the feed full screen or send it into your active Multi-View lineup.")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white.opacity(0.68))
.fixedSize(horizontal: false, vertical: true)
}
.padding(24)
.background(panelBackground)
actionButton(
title: "Watch Full Screen",
subtitle: "Open the Werkout feed in the main player",
systemImage: "play.fill",
fill: .pink.opacity(0.18)
) {
onWatchFullScreen()
}
actionButton(
title: added ? "Remove From Multi-View" : "Add to Multi-View",
subtitle: added ? "Take the feed out of your active grid" : isResolvingMultiViewSource ? "Resolving a playable HLS source from the feed" : "Send the feed into an open tile",
systemImage: added ? "minus.circle.fill" : "plus.circle.fill",
fill: added ? .red.opacity(0.16) : .white.opacity(0.08),
foreground: added ? .red : .white,
disabled: !canToggleIntoMultiview || isResolvingMultiViewSource
) {
if added {
viewModel.removeStream(id: SpecialPlaybackChannelConfig.werkoutNSFWStreamID)
dismiss()
return
}
multiViewErrorMessage = nil
isResolvingMultiViewSource = true
Task { @MainActor in
let didAddStream = await viewModel.addSpecialStreamFromAuthenticatedFeed(
id: SpecialPlaybackChannelConfig.werkoutNSFWStreamID,
label: SpecialPlaybackChannelConfig.werkoutNSFWTitle,
game: SpecialPlaybackChannelConfig.werkoutNSFWGame,
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
forceMuteAudio: true
)
isResolvingMultiViewSource = false
if didAddStream {
dismiss()
} else {
multiViewErrorMessage = "Could not resolve a playable Werkout stream from the authenticated feed."
}
}
}
Spacer(minLength: 0)
}
}
@ViewBuilder
private func actionButton(
title: String,
subtitle: String,
systemImage: String,
fill: Color,
foreground: Color = .white,
disabled: Bool = false,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(alignment: .center, spacing: 16) {
Image(systemName: systemImage)
.font(.system(size: 22, weight: .bold))
.frame(width: 32)
VStack(alignment: .leading, spacing: 5) {
Text(title)
.font(.system(size: 21, weight: .bold, design: .rounded))
HStack(spacing: 10) {
Text(subtitle)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(foreground.opacity(0.68))
.fixedSize(horizontal: false, vertical: true)
if isResolvingMultiViewSource && title == "Add to Multi-View" {
ProgressView()
.scaleEffect(0.8)
.tint(.white.opacity(0.84))
}
}
}
Spacer(minLength: 0)
}
.foregroundStyle(foreground)
.frame(maxWidth: .infinity, minHeight: 88, alignment: .leading)
.padding(.horizontal, 22)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(fill)
)
}
.platformCardStyle()
.disabled(disabled)
}
@ViewBuilder
private func overviewLine(icon: String, title: String, detail: String) -> some View {
HStack(alignment: .top, spacing: 14) {
Image(systemName: icon)
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.pink.opacity(0.9))
.frame(width: 26)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
Text(detail)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.66))
.fixedSize(horizontal: false, vertical: true)
}
}
}
@ViewBuilder
private func statusPill(title: String, color: Color) -> some View {
Text(title)
.font(.system(size: 13, weight: .black, design: .rounded))
.foregroundStyle(color)
.padding(.horizontal, 13)
.padding(.vertical, 9)
.background(color.opacity(0.14))
.clipShape(Capsule())
}
@ViewBuilder
private func featurePill(title: String, systemImage: String) -> some View {
Label(title, systemImage: systemImage)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.84))
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(.white.opacity(0.06))
.clipShape(Capsule())
}
@ViewBuilder
private var panelBackground: some View {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(.black.opacity(0.22))
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
@ViewBuilder
private var sheetBackground: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.07, green: 0.04, blue: 0.08),
Color(red: 0.09, green: 0.05, blue: 0.08),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(.pink.opacity(0.18))
.frame(width: 520, height: 520)
.blur(radius: 92)
.offset(x: -320, y: -220)
Circle()
.fill(.red.opacity(0.15))
.frame(width: 460, height: 460)
.blur(radius: 88)
.offset(x: 380, y: 140)
}
}
}