diff --git a/mlbTVOS/ViewModels/GamesViewModel.swift b/mlbTVOS/ViewModels/GamesViewModel.swift index 308c8fd..accb245 100644 --- a/mlbTVOS/ViewModels/GamesViewModel.swift +++ b/mlbTVOS/ViewModels/GamesViewModel.swift @@ -53,6 +53,8 @@ final class GamesViewModel { @ObservationIgnored private var refreshTask: Task? @ObservationIgnored + private var lastLoadDateString: String? + @ObservationIgnored private var authenticatedVideoFeedCache: [String: AuthenticatedVideoFeedCacheEntry] = [:] @ObservationIgnored private var videoShuffleBagsByModel: [String: [String: [URL]]] = [:] @@ -105,12 +107,21 @@ final class GamesViewModel { func startAutoRefresh() { stopAutoRefresh() refreshTask = Task { [weak self] in + var ticksSinceFullLoad = 0 while !Task.isCancelled { try? await Task.sleep(for: .seconds(60)) guard !Task.isCancelled else { break } guard let self else { break } - // Refresh if there are live games or active streams - if !self.liveGames.isEmpty || !self.activeStreams.isEmpty { + ticksSinceFullLoad += 1 + + // Full schedule reload every 10 minutes (or immediately on day change) + let today = Self.dateFormatter.string(from: Date()) + let dayChanged = self.lastLoadDateString != nil && self.lastLoadDateString != today + if dayChanged || ticksSinceFullLoad >= 10 { + ticksSinceFullLoad = 0 + await self.loadGames() + } else if !self.liveGames.isEmpty || !self.activeStreams.isEmpty { + // Fast score refresh every 60s when games are live await self.refreshScores() } } @@ -122,6 +133,14 @@ final class GamesViewModel { refreshTask = nil } + func refreshIfDayChanged() async { + let today = Self.dateFormatter.string(from: Date()) + if lastLoadDateString != today { + logGamesViewModel("Day changed (\(lastLoadDateString ?? "nil") → \(today)), reloading games") + await loadGames() + } + } + private func refreshScores() async { let statsGames = await fetchStatsGames() guard !statsGames.isEmpty else { return } @@ -227,6 +246,7 @@ final class GamesViewModel { errorMessage = "No games found" } + lastLoadDateString = todayDateString isLoading = false } diff --git a/mlbTVOS/Views/GameCenterView.swift b/mlbTVOS/Views/GameCenterView.swift index 72da1d3..02e6e38 100644 --- a/mlbTVOS/Views/GameCenterView.swift +++ b/mlbTVOS/Views/GameCenterView.swift @@ -69,6 +69,7 @@ struct GameCenterView: View { VideoPlayer(player: player) .ignoresSafeArea() .onAppear { player.play() } + .onDisappear { player.pause() } } } } diff --git a/mlbTVOS/Views/MultiStreamView.swift b/mlbTVOS/Views/MultiStreamView.swift index 8a5f163..b2d3a22 100644 --- a/mlbTVOS/Views/MultiStreamView.swift +++ b/mlbTVOS/Views/MultiStreamView.swift @@ -353,6 +353,7 @@ private struct MultiStreamTile: View { @State private var hasError = false @State private var startupPlaybackTask: Task? @State private var qualityUpgradeTask: Task? + @State private var werkoutMonitorTask: Task? @State private var clipTimeLimitObserver: Any? @State private var isAdvancingClip = false @StateObject private var playbackDiagnostics = MultiStreamPlaybackDiagnostics() @@ -447,7 +448,13 @@ private struct MultiStreamTile: View { startupPlaybackTask = nil qualityUpgradeTask?.cancel() qualityUpgradeTask = nil - if let player { removeClipTimeLimit(from: player) } + werkoutMonitorTask?.cancel() + werkoutMonitorTask = nil + if let player { + removeClipTimeLimit(from: player) + player.pause() + } + player = nil playbackDiagnostics.clear(streamID: stream.id, reason: "tile disappeared") } #if os(tvOS) @@ -557,7 +564,7 @@ private struct MultiStreamTile: View { if !Self.audioSessionConfigured { do { - try AVAudioSession.sharedInstance().setCategory(.playback) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) try AVAudioSession.sharedInstance().setActive(true) Self.audioSessionConfigured = true logMultiView("startStream audio session configured id=\(stream.id)") @@ -604,13 +611,14 @@ private struct MultiStreamTile: View { avPlayer.currentItem?.preferredForwardBufferDuration = 2 self.player = avPlayer + // Set mute state BEFORE playback to prevent audio spikes + viewModel.attachPlayer(avPlayer, to: stream.id) playbackDiagnostics.attach( to: avPlayer, streamID: stream.id, label: stream.label, onPlaybackEnded: playbackEndedHandler(for: avPlayer) ) - viewModel.attachPlayer(avPlayer, to: stream.id) scheduleStartupPlaybackRecovery(for: avPlayer) scheduleQualityUpgrade(for: avPlayer) logMultiView("startStream attached player id=\(stream.id) muted=\(avPlayer.isMuted) startupResolution=\(multiViewStartupResolution) fastStart=true calling playImmediately(atRate: 1.0)") @@ -851,9 +859,11 @@ private struct MultiStreamTile: View { installClipTimeLimit(on: player) // Monitor for failure and auto-skip to next clip - Task { @MainActor in + werkoutMonitorTask?.cancel() + werkoutMonitorTask = Task { @MainActor in for checkDelay in [1.0, 3.0] { try? await Task.sleep(for: .seconds(checkDelay)) + guard !Task.isCancelled else { return } let postItemStatus = player.currentItem?.status let error = player.currentItem?.error?.localizedDescription ?? "nil" logMultiView( diff --git a/mlbTVOS/Views/SingleStreamPlayerView.swift b/mlbTVOS/Views/SingleStreamPlayerView.swift index dcfa7ef..79981cd 100644 --- a/mlbTVOS/Views/SingleStreamPlayerView.swift +++ b/mlbTVOS/Views/SingleStreamPlayerView.swift @@ -583,11 +583,11 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable { static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: Coordinator) { logSingleStream("dismantleUIViewController start isPiPActive=\(coordinator.isPiPActive)") + coordinator.clearDebugObservers() if coordinator.isPiPActive { - logSingleStream("dismantleUIViewController skipped — PiP is active") + logSingleStream("dismantleUIViewController — PiP active, observers cleared but keeping player") return } - coordinator.clearDebugObservers() uiViewController.player?.pause() uiViewController.player = nil logSingleStream("dismantleUIViewController complete") diff --git a/mlbTVOS/mlbTVOSApp.swift b/mlbTVOS/mlbTVOSApp.swift index 8067369..9e30a33 100644 --- a/mlbTVOS/mlbTVOSApp.swift +++ b/mlbTVOS/mlbTVOSApp.swift @@ -4,6 +4,7 @@ import SwiftUI @main struct mlbTVOSApp: App { @State private var viewModel = GamesViewModel() + @Environment(\.scenePhase) private var scenePhase init() { configureAudioSession() @@ -13,6 +14,11 @@ struct mlbTVOSApp: App { WindowGroup { ContentView() .environment(viewModel) + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active { + Task { await viewModel.refreshIfDayChanged() } + } + } } } @@ -24,5 +30,23 @@ struct mlbTVOSApp: App { } catch { print("Failed to set audio session: \(error)") } + + NotificationCenter.default.addObserver( + forName: AVAudioSession.interruptionNotification, + object: AVAudioSession.sharedInstance(), + queue: .main + ) { notification in + guard let info = notification.userInfo, + let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } + + if type == .ended { + let options = (info[AVAudioSessionInterruptionOptionKey] as? UInt) + .flatMap(AVAudioSession.InterruptionOptions.init) ?? [] + if options.contains(.shouldResume) { + try? AVAudioSession.sharedInstance().setActive(true) + } + } + } } }