Files
MLBApp/mlbTVOS/Views/SingleStreamPlayerView.swift
Trey t da033cf12c Fix NSFW sheet scroll on iOS/iPad, clean up audio pin
- WerkoutNSFWSheet: wrap content in ScrollView + ViewThatFits(in: .horizontal)
  so iPad's narrow sheet width falls back to VStack and content scrolls.
- Tighten padding on compact layouts (38→24).
- Revert AAC-preference in pinAudioSelection (stream is all AAC, no Dolby).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:30:25 -05:00

985 lines
42 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 {
let item: AVPlayerItem
if source.httpHeaders.isEmpty {
item = AVPlayerItem(url: source.url)
} else {
let assetOptions: [String: Any] = [
"AVURLAssetHTTPHeaderFieldsKey": source.httpHeaders,
]
let asset = AVURLAsset(url: source.url, options: assetOptions)
item = AVPlayerItem(asset: asset)
logSingleStream(
"Configured authenticated AVURLAsset headerKeys=\(singleStreamHeaderKeysDescription(source.httpHeaders))"
)
}
item.preferredForwardBufferDuration = 8
item.allowedAudioSpatializationFormats = []
logSingleStream("Configured player item preferredForwardBufferDuration=8 allowedAudioSpatializationFormats=[]")
pinSingleStreamAudioSelection(on: item)
return item
}
/// Pin the HLS audio rendition so ABR can't swap channel layouts mid-stream.
private func pinSingleStreamAudioSelection(on item: AVPlayerItem) {
Task { @MainActor in
await enforcePinnedSingleStreamAudioSelection(on: item)
}
}
@MainActor
private func enforcePinnedSingleStreamAudioSelection(on item: AVPlayerItem) async {
let asset = item.asset
guard let group = try? await asset.loadMediaSelectionGroup(for: .audible),
let option = preferredSingleStreamAudioOption(in: group) else { return }
let current = item.currentMediaSelection.selectedMediaOption(in: group)
if current != option {
item.select(option, in: group)
}
logSingleStream(
"pinAudioSelection selected=\(option.displayName) current=\(current?.displayName ?? "nil") options=\(group.options.count)"
)
}
private func preferredSingleStreamAudioOption(in group: AVMediaSelectionGroup) -> AVMediaSelectionOption? {
let defaultOption = group.defaultOption
return group.options.max { lhs, rhs in
audioPreferenceScore(for: lhs, defaultOption: defaultOption) < audioPreferenceScore(for: rhs, defaultOption: defaultOption)
} ?? defaultOption ?? group.options.first
}
private func audioPreferenceScore(for option: AVMediaSelectionOption, defaultOption: AVMediaSelectionOption?) -> Int {
let name = option.displayName.lowercased()
var score = 0
if option == defaultOption { score += 40 }
if name.contains("stereo") || name.contains("2.0") || name.contains("main") { score += 30 }
if name.contains("english") || name.contains("eng") { score += 20 }
if name.contains("surround") || name.contains("5.1") || name.contains("atmos") { score -= 30 }
if name.contains("spanish") || name.contains("sap") || name.contains("descriptive") || name.contains("alternate") {
score -= 25
}
if option.hasMediaCharacteristic(.describesVideoForAccessibility) {
score -= 40
}
return score
}
struct SingleStreamPlaybackScreen: View {
@Environment(\.dismiss) private var dismiss
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
let tickerGames: [Game]
var game: Game? = nil
var onPiPActiveChanged: ((Bool) -> Void)? = nil
@State private var showGameCenter = false
@State private var showPitchInfo = false
@State private var pitchViewModel = GameCenterViewModel()
@State private var isPiPActive = false
var body: some View {
ZStack(alignment: .bottom) {
SingleStreamPlayerView(
resolveSource: resolveSource,
resolveNextSource: resolveNextSource,
hasGamePk: game?.gamePk != nil,
onTogglePitchInfo: {
showPitchInfo.toggle()
if showPitchInfo { showGameCenter = false }
},
onToggleGameCenter: {
showGameCenter.toggle()
if showGameCenter { showPitchInfo = false }
},
onPiPStateChanged: { active in
isPiPActive = active
onPiPActiveChanged?(active)
},
showPitchInfo: showPitchInfo,
showGameCenter: showGameCenter
)
.ignoresSafeArea()
if !showGameCenter && !showPitchInfo {
SingleStreamScoreStripView(games: tickerGames)
.allowsHitTesting(false)
.padding(.horizontal, 18)
.padding(.bottom, 14)
.transition(.opacity)
}
if showGameCenter, let game {
gameCenterOverlay(game: game)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.3), value: showGameCenter)
.animation(.easeInOut(duration: 0.3), value: showPitchInfo)
.overlay(alignment: .bottomLeading) {
if showPitchInfo, let feed = pitchViewModel.feed {
pitchInfoBox(feed: feed)
.transition(.move(edge: .leading).combined(with: .opacity))
}
}
#if os(iOS)
.overlay(alignment: .topTrailing) {
HStack(spacing: 12) {
if game?.gamePk != nil {
Button {
showPitchInfo.toggle()
if showPitchInfo { showGameCenter = false }
} label: {
Image(systemName: showPitchInfo ? "xmark.circle.fill" : "baseball.fill")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white.opacity(0.9))
.padding(6)
.background(.black.opacity(0.5))
.clipShape(Circle())
}
Button {
showGameCenter.toggle()
if showGameCenter { showPitchInfo = false }
} label: {
Image(systemName: showGameCenter ? "xmark.circle.fill" : "chart.bar.fill")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white.opacity(0.9))
.padding(6)
.background(.black.opacity(0.5))
.clipShape(Circle())
}
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white.opacity(0.9))
}
}
}
.padding(20)
}
#endif
.task(id: game?.gamePk) {
guard let gamePk = game?.gamePk else { return }
while !Task.isCancelled {
await pitchViewModel.refresh(gamePk: gamePk)
try? await Task.sleep(for: .seconds(5))
}
}
.ignoresSafeArea()
.onAppear {
logSingleStream("SingleStreamPlaybackScreen appeared tickerGames=\(tickerGames.count) tickerMode=marqueeOverlay")
}
.onDisappear {
logSingleStream("SingleStreamPlaybackScreen disappeared")
}
}
private func gameCenterOverlay(game: Game) -> some View {
ScrollView {
GameCenterView(game: game)
.padding(.horizontal, 20)
.padding(.top, 60)
.padding(.bottom, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.black.opacity(0.82))
}
private func pitchInfoBox(feed: LiveGameFeed) -> some View {
let pitches = feed.currentAtBatPitches
let batter = feed.currentBatter?.displayName ?? ""
let pitcher = feed.currentPitcher?.displayName ?? ""
let countText = feed.currentCountText ?? ""
return VStack(alignment: .leading, spacing: 8) {
// Matchup header use last name only to save space
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text("AB")
.font(.system(size: 10, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.4))
Text(batter)
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("P")
.font(.system(size: 10, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.4))
Text(pitcher)
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
}
if !countText.isEmpty {
Text(countText)
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.7))
}
if !pitches.isEmpty {
// Latest pitch bold and prominent
if let last = pitches.last {
let color = pitchCallColor(last.callCode)
HStack(spacing: 6) {
if let speed = last.speedMPH {
Text("\(speed, specifier: "%.1f")")
.font(.system(size: 24, weight: .black).monospacedDigit())
.foregroundStyle(.white)
Text("mph")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(last.pitchTypeDescription)
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
Text(last.callDescription)
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(color)
}
}
}
// Strike zone + previous pitches side by side
HStack(alignment: .top, spacing: 14) {
StrikeZoneView(pitches: pitches, size: 120)
// Previous pitches compact rows
if pitches.count > 1 {
VStack(alignment: .leading, spacing: 3) {
ForEach(Array(pitches.dropLast().reversed().prefix(8).enumerated()), id: \.offset) { _, pitch in
let color = pitchCallColor(pitch.callCode)
HStack(spacing: 4) {
Text("\(pitch.pitchNumber ?? 0)")
.font(.system(size: 10, weight: .bold).monospacedDigit())
.foregroundStyle(.white.opacity(0.35))
.frame(width: 14, alignment: .trailing)
Text(shortPitchType(pitch.pitchTypeCode))
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.7))
if let speed = pitch.speedMPH {
Text("\(speed, specifier: "%.0f")")
.font(.system(size: 11, weight: .bold).monospacedDigit())
.foregroundStyle(.white.opacity(0.45))
}
Spacer(minLength: 0)
Circle()
.fill(color)
.frame(width: 6, height: 6)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
} else {
Text("Waiting for pitch data...")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.white.opacity(0.5))
}
}
.frame(width: 300)
.padding(16)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.black.opacity(0.78))
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(.white.opacity(0.1), lineWidth: 1)
}
)
.padding(.leading, 24)
.padding(.bottom, 50)
}
}
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
var hasGamePk: Bool = false
var onTogglePitchInfo: (() -> Void)? = nil
var onToggleGameCenter: (() -> Void)? = nil
var onPiPStateChanged: ((Bool) -> Void)? = nil
var showPitchInfo: Bool = false
var showGameCenter: Bool = false
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIViewController(context: Context) -> AVPlayerViewController {
logSingleStream("makeUIViewController start")
let controller = AVPlayerViewController()
controller.allowsPictureInPicturePlayback = true
controller.showsPlaybackControls = true
context.coordinator.onPiPStateChanged = onPiPStateChanged
controller.delegate = context.coordinator
#if os(iOS)
controller.canStartPictureInPictureAutomaticallyFromInline = true
#endif
#if os(tvOS)
if hasGamePk {
context.coordinator.onTogglePitchInfo = onTogglePitchInfo
context.coordinator.onToggleGameCenter = onToggleGameCenter
controller.transportBarCustomMenuItems = context.coordinator.buildTransportBarItems(
showPitchInfo: showPitchInfo,
showGameCenter: showGameCenter
)
}
#endif
logSingleStream("AVPlayerViewController configured")
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: .default)
try AVAudioSession.sharedInstance().setActive(true)
logSingleStream("AVAudioSession configured for playback mode=default")
} catch {
logSingleStream("AVAudioSession configuration failed error=\(error.localizedDescription)")
}
let playerItem = makeSingleStreamPlayerItem(from: source)
let player = AVPlayer(playerItem: playerItem)
player.appliesMediaSelectionCriteriaAutomatically = false
player.automaticallyWaitsToMinimizeStalling = true
player.isMuted = source.forceMuteAudio
logSingleStream(
"Configured player for quality ramp preferredForwardBufferDuration=8 automaticallyWaitsToMinimizeStalling=true appliesMediaSelectionCriteriaAutomatically=false"
)
context.coordinator.attachDebugObservers(to: player, url: url, resolveNextSource: resolveNextSource)
controller.player = player
if context.coordinator.audioDiagnostics == nil {
context.coordinator.audioDiagnostics = AudioDiagnostics(tag: "single")
}
context.coordinator.audioDiagnostics?.attach(to: player)
logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)")
player.playImmediately(atRate: 1.0)
context.coordinator.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
context.coordinator.scheduleStartupRecovery(for: player)
context.coordinator.scheduleQualityMonitor(for: player)
}
return controller
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
context.coordinator.onPiPStateChanged = onPiPStateChanged
#if os(tvOS)
if hasGamePk {
context.coordinator.onTogglePitchInfo = onTogglePitchInfo
context.coordinator.onToggleGameCenter = onToggleGameCenter
uiViewController.transportBarCustomMenuItems = context.coordinator.buildTransportBarItems(
showPitchInfo: showPitchInfo,
showGameCenter: showGameCenter
)
}
#endif
}
static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: Coordinator) {
logSingleStream("dismantleUIViewController start isPiPActive=\(coordinator.isPiPActive)")
coordinator.clearDebugObservers()
if coordinator.isPiPActive {
logSingleStream("dismantleUIViewController — PiP active, observers cleared but keeping player")
return
}
Task { @MainActor in
coordinator.audioDiagnostics?.detach()
coordinator.audioDiagnostics = nil
}
uiViewController.player?.pause()
uiViewController.player = nil
logSingleStream("dismantleUIViewController complete")
}
final class Coordinator: NSObject, @unchecked Sendable, AVPlayerViewControllerDelegate {
private var playerObservations: [NSKeyValueObservation] = []
private var notificationTokens: [NSObjectProtocol] = []
private var startupRecoveryTask: Task<Void, Never>?
private var qualityMonitorTask: Task<Void, Never>?
private var clipTimeLimitObserver: Any?
var audioDiagnostics: AudioDiagnostics?
private static let maxClipDuration: Double = 15.0
var onTogglePitchInfo: (() -> Void)?
var onToggleGameCenter: (() -> Void)?
var isPiPActive = false
var onPiPStateChanged: ((Bool) -> Void)?
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool {
logSingleStream("PiP: shouldAutomaticallyDismiss returning false")
return false
}
func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
logSingleStream("PiP: willStart")
isPiPActive = true
onPiPStateChanged?(true)
}
func playerViewControllerDidStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
logSingleStream("PiP: didStart")
}
func playerViewControllerWillStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
logSingleStream("PiP: willStop")
}
func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
logSingleStream("PiP: didStop")
isPiPActive = false
onPiPStateChanged?(false)
}
func playerViewController(
_ playerViewController: AVPlayerViewController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
) {
logSingleStream("PiP: restoreUserInterface")
completionHandler(true)
}
#if os(tvOS)
func buildTransportBarItems(showPitchInfo: Bool, showGameCenter: Bool) -> [UIAction] {
let pitchAction = UIAction(
title: showPitchInfo ? "Hide Pitch Info" : "Pitch Info",
image: UIImage(systemName: showPitchInfo ? "xmark.circle.fill" : "baseball.fill")
) { [weak self] _ in
self?.onTogglePitchInfo?()
}
let gcAction = UIAction(
title: showGameCenter ? "Hide Game Center" : "Game Center",
image: UIImage(systemName: showGameCenter ? "xmark.circle.fill" : "chart.bar.fill")
) { [weak self] _ in
self?.onToggleGameCenter?()
}
return [pitchAction, gcAction]
}
#endif
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.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
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")
}
}
)
notificationTokens.append(
NotificationCenter.default.addObserver(
forName: AVPlayerItem.mediaSelectionDidChangeNotification,
object: item,
queue: .main
) { _ in
logSingleStream("Notification mediaSelectionDidChange")
Task { @MainActor in
await enforcePinnedSingleStreamAudioSelection(on: item)
}
}
)
}
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 installClipTimeLimit(
on player: AVPlayer,
resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)?
) {
removeClipTimeLimit(from: player)
guard resolveNextSource != nil else { return }
let limit = CMTime(seconds: Self.maxClipDuration, preferredTimescale: 600)
clipTimeLimitObserver = player.addBoundaryTimeObserver(
forTimes: [NSValue(time: limit)],
queue: .main
) { [weak self, weak player] in
guard let self, let player, let resolveNextSource else { return }
logSingleStream("clipTimeLimit hit \(Self.maxClipDuration)s — advancing to next clip")
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("clipTimeLimit next source 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)
player.playImmediately(atRate: 1.0)
self.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
self.scheduleStartupRecovery(for: player)
}
}
}
private func removeClipTimeLimit(from player: AVPlayer) {
if let observer = clipTimeLimitObserver {
player.removeTimeObserver(observer)
clipTimeLimitObserver = nil
}
}
func scheduleQualityMonitor(for player: AVPlayer) {
qualityMonitorTask?.cancel()
qualityMonitorTask = Task { @MainActor [weak player] in
// Check at 5s, 15s, and 30s whether AVPlayer has ramped to a reasonable bitrate
for delay in [5.0, 15.0, 30.0] {
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled, let player else { return }
let indicatedBitrate = player.currentItem?.accessLog()?.events.last?.indicatedBitrate ?? 0
let observedBitrate = player.currentItem?.accessLog()?.events.last?.observedBitrate ?? 0
let likelyToKeepUp = player.currentItem?.isPlaybackLikelyToKeepUp ?? false
logSingleStream(
"qualityMonitor check delay=\(delay)s indicatedBitrate=\(Int(indicatedBitrate)) observedBitrate=\(Int(observedBitrate)) likelyToKeepUp=\(likelyToKeepUp) rate=\(player.rate)"
)
// If observed bandwidth supports higher quality but indicated is low, nudge AVPlayer
if likelyToKeepUp && indicatedBitrate > 0 && observedBitrate > indicatedBitrate * 2 {
logSingleStream(
"qualityMonitor nudge delay=\(delay)s — observed bandwidth \(Int(observedBitrate)) >> indicated \(Int(indicatedBitrate)), setting preferredPeakBitRate=0 to uncap"
)
player.currentItem?.preferredPeakBitRate = 0
}
}
}
}
func clearDebugObservers() {
startupRecoveryTask?.cancel()
startupRecoveryTask = nil
qualityMonitorTask?.cancel()
qualityMonitorTask = nil
playerObservations.removeAll()
for token in notificationTokens {
NotificationCenter.default.removeObserver(token)
}
notificationTokens.removeAll()
}
deinit {
clearDebugObservers()
}
}
}