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 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" } } struct SingleStreamPlaybackScreen: View { let resolveURL: @Sendable () async -> URL? let tickerGames: [Game] var body: some View { ZStack(alignment: .bottom) { SingleStreamPlayerView(resolveURL: resolveURL) .ignoresSafeArea() SingleStreamScoreStripView(games: tickerGames) .allowsHitTesting(false) .padding(.horizontal, 18) .padding(.bottom, 14) } .ignoresSafeArea() .onAppear { logSingleStream("SingleStreamPlaybackScreen appeared tickerGames=\(tickerGames.count) tickerMode=marqueeOverlay") } .onDisappear { logSingleStream("SingleStreamPlaybackScreen disappeared") } } } 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 resolveURL: @Sendable () async -> URL? func makeCoordinator() -> Coordinator { Coordinator() } func makeUIViewController(context: Context) -> AVPlayerViewController { logSingleStream("makeUIViewController start") let controller = AVPlayerViewController() controller.allowsPictureInPicturePlayback = true controller.showsPlaybackControls = true logSingleStream("AVPlayerViewController configured without contentOverlayView ticker") Task { @MainActor in let resolveStartedAt = Date() logSingleStream("Starting stream URL resolution") guard let url = await resolveURL() else { logSingleStream("resolveURL returned nil; aborting player startup") return } let resolveElapsedMs = Int(Date().timeIntervalSince(resolveStartedAt) * 1000) logSingleStream("Resolved stream URL elapsedMs=\(resolveElapsedMs) url=\(singleStreamDebugURLDescription(url))") 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 = AVPlayerItem(url: url) playerItem.preferredForwardBufferDuration = 2 let player = AVPlayer(playerItem: playerItem) player.automaticallyWaitsToMinimizeStalling = false logSingleStream("Configured player for fast start preferredForwardBufferDuration=2 automaticallyWaitsToMinimizeStalling=false") context.coordinator.attachDebugObservers(to: player, url: url) controller.player = player logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)") player.playImmediately(atRate: 1.0) } 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 { private var playerObservations: [NSKeyValueObservation] = [] private var notificationTokens: [NSObjectProtocol] = [] func attachDebugObservers(to player: AVPlayer, url: URL) { 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: .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 clearDebugObservers() { playerObservations.removeAll() for token in notificationTokens { NotificationCenter.default.removeObserver(token) } notificationTokens.removeAll() } deinit { clearDebugObservers() } } }