From ba24c767a058d05d2f12ce16b8916970da430111 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 12 Apr 2026 12:38:38 -0500 Subject: [PATCH] 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) --- mlbTVOS/Views/DashboardView.swift | 6 +++- mlbTVOS/Views/MultiStreamView.swift | 18 ++-------- mlbTVOS/Views/SingleStreamPlayerView.swift | 39 +++++++++++++++++++--- 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/mlbTVOS/Views/DashboardView.swift b/mlbTVOS/Views/DashboardView.swift index 4cf6c00..0b8b1b8 100644 --- a/mlbTVOS/Views/DashboardView.swift +++ b/mlbTVOS/Views/DashboardView.swift @@ -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) } diff --git a/mlbTVOS/Views/MultiStreamView.swift b/mlbTVOS/Views/MultiStreamView.swift index b2d3a22..422dfc2 100644 --- a/mlbTVOS/Views/MultiStreamView.swift +++ b/mlbTVOS/Views/MultiStreamView.swift @@ -702,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)) @@ -715,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, @@ -764,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) diff --git a/mlbTVOS/Views/SingleStreamPlayerView.swift b/mlbTVOS/Views/SingleStreamPlayerView.swift index 79981cd..785c464 100644 --- a/mlbTVOS/Views/SingleStreamPlayerView.swift +++ b/mlbTVOS/Views/SingleStreamPlayerView.swift @@ -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 @@ -597,6 +598,7 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable { private var playerObservations: [NSKeyValueObservation] = [] private var notificationTokens: [NSObjectProtocol] = [] private var startupRecoveryTask: Task? + private var qualityMonitorTask: Task? 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)