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 .platformFocusSection() if viewModel.isLoading { loadingState } else if let error = viewModel.errorMessage, viewModel.games.isEmpty { errorState(error) } else { overviewStrip .platformFocusSection() heroAndControlSection .platformFocusSection() featuredChannelsSection .platformFocusSection() 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 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 { HStack(spacing: DS.Spacing.cardGap) { mlbNetworkCard .frame(maxWidth: .infinity) nsfwVideosCard .frame(maxWidth: .infinity) } } 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) } } }