Add Werkout channel playback and autoplay

This commit is contained in:
Trey t
2026-03-26 20:53:08 -05:00
parent bae265b132
commit 127125ae1b
6 changed files with 957 additions and 48 deletions

View File

@@ -24,6 +24,11 @@ private func singleStreamDebugURLDescription(_ url: URL) -> String {
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"
@@ -51,13 +56,33 @@ private func singleStreamTimeControlDescription(_ status: AVPlayer.TimeControlSt
}
}
private func makeSingleStreamPlayerItem(from source: SingleStreamPlaybackSource) -> AVPlayerItem {
if source.httpHeaders.isEmpty {
let item = AVPlayerItem(url: source.url)
item.preferredForwardBufferDuration = 2
return item
}
let assetOptions: [String: Any] = [
"AVURLAssetHTTPHeaderFieldsKey": source.httpHeaders,
]
let asset = AVURLAsset(url: source.url, options: assetOptions)
let item = AVPlayerItem(asset: asset)
item.preferredForwardBufferDuration = 2
logSingleStream(
"Configured authenticated AVURLAsset headerKeys=\(singleStreamHeaderKeysDescription(source.httpHeaders))"
)
return item
}
struct SingleStreamPlaybackScreen: View {
let resolveURL: @Sendable () async -> URL?
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
let tickerGames: [Game]
var body: some View {
ZStack(alignment: .bottom) {
SingleStreamPlayerView(resolveURL: resolveURL)
SingleStreamPlayerView(resolveSource: resolveSource, resolveNextSource: resolveNextSource)
.ignoresSafeArea()
SingleStreamScoreStripView(games: tickerGames)
@@ -75,6 +100,16 @@ struct SingleStreamPlaybackScreen: View {
}
}
struct SingleStreamPlaybackSource: Sendable {
let url: URL
let httpHeaders: [String: String]
init(url: URL, httpHeaders: [String: String] = [:]) {
self.url = url
self.httpHeaders = httpHeaders
}
}
private struct SingleStreamScoreStripView: View {
let games: [Game]
@@ -238,7 +273,8 @@ private final class SingleStreamMarqueeContainerView: UIView {
/// Full-screen player using AVPlayerViewController for PiP support on tvOS.
struct SingleStreamPlayerView: UIViewControllerRepresentable {
let resolveURL: @Sendable () async -> URL?
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
func makeCoordinator() -> Coordinator {
Coordinator()
@@ -253,13 +289,16 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
Task { @MainActor in
let resolveStartedAt = Date()
logSingleStream("Starting stream URL resolution")
guard let url = await resolveURL() else {
logSingleStream("resolveURL returned nil; aborting player startup")
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 URL elapsedMs=\(resolveElapsedMs) url=\(singleStreamDebugURLDescription(url))")
logSingleStream(
"Resolved stream source elapsedMs=\(resolveElapsedMs) url=\(singleStreamDebugURLDescription(url)) headerKeys=\(singleStreamHeaderKeysDescription(source.httpHeaders))"
)
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
@@ -269,15 +308,15 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
logSingleStream("AVAudioSession configuration failed error=\(error.localizedDescription)")
}
let playerItem = AVPlayerItem(url: url)
playerItem.preferredForwardBufferDuration = 2
let playerItem = makeSingleStreamPlayerItem(from: source)
let player = AVPlayer(playerItem: playerItem)
player.automaticallyWaitsToMinimizeStalling = false
logSingleStream("Configured player for fast start preferredForwardBufferDuration=2 automaticallyWaitsToMinimizeStalling=false")
context.coordinator.attachDebugObservers(to: player, url: url)
context.coordinator.attachDebugObservers(to: player, url: url, resolveNextSource: resolveNextSource)
controller.player = player
logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)")
player.playImmediately(atRate: 1.0)
context.coordinator.scheduleStartupRecovery(for: player)
}
return controller
@@ -293,11 +332,16 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
logSingleStream("dismantleUIViewController complete")
}
final class Coordinator: NSObject {
final class Coordinator: NSObject, @unchecked Sendable {
private var playerObservations: [NSKeyValueObservation] = []
private var notificationTokens: [NSObjectProtocol] = []
private var startupRecoveryTask: Task<Void, Never>?
func attachDebugObservers(to player: AVPlayer, url: URL) {
func attachDebugObservers(
to player: AVPlayer,
url: URL,
resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
) {
clearDebugObservers()
logSingleStream("Attaching AVPlayer observers url=\(singleStreamDebugURLDescription(url))")
@@ -371,6 +415,32 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
}
)
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
self.attachDebugObservers(to: player, url: nextSource.url, resolveNextSource: resolveNextSource)
logSingleStream("Autoplay replacing item and replaying url=\(singleStreamDebugURLDescription(nextSource.url))")
player.playImmediately(atRate: 1.0)
self.scheduleStartupRecovery(for: player)
}
}
)
notificationTokens.append(
NotificationCenter.default.addObserver(
forName: .AVPlayerItemNewErrorLogEntry,
@@ -399,7 +469,42 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
)
}
func scheduleStartupRecovery(for player: AVPlayer) {
startupRecoveryTask?.cancel()
startupRecoveryTask = Task { @MainActor [weak player] in
let retryDelays: [Double] = [0.35, 1.0, 2.0, 4.0]
for delay in retryDelays {
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled, let player else { return }
let itemStatus = player.currentItem?.status ?? .unknown
let likelyToKeepUp = player.currentItem?.isPlaybackLikelyToKeepUp ?? false
let bufferEmpty = player.currentItem?.isPlaybackBufferEmpty ?? false
let timeControl = player.timeControlStatus
let startupSatisfied = player.rate > 0 && (itemStatus == .readyToPlay || likelyToKeepUp)
logSingleStream(
"startupRecovery check delay=\(delay)s rate=\(player.rate) timeControl=\(singleStreamTimeControlDescription(timeControl)) itemStatus=\(singleStreamItemStatusDescription(itemStatus)) likelyToKeepUp=\(likelyToKeepUp) bufferEmpty=\(bufferEmpty)"
)
if startupSatisfied {
logSingleStream("startupRecovery satisfied delay=\(delay)s")
return
}
if player.rate == 0 {
logSingleStream("startupRecovery replay delay=\(delay)s")
player.playImmediately(atRate: 1.0)
}
}
}
}
func clearDebugObservers() {
startupRecoveryTask?.cancel()
startupRecoveryTask = nil
playerObservations.removeAll()
for token in notificationTokens {
NotificationCenter.default.removeObserver(token)