540 lines
22 KiB
Swift
540 lines
22 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 {
|
|
@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<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
|
|
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()
|
|
}
|
|
}
|
|
}
|