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)