Add game center, per-model shuffle, audio focus fixes, README, tests
- README.md with build/architecture overview - Game Center screen with at-bat timeline, pitch sequence, spray chart, and strike zone component views - VideoShuffle service: per-model bucketed random selection with no-back-to-back guarantee; replaces flat shuffle-bag approach - Refresh JWT token for authenticated NSFW feed; add josie-hamming-2 and dani-speegle-2 to the user list - MultiStreamView audio focus: remove redundant isMuted writes during startStream and playNextWerkoutClip so audio stops ducking during clip transitions; gate AVAudioSession.setCategory(.playback) behind a one-shot flag - GamesViewModel.attachPlayer: skip mute recalculation when the same player is re-attached (prevents toggle flicker on item replace) - mlbTVOSTests target wired through project.yml with GENERATE_INFOPLIST_FILE; VideoShuffleTests covers groupByModel, pickRandomFromBuckets, real-distribution no-back-to-back invariant, and uniform model distribution over 6000 picks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -353,8 +353,13 @@ private struct MultiStreamTile: View {
|
||||
@State private var hasError = false
|
||||
@State private var startupPlaybackTask: Task<Void, Never>?
|
||||
@State private var qualityUpgradeTask: Task<Void, Never>?
|
||||
@State private var clipTimeLimitObserver: Any?
|
||||
@State private var isAdvancingClip = false
|
||||
@StateObject private var playbackDiagnostics = MultiStreamPlaybackDiagnostics()
|
||||
|
||||
private static let maxClipDuration: Double = 15.0
|
||||
private static var audioSessionConfigured = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
videoLayer
|
||||
@@ -442,6 +447,7 @@ private struct MultiStreamTile: View {
|
||||
startupPlaybackTask = nil
|
||||
qualityUpgradeTask?.cancel()
|
||||
qualityUpgradeTask = nil
|
||||
if let player { removeClipTimeLimit(from: player) }
|
||||
playbackDiagnostics.clear(streamID: stream.id, reason: "tile disappeared")
|
||||
}
|
||||
#if os(tvOS)
|
||||
@@ -536,7 +542,6 @@ private struct MultiStreamTile: View {
|
||||
)
|
||||
|
||||
if let player {
|
||||
player.isMuted = stream.forceMuteAudio || viewModel.audioFocusStreamID != stream.id
|
||||
playbackDiagnostics.attach(
|
||||
to: player,
|
||||
streamID: stream.id,
|
||||
@@ -545,20 +550,23 @@ private struct MultiStreamTile: View {
|
||||
)
|
||||
scheduleStartupPlaybackRecovery(for: player)
|
||||
scheduleQualityUpgrade(for: player)
|
||||
installClipTimeLimit(on: player)
|
||||
logMultiView("startStream reused inline player id=\(stream.id) muted=\(player.isMuted)")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
logMultiView("startStream audio session configured id=\(stream.id)")
|
||||
} catch {
|
||||
logMultiView("startStream audio session failed id=\(stream.id) error=\(error.localizedDescription)")
|
||||
if !Self.audioSessionConfigured {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
Self.audioSessionConfigured = true
|
||||
logMultiView("startStream audio session configured id=\(stream.id)")
|
||||
} catch {
|
||||
logMultiView("startStream audio session failed id=\(stream.id) error=\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
if let existingPlayer = stream.player {
|
||||
existingPlayer.isMuted = stream.forceMuteAudio || viewModel.audioFocusStreamID != stream.id
|
||||
self.player = existingPlayer
|
||||
hasError = false
|
||||
playbackDiagnostics.attach(
|
||||
@@ -569,6 +577,7 @@ private struct MultiStreamTile: View {
|
||||
)
|
||||
scheduleStartupPlaybackRecovery(for: existingPlayer)
|
||||
scheduleQualityUpgrade(for: existingPlayer)
|
||||
installClipTimeLimit(on: existingPlayer)
|
||||
logMultiView("startStream reused shared player id=\(stream.id) muted=\(existingPlayer.isMuted)")
|
||||
return
|
||||
}
|
||||
@@ -606,6 +615,7 @@ private struct MultiStreamTile: View {
|
||||
scheduleQualityUpgrade(for: avPlayer)
|
||||
logMultiView("startStream attached player id=\(stream.id) muted=\(avPlayer.isMuted) startupResolution=\(multiViewStartupResolution) fastStart=true calling playImmediately(atRate: 1.0)")
|
||||
avPlayer.playImmediately(atRate: 1.0)
|
||||
installClipTimeLimit(on: avPlayer)
|
||||
}
|
||||
|
||||
private func makePlayer(url: URL, headers: [String: String]?) -> AVPlayer {
|
||||
@@ -754,28 +764,73 @@ private struct MultiStreamTile: View {
|
||||
.value
|
||||
}
|
||||
|
||||
private func installClipTimeLimit(on player: AVPlayer) {
|
||||
removeClipTimeLimit(from: player)
|
||||
guard stream.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { return }
|
||||
let limit = CMTime(seconds: Self.maxClipDuration, preferredTimescale: 600)
|
||||
logMultiView("installClipTimeLimit id=\(stream.id) limit=\(Self.maxClipDuration)s")
|
||||
clipTimeLimitObserver = player.addBoundaryTimeObserver(
|
||||
forTimes: [NSValue(time: limit)],
|
||||
queue: .main
|
||||
) { [weak player] in
|
||||
guard let player else {
|
||||
logMultiView("clipTimeLimit STOPPED id=\(stream.id) reason=player-deallocated")
|
||||
return
|
||||
}
|
||||
let currentTime = CMTimeGetSeconds(player.currentTime())
|
||||
logMultiView("clipTimeLimit fired id=\(stream.id) currentTime=\(String(format: "%.1f", currentTime))s rate=\(player.rate) — advancing")
|
||||
Task { @MainActor in
|
||||
await playNextWerkoutClip(on: player)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeClipTimeLimit(from player: AVPlayer) {
|
||||
if let observer = clipTimeLimitObserver {
|
||||
player.removeTimeObserver(observer)
|
||||
clipTimeLimitObserver = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func playbackEndedHandler(for player: AVPlayer) -> (@MainActor @Sendable () async -> Void)? {
|
||||
guard stream.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { return nil }
|
||||
return {
|
||||
let currentTime = CMTimeGetSeconds(player.currentTime())
|
||||
logMultiView("playbackEnded (didPlayToEnd) id=\(stream.id) currentTime=\(String(format: "%.1f", currentTime))s rate=\(player.rate)")
|
||||
await playNextWerkoutClip(on: player)
|
||||
}
|
||||
}
|
||||
|
||||
private func playNextWerkoutClip(on player: AVPlayer) async {
|
||||
guard !isAdvancingClip else {
|
||||
logMultiView("playNextWerkoutClip SKIPPED id=\(stream.id) reason=already-advancing")
|
||||
return
|
||||
}
|
||||
isAdvancingClip = true
|
||||
defer { isAdvancingClip = false }
|
||||
|
||||
let currentURL = currentStreamURL(for: player)
|
||||
let playerRate = player.rate
|
||||
let playerStatus = player.status.rawValue
|
||||
let itemStatus = player.currentItem?.status.rawValue ?? -1
|
||||
let timeControl = player.timeControlStatus.rawValue
|
||||
logMultiView(
|
||||
"playNextWerkoutClip begin id=\(stream.id) currentURL=\(currentURL?.absoluteString ?? "nil")"
|
||||
"playNextWerkoutClip begin id=\(stream.id) currentURL=\(currentURL?.absoluteString ?? "nil") playerRate=\(playerRate) playerStatus=\(playerStatus) itemStatus=\(itemStatus) timeControl=\(timeControl)"
|
||||
)
|
||||
|
||||
let resolveStart = Date()
|
||||
guard let nextURL = await viewModel.resolveNextAuthenticatedFeedURLForActiveStream(
|
||||
id: stream.id,
|
||||
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
|
||||
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
|
||||
maxRetries: 3
|
||||
) else {
|
||||
logMultiView("playNextWerkoutClip failed id=\(stream.id) reason=resolve-nil-after-retries")
|
||||
let elapsedMs = Int(Date().timeIntervalSince(resolveStart) * 1000)
|
||||
logMultiView("playNextWerkoutClip STOPPED id=\(stream.id) reason=resolve-nil-after-retries elapsedMs=\(elapsedMs)")
|
||||
return
|
||||
}
|
||||
let resolveMs = Int(Date().timeIntervalSince(resolveStart) * 1000)
|
||||
logMultiView("playNextWerkoutClip resolved id=\(stream.id) resolveMs=\(resolveMs) nextURL=\(nextURL.lastPathComponent)")
|
||||
|
||||
let nextItem = makePlayerItem(
|
||||
url: nextURL,
|
||||
@@ -790,10 +845,27 @@ private struct MultiStreamTile: View {
|
||||
label: stream.label,
|
||||
onPlaybackEnded: playbackEndedHandler(for: player)
|
||||
)
|
||||
viewModel.attachPlayer(player, to: stream.id)
|
||||
scheduleStartupPlaybackRecovery(for: player)
|
||||
logMultiView("playNextWerkoutClip replay id=\(stream.id) url=\(nextURL.absoluteString)")
|
||||
logMultiView("playNextWerkoutClip replay id=\(stream.id) url=\(nextURL.lastPathComponent)")
|
||||
player.playImmediately(atRate: 1.0)
|
||||
installClipTimeLimit(on: player)
|
||||
|
||||
// Monitor for failure and auto-skip to next clip
|
||||
Task { @MainActor in
|
||||
for checkDelay in [1.0, 3.0] {
|
||||
try? await Task.sleep(for: .seconds(checkDelay))
|
||||
let postItemStatus = player.currentItem?.status
|
||||
let error = player.currentItem?.error?.localizedDescription ?? "nil"
|
||||
logMultiView(
|
||||
"playNextWerkoutClip postCheck id=\(stream.id) delay=\(checkDelay)s rate=\(player.rate) itemStatus=\(postItemStatus?.rawValue ?? -1) error=\(error)"
|
||||
)
|
||||
if postItemStatus == .failed {
|
||||
logMultiView("playNextWerkoutClip AUTO-SKIP id=\(stream.id) reason=item-failed error=\(error)")
|
||||
await playNextWerkoutClip(on: player)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user