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:
@@ -42,6 +42,10 @@ actor MLBStatsAPI {
|
||||
try await fetchJSON("\(baseURL).1/game/\(gamePk)/feed/live")
|
||||
}
|
||||
|
||||
func fetchWinProbability(gamePk: String) async throws -> [WinProbabilityEntry] {
|
||||
try await fetchJSON("\(baseURL).1/game/\(gamePk)/winProbability")
|
||||
}
|
||||
|
||||
func fetchLeagueTeams(season: String) async throws -> [LeagueTeamSummary] {
|
||||
let response: TeamsResponse = try await fetchJSON(
|
||||
"\(baseURL)/teams?sportId=1&season=\(season)&hydrate=league,division,venue,record"
|
||||
@@ -828,6 +832,19 @@ struct LiveGameFeed: Codable, Sendable {
|
||||
var homeLineup: [LiveFeedBoxscorePlayer] {
|
||||
liveData.boxscore?.teams?.home.lineupPlayers ?? []
|
||||
}
|
||||
|
||||
var currentAtBatPitches: [LiveFeedPlayEvent] {
|
||||
currentPlay?.pitches ?? []
|
||||
}
|
||||
|
||||
var allGameHits: [(play: LiveFeedPlay, hitData: LiveFeedHitData)] {
|
||||
liveData.plays.allPlays.compactMap { play in
|
||||
guard let lastEvent = play.playEvents?.last,
|
||||
let hitData = lastEvent.hitData,
|
||||
hitData.coordinates?.coordX != nil else { return nil }
|
||||
return (play: play, hitData: hitData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LiveFeedMetaData: Codable, Sendable {
|
||||
@@ -890,7 +907,7 @@ struct LiveFeedVenue: Codable, Sendable {
|
||||
|
||||
struct LiveFeedWeather: Codable, Sendable {
|
||||
let condition: String?
|
||||
let temp: Int?
|
||||
let temp: String?
|
||||
let wind: String?
|
||||
}
|
||||
|
||||
@@ -911,6 +928,11 @@ struct LiveFeedPlay: Codable, Sendable, Identifiable {
|
||||
let about: LiveFeedPlayAbout?
|
||||
let count: LiveFeedPlayCount?
|
||||
let matchup: LiveFeedPlayMatchup?
|
||||
let playEvents: [LiveFeedPlayEvent]?
|
||||
|
||||
var pitches: [LiveFeedPlayEvent] {
|
||||
playEvents?.filter { $0.isPitch == true } ?? []
|
||||
}
|
||||
|
||||
var id: String {
|
||||
let inning = about?.inning ?? -1
|
||||
@@ -958,6 +980,11 @@ struct LiveFeedPlayerReference: Codable, Sendable, Identifiable {
|
||||
var displayName: String {
|
||||
fullName ?? "TBD"
|
||||
}
|
||||
|
||||
var headshotURL: URL? {
|
||||
guard let id else { return nil }
|
||||
return URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_213,q_auto:best/v1/people/\(id)/headshot/67/current")
|
||||
}
|
||||
}
|
||||
|
||||
struct LiveFeedLinescore: Codable, Sendable {
|
||||
@@ -1054,3 +1081,87 @@ struct LiveFeedDecisions: Codable, Sendable {
|
||||
let loser: LiveFeedPlayerReference?
|
||||
let save: LiveFeedPlayerReference?
|
||||
}
|
||||
|
||||
// MARK: - Play Event / Pitch Data Models
|
||||
|
||||
struct LiveFeedPlayEvent: Codable, Sendable, Identifiable {
|
||||
let details: LiveFeedPlayEventDetails?
|
||||
let count: LiveFeedPlayCount?
|
||||
let pitchData: LiveFeedPitchData?
|
||||
let hitData: LiveFeedHitData?
|
||||
let isPitch: Bool?
|
||||
let pitchNumber: Int?
|
||||
let index: Int?
|
||||
|
||||
var id: Int { index ?? pitchNumber ?? 0 }
|
||||
|
||||
var callCode: String {
|
||||
details?.call?.code ?? ""
|
||||
}
|
||||
|
||||
var callDescription: String {
|
||||
details?.call?.description ?? details?.description ?? ""
|
||||
}
|
||||
|
||||
var pitchTypeDescription: String {
|
||||
details?.type?.description ?? "Unknown"
|
||||
}
|
||||
|
||||
var pitchTypeCode: String {
|
||||
details?.type?.code ?? ""
|
||||
}
|
||||
|
||||
var speedMPH: Double? {
|
||||
pitchData?.startSpeed
|
||||
}
|
||||
}
|
||||
|
||||
struct LiveFeedPlayEventDetails: Codable, Sendable {
|
||||
let call: LiveFeedCallInfo?
|
||||
let description: String?
|
||||
let type: LiveFeedPitchType?
|
||||
}
|
||||
|
||||
struct LiveFeedCallInfo: Codable, Sendable {
|
||||
let code: String?
|
||||
let description: String?
|
||||
}
|
||||
|
||||
struct LiveFeedPitchType: Codable, Sendable {
|
||||
let code: String?
|
||||
let description: String?
|
||||
}
|
||||
|
||||
struct LiveFeedPitchData: Codable, Sendable {
|
||||
let startSpeed: Double?
|
||||
let endSpeed: Double?
|
||||
let strikeZoneTop: Double?
|
||||
let strikeZoneBottom: Double?
|
||||
let coordinates: LiveFeedPitchCoordinates?
|
||||
let zone: Int?
|
||||
}
|
||||
|
||||
struct LiveFeedPitchCoordinates: Codable, Sendable {
|
||||
let pX: Double?
|
||||
let pZ: Double?
|
||||
}
|
||||
|
||||
struct LiveFeedHitData: Codable, Sendable {
|
||||
let launchSpeed: Double?
|
||||
let launchAngle: Double?
|
||||
let totalDistance: Double?
|
||||
let coordinates: LiveFeedHitCoordinates?
|
||||
}
|
||||
|
||||
struct LiveFeedHitCoordinates: Codable, Sendable {
|
||||
let coordX: Double?
|
||||
let coordY: Double?
|
||||
}
|
||||
|
||||
// MARK: - Win Probability
|
||||
|
||||
struct WinProbabilityEntry: Codable, Sendable {
|
||||
let atBatIndex: Int?
|
||||
let homeTeamWinProbability: Double?
|
||||
let awayTeamWinProbability: Double?
|
||||
}
|
||||
|
||||
78
mlbTVOS/Services/VideoShuffle.swift
Normal file
78
mlbTVOS/Services/VideoShuffle.swift
Normal file
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
|
||||
enum VideoShuffle {
|
||||
/// Groups items by key, shuffling within each group.
|
||||
///
|
||||
/// Items whose key is `nil` are grouped under the empty string `""` so they
|
||||
/// form their own bucket rather than being dropped.
|
||||
static func groupByModel<T>(
|
||||
_ items: [T],
|
||||
keyFor: (T) -> String?,
|
||||
using rng: inout some RandomNumberGenerator
|
||||
) -> [String: [T]] {
|
||||
var groups: [String: [T]] = [:]
|
||||
for item in items {
|
||||
let key = keyFor(item) ?? ""
|
||||
groups[key, default: []].append(item)
|
||||
}
|
||||
for key in groups.keys {
|
||||
groups[key]!.shuffle(using: &rng)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/// Picks a random item from a random non-empty bucket, avoiding
|
||||
/// `excludingKey` when possible.
|
||||
///
|
||||
/// Returned buckets are a mutated copy of the input with the picked item
|
||||
/// removed (pure — input is not modified). When the excluded bucket is the
|
||||
/// only non-empty one, it is still picked (falling back gracefully).
|
||||
///
|
||||
/// - Returns: the picked item, the key of the bucket it came from, and the
|
||||
/// updated bucket dictionary — or `nil` if all buckets are empty.
|
||||
static func pickRandomFromBuckets<T>(
|
||||
_ buckets: [String: [T]],
|
||||
excludingKey: String? = nil,
|
||||
using rng: inout some RandomNumberGenerator
|
||||
) -> (item: T, key: String, remaining: [String: [T]])? {
|
||||
// Collect keys with at least one item. Sort for deterministic ordering
|
||||
// before the random pick so test output is reproducible when a seeded
|
||||
// RNG is used.
|
||||
let nonEmptyKeys = buckets
|
||||
.filter { !$0.value.isEmpty }
|
||||
.keys
|
||||
.sorted()
|
||||
guard !nonEmptyKeys.isEmpty else { return nil }
|
||||
|
||||
// Prefer keys other than the excluded one, but fall back if excluding
|
||||
// would leave no candidates.
|
||||
let candidateKeys: [String]
|
||||
if let excludingKey, nonEmptyKeys.contains(excludingKey), nonEmptyKeys.count > 1 {
|
||||
candidateKeys = nonEmptyKeys.filter { $0 != excludingKey }
|
||||
} else {
|
||||
candidateKeys = nonEmptyKeys
|
||||
}
|
||||
|
||||
guard let pickedKey = candidateKeys.randomElement(using: &rng),
|
||||
var items = buckets[pickedKey],
|
||||
!items.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Items were shuffled during groupByModel, so removeFirst is a
|
||||
// uniform random pick among the remaining items in this bucket.
|
||||
let item = items.removeFirst()
|
||||
var remaining = buckets
|
||||
remaining[pickedKey] = items
|
||||
return (item, pickedKey, remaining)
|
||||
}
|
||||
|
||||
/// Convenience overload using the system random generator.
|
||||
static func pickRandomFromBuckets<T>(
|
||||
_ buckets: [String: [T]],
|
||||
excludingKey: String? = nil
|
||||
) -> (item: T, key: String, remaining: [String: [T]])? {
|
||||
var rng = SystemRandomNumberGenerator()
|
||||
return pickRandomFromBuckets(buckets, excludingKey: excludingKey, using: &rng)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user