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:
Trey t
2026-04-11 11:02:46 -05:00
parent 58e4c36963
commit 88308b46f5
17 changed files with 2069 additions and 313 deletions

View File

@@ -55,7 +55,7 @@ final class GamesViewModel {
@ObservationIgnored
private var authenticatedVideoFeedCache: [String: AuthenticatedVideoFeedCacheEntry] = [:]
@ObservationIgnored
private var videoShuffleBags: [String: [URL]] = [:]
private var videoShuffleBagsByModel: [String: [String: [URL]]] = [:]
@ObservationIgnored
private var cachedStandings: (date: String, standings: [Int: TeamStanding])?
@@ -536,11 +536,14 @@ final class GamesViewModel {
func attachPlayer(_ player: AVPlayer, to streamID: String) {
guard let index = activeStreams.firstIndex(where: { $0.id == streamID }) else { return }
let alreadyAttached = activeStreams[index].player === player
activeStreams[index].player = player
activeStreams[index].isPlaying = true
let shouldMute = shouldMuteAudio(for: activeStreams[index])
activeStreams[index].isMuted = shouldMute
player.isMuted = shouldMute
if !alreadyAttached {
let shouldMute = shouldMuteAudio(for: activeStreams[index])
activeStreams[index].isMuted = shouldMute
player.isMuted = shouldMute
}
}
func updateStreamOverrideSource(id: String, url: URL, headers: [String: String] = [:]) {
@@ -614,30 +617,79 @@ final class GamesViewModel {
excluding excludedURL: URL? = nil
) async -> URL? {
guard let urls = await fetchAuthenticatedVideoFeedURLs(feedURL: feedURL, headers: headers) else {
logGamesViewModel("resolveAuthenticatedVideoFeedURL FAILED reason=fetchReturned-nil")
return nil
}
let cacheKey = authenticatedVideoFeedCacheKey(feedURL: feedURL, headers: headers)
var bag = videoShuffleBags[cacheKey] ?? []
var buckets = videoShuffleBagsByModel[cacheKey] ?? [:]
// Refill the bag when empty (or first time), shuffled
if bag.isEmpty {
bag = urls.shuffled()
logGamesViewModel("resolveAuthenticatedVideoFeedURL reshuffled bag count=\(bag.count)")
// Refill ANY model bucket that is empty this keeps all models in
// rotation so small models cycle through their videos while large
// models continue drawing from unplayed ones.
var refilledKeys: [String] = []
if buckets.isEmpty {
// First access: build every bucket from scratch.
var rng = SystemRandomNumberGenerator()
buckets = VideoShuffle.groupByModel(
urls,
keyFor: Self.modelKey(from:),
using: &rng
)
refilledKeys = buckets.keys.sorted()
} else {
// Refill just the depleted buckets.
var rng = SystemRandomNumberGenerator()
for key in buckets.keys where buckets[key]?.isEmpty ?? true {
let modelURLs = urls.filter { Self.modelKey(from: $0) ?? "" == key }
guard !modelURLs.isEmpty else { continue }
buckets[key] = modelURLs.shuffled(using: &rng)
refilledKeys.append(key)
}
}
if !refilledKeys.isEmpty {
let counts = buckets
.map { "\($0.key):\($0.value.count)" }
.sorted()
.joined(separator: ",")
logGamesViewModel(
"resolveAuthenticatedVideoFeedURL refilled buckets=[\(refilledKeys.joined(separator: ","))] currentCounts=[\(counts)]"
)
}
// If the next video is the one we just played, push it to the back
if let excludedURL, bag.count > 1, bag.first == excludedURL {
bag.append(bag.removeFirst())
let excludedModel = excludedURL.flatMap(Self.modelKey(from:))
guard let pick = VideoShuffle.pickRandomFromBuckets(
buckets,
excludingKey: excludedModel
) else {
logGamesViewModel("resolveAuthenticatedVideoFeedURL FAILED reason=all-buckets-empty")
return nil
}
let selectedURL = bag.removeFirst()
videoShuffleBags[cacheKey] = bag
videoShuffleBagsByModel[cacheKey] = pick.remaining
let remainingTotal = pick.remaining.values.map(\.count).reduce(0, +)
logGamesViewModel(
"resolveAuthenticatedVideoFeedURL success resolvedURL=\(gamesViewModelDebugURLDescription(selectedURL)) remaining=\(bag.count) excludedURL=\(excludedURL.map(gamesViewModelDebugURLDescription) ?? "nil")"
"resolveAuthenticatedVideoFeedURL pop model=\(pick.key) remainingTotal=\(remainingTotal) excludedModel=\(excludedModel ?? "nil")"
)
return selectedURL
return pick.item
}
/// Extracts the model/folder identifier from an authenticated feed URL.
/// Expected path shapes:
/// /api/hls/<model>/<file>/master.m3u8
/// /data/media/<model>/<file>.mp4
private static func modelKey(from url: URL) -> String? {
let components = url.pathComponents
if let hlsIndex = components.firstIndex(of: "hls"),
components.index(after: hlsIndex) < components.endIndex {
return components[components.index(after: hlsIndex)]
}
if let mediaIndex = components.firstIndex(of: "media"),
components.index(after: mediaIndex) < components.endIndex {
return components[components.index(after: mediaIndex)]
}
return nil
}
private func fetchAuthenticatedVideoFeedURLs(