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 = 2 return item } let assetOptions: [String: Any] = [ "AVURLAssetHTTPHeaderFieldsKey": source.httpHeaders, ] let asset = AVURLAsset(url: source.url, options: assetOptions) let item = AVPlayerItem(asset: asset) item.preferredForwardBufferDuration = 2 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 = false player.isMuted = source.forceMuteAudio logSingleStream("Configured player for fast start preferredForwardBufferDuration=2 automaticallyWaitsToMinimizeStalling=false") 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) } 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)") if coordinator.isPiPActive { logSingleStream("dismantleUIViewController skipped — PiP is active") return } coordinator.clearDebugObservers() 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 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 clearDebugObservers() { startupRecoveryTask?.cancel() startupRecoveryTask = nil playerObservations.removeAll() for token in notificationTokens { NotificationCenter.default.removeObserver(token) } notificationTokens.removeAll() } deinit { clearDebugObservers() } } }