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:
@@ -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