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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user