Initial commit
This commit is contained in:
414
mlbTVOS/Views/SingleStreamPlayerView.swift
Normal file
414
mlbTVOS/Views/SingleStreamPlayerView.swift
Normal file
@@ -0,0 +1,414 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user