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>
This commit is contained in:
Trey t
2026-04-12 12:38:38 -05:00
parent bf44a7b7eb
commit ba24c767a0
3 changed files with 43 additions and 20 deletions

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

@@ -702,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))
@@ -715,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,
@@ -764,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)

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