Add iOS/iPad target with platform-adaptive UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-30 21:30:28 -05:00
parent 127125ae1b
commit fda809fd2f
21 changed files with 851 additions and 129 deletions

View File

@@ -25,7 +25,8 @@ private func gamesViewModelDebugURLDescription(_ url: URL) -> String {
}
private struct RemoteVideoFeedEntry: Decodable {
let videoFile: String
let videoFile: String?
let hlsUrl: String?
let genderValue: String?
}
@@ -46,7 +47,7 @@ final class GamesViewModel {
var multiViewLayoutMode: MultiViewLayoutMode = .balanced
var audioFocusStreamID: String?
var serverBaseURL: String = "http://10.3.3.11:5714"
var serverBaseURL: String = MLBServerAPI.defaultBaseURL
var defaultResolution: String = "best"
@ObservationIgnored
@@ -172,7 +173,8 @@ final class GamesViewModel {
overrideHeaders: activeStreams[streamIdx].overrideHeaders,
player: activeStreams[streamIdx].player,
isPlaying: activeStreams[streamIdx].isPlaying,
isMuted: activeStreams[streamIdx].isMuted
isMuted: activeStreams[streamIdx].isMuted,
forceMuteAudio: activeStreams[streamIdx].forceMuteAudio
)
}
}
@@ -358,8 +360,6 @@ final class GamesViewModel {
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,
@@ -368,7 +368,7 @@ final class GamesViewModel {
streamURLString: broadcast.streamURL
)
activeStreams.append(stream)
if shouldCaptureAudio {
if shouldCaptureAudio(for: stream) {
audioFocusStreamID = stream.id
}
syncAudioFocus()
@@ -376,8 +376,6 @@ final class GamesViewModel {
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)",
@@ -386,7 +384,7 @@ final class GamesViewModel {
config: config
)
activeStreams.append(stream)
if shouldCaptureAudio {
if shouldCaptureAudio(for: stream) {
audioFocusStreamID = stream.id
}
syncAudioFocus()
@@ -397,21 +395,22 @@ final class GamesViewModel {
label: String,
game: Game,
url: URL,
headers: [String: String] = [:]
headers: [String: String] = [:],
forceMuteAudio: Bool = false
) {
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
overrideHeaders: headers.isEmpty ? nil : headers,
forceMuteAudio: forceMuteAudio
)
activeStreams.append(stream)
if shouldCaptureAudio {
if shouldCaptureAudio(for: stream) {
audioFocusStreamID = stream.id
}
syncAudioFocus()
@@ -422,7 +421,8 @@ final class GamesViewModel {
label: String,
game: Game,
feedURL: URL,
headers: [String: String] = [:]
headers: [String: String] = [:],
forceMuteAudio: Bool = false
) async -> Bool {
guard let resolvedURL = await resolveAuthenticatedVideoFeedURL(feedURL: feedURL, headers: headers) else {
return false
@@ -433,7 +433,8 @@ final class GamesViewModel {
label: label,
game: game,
url: resolvedURL,
headers: headers
headers: headers,
forceMuteAudio: forceMuteAudio
)
return true
}
@@ -464,7 +465,7 @@ final class GamesViewModel {
audioFocusStreamID = nil
} else if removedWasAudioFocus {
let replacementIndex = min(index, activeStreams.count - 1)
audioFocusStreamID = activeStreams[replacementIndex].id
audioFocusStreamID = preferredAudioFocusStreamID(preferredIndex: replacementIndex)
}
syncAudioFocus()
}
@@ -496,8 +497,13 @@ final class GamesViewModel {
}
func setAudioFocus(streamID: String?) {
if let streamID, activeStreams.contains(where: { $0.id == streamID }) {
if let streamID,
let stream = activeStreams.first(where: { $0.id == streamID }),
!stream.forceMuteAudio {
audioFocusStreamID = streamID
} else if streamID != nil {
syncAudioFocus()
return
} else {
audioFocusStreamID = nil
}
@@ -512,7 +518,7 @@ final class GamesViewModel {
guard let index = activeStreams.firstIndex(where: { $0.id == streamID }) else { return }
activeStreams[index].player = player
activeStreams[index].isPlaying = true
let shouldMute = audioFocusStreamID != streamID
let shouldMute = shouldMuteAudio(for: activeStreams[index])
activeStreams[index].isMuted = shouldMute
player.isMuted = shouldMute
}
@@ -528,13 +534,42 @@ final class GamesViewModel {
}
private func syncAudioFocus() {
if let audioFocusStreamID,
!activeStreams.contains(where: { $0.id == audioFocusStreamID && !$0.forceMuteAudio }) {
self.audioFocusStreamID = preferredAudioFocusStreamID()
}
for index in activeStreams.indices {
let shouldMute = activeStreams[index].id != audioFocusStreamID
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)")
@@ -623,8 +658,9 @@ final class GamesViewModel {
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
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 {
@@ -763,7 +799,6 @@ final class GamesViewModel {
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",
@@ -778,7 +813,7 @@ final class GamesViewModel {
id: "MLBN", game: dummyGame, label: "MLB Network", overrideURL: url
)
activeStreams.append(stream)
if shouldCaptureAudio {
if shouldCaptureAudio(for: stream) {
audioFocusStreamID = stream.id
}
syncAudioFocus()
@@ -818,4 +853,5 @@ struct ActiveStream: Identifiable, @unchecked Sendable {
var player: AVPlayer?
var isPlaying = false
var isMuted = false
var forceMuteAudio = false
}