822 lines
31 KiB
Swift
822 lines
31 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 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 = "http://10.3.3.11:5714"
|
|
var defaultResolution: String = "best"
|
|
|
|
@ObservationIgnored
|
|
private var refreshTask: Task<Void, Never>?
|
|
@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
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 shouldCaptureAudio = activeStreams.isEmpty && audioFocusStreamID == nil
|
|
|
|
let stream = ActiveStream(
|
|
id: broadcast.id,
|
|
game: game,
|
|
label: broadcast.displayLabel,
|
|
mediaId: broadcast.mediaId,
|
|
streamURLString: broadcast.streamURL
|
|
)
|
|
activeStreams.append(stream)
|
|
if shouldCaptureAudio {
|
|
audioFocusStreamID = stream.id
|
|
}
|
|
syncAudioFocus()
|
|
}
|
|
|
|
func addStreamByTeam(teamCode: String, game: Game) {
|
|
guard activeStreams.count < 4 else { return }
|
|
let shouldCaptureAudio = activeStreams.isEmpty && audioFocusStreamID == nil
|
|
|
|
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 {
|
|
audioFocusStreamID = stream.id
|
|
}
|
|
syncAudioFocus()
|
|
}
|
|
|
|
func addSpecialStream(
|
|
id: String,
|
|
label: String,
|
|
game: Game,
|
|
url: URL,
|
|
headers: [String: String] = [:]
|
|
) {
|
|
guard activeStreams.count < 4 else { return }
|
|
guard !activeStreams.contains(where: { $0.id == id }) else { return }
|
|
let shouldCaptureAudio = activeStreams.isEmpty && audioFocusStreamID == nil
|
|
|
|
let stream = ActiveStream(
|
|
id: id,
|
|
game: game,
|
|
label: label,
|
|
overrideURL: url,
|
|
overrideHeaders: headers.isEmpty ? nil : headers
|
|
)
|
|
activeStreams.append(stream)
|
|
if shouldCaptureAudio {
|
|
audioFocusStreamID = stream.id
|
|
}
|
|
syncAudioFocus()
|
|
}
|
|
|
|
func addSpecialStreamFromAuthenticatedFeed(
|
|
id: String,
|
|
label: String,
|
|
game: Game,
|
|
feedURL: URL,
|
|
headers: [String: String] = [:]
|
|
) 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
|
|
)
|
|
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 = activeStreams[replacementIndex].id
|
|
}
|
|
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, activeStreams.contains(where: { $0.id == streamID }) {
|
|
audioFocusStreamID = streamID
|
|
} 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 = audioFocusStreamID != streamID
|
|
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() {
|
|
for index in activeStreams.indices {
|
|
let shouldMute = activeStreams[index].id != audioFocusStreamID
|
|
activeStreams[index].isMuted = shouldMute
|
|
activeStreams[index].player?.isMuted = shouldMute
|
|
}
|
|
}
|
|
|
|
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 = entries.compactMap { entry in
|
|
URL(string: entry.videoFile, 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 shouldCaptureAudio = activeStreams.isEmpty && audioFocusStreamID == nil
|
|
|
|
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 {
|
|
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
|
|
}
|