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

@@ -1,24 +1,41 @@
import Foundation
import Observation
import OSLog
private let gameCenterLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "GameCenter")
private func logGameCenter(_ message: String) {
gameCenterLogger.debug("\(message, privacy: .public)")
print("[GameCenter] \(message)")
}
@Observable
@MainActor
final class GameCenterViewModel {
var feed: LiveGameFeed?
var highlights: [Highlight] = []
var winProbabilityHome: Double?
var winProbabilityAway: Double?
var isLoading = false
var errorMessage: String?
var lastUpdated: Date?
private let statsAPI = MLBStatsAPI()
@ObservationIgnored
private var lastHighlightsFetch: Date?
func watch(game: Game) async {
guard let gamePk = game.gamePk else {
logGameCenter("watch: no gamePk for game id=\(game.id)")
errorMessage = "No live game feed is available for this matchup."
return
}
logGameCenter("watch: starting for gamePk=\(gamePk)")
while !Task.isCancelled {
await refresh(gamePk: gamePk)
await refreshHighlightsIfNeeded(gamePk: gamePk, gameDate: game.gameDate)
await refreshWinProbability(gamePk: gamePk)
let liveState = feed?.gameData.status?.abstractGameState == "Live"
if !liveState {
@@ -34,12 +51,44 @@ final class GameCenterViewModel {
errorMessage = nil
do {
logGameCenter("refresh: fetching feed for gamePk=\(gamePk)")
feed = try await statsAPI.fetchGameFeed(gamePk: gamePk)
logGameCenter("refresh: success playEvents=\(feed?.currentPlay?.playEvents?.count ?? 0) allPlays=\(feed?.liveData.plays.allPlays.count ?? 0)")
lastUpdated = Date()
} catch {
logGameCenter("refresh: FAILED gamePk=\(gamePk) error=\(error)")
errorMessage = "Failed to load game center."
}
isLoading = false
}
private func refreshHighlightsIfNeeded(gamePk: String, gameDate: String) async {
// Only fetch highlights every 60 seconds
if let last = lastHighlightsFetch, Date().timeIntervalSince(last) < 60 {
return
}
let serverAPI = MLBServerAPI()
do {
highlights = try await serverAPI.fetchHighlights(gamePk: gamePk, gameDate: gameDate)
lastHighlightsFetch = Date()
} catch {
// Highlights are supplementary don't surface errors
}
}
private func refreshWinProbability(gamePk: String) async {
do {
let entries = try await statsAPI.fetchWinProbability(gamePk: gamePk)
if let latest = entries.last,
let home = latest.homeTeamWinProbability,
let away = latest.awayTeamWinProbability {
winProbabilityHome = home
winProbabilityAway = away
}
} catch {
// Win probability is supplementary don't surface errors
}
}
}

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(