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

View File

@@ -260,7 +260,11 @@ struct DashboardView: View {
mediaId: selection.broadcast.mediaId,
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)
}

View File

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

View File

@@ -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)")
@@ -694,7 +702,7 @@ private struct MultiStreamTile: View {
let streamID = stream.id
let label = stream.label
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 {
try? await Task.sleep(for: .seconds(delay))
@@ -707,20 +715,15 @@ private struct MultiStreamTile: View {
let itemStatus = multiViewItemStatusDescription(player.currentItem?.status ?? .unknown)
let likelyToKeepUp = player.currentItem?.isPlaybackLikelyToKeepUp ?? 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
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 }
if currentResolution == targetResolution {
logMultiView("qualityUpgrade skip id=\(streamID) reason=already-\(targetResolution)")
return
}
guard let upgradedURL = await viewModel.resolveStreamURL(
for: stream,
resolutionOverride: targetResolution,
@@ -756,13 +759,6 @@ private struct MultiStreamTile: View {
(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) {
removeClipTimeLimit(from: player)
@@ -851,9 +847,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(

View File

@@ -59,7 +59,7 @@ private func singleStreamTimeControlDescription(_ status: AVPlayer.TimeControlSt
private func makeSingleStreamPlayerItem(from source: SingleStreamPlaybackSource) -> AVPlayerItem {
if source.httpHeaders.isEmpty {
let item = AVPlayerItem(url: source.url)
item.preferredForwardBufferDuration = 2
item.preferredForwardBufferDuration = 8
return item
}
@@ -68,7 +68,7 @@ private func makeSingleStreamPlayerItem(from source: SingleStreamPlaybackSource)
]
let asset = AVURLAsset(url: source.url, options: assetOptions)
let item = AVPlayerItem(asset: asset)
item.preferredForwardBufferDuration = 2
item.preferredForwardBufferDuration = 8
logSingleStream(
"Configured authenticated AVURLAsset headerKeys=\(singleStreamHeaderKeysDescription(source.httpHeaders))"
)
@@ -553,15 +553,16 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
let playerItem = makeSingleStreamPlayerItem(from: source)
let player = AVPlayer(playerItem: playerItem)
player.automaticallyWaitsToMinimizeStalling = false
player.automaticallyWaitsToMinimizeStalling = true
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)
controller.player = player
logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)")
player.playImmediately(atRate: 1.0)
context.coordinator.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
context.coordinator.scheduleStartupRecovery(for: player)
context.coordinator.scheduleQualityMonitor(for: player)
}
return controller
@@ -583,11 +584,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")
@@ -597,6 +598,7 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
private var playerObservations: [NSKeyValueObservation] = []
private var notificationTokens: [NSObjectProtocol] = []
private var startupRecoveryTask: Task<Void, Never>?
private var qualityMonitorTask: Task<Void, Never>?
private var clipTimeLimitObserver: Any?
private static let maxClipDuration: Double = 15.0
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() {
startupRecoveryTask?.cancel()
startupRecoveryTask = nil
qualityMonitorTask?.cancel()
qualityMonitorTask = nil
playerObservations.removeAll()
for token in notificationTokens {
NotificationCenter.default.removeObserver(token)

View File

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