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? @ObservationIgnored private var authenticatedVideoFeedCache: [String: AuthenticatedVideoFeedCacheEntry] = [:] // 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 year = String(todayDateString.prefix(4)) do { return try await statsAPI.fetchStandings(season: year) } catch { return [:] } } 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] = [:] ) async -> URL? { let currentURL = activeStreams.first(where: { $0.id == id })?.overrideURL guard let nextURL = await resolveAuthenticatedVideoFeedURL( feedURL: feedURL, headers: headers, excluding: currentURL ) else { return nil } updateStreamOverrideSource(id: id, url: nextURL, headers: headers) return nextURL } 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 } activeStreams[index].player = player activeStreams[index].isPlaying = true 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 { return nil } let selectableURLs: [URL] if let excludedURL, urls.count > 1 { let filteredURLs = urls.filter { $0 != excludedURL } selectableURLs = filteredURLs.isEmpty ? urls : filteredURLs } else { selectableURLs = urls } guard let selectedURL = selectableURLs.randomElement() else { logGamesViewModel("resolveAuthenticatedVideoFeedURL failed reason=no-selectable-urls") return nil } logGamesViewModel( "resolveAuthenticatedVideoFeedURL success resolvedURL=\(gamesViewModelDebugURLDescription(selectedURL)) excludedURL=\(excludedURL.map(gamesViewModelDebugURLDescription) ?? "nil")" ) return selectedURL } private func fetchAuthenticatedVideoFeedURLs( feedURL: URL, headers: [String: String] = [:] ) async -> [URL]? { let cacheKey = authenticatedVideoFeedCacheKey(feedURL: feedURL, headers: headers) if let cachedEntry = authenticatedVideoFeedCache[cacheKey], Date().timeIntervalSince(cachedEntry.loadedAt) < 300, !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 }