341 lines
12 KiB
Swift
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
|
|
}
|
|
}
|