Fix memory leaks, stale game data, and audio volume fluctuation
Memory: clean observers even during PiP, nil player on tile disappear, track/cancel Werkout monitor tasks, add highlight player cleanup. Data: add scenePhase-triggered reload on day change, unconditional 10-minute full schedule refresh, keep fast 60s score refresh for live games. Audio: set mute state before playback starts, use consistent .moviePlayback mode, add audio session interruption recovery handler. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,8 @@ final class GamesViewModel {
|
||||
@ObservationIgnored
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
@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
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ struct GameCenterView: View {
|
||||
VideoPlayer(player: player)
|
||||
.ignoresSafeArea()
|
||||
.onAppear { player.play() }
|
||||
.onDisappear { player.pause() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +353,7 @@ private struct MultiStreamTile: View {
|
||||
@State private var hasError = false
|
||||
@State private var startupPlaybackTask: Task<Void, Never>?
|
||||
@State private var qualityUpgradeTask: Task<Void, Never>?
|
||||
@State private var werkoutMonitorTask: Task<Void, Never>?
|
||||
@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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user