Files
MLBApp/mlbTVOS/Views/DashboardView.swift
2026-03-26 15:37:31 -05:00

341 lines
12 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)")
}
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
}
}