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:
Trey t
2026-04-12 12:21:21 -05:00
parent 88308b46f5
commit bf44a7b7eb
5 changed files with 63 additions and 8 deletions

View File

@@ -53,6 +53,8 @@ final class GamesViewModel {
@ObservationIgnored @ObservationIgnored
private var refreshTask: Task<Void, Never>? private var refreshTask: Task<Void, Never>?
@ObservationIgnored @ObservationIgnored
private var lastLoadDateString: String?
@ObservationIgnored
private var authenticatedVideoFeedCache: [String: AuthenticatedVideoFeedCacheEntry] = [:] private var authenticatedVideoFeedCache: [String: AuthenticatedVideoFeedCacheEntry] = [:]
@ObservationIgnored @ObservationIgnored
private var videoShuffleBagsByModel: [String: [String: [URL]]] = [:] private var videoShuffleBagsByModel: [String: [String: [URL]]] = [:]
@@ -105,12 +107,21 @@ final class GamesViewModel {
func startAutoRefresh() { func startAutoRefresh() {
stopAutoRefresh() stopAutoRefresh()
refreshTask = Task { [weak self] in refreshTask = Task { [weak self] in
var ticksSinceFullLoad = 0
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(for: .seconds(60)) try? await Task.sleep(for: .seconds(60))
guard !Task.isCancelled else { break } guard !Task.isCancelled else { break }
guard let self else { break } guard let self else { break }
// Refresh if there are live games or active streams ticksSinceFullLoad += 1
if !self.liveGames.isEmpty || !self.activeStreams.isEmpty {
// 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() await self.refreshScores()
} }
} }
@@ -122,6 +133,14 @@ final class GamesViewModel {
refreshTask = nil 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 { private func refreshScores() async {
let statsGames = await fetchStatsGames() let statsGames = await fetchStatsGames()
guard !statsGames.isEmpty else { return } guard !statsGames.isEmpty else { return }
@@ -227,6 +246,7 @@ final class GamesViewModel {
errorMessage = "No games found" errorMessage = "No games found"
} }
lastLoadDateString = todayDateString
isLoading = false isLoading = false
} }

View File

@@ -69,6 +69,7 @@ struct GameCenterView: View {
VideoPlayer(player: player) VideoPlayer(player: player)
.ignoresSafeArea() .ignoresSafeArea()
.onAppear { player.play() } .onAppear { player.play() }
.onDisappear { player.pause() }
} }
} }
} }

View File

@@ -353,6 +353,7 @@ private struct MultiStreamTile: View {
@State private var hasError = false @State private var hasError = false
@State private var startupPlaybackTask: Task<Void, Never>? @State private var startupPlaybackTask: Task<Void, Never>?
@State private var qualityUpgradeTask: 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 clipTimeLimitObserver: Any?
@State private var isAdvancingClip = false @State private var isAdvancingClip = false
@StateObject private var playbackDiagnostics = MultiStreamPlaybackDiagnostics() @StateObject private var playbackDiagnostics = MultiStreamPlaybackDiagnostics()
@@ -447,7 +448,13 @@ private struct MultiStreamTile: View {
startupPlaybackTask = nil startupPlaybackTask = nil
qualityUpgradeTask?.cancel() qualityUpgradeTask?.cancel()
qualityUpgradeTask = nil 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") playbackDiagnostics.clear(streamID: stream.id, reason: "tile disappeared")
} }
#if os(tvOS) #if os(tvOS)
@@ -557,7 +564,7 @@ private struct MultiStreamTile: View {
if !Self.audioSessionConfigured { if !Self.audioSessionConfigured {
do { do {
try AVAudioSession.sharedInstance().setCategory(.playback) try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setActive(true) try AVAudioSession.sharedInstance().setActive(true)
Self.audioSessionConfigured = true Self.audioSessionConfigured = true
logMultiView("startStream audio session configured id=\(stream.id)") logMultiView("startStream audio session configured id=\(stream.id)")
@@ -604,13 +611,14 @@ private struct MultiStreamTile: View {
avPlayer.currentItem?.preferredForwardBufferDuration = 2 avPlayer.currentItem?.preferredForwardBufferDuration = 2
self.player = avPlayer self.player = avPlayer
// Set mute state BEFORE playback to prevent audio spikes
viewModel.attachPlayer(avPlayer, to: stream.id)
playbackDiagnostics.attach( playbackDiagnostics.attach(
to: avPlayer, to: avPlayer,
streamID: stream.id, streamID: stream.id,
label: stream.label, label: stream.label,
onPlaybackEnded: playbackEndedHandler(for: avPlayer) onPlaybackEnded: playbackEndedHandler(for: avPlayer)
) )
viewModel.attachPlayer(avPlayer, to: stream.id)
scheduleStartupPlaybackRecovery(for: avPlayer) scheduleStartupPlaybackRecovery(for: avPlayer)
scheduleQualityUpgrade(for: avPlayer) scheduleQualityUpgrade(for: avPlayer)
logMultiView("startStream attached player id=\(stream.id) muted=\(avPlayer.isMuted) startupResolution=\(multiViewStartupResolution) fastStart=true calling playImmediately(atRate: 1.0)") 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) installClipTimeLimit(on: player)
// Monitor for failure and auto-skip to next clip // 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] { for checkDelay in [1.0, 3.0] {
try? await Task.sleep(for: .seconds(checkDelay)) try? await Task.sleep(for: .seconds(checkDelay))
guard !Task.isCancelled else { return }
let postItemStatus = player.currentItem?.status let postItemStatus = player.currentItem?.status
let error = player.currentItem?.error?.localizedDescription ?? "nil" let error = player.currentItem?.error?.localizedDescription ?? "nil"
logMultiView( logMultiView(

View File

@@ -583,11 +583,11 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: Coordinator) { static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: Coordinator) {
logSingleStream("dismantleUIViewController start isPiPActive=\(coordinator.isPiPActive)") logSingleStream("dismantleUIViewController start isPiPActive=\(coordinator.isPiPActive)")
coordinator.clearDebugObservers()
if coordinator.isPiPActive { if coordinator.isPiPActive {
logSingleStream("dismantleUIViewController skipped — PiP is active") logSingleStream("dismantleUIViewController — PiP active, observers cleared but keeping player")
return return
} }
coordinator.clearDebugObservers()
uiViewController.player?.pause() uiViewController.player?.pause()
uiViewController.player = nil uiViewController.player = nil
logSingleStream("dismantleUIViewController complete") logSingleStream("dismantleUIViewController complete")

View File

@@ -4,6 +4,7 @@ import SwiftUI
@main @main
struct mlbTVOSApp: App { struct mlbTVOSApp: App {
@State private var viewModel = GamesViewModel() @State private var viewModel = GamesViewModel()
@Environment(\.scenePhase) private var scenePhase
init() { init() {
configureAudioSession() configureAudioSession()
@@ -13,6 +14,11 @@ struct mlbTVOSApp: App {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environment(viewModel) .environment(viewModel)
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
Task { await viewModel.refreshIfDayChanged() }
}
}
} }
} }
@@ -24,5 +30,23 @@ struct mlbTVOSApp: App {
} catch { } catch {
print("Failed to set audio session: \(error)") 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)
}
}
}
} }
} }