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 body: some View { ZStack(alignment: .bottom) { SingleStreamPlayerView(resolveSource: resolveSource, resolveNextSource: resolveNextSource) .ignoresSafeArea() SingleStreamScoreStripView(games: tickerGames) .allowsHitTesting(false) .padding(.horizontal, 18) .padding(.bottom, 14) } .overlay(alignment: .topTrailing) { #if os(iOS) Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 28, weight: .bold)) .foregroundStyle(.white.opacity(0.9)) .padding(20) } #endif } .ignoresSafeArea() .onAppear { logSingleStream("SingleStreamPlaybackScreen appeared tickerGames=\(tickerGames.count) tickerMode=marqueeOverlay") } .onDisappear { logSingleStream("SingleStreamPlaybackScreen disappeared") } } } 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 func makeCoordinator() -> Coordinator { Coordinator() } func makeUIViewController(context: Context) -> AVPlayerViewController { logSingleStream("makeUIViewController start") let controller = AVPlayerViewController() controller.allowsPictureInPicturePlayback = true controller.showsPlaybackControls = true #if os(iOS) controller.canStartPictureInPictureAutomaticallyFromInline = true #endif logSingleStream("AVPlayerViewController configured without contentOverlayView ticker") 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.scheduleStartupRecovery(for: player) } return controller } func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {} static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: Coordinator) { logSingleStream("dismantleUIViewController start") coordinator.clearDebugObservers() uiViewController.player?.pause() uiViewController.player = nil logSingleStream("dismantleUIViewController complete") } final class Coordinator: NSObject, @unchecked Sendable { private var playerObservations: [NSKeyValueObservation] = [] private var notificationTokens: [NSObjectProtocol] = [] private var startupRecoveryTask: Task? 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.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 clearDebugObservers() { startupRecoveryTask?.cancel() startupRecoveryTask = nil playerObservations.removeAll() for token in notificationTokens { NotificationCenter.default.removeObserver(token) } notificationTokens.removeAll() } deinit { clearDebugObservers() } } }