Files
MLBApp/mlbTVOS/Views/SingleStreamPlayerView.swift
2026-03-26 15:37:31 -05:00

415 lines
16 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 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()
}
}
}