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:
@@ -80,28 +80,104 @@ struct SingleStreamPlaybackScreen: View {
|
||||
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)
|
||||
.ignoresSafeArea()
|
||||
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()
|
||||
|
||||
SingleStreamScoreStripView(games: tickerGames)
|
||||
.allowsHitTesting(false)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
#if os(iOS)
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.padding(20)
|
||||
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))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.onAppear {
|
||||
@@ -111,6 +187,135 @@ struct SingleStreamPlaybackScreen: View {
|
||||
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 {
|
||||
@@ -290,6 +495,12 @@ private final class SingleStreamMarqueeContainerView: UIView {
|
||||
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()
|
||||
@@ -300,10 +511,24 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
||||
let controller = AVPlayerViewController()
|
||||
controller.allowsPictureInPicturePlayback = true
|
||||
controller.showsPlaybackControls = true
|
||||
context.coordinator.onPiPStateChanged = onPiPStateChanged
|
||||
controller.delegate = context.coordinator
|
||||
#if os(iOS)
|
||||
controller.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
#endif
|
||||
logSingleStream("AVPlayerViewController configured without contentOverlayView ticker")
|
||||
|
||||
#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()
|
||||
@@ -335,26 +560,102 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
||||
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)
|
||||
}
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {}
|
||||
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")
|
||||
logSingleStream("dismantleUIViewController start isPiPActive=\(coordinator.isPiPActive)")
|
||||
if coordinator.isPiPActive {
|
||||
logSingleStream("dismantleUIViewController skipped — PiP is active")
|
||||
return
|
||||
}
|
||||
coordinator.clearDebugObservers()
|
||||
uiViewController.player?.pause()
|
||||
uiViewController.player = nil
|
||||
logSingleStream("dismantleUIViewController complete")
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, @unchecked Sendable {
|
||||
final class Coordinator: NSObject, @unchecked Sendable, AVPlayerViewControllerDelegate {
|
||||
private var playerObservations: [NSKeyValueObservation] = []
|
||||
private var notificationTokens: [NSObjectProtocol] = []
|
||||
private var startupRecoveryTask: 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,
|
||||
@@ -456,6 +757,7 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -522,6 +824,45 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
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 clearDebugObservers() {
|
||||
startupRecoveryTask?.cancel()
|
||||
startupRecoveryTask = nil
|
||||
|
||||
Reference in New Issue
Block a user