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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user