Root cause: the quality upgrade path called replaceCurrentItem mid-stream, which re-loaded the HLS master manifest and re-picked an audio rendition, producing a perceived loudness jump 10-30s into playback. .moviePlayback mode amplified this by re-initializing cinematic audio processing on each variant change. - Start streams directly at user's desiredResolution; remove scheduleQualityUpgrade, qualityUpgradeTask, and the 504p->best swap. - Switch AVAudioSession mode from .moviePlayback to .default in both MultiStreamView and SingleStreamPlayerView. - Pin the HLS audio rendition by selecting the default audible MediaSelectionGroup option on every new AVPlayerItem, preventing ABR from swapping channel layouts mid-stream. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
925 lines
39 KiB
Swift
925 lines
39 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
|
|
pinSingleStreamAudioSelection(on: item)
|
|
return item
|
|
}
|
|
|
|
/// Lock the HLS audio rendition to the default option so ABR can't swap
|
|
/// to a different channel layout / loudness mid-stream.
|
|
private func pinSingleStreamAudioSelection(on item: AVPlayerItem) {
|
|
let asset = item.asset
|
|
Task { @MainActor [weak item] in
|
|
guard let group = try? await asset.loadMediaSelectionGroup(for: .audible),
|
|
let option = group.defaultOption ?? group.options.first,
|
|
let item else { return }
|
|
item.select(option, in: group)
|
|
logSingleStream("pinAudioSelection selected=\(option.displayName) options=\(group.options.count)")
|
|
}
|
|
}
|
|
|
|
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.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()
|
|
}
|
|
}
|
|
}
|