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 { let item: AVPlayerItem if source.httpHeaders.isEmpty { item = AVPlayerItem(url: source.url) } else { let assetOptions: [String: Any] = [ "AVURLAssetHTTPHeaderFieldsKey": source.httpHeaders, ] let asset = AVURLAsset(url: source.url, options: assetOptions) item = AVPlayerItem(asset: asset) logSingleStream( "Configured authenticated AVURLAsset headerKeys=\(singleStreamHeaderKeysDescription(source.httpHeaders))" ) } item.preferredForwardBufferDuration = 8 item.allowedAudioSpatializationFormats = [] logSingleStream("Configured player item preferredForwardBufferDuration=8 allowedAudioSpatializationFormats=[]") pinSingleStreamAudioSelection(on: item) return item } /// Pin the HLS audio rendition so ABR can't swap channel layouts mid-stream. private func pinSingleStreamAudioSelection(on item: AVPlayerItem) { Task { @MainActor in await enforcePinnedSingleStreamAudioSelection(on: item) } } @MainActor private func enforcePinnedSingleStreamAudioSelection(on item: AVPlayerItem) async { let asset = item.asset guard let group = try? await asset.loadMediaSelectionGroup(for: .audible), let option = preferredSingleStreamAudioOption(in: group) else { return } let current = item.currentMediaSelection.selectedMediaOption(in: group) if current != option { item.select(option, in: group) } logSingleStream( "pinAudioSelection selected=\(option.displayName) current=\(current?.displayName ?? "nil") options=\(group.options.count)" ) } private func preferredSingleStreamAudioOption(in group: AVMediaSelectionGroup) -> AVMediaSelectionOption? { let defaultOption = group.defaultOption return group.options.max { lhs, rhs in audioPreferenceScore(for: lhs, defaultOption: defaultOption) < audioPreferenceScore(for: rhs, defaultOption: defaultOption) } ?? defaultOption ?? group.options.first } private func audioPreferenceScore(for option: AVMediaSelectionOption, defaultOption: AVMediaSelectionOption?) -> Int { let name = option.displayName.lowercased() var score = 0 if option == defaultOption { score += 40 } if name.contains("stereo") || name.contains("2.0") || name.contains("main") { score += 30 } if name.contains("english") || name.contains("eng") { score += 20 } if name.contains("surround") || name.contains("5.1") || name.contains("atmos") { score -= 30 } if name.contains("spanish") || name.contains("sap") || name.contains("descriptive") || name.contains("alternate") { score -= 25 } if option.hasMediaCharacteristic(.describesVideoForAccessibility) { score -= 40 } return score } 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: .default) try AVAudioSession.sharedInstance().setActive(true) logSingleStream("AVAudioSession configured for playback mode=default") } catch { logSingleStream("AVAudioSession configuration failed error=\(error.localizedDescription)") } let playerItem = makeSingleStreamPlayerItem(from: source) let player = AVPlayer(playerItem: playerItem) player.appliesMediaSelectionCriteriaAutomatically = false player.automaticallyWaitsToMinimizeStalling = true player.isMuted = source.forceMuteAudio logSingleStream( "Configured player for quality ramp preferredForwardBufferDuration=8 automaticallyWaitsToMinimizeStalling=true appliesMediaSelectionCriteriaAutomatically=false" ) context.coordinator.attachDebugObservers(to: player, url: url, resolveNextSource: resolveNextSource) controller.player = player if context.coordinator.audioDiagnostics == nil { context.coordinator.audioDiagnostics = AudioDiagnostics(tag: "single") } context.coordinator.audioDiagnostics?.attach(to: 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 } Task { @MainActor in coordinator.audioDiagnostics?.detach() coordinator.audioDiagnostics = nil } 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? private var qualityMonitorTask: Task? private var clipTimeLimitObserver: Any? var audioDiagnostics: AudioDiagnostics? 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") } } ) notificationTokens.append( NotificationCenter.default.addObserver( forName: AVPlayerItem.mediaSelectionDidChangeNotification, object: item, queue: .main ) { _ in logSingleStream("Notification mediaSelectionDidChange") Task { @MainActor in await enforcePinnedSingleStreamAudioSelection(on: item) } } ) } 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() } } }