SingleStream: pass preserveServerResolutionWhenBest=false so "best" always reaches the server for a full multi-variant manifest. Increase buffer to 8s and enable automaticallyWaitsToMinimizeStalling so AVPlayer can measure bandwidth and select higher variants. Add quality monitor that nudges AVPlayer if observed bandwidth far exceeds indicated bitrate. MultiStream: remove broken URL-param resolution detection that falsely skipped upgrades, log actual indicatedBitrate instead. Extend upgrade check windows from [2,4,7]s to [2,4,7,15,30]s for slow-to-stabilize streams. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
912 lines
38 KiB
Swift
912 lines
38 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 = 8
|
|
return item
|
|
}
|
|
|
|
let assetOptions: [String: Any] = [
|
|
"AVURLAssetHTTPHeaderFieldsKey": source.httpHeaders,
|
|
]
|
|
let asset = AVURLAsset(url: source.url, options: assetOptions)
|
|
let item = AVPlayerItem(asset: asset)
|
|
item.preferredForwardBufferDuration = 8
|
|
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 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: .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 = true
|
|
player.isMuted = source.forceMuteAudio
|
|
logSingleStream("Configured player for quality ramp preferredForwardBufferDuration=8 automaticallyWaitsToMinimizeStalling=true")
|
|
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.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
|
|
}
|
|
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?
|
|
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")
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|