Files
MLBApp/mlbTVOS/Views/SingleStreamPlayerView.swift
2026-03-26 20:53:08 -05:00

520 lines
21 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 = 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 {
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)
}
.ignoresSafeArea()
.onAppear {
logSingleStream("SingleStreamPlaybackScreen appeared tickerGames=\(tickerGames.count) tickerMode=marqueeOverlay")
}
.onDisappear {
logSingleStream("SingleStreamPlaybackScreen disappeared")
}
}
}
struct SingleStreamPlaybackSource: Sendable {
let url: URL
let httpHeaders: [String: String]
init(url: URL, httpHeaders: [String: String] = [:]) {
self.url = url
self.httpHeaders = httpHeaders
}
}
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
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
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<Void, Never>?
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
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()
}
}
}