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)") } struct DashboardView: View { @Environment(GamesViewModel.self) private var viewModel @State private var selectedGame: Game? @State private var fullScreenBroadcast: BroadcastSelection? @State private var pendingFullScreenBroadcast: BroadcastSelection? @State private var showMLBNetworkSheet = false var body: some View { ScrollView { VStack(alignment: .leading, spacing: 50) { headerSection if viewModel.isLoading { HStack { Spacer() ProgressView("Loading games...") .font(.title3) Spacer() } .padding(.top, 80) } 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) } else { // Hero featured game if let featured = viewModel.featuredGame { FeaturedGameCard(game: featured) { selectedGame = featured } } if !viewModel.liveGames.isEmpty { gameShelf(title: "Live", icon: "antenna.radiowaves.left.and.right", games: viewModel.liveGames, excludeId: viewModel.featuredGame?.id) } if !viewModel.scheduledGames.isEmpty { gameShelf(title: "Upcoming", icon: "calendar", games: viewModel.scheduledGames, excludeId: viewModel.featuredGame?.id) } if !viewModel.finalGames.isEmpty { gameShelf(title: "Final", icon: "checkmark.circle", games: viewModel.finalGames, excludeId: viewModel.featuredGame?.id) } } mlbNetworkCard if !viewModel.activeStreams.isEmpty { multiViewStatus } } .padding(.horizontal, 60) .padding(.vertical, 40) } .onAppear { logDashboard("DashboardView appeared") viewModel.startAutoRefresh() } .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) { selection in SingleStreamPlaybackScreen( resolveURL: { logDashboard("resolveURL closure invoked broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)") if selection.broadcast.id == "MLBN" { return await viewModel.buildEventStreamURL(event: "MLBN") } let s = ActiveStream( id: selection.broadcast.id, game: selection.game, label: selection.broadcast.displayLabel, mediaId: selection.broadcast.mediaId, streamURLString: selection.broadcast.streamURL ) return await viewModel.resolveStreamURL(for: s) }, tickerGames: viewModel.games ) .ignoresSafeArea() .onAppear { logDashboard("fullScreenCover content mounted broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)") } } .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 } ) } } private func presentPendingFullScreenBroadcast() { guard selectedGame == nil, !showMLBNetworkSheet 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 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) } // MARK: - Game Shelf (Horizontal) @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(.secondary) ScrollView(.horizontal) { LazyHStack(spacing: 30) { ForEach(filtered) { game in GameCardView(game: game) { selectedGame = game } .frame(width: 400) } } .padding(.vertical, 20) } .scrollClipDisabled() } } } // MARK: - Header @ViewBuilder private var headerSection: some View { VStack(spacing: 24) { HStack(alignment: .bottom) { VStack(alignment: .leading, spacing: 6) { Text("MLB") .font(.headline.weight(.black)) .foregroundStyle(.secondary) .kerning(4) Text(viewModel.displayDateString) .font(.system(size: 40, weight: .bold)) .contentTransition(.numericText()) } Spacer() HStack(spacing: 16) { statPill("\(viewModel.games.count)", label: "Games") if !viewModel.liveGames.isEmpty { statPill("\(viewModel.liveGames.count)", label: "Live", color: .red) } if !viewModel.activeStreams.isEmpty { statPill("\(viewModel.activeStreams.count)/4", label: "Streams", color: .green) } } } HStack(spacing: 16) { Button { Task { await viewModel.goToPreviousDay() } } label: { Label("Previous Day", systemImage: "chevron.left") } if !viewModel.isToday { Button { Task { await viewModel.goToToday() } } label: { Label("Today", systemImage: "calendar") } .tint(.blue) } Button { Task { await viewModel.goToNextDay() } } label: { HStack(spacing: 6) { Text("Next Day") Image(systemName: "chevron.right") } } Spacer() } } } @ViewBuilder private func statPill(_ value: String, label: String, color: Color = .blue) -> some View { VStack(spacing: 2) { Text(value) .font(.title3.weight(.bold).monospacedDigit()) .foregroundStyle(color) Text(label) .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) } .padding(.horizontal, 20) .padding(.vertical, 10) .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 14)) } // MARK: - MLB Network @ViewBuilder private var mlbNetworkCard: some View { let added = viewModel.activeStreams.contains(where: { $0.id == "MLBN" }) Button { 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)) Text("Live coverage, analysis & highlights") .font(.subheadline) .foregroundStyle(.secondary) } Spacer() if added { Label("In Multi-View", systemImage: "checkmark.circle.fill") .font(.subheadline.weight(.bold)) .foregroundStyle(.green) } } .padding(24) .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 20)) } .buttonStyle(.card) } // 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(.secondary) HStack(spacing: 12) { ForEach(viewModel.activeStreams) { stream in HStack(spacing: 8) { Circle().fill(.green).frame(width: 8, height: 8) Text(stream.label) .font(.subheadline.weight(.semibold)) } .padding(.horizontal, 14) .padding(.vertical, 8) .background(.regularMaterial) .clipShape(Capsule()) } } } } } struct BroadcastSelection: Identifiable { let id: String let broadcast: Broadcast let game: Game init(broadcast: Broadcast, game: Game) { self.id = broadcast.id self.broadcast = broadcast self.game = game } }