Compare commits

...

2 Commits

Author SHA1 Message Date
Trey t
ba24c767a0 Improve stream quality: stop capping resolution, allow AVPlayer to ramp
SingleStream: pass preserveServerResolutionWhenBest=false so "best"
always reaches the server for a full multi-variant manifest. Increase
buffer to 8s and enable automaticallyWaitsToMinimizeStalling so AVPlayer
can measure bandwidth and select higher variants. Add quality monitor
that nudges AVPlayer if observed bandwidth far exceeds indicated bitrate.

MultiStream: remove broken URL-param resolution detection that falsely
skipped upgrades, log actual indicatedBitrate instead. Extend upgrade
check windows from [2,4,7]s to [2,4,7,15,30]s for slow-to-stabilize
streams.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:38:38 -05:00
Trey t
bf44a7b7eb 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>
2026-04-12 12:21:21 -05:00
6 changed files with 106 additions and 28 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

@@ -260,7 +260,11 @@ struct DashboardView: View {
mediaId: selection.broadcast.mediaId, mediaId: selection.broadcast.mediaId,
streamURLString: selection.broadcast.streamURL streamURLString: selection.broadcast.streamURL
) )
guard let url = await viewModel.resolveStreamURL(for: stream) else { return nil } guard let url = await viewModel.resolveStreamURL(
for: stream,
resolutionOverride: viewModel.defaultResolution,
preserveServerResolutionWhenBest: false
) else { return nil }
return SingleStreamPlaybackSource(url: url) return SingleStreamPlaybackSource(url: url)
} }

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)")
@@ -694,7 +702,7 @@ private struct MultiStreamTile: View {
let streamID = stream.id let streamID = stream.id
let label = stream.label let label = stream.label
qualityUpgradeTask = Task { @MainActor in qualityUpgradeTask = Task { @MainActor in
let checkDelays: [Double] = [2.0, 4.0, 7.0] let checkDelays: [Double] = [2.0, 4.0, 7.0, 15.0, 30.0]
for delay in checkDelays { for delay in checkDelays {
try? await Task.sleep(for: .seconds(delay)) try? await Task.sleep(for: .seconds(delay))
@@ -707,20 +715,15 @@ private struct MultiStreamTile: View {
let itemStatus = multiViewItemStatusDescription(player.currentItem?.status ?? .unknown) let itemStatus = multiViewItemStatusDescription(player.currentItem?.status ?? .unknown)
let likelyToKeepUp = player.currentItem?.isPlaybackLikelyToKeepUp ?? false let likelyToKeepUp = player.currentItem?.isPlaybackLikelyToKeepUp ?? false
let bufferEmpty = player.currentItem?.isPlaybackBufferEmpty ?? false let bufferEmpty = player.currentItem?.isPlaybackBufferEmpty ?? false
let currentResolution = currentStreamResolution(for: player) ?? "unknown" let indicatedBitrate = player.currentItem?.accessLog()?.events.last?.indicatedBitrate ?? 0
let stable = (itemStatus == "readyToPlay" || likelyToKeepUp) && !bufferEmpty let stable = (itemStatus == "readyToPlay" || likelyToKeepUp) && !bufferEmpty
logMultiView( logMultiView(
"qualityUpgrade check id=\(streamID) delay=\(delay)s currentResolution=\(currentResolution) targetResolution=\(targetResolution) stable=\(stable) rate=\(player.rate)" "qualityUpgrade check id=\(streamID) delay=\(delay)s targetResolution=\(targetResolution) stable=\(stable) rate=\(player.rate) indicatedBitrate=\(Int(indicatedBitrate))"
) )
guard stable else { continue } guard stable else { continue }
if currentResolution == targetResolution {
logMultiView("qualityUpgrade skip id=\(streamID) reason=already-\(targetResolution)")
return
}
guard let upgradedURL = await viewModel.resolveStreamURL( guard let upgradedURL = await viewModel.resolveStreamURL(
for: stream, for: stream,
resolutionOverride: targetResolution, resolutionOverride: targetResolution,
@@ -756,13 +759,6 @@ private struct MultiStreamTile: View {
(player.currentItem?.asset as? AVURLAsset)?.url (player.currentItem?.asset as? AVURLAsset)?.url
} }
private func currentStreamResolution(for player: AVPlayer) -> String? {
guard let url = currentStreamURL(for: player) else { return nil }
return URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.first(where: { $0.name == "resolution" })?
.value
}
private func installClipTimeLimit(on player: AVPlayer) { private func installClipTimeLimit(on player: AVPlayer) {
removeClipTimeLimit(from: player) removeClipTimeLimit(from: player)
@@ -851,9 +847,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

@@ -59,7 +59,7 @@ private func singleStreamTimeControlDescription(_ status: AVPlayer.TimeControlSt
private func makeSingleStreamPlayerItem(from source: SingleStreamPlaybackSource) -> AVPlayerItem { private func makeSingleStreamPlayerItem(from source: SingleStreamPlaybackSource) -> AVPlayerItem {
if source.httpHeaders.isEmpty { if source.httpHeaders.isEmpty {
let item = AVPlayerItem(url: source.url) let item = AVPlayerItem(url: source.url)
item.preferredForwardBufferDuration = 2 item.preferredForwardBufferDuration = 8
return item return item
} }
@@ -68,7 +68,7 @@ private func makeSingleStreamPlayerItem(from source: SingleStreamPlaybackSource)
] ]
let asset = AVURLAsset(url: source.url, options: assetOptions) let asset = AVURLAsset(url: source.url, options: assetOptions)
let item = AVPlayerItem(asset: asset) let item = AVPlayerItem(asset: asset)
item.preferredForwardBufferDuration = 2 item.preferredForwardBufferDuration = 8
logSingleStream( logSingleStream(
"Configured authenticated AVURLAsset headerKeys=\(singleStreamHeaderKeysDescription(source.httpHeaders))" "Configured authenticated AVURLAsset headerKeys=\(singleStreamHeaderKeysDescription(source.httpHeaders))"
) )
@@ -553,15 +553,16 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
let playerItem = makeSingleStreamPlayerItem(from: source) let playerItem = makeSingleStreamPlayerItem(from: source)
let player = AVPlayer(playerItem: playerItem) let player = AVPlayer(playerItem: playerItem)
player.automaticallyWaitsToMinimizeStalling = false player.automaticallyWaitsToMinimizeStalling = true
player.isMuted = source.forceMuteAudio player.isMuted = source.forceMuteAudio
logSingleStream("Configured player for fast start preferredForwardBufferDuration=2 automaticallyWaitsToMinimizeStalling=false") logSingleStream("Configured player for quality ramp preferredForwardBufferDuration=8 automaticallyWaitsToMinimizeStalling=true")
context.coordinator.attachDebugObservers(to: player, url: url, resolveNextSource: resolveNextSource) context.coordinator.attachDebugObservers(to: player, url: url, resolveNextSource: resolveNextSource)
controller.player = player controller.player = player
logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)") logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)")
player.playImmediately(atRate: 1.0) player.playImmediately(atRate: 1.0)
context.coordinator.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource) context.coordinator.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
context.coordinator.scheduleStartupRecovery(for: player) context.coordinator.scheduleStartupRecovery(for: player)
context.coordinator.scheduleQualityMonitor(for: player)
} }
return controller return controller
@@ -583,11 +584,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")
@@ -597,6 +598,7 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
private var playerObservations: [NSKeyValueObservation] = [] private var playerObservations: [NSKeyValueObservation] = []
private var notificationTokens: [NSObjectProtocol] = [] private var notificationTokens: [NSObjectProtocol] = []
private var startupRecoveryTask: Task<Void, Never>? private var startupRecoveryTask: Task<Void, Never>?
private var qualityMonitorTask: Task<Void, Never>?
private var clipTimeLimitObserver: Any? private var clipTimeLimitObserver: Any?
private static let maxClipDuration: Double = 15.0 private static let maxClipDuration: Double = 15.0
var onTogglePitchInfo: (() -> Void)? var onTogglePitchInfo: (() -> Void)?
@@ -863,9 +865,38 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
} }
} }
func scheduleQualityMonitor(for player: AVPlayer) {
qualityMonitorTask?.cancel()
qualityMonitorTask = Task { @MainActor [weak player] in
// Check at 5s, 15s, and 30s whether AVPlayer has ramped to a reasonable bitrate
for delay in [5.0, 15.0, 30.0] {
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled, let player else { return }
let indicatedBitrate = player.currentItem?.accessLog()?.events.last?.indicatedBitrate ?? 0
let observedBitrate = player.currentItem?.accessLog()?.events.last?.observedBitrate ?? 0
let likelyToKeepUp = player.currentItem?.isPlaybackLikelyToKeepUp ?? false
logSingleStream(
"qualityMonitor check delay=\(delay)s indicatedBitrate=\(Int(indicatedBitrate)) observedBitrate=\(Int(observedBitrate)) likelyToKeepUp=\(likelyToKeepUp) rate=\(player.rate)"
)
// If observed bandwidth supports higher quality but indicated is low, nudge AVPlayer
if likelyToKeepUp && indicatedBitrate > 0 && observedBitrate > indicatedBitrate * 2 {
logSingleStream(
"qualityMonitor nudge delay=\(delay)s — observed bandwidth \(Int(observedBitrate)) >> indicated \(Int(indicatedBitrate)), setting preferredPeakBitRate=0 to uncap"
)
player.currentItem?.preferredPeakBitRate = 0
}
}
}
}
func clearDebugObservers() { func clearDebugObservers() {
startupRecoveryTask?.cancel() startupRecoveryTask?.cancel()
startupRecoveryTask = nil startupRecoveryTask = nil
qualityMonitorTask?.cancel()
qualityMonitorTask = nil
playerObservations.removeAll() playerObservations.removeAll()
for token in notificationTokens { for token in notificationTokens {
NotificationCenter.default.removeObserver(token) NotificationCenter.default.removeObserver(token)

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)
}
}
}
} }
} }