Files
MLBApp/mlbTVOS/ViewModels/GamesViewModel.swift
Trey t 88308b46f5 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>
2026-04-11 11:02:46 -05:00

933 lines
36 KiB
Swift

import AVFoundation
import Foundation
import Observation
import OSLog
private let gamesViewModelLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "GamesViewModel")
private func logGamesViewModel(_ message: String) {
gamesViewModelLogger.debug("\(message, privacy: .public)")
print("[GamesViewModel] \(message)")
}
private func gamesViewModelDebugURLDescription(_ url: URL) -> String {
var host = url.host ?? "unknown-host"
if let port = url.port {
host += ":\(port)"
}
let queryKeys = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.map(\.name) ?? []
let querySuffix = queryKeys.isEmpty ? "" : "?\(queryKeys.joined(separator: "&"))"
return "\(url.scheme ?? "unknown")://\(host)\(url.path)\(querySuffix)"
}
private struct RemoteVideoFeedEntry: Decodable {
let videoFile: String?
let hlsUrl: String?
let genderValue: String?
}
private struct AuthenticatedVideoFeedCacheEntry {
let loadedAt: Date
let urls: [URL]
}
@Observable
@MainActor
final class GamesViewModel {
var games: [Game] = []
var isLoading = false
var errorMessage: String?
var selectedDate: String?
var activeStreams: [ActiveStream] = []
var multiViewLayoutMode: MultiViewLayoutMode = .balanced
var audioFocusStreamID: String?
var serverBaseURL: String = MLBServerAPI.defaultBaseURL
var defaultResolution: String = "best"
@ObservationIgnored
private var refreshTask: Task<Void, Never>?
@ObservationIgnored
private var authenticatedVideoFeedCache: [String: AuthenticatedVideoFeedCacheEntry] = [:]
@ObservationIgnored
private var videoShuffleBagsByModel: [String: [String: [URL]]] = [:]
@ObservationIgnored
private var cachedStandings: (date: String, standings: [Int: TeamStanding])?
// Computed properties for dashboard
var liveGames: [Game] { games.filter(\.isLive) }
var scheduledGames: [Game] { games.filter { $0.status.isScheduled } }
var finalGames: [Game] { games.filter(\.isFinal) }
var featuredGame: Game? {
let astrosGame = games.first { $0.awayTeam.code == "HOU" || $0.homeTeam.code == "HOU" }
return astrosGame ?? liveGames.first ?? scheduledGames.first ?? games.first
}
var activeAudioStream: ActiveStream? {
guard let audioFocusStreamID else { return nil }
return activeStreams.first { $0.id == audioFocusStreamID }
}
private var mlbServerAPI: MLBServerAPI { MLBServerAPI(baseURL: serverBaseURL) }
private let statsAPI = MLBStatsAPI()
private static let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return f
}()
private var currentDate: Date {
if let s = selectedDate, let d = Self.dateFormatter.date(from: s) { return d }
return Date()
}
var todayDateString: String {
Self.dateFormatter.string(from: currentDate)
}
var displayDateString: String {
let display = DateFormatter()
display.dateFormat = "EEEE, MMMM d"
return display.string(from: currentDate)
}
var isToday: Bool {
Calendar.current.isDateInToday(currentDate)
}
// MARK: - Auto-Refresh
func startAutoRefresh() {
stopAutoRefresh()
refreshTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(60))
guard !Task.isCancelled else { break }
guard let self else { break }
// Refresh if there are live games or active streams
if !self.liveGames.isEmpty || !self.activeStreams.isEmpty {
await self.refreshScores()
}
}
}
}
func stopAutoRefresh() {
refreshTask?.cancel()
refreshTask = nil
}
private func refreshScores() async {
let statsGames = await fetchStatsGames()
guard !statsGames.isEmpty else { return }
// Update scores/innings on existing games without full reload
for sg in statsGames {
let pkStr = String(sg.gamePk)
if let idx = games.firstIndex(where: { $0.id == pkStr }) {
if !sg.isScheduled {
games[idx] = Game(
id: games[idx].id,
awayTeam: TeamInfo(
code: games[idx].awayTeam.code,
name: games[idx].awayTeam.name,
score: sg.teams.away.score,
teamId: games[idx].awayTeam.teamId,
record: games[idx].awayTeam.record
),
homeTeam: TeamInfo(
code: games[idx].homeTeam.code,
name: games[idx].homeTeam.name,
score: sg.teams.home.score,
teamId: games[idx].homeTeam.teamId,
record: games[idx].homeTeam.record
),
status: sg.isLive ? .live(sg.linescore?.currentInningDisplay) : sg.isFinal ? .final_ : games[idx].status,
gameType: games[idx].gameType,
startTime: games[idx].startTime,
venue: games[idx].venue,
pitchers: games[idx].pitchers,
gamePk: games[idx].gamePk,
gameDate: games[idx].gameDate,
broadcasts: games[idx].broadcasts,
isBlackedOut: games[idx].isBlackedOut,
linescore: sg.linescore,
currentInningDisplay: sg.linescore?.currentInningDisplay,
awayPitcherId: games[idx].awayPitcherId,
homePitcherId: games[idx].homePitcherId
)
}
// Also update the game reference in active streams
for streamIdx in activeStreams.indices {
if activeStreams[streamIdx].game.id == pkStr {
activeStreams[streamIdx] = ActiveStream(
id: activeStreams[streamIdx].id,
game: games[idx],
label: activeStreams[streamIdx].label,
mediaId: activeStreams[streamIdx].mediaId,
streamURLString: activeStreams[streamIdx].streamURLString,
config: activeStreams[streamIdx].config,
overrideURL: activeStreams[streamIdx].overrideURL,
overrideHeaders: activeStreams[streamIdx].overrideHeaders,
player: activeStreams[streamIdx].player,
isPlaying: activeStreams[streamIdx].isPlaying,
isMuted: activeStreams[streamIdx].isMuted,
forceMuteAudio: activeStreams[streamIdx].forceMuteAudio
)
}
}
}
}
}
// MARK: - Date Navigation
func goToPreviousDay() async {
let prev = Calendar.current.date(byAdding: .day, value: -1, to: currentDate)!
selectedDate = Self.dateFormatter.string(from: prev)
await loadGames()
}
func goToNextDay() async {
let next = Calendar.current.date(byAdding: .day, value: 1, to: currentDate)!
selectedDate = Self.dateFormatter.string(from: next)
await loadGames()
}
func goToToday() async {
selectedDate = nil
await loadGames()
}
// MARK: - Load Games
func loadGames() async {
isLoading = true
errorMessage = nil
// Fetch all sources concurrently
async let serverGamesTask = fetchServerGames()
async let statsGamesTask = fetchStatsGames()
async let standingsTask = fetchStandings()
let serverGames = await serverGamesTask
let statsGames = await statsGamesTask
let standings = await standingsTask
// Merge: Stats API is primary for rich data, mlbserver provides stream links
games = mergeGames(serverGames: serverGames, statsGames: statsGames, standings: standings)
if games.isEmpty {
errorMessage = "No games found"
}
isLoading = false
}
private func fetchServerGames() async -> [Game] {
do {
return try await mlbServerAPI.fetchGames(date: selectedDate)
} catch {
return []
}
}
private func fetchStatsGames() async -> [StatsGame] {
do {
return try await statsAPI.fetchSchedule(date: todayDateString)
} catch {
return []
}
}
private func fetchStandings() async -> [Int: TeamStanding] {
let today = todayDateString
if let cached = cachedStandings, cached.date == today {
logGamesViewModel("fetchStandings cache hit date=\(today)")
return cached.standings
}
let year = String(today.prefix(4))
do {
let standings = try await statsAPI.fetchStandings(season: year)
cachedStandings = (date: today, standings: standings)
logGamesViewModel("fetchStandings fetched date=\(today) teams=\(standings.count)")
return standings
} catch {
return cachedStandings?.standings ?? [:]
}
}
private func mergeGames(serverGames: [Game], statsGames: [StatsGame], standings: [Int: TeamStanding] = [:]) -> [Game] {
// Build lookup from server games by gamePk
var serverByPk: [String: Game] = [:]
for g in serverGames {
if let pk = g.gamePk { serverByPk[pk] = g }
}
var merged: [Game] = []
for sg in statsGames {
let pkStr = String(sg.gamePk)
let serverGame = serverByPk[pkStr]
let awayAbbr = sg.teams.away.team.abbreviation ?? "???"
let homeAbbr = sg.teams.home.team.abbreviation ?? "???"
let awayRecord: String? = {
guard let r = sg.teams.away.leagueRecord else { return nil }
return "\(r.wins)-\(r.losses)"
}()
let homeRecord: String? = {
guard let r = sg.teams.home.leagueRecord else { return nil }
return "\(r.wins)-\(r.losses)"
}()
let pitchers: String? = {
let away = sg.teams.away.probablePitcher?.fullName
let home = sg.teams.home.probablePitcher?.fullName
if let away, let home { return "\(away) vs \(home)" }
return away ?? home
}()
let status: GameStatus
if sg.isLive {
status = .live(sg.linescore?.currentInningDisplay)
} else if sg.isFinal {
status = .final_
} else if let time = sg.startTime {
status = .scheduled(time)
} else {
status = .unknown
}
let awayStanding = standings[sg.teams.away.team.id]
let homeStanding = standings[sg.teams.home.team.id]
let game = Game(
id: pkStr,
awayTeam: TeamInfo(
code: awayAbbr,
name: sg.teams.away.team.name ?? awayAbbr,
score: sg.isScheduled ? nil : sg.teams.away.score,
teamId: sg.teams.away.team.id,
record: awayRecord,
divisionRank: awayStanding?.divisionRank,
gamesBack: awayStanding?.gamesBack,
streak: awayStanding?.streak
),
homeTeam: TeamInfo(
code: homeAbbr,
name: sg.teams.home.team.name ?? homeAbbr,
score: sg.isScheduled ? nil : sg.teams.home.score,
teamId: sg.teams.home.team.id,
record: homeRecord,
divisionRank: homeStanding?.divisionRank,
gamesBack: homeStanding?.gamesBack,
streak: homeStanding?.streak
),
status: status,
gameType: sg.seriesDescription ?? sg.gameType,
startTime: sg.startTime,
venue: sg.venue?.name,
pitchers: pitchers ?? serverGame?.pitchers,
gamePk: pkStr,
gameDate: todayDateString,
broadcasts: serverGame?.broadcasts ?? [],
isBlackedOut: serverGame?.isBlackedOut ?? false,
linescore: sg.linescore,
currentInningDisplay: sg.linescore?.currentInningDisplay,
awayPitcherId: sg.teams.away.probablePitcher?.id,
homePitcherId: sg.teams.home.probablePitcher?.id
)
merged.append(game)
serverByPk.removeValue(forKey: pkStr)
}
// Add any server-only games not in Stats API
for (_, serverGame) in serverByPk {
merged.append(serverGame)
}
// Sort: live first, then scheduled by time, then final
merged.sort { a, b in
let order: (Game) -> Int = { g in
if g.isLive { return 0 }
if g.status.isScheduled { return 1 }
return 2
}
return order(a) < order(b)
}
return merged
}
// MARK: - Stream Management
func addStream(broadcast: Broadcast, game: Game) {
guard activeStreams.count < 4 else { return }
guard !activeStreams.contains(where: { $0.id == broadcast.id }) else { return }
let stream = ActiveStream(
id: broadcast.id,
game: game,
label: broadcast.displayLabel,
mediaId: broadcast.mediaId,
streamURLString: broadcast.streamURL
)
activeStreams.append(stream)
if shouldCaptureAudio(for: stream) {
audioFocusStreamID = stream.id
}
syncAudioFocus()
}
func addStreamByTeam(teamCode: String, game: Game) {
guard activeStreams.count < 4 else { return }
let config = StreamConfig(team: teamCode, resolution: defaultResolution, date: selectedDate)
let stream = ActiveStream(
id: "\(teamCode)-\(game.id)",
game: game,
label: teamCode,
config: config
)
activeStreams.append(stream)
if shouldCaptureAudio(for: stream) {
audioFocusStreamID = stream.id
}
syncAudioFocus()
}
func addSpecialStream(
id: String,
label: String,
game: Game,
url: URL,
headers: [String: String] = [:],
forceMuteAudio: Bool = false
) {
guard activeStreams.count < 4 else { return }
guard !activeStreams.contains(where: { $0.id == id }) else { return }
let stream = ActiveStream(
id: id,
game: game,
label: label,
overrideURL: url,
overrideHeaders: headers.isEmpty ? nil : headers,
forceMuteAudio: forceMuteAudio
)
activeStreams.append(stream)
if shouldCaptureAudio(for: stream) {
audioFocusStreamID = stream.id
}
syncAudioFocus()
}
func addSpecialStreamFromAuthenticatedFeed(
id: String,
label: String,
game: Game,
feedURL: URL,
headers: [String: String] = [:],
forceMuteAudio: Bool = false
) async -> Bool {
guard let resolvedURL = await resolveAuthenticatedVideoFeedURL(feedURL: feedURL, headers: headers) else {
return false
}
addSpecialStream(
id: id,
label: label,
game: game,
url: resolvedURL,
headers: headers,
forceMuteAudio: forceMuteAudio
)
return true
}
func resolveNextAuthenticatedFeedURLForActiveStream(
id: String,
feedURL: URL,
headers: [String: String] = [:],
maxRetries: Int = 3
) async -> URL? {
let currentURL = activeStreams.first(where: { $0.id == id })?.overrideURL
for attempt in 1...maxRetries {
if let nextURL = await resolveAuthenticatedVideoFeedURL(
feedURL: feedURL,
headers: headers,
excluding: currentURL
) {
updateStreamOverrideSource(id: id, url: nextURL, headers: headers)
return nextURL
}
logGamesViewModel("resolveNextAuthenticatedFeedURL retry attempt=\(attempt)/\(maxRetries) id=\(id)")
if attempt < maxRetries {
try? await Task.sleep(nanoseconds: UInt64(attempt) * 500_000_000)
}
}
logGamesViewModel("resolveNextAuthenticatedFeedURL exhausted retries id=\(id)")
return nil
}
func removeStream(id: String) {
if let index = activeStreams.firstIndex(where: { $0.id == id }) {
let removedWasAudioFocus = activeStreams[index].id == audioFocusStreamID
activeStreams[index].player?.pause()
activeStreams.remove(at: index)
if activeStreams.isEmpty {
audioFocusStreamID = nil
} else if removedWasAudioFocus {
let replacementIndex = min(index, activeStreams.count - 1)
audioFocusStreamID = preferredAudioFocusStreamID(preferredIndex: replacementIndex)
}
syncAudioFocus()
}
}
func clearAllStreams() {
for s in activeStreams { s.player?.pause() }
activeStreams.removeAll()
audioFocusStreamID = nil
}
func promoteStream(id: String) {
guard let index = activeStreams.firstIndex(where: { $0.id == id }), index > 0 else { return }
let stream = activeStreams.remove(at: index)
activeStreams.insert(stream, at: 0)
}
func canMoveStream(id: String, direction: Int) -> Bool {
guard let index = activeStreams.firstIndex(where: { $0.id == id }) else { return false }
let newIndex = index + direction
return activeStreams.indices.contains(newIndex)
}
func moveStream(id: String, direction: Int) {
guard let index = activeStreams.firstIndex(where: { $0.id == id }) else { return }
let newIndex = index + direction
guard activeStreams.indices.contains(newIndex) else { return }
activeStreams.swapAt(index, newIndex)
}
func setAudioFocus(streamID: String?) {
if let streamID,
let stream = activeStreams.first(where: { $0.id == streamID }),
!stream.forceMuteAudio {
audioFocusStreamID = streamID
} else if streamID != nil {
syncAudioFocus()
return
} else {
audioFocusStreamID = nil
}
syncAudioFocus()
}
func toggleAudioFocus(streamID: String) {
setAudioFocus(streamID: audioFocusStreamID == streamID ? nil : streamID)
}
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
if !alreadyAttached {
let shouldMute = shouldMuteAudio(for: activeStreams[index])
activeStreams[index].isMuted = shouldMute
player.isMuted = shouldMute
}
}
func updateStreamOverrideSource(id: String, url: URL, headers: [String: String] = [:]) {
guard let index = activeStreams.firstIndex(where: { $0.id == id }) else { return }
activeStreams[index].overrideURL = url
activeStreams[index].overrideHeaders = headers.isEmpty ? nil : headers
}
func isPrimaryStream(_ streamID: String) -> Bool {
activeStreams.first?.id == streamID
}
private func syncAudioFocus() {
if let audioFocusStreamID,
!activeStreams.contains(where: { $0.id == audioFocusStreamID && !$0.forceMuteAudio }) {
self.audioFocusStreamID = preferredAudioFocusStreamID()
}
for index in activeStreams.indices {
let shouldMute = shouldMuteAudio(for: activeStreams[index])
activeStreams[index].isMuted = shouldMute
activeStreams[index].player?.isMuted = shouldMute
}
}
private func shouldCaptureAudio(for stream: ActiveStream) -> Bool {
!stream.forceMuteAudio && audioFocusStreamID == nil
}
private func shouldMuteAudio(for stream: ActiveStream) -> Bool {
stream.forceMuteAudio || audioFocusStreamID != stream.id
}
private func preferredAudioFocusStreamID(preferredIndex: Int? = nil) -> String? {
let eligibleIndices = activeStreams.indices.filter { !activeStreams[$0].forceMuteAudio }
guard !eligibleIndices.isEmpty else { return nil }
if let preferredIndex {
if let forwardIndex = eligibleIndices.first(where: { $0 >= preferredIndex }) {
return activeStreams[forwardIndex].id
}
if let fallbackIndex = eligibleIndices.last {
return activeStreams[fallbackIndex].id
}
}
return activeStreams[eligibleIndices[0]].id
}
func buildStreamURL(for config: StreamConfig) async -> URL {
let startedAt = Date()
logGamesViewModel("buildStreamURL start mediaId=\(config.mediaId) resolution=\(config.resolution)")
let url = await mlbServerAPI.streamURL(for: config)
let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000)
logGamesViewModel("buildStreamURL success mediaId=\(config.mediaId) elapsedMs=\(elapsedMs) url=\(gamesViewModelDebugURLDescription(url))")
return url
}
func buildEventStreamURL(event: String, resolution: String = "best") async -> URL {
let startedAt = Date()
logGamesViewModel("buildEventStreamURL start event=\(event) resolution=\(resolution)")
let url = await mlbServerAPI.eventStreamURL(event: event, resolution: resolution)
let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000)
logGamesViewModel("buildEventStreamURL success event=\(event) elapsedMs=\(elapsedMs) url=\(gamesViewModelDebugURLDescription(url))")
return url
}
func resolveAuthenticatedVideoFeedURL(
feedURL: URL,
headers: [String: String] = [:],
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 buckets = videoShuffleBagsByModel[cacheKey] ?? [:]
// 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)]"
)
}
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
}
videoShuffleBagsByModel[cacheKey] = pick.remaining
let remainingTotal = pick.remaining.values.map(\.count).reduce(0, +)
logGamesViewModel(
"resolveAuthenticatedVideoFeedURL pop model=\(pick.key) remainingTotal=\(remainingTotal) excludedModel=\(excludedModel ?? "nil")"
)
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(
feedURL: URL,
headers: [String: String] = [:]
) async -> [URL]? {
let cacheKey = authenticatedVideoFeedCacheKey(feedURL: feedURL, headers: headers)
if let cachedEntry = authenticatedVideoFeedCache[cacheKey],
!cachedEntry.urls.isEmpty {
logGamesViewModel(
"fetchAuthenticatedVideoFeedURLs cache hit feedURL=\(gamesViewModelDebugURLDescription(feedURL)) count=\(cachedEntry.urls.count)"
)
return cachedEntry.urls
}
logGamesViewModel(
"fetchAuthenticatedVideoFeedURLs start feedURL=\(gamesViewModelDebugURLDescription(feedURL)) headerKeys=\(headers.keys.sorted().joined(separator: ","))"
)
var request = URLRequest(url: feedURL)
request.httpMethod = "GET"
request.timeoutInterval = 20
request.cachePolicy = .reloadIgnoringLocalCacheData
for (header, value) in headers {
request.setValue(value, forHTTPHeaderField: header)
}
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
logGamesViewModel("fetchAuthenticatedVideoFeedURLs failed reason=non-http-response")
return nil
}
guard (200 ... 299).contains(httpResponse.statusCode) else {
logGamesViewModel("fetchAuthenticatedVideoFeedURLs failed statusCode=\(httpResponse.statusCode)")
return nil
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let entries = try decoder.decode([RemoteVideoFeedEntry].self, from: data)
let urls: [URL] = entries.compactMap { entry -> URL? in
guard let path = entry.hlsUrl ?? entry.videoFile else { return nil }
return URL(string: path, relativeTo: feedURL)?.absoluteURL
}
guard !urls.isEmpty else {
logGamesViewModel("fetchAuthenticatedVideoFeedURLs failed reason=no-feed-entries")
return nil
}
authenticatedVideoFeedCache[cacheKey] = AuthenticatedVideoFeedCacheEntry(loadedAt: Date(), urls: urls)
logGamesViewModel(
"fetchAuthenticatedVideoFeedURLs success feedURL=\(gamesViewModelDebugURLDescription(feedURL)) count=\(urls.count)"
)
return urls
} catch {
logGamesViewModel("fetchAuthenticatedVideoFeedURLs failed error=\(error.localizedDescription)")
return nil
}
}
private func authenticatedVideoFeedCacheKey(feedURL: URL, headers: [String: String]) -> String {
let serializedHeaders = headers
.sorted { $0.key < $1.key }
.map { "\($0.key)=\($0.value)" }
.joined(separator: "&")
return "\(feedURL.absoluteString)|\(serializedHeaders)"
}
func resolveStreamURL(for stream: ActiveStream) async -> URL? {
await resolveStreamURLImpl(
for: stream,
resolutionOverride: nil,
preserveServerResolutionWhenBest: true
)
}
func resolveStreamURL(
for stream: ActiveStream,
resolutionOverride: String,
preserveServerResolutionWhenBest: Bool = false
) async -> URL? {
await resolveStreamURLImpl(
for: stream,
resolutionOverride: resolutionOverride,
preserveServerResolutionWhenBest: preserveServerResolutionWhenBest
)
}
private func resolveStreamURLImpl(
for stream: ActiveStream,
resolutionOverride: String?,
preserveServerResolutionWhenBest: Bool
) async -> URL? {
let requestedResolution = resolutionOverride ?? defaultResolution
logGamesViewModel(
"resolveStreamURL start id=\(stream.id) label=\(stream.label) requestedResolution=\(requestedResolution) hasDirectURL=\(stream.streamURLString != nil) hasMediaId=\(stream.mediaId != nil) hasConfig=\(stream.config != nil) hasOverride=\(stream.overrideURL != nil)"
)
if let urlStr = stream.streamURLString {
logGamesViewModel("resolveStreamURL using direct stream URL id=\(stream.id) raw=\(urlStr)")
let rewritten = urlStr.replacingOccurrences(of: "http://127.0.0.1:9999", with: serverBaseURL)
if let url = URL(string: rewritten), var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
var items = components.queryItems ?? []
let hasResolution = items.contains { $0.name == "resolution" }
if requestedResolution == "best", hasResolution, preserveServerResolutionWhenBest {
logGamesViewModel("resolveStreamURL preserving server resolution for direct URL id=\(stream.id) because requestedResolution=best")
} else if let idx = items.firstIndex(where: { $0.name == "resolution" }) {
items[idx] = URLQueryItem(name: "resolution", value: requestedResolution)
} else if resolutionOverride != nil || requestedResolution != "best" {
items.append(URLQueryItem(name: "resolution", value: requestedResolution))
}
components.queryItems = items
if let resolvedURL = components.url {
logGamesViewModel(
"resolveStreamURL direct success id=\(stream.id) url=\(gamesViewModelDebugURLDescription(resolvedURL))"
)
return resolvedURL
}
logGamesViewModel("resolveStreamURL direct failed id=\(stream.id) could not rebuild URL components")
} else {
logGamesViewModel("resolveStreamURL direct failed id=\(stream.id) invalid rewritten URL")
}
}
if let mediaId = stream.mediaId {
let startedAt = Date()
logGamesViewModel("resolveStreamURL fetching mediaId id=\(stream.id) mediaId=\(mediaId) resolution=\(requestedResolution)")
let url = await mlbServerAPI.streamURL(for: StreamConfig(mediaId: mediaId, resolution: requestedResolution))
let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000)
logGamesViewModel(
"resolveStreamURL mediaId success id=\(stream.id) mediaId=\(mediaId) elapsedMs=\(elapsedMs) url=\(gamesViewModelDebugURLDescription(url))"
)
return url
}
if let config = stream.config {
let startedAt = Date()
let configResolution = resolutionOverride ?? config.resolution
logGamesViewModel("resolveStreamURL fetching config id=\(stream.id) mediaId=\(config.mediaId) resolution=\(configResolution)")
let requestConfig: StreamConfig
if let mediaId = config.mediaId {
requestConfig = StreamConfig(
mediaId: mediaId,
resolution: configResolution,
audioTrack: config.audioTrack
)
} else if let team = config.team {
requestConfig = StreamConfig(
team: team,
resolution: configResolution,
date: config.date,
level: config.level,
skip: config.skip,
audioTrack: config.audioTrack,
mediaType: config.mediaType,
game: config.game
)
} else {
logGamesViewModel("resolveStreamURL config failed id=\(stream.id) missing mediaId and team")
return nil
}
let url = await mlbServerAPI.streamURL(for: requestConfig)
let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000)
logGamesViewModel(
"resolveStreamURL config success id=\(stream.id) mediaId=\(config.mediaId) elapsedMs=\(elapsedMs) url=\(gamesViewModelDebugURLDescription(url))"
)
return url
}
logGamesViewModel("resolveStreamURL failed id=\(stream.id) no usable stream source")
return nil
}
func addMLBNetwork() async {
guard activeStreams.count < 4 else { return }
guard !activeStreams.contains(where: { $0.id == "MLBN" }) else { return }
let dummyGame = Game(
id: "MLBN",
awayTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
homeTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
status: .live(nil), gameType: nil, startTime: nil, venue: nil,
pitchers: nil, gamePk: nil, gameDate: "",
broadcasts: [], isBlackedOut: false
)
let url = await mlbServerAPI.eventStreamURL(event: "MLBN", resolution: defaultResolution)
let stream = ActiveStream(
id: "MLBN", game: dummyGame, label: "MLB Network", overrideURL: url
)
activeStreams.append(stream)
if shouldCaptureAudio(for: stream) {
audioFocusStreamID = stream.id
}
syncAudioFocus()
}
}
enum MultiViewLayoutMode: String, CaseIterable, Identifiable, Sendable {
case balanced
case spotlight
var id: String { rawValue }
var title: String {
switch self {
case .balanced: "Balanced"
case .spotlight: "Spotlight"
}
}
var systemImage: String {
switch self {
case .balanced: "square.grid.2x2"
case .spotlight: "rectangle.leadinghalf.filled"
}
}
}
struct ActiveStream: Identifiable, @unchecked Sendable {
let id: String
let game: Game
let label: String
var mediaId: String?
var streamURLString: String?
var config: StreamConfig?
var overrideURL: URL?
var overrideHeaders: [String: String]?
var player: AVPlayer?
var isPlaying = false
var isMuted = false
var forceMuteAudio = false
}