Files
MLBApp/mlbTVOS/Views/SingleStreamPlayerView.swift
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

912 lines
38 KiB
Swift

import AVFoundation
import AVKit
import OSLog
import SwiftUI
private let singleStreamLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "SingleStreamPlayer")
private func logSingleStream(_ message: String) {
singleStreamLogger.debug("\(message, privacy: .public)")
print("[SingleStream] \(message)")
}
private func singleStreamDebugURLDescription(_ url: URL) -> String {
var host = url.host ?? "unknown-host"
if let port = url.port {
host += ":\(port)"
}
let queryKeys = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.map(\.name) ?? []
let querySuffix = queryKeys.isEmpty ? "" : "?\(queryKeys.joined(separator: "&"))"
return "\(url.scheme ?? "unknown")://\(host)\(url.path)\(querySuffix)"
}
private func singleStreamHeaderKeysDescription(_ headers: [String: String]) -> String {
guard !headers.isEmpty else { return "none" }
return headers.keys.sorted().joined(separator: ",")
}
private func singleStreamStatusDescription(_ status: AVPlayer.Status) -> String {
switch status {
case .unknown: "unknown"
case .readyToPlay: "readyToPlay"
case .failed: "failed"
@unknown default: "unknown-future"
}
}
private func singleStreamItemStatusDescription(_ status: AVPlayerItem.Status) -> String {
switch status {
case .unknown: "unknown"
case .readyToPlay: "readyToPlay"
case .failed: "failed"
@unknown default: "unknown-future"
}
}
private func singleStreamTimeControlDescription(_ status: AVPlayer.TimeControlStatus) -> String {
switch status {
case .paused: "paused"
case .waitingToPlayAtSpecifiedRate: "waitingToPlayAtSpecifiedRate"
case .playing: "playing"
@unknown default: "unknown-future"
}
}
private func makeSingleStreamPlayerItem(from source: SingleStreamPlaybackSource) -> AVPlayerItem {
if source.httpHeaders.isEmpty {
let item = AVPlayerItem(url: source.url)
item.preferredForwardBufferDuration = 8
return item
}
let assetOptions: [String: Any] = [
"AVURLAssetHTTPHeaderFieldsKey": source.httpHeaders,
]
let asset = AVURLAsset(url: source.url, options: assetOptions)
let item = AVPlayerItem(asset: asset)
item.preferredForwardBufferDuration = 8
logSingleStream(
"Configured authenticated AVURLAsset headerKeys=\(singleStreamHeaderKeysDescription(source.httpHeaders))"
)
return item
}
struct SingleStreamPlaybackScreen: View {
@Environment(\.dismiss) private var dismiss
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
let tickerGames: [Game]
var game: Game? = nil
var onPiPActiveChanged: ((Bool) -> Void)? = nil
@State private var showGameCenter = false
@State private var showPitchInfo = false
@State private var pitchViewModel = GameCenterViewModel()
@State private var isPiPActive = false
var body: some View {
ZStack(alignment: .bottom) {
SingleStreamPlayerView(
resolveSource: resolveSource,
resolveNextSource: resolveNextSource,
hasGamePk: game?.gamePk != nil,
onTogglePitchInfo: {
showPitchInfo.toggle()
if showPitchInfo { showGameCenter = false }
},
onToggleGameCenter: {
showGameCenter.toggle()
if showGameCenter { showPitchInfo = false }
},
onPiPStateChanged: { active in
isPiPActive = active
onPiPActiveChanged?(active)
},
showPitchInfo: showPitchInfo,
showGameCenter: showGameCenter
)
.ignoresSafeArea()
if !showGameCenter && !showPitchInfo {
SingleStreamScoreStripView(games: tickerGames)
.allowsHitTesting(false)
.padding(.horizontal, 18)
.padding(.bottom, 14)
.transition(.opacity)
}
if showGameCenter, let game {
gameCenterOverlay(game: game)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.3), value: showGameCenter)
.animation(.easeInOut(duration: 0.3), value: showPitchInfo)
.overlay(alignment: .bottomLeading) {
if showPitchInfo, let feed = pitchViewModel.feed {
pitchInfoBox(feed: feed)
.transition(.move(edge: .leading).combined(with: .opacity))
}
}
#if os(iOS)
.overlay(alignment: .topTrailing) {
HStack(spacing: 12) {
if game?.gamePk != nil {
Button {
showPitchInfo.toggle()
if showPitchInfo { showGameCenter = false }
} label: {
Image(systemName: showPitchInfo ? "xmark.circle.fill" : "baseball.fill")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white.opacity(0.9))
.padding(6)
.background(.black.opacity(0.5))
.clipShape(Circle())
}
Button {
showGameCenter.toggle()
if showGameCenter { showPitchInfo = false }
} label: {
Image(systemName: showGameCenter ? "xmark.circle.fill" : "chart.bar.fill")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white.opacity(0.9))
.padding(6)
.background(.black.opacity(0.5))
.clipShape(Circle())
}
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white.opacity(0.9))
}
}
}
.padding(20)
}
#endif
.task(id: game?.gamePk) {
guard let gamePk = game?.gamePk else { return }
while !Task.isCancelled {
await pitchViewModel.refresh(gamePk: gamePk)
try? await Task.sleep(for: .seconds(5))
}
}
.ignoresSafeArea()
.onAppear {
logSingleStream("SingleStreamPlaybackScreen appeared tickerGames=\(tickerGames.count) tickerMode=marqueeOverlay")
}
.onDisappear {
logSingleStream("SingleStreamPlaybackScreen disappeared")
}
}
private func gameCenterOverlay(game: Game) -> some View {
ScrollView {
GameCenterView(game: game)
.padding(.horizontal, 20)
.padding(.top, 60)
.padding(.bottom, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.black.opacity(0.82))
}
private func pitchInfoBox(feed: LiveGameFeed) -> some View {
let pitches = feed.currentAtBatPitches
let batter = feed.currentBatter?.displayName ?? ""
let pitcher = feed.currentPitcher?.displayName ?? ""
let countText = feed.currentCountText ?? ""
return VStack(alignment: .leading, spacing: 8) {
// Matchup header use last name only to save space
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text("AB")
.font(.system(size: 10, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.4))
Text(batter)
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("P")
.font(.system(size: 10, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.4))
Text(pitcher)
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
}
if !countText.isEmpty {
Text(countText)
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.7))
}
if !pitches.isEmpty {
// Latest pitch bold and prominent
if let last = pitches.last {
let color = pitchCallColor(last.callCode)
HStack(spacing: 6) {
if let speed = last.speedMPH {
Text("\(speed, specifier: "%.1f")")
.font(.system(size: 24, weight: .black).monospacedDigit())
.foregroundStyle(.white)
Text("mph")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(last.pitchTypeDescription)
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
Text(last.callDescription)
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(color)
}
}
}
// Strike zone + previous pitches side by side
HStack(alignment: .top, spacing: 14) {
StrikeZoneView(pitches: pitches, size: 120)
// Previous pitches compact rows
if pitches.count > 1 {
VStack(alignment: .leading, spacing: 3) {
ForEach(Array(pitches.dropLast().reversed().prefix(8).enumerated()), id: \.offset) { _, pitch in
let color = pitchCallColor(pitch.callCode)
HStack(spacing: 4) {
Text("\(pitch.pitchNumber ?? 0)")
.font(.system(size: 10, weight: .bold).monospacedDigit())
.foregroundStyle(.white.opacity(0.35))
.frame(width: 14, alignment: .trailing)
Text(shortPitchType(pitch.pitchTypeCode))
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.7))
if let speed = pitch.speedMPH {
Text("\(speed, specifier: "%.0f")")
.font(.system(size: 11, weight: .bold).monospacedDigit())
.foregroundStyle(.white.opacity(0.45))
}
Spacer(minLength: 0)
Circle()
.fill(color)
.frame(width: 6, height: 6)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
} else {
Text("Waiting for pitch data...")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.white.opacity(0.5))
}
}
.frame(width: 300)
.padding(16)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.black.opacity(0.78))
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(.white.opacity(0.1), lineWidth: 1)
}
)
.padding(.leading, 24)
.padding(.bottom, 50)
}
}
struct SingleStreamPlaybackSource: Sendable {
let url: URL
let httpHeaders: [String: String]
let forceMuteAudio: Bool
init(url: URL, httpHeaders: [String: String] = [:], forceMuteAudio: Bool = false) {
self.url = url
self.httpHeaders = httpHeaders
self.forceMuteAudio = forceMuteAudio
}
}
private struct SingleStreamScoreStripView: View {
let games: [Game]
private var summaries: [String] {
games.map { game in
let matchup = "\(game.awayTeam.code) \(game.awayTeam.score.map(String.init) ?? "-") - \(game.homeTeam.code) \(game.homeTeam.score.map(String.init) ?? "-")"
if game.isLive, let linescore = game.linescore {
let inning = linescore.currentInningOrdinal ?? game.currentInningDisplay ?? "LIVE"
let state = (linescore.inningState ?? linescore.inningHalf ?? "Live").uppercased()
let outs = linescore.outs ?? 0
return "\(matchup) \(state) \(inning.uppercased())\(outs) OUT\(outs == 1 ? "" : "S")"
}
if game.isFinal {
return "\(matchup) FINAL"
}
if let startTime = game.startTime {
return "\(matchup) \(startTime.uppercased())"
}
return "\(matchup) \(game.status.label.uppercased())"
}
}
private var stripText: String {
summaries.joined(separator: " | ")
}
var body: some View {
if !games.isEmpty {
SingleStreamMarqueeView(text: stripText)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 22)
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.black.opacity(0.72))
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
)
.accessibilityHidden(true)
}
}
}
private struct SingleStreamMarqueeView: UIViewRepresentable {
let text: String
func makeUIView(context: Context) -> SingleStreamMarqueeContainerView {
let view = SingleStreamMarqueeContainerView()
view.setText(text)
return view
}
func updateUIView(_ uiView: SingleStreamMarqueeContainerView, context: Context) {
uiView.setText(text)
}
}
private final class SingleStreamMarqueeContainerView: UIView {
private let trackView = UIView()
private let primaryLabel = UILabel()
private let secondaryLabel = UILabel()
private let spacing: CGFloat = 48
private let pointsPerSecond: CGFloat = 64
private var currentText = ""
private var previousBoundsWidth: CGFloat = 0
private var previousContentWidth: CGFloat = 0
override init(frame: CGRect) {
super.init(frame: frame)
clipsToBounds = true
isUserInteractionEnabled = false
let baseFont = UIFont.systemFont(ofSize: 15, weight: .bold)
let roundedFont = UIFont(descriptor: baseFont.fontDescriptor.withDesign(.rounded) ?? baseFont.fontDescriptor, size: 15)
[primaryLabel, secondaryLabel].forEach { label in
label.font = roundedFont
label.textColor = UIColor.white.withAlphaComponent(0.92)
label.numberOfLines = 1
label.lineBreakMode = .byClipping
trackView.addSubview(label)
}
addSubview(trackView)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setText(_ text: String) {
guard currentText != text else { return }
currentText = text
primaryLabel.text = text
secondaryLabel.text = text
previousContentWidth = 0
setNeedsLayout()
}
override func layoutSubviews() {
super.layoutSubviews()
guard bounds.width > 0, bounds.height > 0 else { return }
let contentWidth = ceil(primaryLabel.sizeThatFits(CGSize(width: .greatestFiniteMagnitude, height: bounds.height)).width)
let contentHeight = bounds.height
primaryLabel.frame = CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight)
secondaryLabel.frame = CGRect(x: contentWidth + spacing, y: 0, width: contentWidth, height: contentHeight)
let cycleWidth = contentWidth + spacing
if contentWidth <= bounds.width {
trackView.layer.removeAllAnimations()
secondaryLabel.isHidden = true
trackView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
primaryLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
primaryLabel.textAlignment = .left
previousBoundsWidth = bounds.width
previousContentWidth = contentWidth
return
}
primaryLabel.textAlignment = .left
secondaryLabel.isHidden = false
trackView.frame = CGRect(x: 0, y: 0, width: contentWidth * 2 + spacing, height: contentHeight)
let shouldRestart = abs(previousBoundsWidth - bounds.width) > 0.5
|| abs(previousContentWidth - contentWidth) > 0.5
|| trackView.layer.animation(forKey: "singleStreamMarquee") == nil
previousBoundsWidth = bounds.width
previousContentWidth = contentWidth
guard shouldRestart else { return }
trackView.layer.removeAllAnimations()
trackView.transform = .identity
let animation = CABasicAnimation(keyPath: "transform.translation.x")
animation.fromValue = 0
animation.toValue = -cycleWidth
animation.duration = Double(cycleWidth / pointsPerSecond)
animation.repeatCount = .infinity
animation.timingFunction = CAMediaTimingFunction(name: .linear)
animation.isRemovedOnCompletion = false
trackView.layer.add(animation, forKey: "singleStreamMarquee")
}
}
/// Full-screen player using AVPlayerViewController for PiP support on tvOS.
struct SingleStreamPlayerView: UIViewControllerRepresentable {
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
var hasGamePk: Bool = false
var onTogglePitchInfo: (() -> Void)? = nil
var onToggleGameCenter: (() -> Void)? = nil
var onPiPStateChanged: ((Bool) -> Void)? = nil
var showPitchInfo: Bool = false
var showGameCenter: Bool = false
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIViewController(context: Context) -> AVPlayerViewController {
logSingleStream("makeUIViewController start")
let controller = AVPlayerViewController()
controller.allowsPictureInPicturePlayback = true
controller.showsPlaybackControls = true
context.coordinator.onPiPStateChanged = onPiPStateChanged
controller.delegate = context.coordinator
#if os(iOS)
controller.canStartPictureInPictureAutomaticallyFromInline = true
#endif
#if os(tvOS)
if hasGamePk {
context.coordinator.onTogglePitchInfo = onTogglePitchInfo
context.coordinator.onToggleGameCenter = onToggleGameCenter
controller.transportBarCustomMenuItems = context.coordinator.buildTransportBarItems(
showPitchInfo: showPitchInfo,
showGameCenter: showGameCenter
)
}
#endif
logSingleStream("AVPlayerViewController configured")
Task { @MainActor in
let resolveStartedAt = Date()
logSingleStream("Starting stream source resolution")
guard let source = await resolveSource() else {
logSingleStream("resolveSource returned nil; aborting player startup")
return
}
let url = source.url
let resolveElapsedMs = Int(Date().timeIntervalSince(resolveStartedAt) * 1000)
logSingleStream(
"Resolved stream source elapsedMs=\(resolveElapsedMs) url=\(singleStreamDebugURLDescription(url)) headerKeys=\(singleStreamHeaderKeysDescription(source.httpHeaders))"
)
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setActive(true)
logSingleStream("AVAudioSession configured for playback")
} catch {
logSingleStream("AVAudioSession configuration failed error=\(error.localizedDescription)")
}
let playerItem = makeSingleStreamPlayerItem(from: source)
let player = AVPlayer(playerItem: playerItem)
player.automaticallyWaitsToMinimizeStalling = true
player.isMuted = source.forceMuteAudio
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
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
context.coordinator.onPiPStateChanged = onPiPStateChanged
#if os(tvOS)
if hasGamePk {
context.coordinator.onTogglePitchInfo = onTogglePitchInfo
context.coordinator.onToggleGameCenter = onToggleGameCenter
uiViewController.transportBarCustomMenuItems = context.coordinator.buildTransportBarItems(
showPitchInfo: showPitchInfo,
showGameCenter: showGameCenter
)
}
#endif
}
static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: Coordinator) {
logSingleStream("dismantleUIViewController start isPiPActive=\(coordinator.isPiPActive)")
coordinator.clearDebugObservers()
if coordinator.isPiPActive {
logSingleStream("dismantleUIViewController — PiP active, observers cleared but keeping player")
return
}
uiViewController.player?.pause()
uiViewController.player = nil
logSingleStream("dismantleUIViewController complete")
}
final class Coordinator: NSObject, @unchecked Sendable, AVPlayerViewControllerDelegate {
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)?
var onToggleGameCenter: (() -> Void)?
var isPiPActive = false
var onPiPStateChanged: ((Bool) -> Void)?
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool {
logSingleStream("PiP: shouldAutomaticallyDismiss returning false")
return false
}
func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
logSingleStream("PiP: willStart")
isPiPActive = true
onPiPStateChanged?(true)
}
func playerViewControllerDidStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
logSingleStream("PiP: didStart")
}
func playerViewControllerWillStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
logSingleStream("PiP: willStop")
}
func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
logSingleStream("PiP: didStop")
isPiPActive = false
onPiPStateChanged?(false)
}
func playerViewController(
_ playerViewController: AVPlayerViewController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
) {
logSingleStream("PiP: restoreUserInterface")
completionHandler(true)
}
#if os(tvOS)
func buildTransportBarItems(showPitchInfo: Bool, showGameCenter: Bool) -> [UIAction] {
let pitchAction = UIAction(
title: showPitchInfo ? "Hide Pitch Info" : "Pitch Info",
image: UIImage(systemName: showPitchInfo ? "xmark.circle.fill" : "baseball.fill")
) { [weak self] _ in
self?.onTogglePitchInfo?()
}
let gcAction = UIAction(
title: showGameCenter ? "Hide Game Center" : "Game Center",
image: UIImage(systemName: showGameCenter ? "xmark.circle.fill" : "chart.bar.fill")
) { [weak self] _ in
self?.onToggleGameCenter?()
}
return [pitchAction, gcAction]
}
#endif
func attachDebugObservers(
to player: AVPlayer,
url: URL,
resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
) {
clearDebugObservers()
logSingleStream("Attaching AVPlayer observers url=\(singleStreamDebugURLDescription(url))")
playerObservations.append(
player.observe(\.status, options: [.initial, .new]) { player, _ in
logSingleStream("Player status changed status=\(singleStreamStatusDescription(player.status)) error=\(player.error?.localizedDescription ?? "nil")")
}
)
playerObservations.append(
player.observe(\.timeControlStatus, options: [.initial, .new]) { player, _ in
let reason = player.reasonForWaitingToPlay?.rawValue ?? "nil"
logSingleStream("Player timeControlStatus=\(singleStreamTimeControlDescription(player.timeControlStatus)) reasonForWaiting=\(reason)")
}
)
playerObservations.append(
player.observe(\.reasonForWaitingToPlay, options: [.initial, .new]) { player, _ in
logSingleStream("Player reasonForWaitingToPlay changed value=\(player.reasonForWaitingToPlay?.rawValue ?? "nil")")
}
)
guard let item = player.currentItem else {
logSingleStream("Player currentItem missing immediately after creation")
return
}
playerObservations.append(
item.observe(\.status, options: [.initial, .new]) { item, _ in
logSingleStream("PlayerItem status changed status=\(singleStreamItemStatusDescription(item.status)) error=\(item.error?.localizedDescription ?? "nil")")
}
)
playerObservations.append(
item.observe(\.isPlaybackBufferEmpty, options: [.initial, .new]) { item, _ in
logSingleStream("PlayerItem isPlaybackBufferEmpty=\(item.isPlaybackBufferEmpty)")
}
)
playerObservations.append(
item.observe(\.isPlaybackLikelyToKeepUp, options: [.initial, .new]) { item, _ in
logSingleStream("PlayerItem isPlaybackLikelyToKeepUp=\(item.isPlaybackLikelyToKeepUp)")
}
)
playerObservations.append(
item.observe(\.isPlaybackBufferFull, options: [.initial, .new]) { item, _ in
logSingleStream("PlayerItem isPlaybackBufferFull=\(item.isPlaybackBufferFull)")
}
)
notificationTokens.append(
NotificationCenter.default.addObserver(
forName: .AVPlayerItemPlaybackStalled,
object: item,
queue: .main
) { _ in
logSingleStream("Notification AVPlayerItemPlaybackStalled")
}
)
notificationTokens.append(
NotificationCenter.default.addObserver(
forName: .AVPlayerItemFailedToPlayToEndTime,
object: item,
queue: .main
) { notification in
let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError
logSingleStream("Notification AVPlayerItemFailedToPlayToEndTime error=\(error?.localizedDescription ?? "nil")")
}
)
notificationTokens.append(
NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: item,
queue: .main
) { [weak self] _ in
logSingleStream("Notification AVPlayerItemDidPlayToEndTime")
guard let self, let resolveNextSource else { return }
Task { @MainActor [weak self] in
guard let self else { return }
let currentURL = (player.currentItem?.asset as? AVURLAsset)?.url
guard let nextSource = await resolveNextSource(currentURL) else {
logSingleStream("Autoplay next source resolution returned nil")
return
}
let nextItem = makeSingleStreamPlayerItem(from: nextSource)
player.replaceCurrentItem(with: nextItem)
player.automaticallyWaitsToMinimizeStalling = false
player.isMuted = nextSource.forceMuteAudio
self.attachDebugObservers(to: player, url: nextSource.url, resolveNextSource: resolveNextSource)
logSingleStream("Autoplay replacing item and replaying url=\(singleStreamDebugURLDescription(nextSource.url))")
player.playImmediately(atRate: 1.0)
self.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
self.scheduleStartupRecovery(for: player)
}
}
)
notificationTokens.append(
NotificationCenter.default.addObserver(
forName: .AVPlayerItemNewErrorLogEntry,
object: item,
queue: .main
) { _ in
let event = item.errorLog()?.events.last
logSingleStream("Notification AVPlayerItemNewErrorLogEntry domain=\(event?.errorDomain ?? "nil") comment=\(event?.errorComment ?? "nil") statusCode=\(event?.errorStatusCode ?? 0)")
}
)
notificationTokens.append(
NotificationCenter.default.addObserver(
forName: .AVPlayerItemNewAccessLogEntry,
object: item,
queue: .main
) { _ in
if let event = item.accessLog()?.events.last {
logSingleStream(
"Notification AVPlayerItemNewAccessLogEntry indicatedBitrate=\(Int(event.indicatedBitrate)) observedBitrate=\(Int(event.observedBitrate)) segmentsDownloaded=\(event.segmentsDownloadedDuration)"
)
} else {
logSingleStream("Notification AVPlayerItemNewAccessLogEntry with no access log event")
}
}
)
}
func scheduleStartupRecovery(for player: AVPlayer) {
startupRecoveryTask?.cancel()
startupRecoveryTask = Task { @MainActor [weak player] in
let retryDelays: [Double] = [0.35, 1.0, 2.0, 4.0]
for delay in retryDelays {
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled, let player else { return }
let itemStatus = player.currentItem?.status ?? .unknown
let likelyToKeepUp = player.currentItem?.isPlaybackLikelyToKeepUp ?? false
let bufferEmpty = player.currentItem?.isPlaybackBufferEmpty ?? false
let timeControl = player.timeControlStatus
let startupSatisfied = player.rate > 0 && (itemStatus == .readyToPlay || likelyToKeepUp)
logSingleStream(
"startupRecovery check delay=\(delay)s rate=\(player.rate) timeControl=\(singleStreamTimeControlDescription(timeControl)) itemStatus=\(singleStreamItemStatusDescription(itemStatus)) likelyToKeepUp=\(likelyToKeepUp) bufferEmpty=\(bufferEmpty)"
)
if startupSatisfied {
logSingleStream("startupRecovery satisfied delay=\(delay)s")
return
}
if player.rate == 0 {
logSingleStream("startupRecovery replay delay=\(delay)s")
player.playImmediately(atRate: 1.0)
}
}
}
}
func installClipTimeLimit(
on player: AVPlayer,
resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)?
) {
removeClipTimeLimit(from: player)
guard resolveNextSource != nil else { return }
let limit = CMTime(seconds: Self.maxClipDuration, preferredTimescale: 600)
clipTimeLimitObserver = player.addBoundaryTimeObserver(
forTimes: [NSValue(time: limit)],
queue: .main
) { [weak self, weak player] in
guard let self, let player, let resolveNextSource else { return }
logSingleStream("clipTimeLimit hit \(Self.maxClipDuration)s — advancing to next clip")
Task { @MainActor [weak self] in
guard let self else { return }
let currentURL = (player.currentItem?.asset as? AVURLAsset)?.url
guard let nextSource = await resolveNextSource(currentURL) else {
logSingleStream("clipTimeLimit next source nil")
return
}
let nextItem = makeSingleStreamPlayerItem(from: nextSource)
player.replaceCurrentItem(with: nextItem)
player.automaticallyWaitsToMinimizeStalling = false
player.isMuted = nextSource.forceMuteAudio
self.attachDebugObservers(to: player, url: nextSource.url, resolveNextSource: resolveNextSource)
player.playImmediately(atRate: 1.0)
self.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
self.scheduleStartupRecovery(for: player)
}
}
}
private func removeClipTimeLimit(from player: AVPlayer) {
if let observer = clipTimeLimitObserver {
player.removeTimeObserver(observer)
clipTimeLimitObserver = nil
}
}
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)
}
notificationTokens.removeAll()
}
deinit {
clearDebugObservers()
}
}
}