Add Werkout channel playback and autoplay
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user