Aggiunge il mute del microfono in diretta per evitare claim YouTube sulla musica in palestra.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-12 22:38:05 +02:00
co-authored by Cursor
parent bc2257efd2
commit 1fecccaafd
53 changed files with 827 additions and 25 deletions
@@ -119,6 +119,7 @@ enum L10n {
"broadcast.error.rtmp.url.invalid": "URL RTMP non valido",
"broadcast.error.session.unavailable": "Sessione non disponibile",
"broadcast.hide.controls.cd": "Nascondi controlli",
"broadcast.mute.cd": "Silenzia audio",
"broadcast.next.period.cd": "Periodo successivo",
"broadcast.pause.cd": "Pausa diretta",
"broadcast.period.label.timed": "%1$d° tempo",
@@ -140,6 +141,9 @@ enum L10n {
"broadcast.side.away": "ospite",
"broadcast.side.home": "casa",
"broadcast.snackbar.closed.remote": "Diretta chiusa dalla regia",
"broadcast.snackbar.mute.error": "Audio: %1$@",
"broadcast.snackbar.muted": "Audio silenziato",
"broadcast.snackbar.muted.remote": "Audio silenziato dalla regia",
"broadcast.snackbar.pause.error": "Pausa: %1$@",
"broadcast.snackbar.paused": "Diretta in pausa",
"broadcast.snackbar.paused.remote": "Pausa dalla regia",
@@ -148,6 +152,8 @@ enum L10n {
"broadcast.snackbar.resume.rtmp.error": "Ripresa RTMP: %1$@",
"broadcast.snackbar.resumed": "Diretta ripresa",
"broadcast.snackbar.resumed.remote": "Ripresa dalla regia",
"broadcast.snackbar.unmuted": "Audio riattivato",
"broadcast.snackbar.unmuted.remote": "Audio riattivato dalla regia",
"broadcast.status.connecting": "CONNESSIONE…",
"broadcast.status.error.fallback": "ERRORE",
"broadcast.status.live": "IN DIRETTA",
@@ -164,6 +170,8 @@ enum L10n {
"broadcast.tooltip.add.point": "Aggiungi punto %1$@",
"broadcast.tooltip.plus.side": "+%1$d %2$@",
"broadcast.tooltip.remove.point": "Togli punto %1$@",
"broadcast.unmute.cd": "Riattiva audio",
"broadcast.audio.muted.label": "Audio off",
"common.close": "Chiudi",
"common.error.generic": "errore",
"common.error.title": "Errore",
@@ -409,6 +417,7 @@ enum L10n {
"broadcast.error.rtmp.url.invalid": "Invalid RTMP URL",
"broadcast.error.session.unavailable": "Session not available",
"broadcast.hide.controls.cd": "Hide controls",
"broadcast.mute.cd": "Mute audio",
"broadcast.next.period.cd": "Next period",
"broadcast.pause.cd": "Pause live",
"broadcast.period.label.timed": "Period %1$d",
@@ -430,6 +439,9 @@ enum L10n {
"broadcast.side.away": "away",
"broadcast.side.home": "home",
"broadcast.snackbar.closed.remote": "Live closed from control room",
"broadcast.snackbar.mute.error": "Audio: %1$@",
"broadcast.snackbar.muted": "Audio muted",
"broadcast.snackbar.muted.remote": "Audio muted from control room",
"broadcast.snackbar.pause.error": "Pause: %1$@",
"broadcast.snackbar.paused": "Live paused",
"broadcast.snackbar.paused.remote": "Paused from control room",
@@ -438,6 +450,8 @@ enum L10n {
"broadcast.snackbar.resume.rtmp.error": "RTMP resume: %1$@",
"broadcast.snackbar.resumed": "Live resumed",
"broadcast.snackbar.resumed.remote": "Resumed from control room",
"broadcast.snackbar.unmuted": "Audio restored",
"broadcast.snackbar.unmuted.remote": "Audio restored from control room",
"broadcast.status.connecting": "CONNECTING…",
"broadcast.status.error.fallback": "ERROR",
"broadcast.status.live": "LIVE",
@@ -454,6 +468,8 @@ enum L10n {
"broadcast.tooltip.add.point": "Add point %1$@",
"broadcast.tooltip.plus.side": "+%1$d %2$@",
"broadcast.tooltip.remove.point": "Remove point %1$@",
"broadcast.unmute.cd": "Unmute audio",
"broadcast.audio.muted.label": "Audio off",
"common.close": "Close",
"common.error.generic": "error",
"common.error.title": "Error",
@@ -699,6 +715,7 @@ enum L10n {
"broadcast.error.rtmp.url.invalid": "URL RTMP non valide",
"broadcast.error.session.unavailable": "Session non disponible",
"broadcast.hide.controls.cd": "Masquer les commandes",
"broadcast.mute.cd": "Couper l'audio",
"broadcast.next.period.cd": "Période suivante",
"broadcast.pause.cd": "Mettre le direct en pause",
"broadcast.period.label.timed": "Période %1$d",
@@ -720,6 +737,9 @@ enum L10n {
"broadcast.side.away": "extérieur",
"broadcast.side.home": "domicile",
"broadcast.snackbar.closed.remote": "Direct fermé depuis la régie",
"broadcast.snackbar.mute.error": "Audio : %1$@",
"broadcast.snackbar.muted": "Audio coupé",
"broadcast.snackbar.muted.remote": "Audio coupé depuis la régie",
"broadcast.snackbar.pause.error": "Pause : %1$@",
"broadcast.snackbar.paused": "Direct en pause",
"broadcast.snackbar.paused.remote": "Mis en pause depuis la régie",
@@ -728,6 +748,8 @@ enum L10n {
"broadcast.snackbar.resume.rtmp.error": "Reprise RTMP : %1$@",
"broadcast.snackbar.resumed": "Direct repris",
"broadcast.snackbar.resumed.remote": "Repris depuis la régie",
"broadcast.snackbar.unmuted": "Audio rétabli",
"broadcast.snackbar.unmuted.remote": "Audio rétabli depuis la régie",
"broadcast.status.connecting": "CONNEXION…",
"broadcast.status.error.fallback": "ERREUR",
"broadcast.status.live": "EN DIRECT",
@@ -744,6 +766,8 @@ enum L10n {
"broadcast.tooltip.add.point": "Ajouter un point %1$@",
"broadcast.tooltip.plus.side": "+%1$d %2$@",
"broadcast.tooltip.remove.point": "Retirer un point %1$@",
"broadcast.unmute.cd": "Réactiver l'audio",
"broadcast.audio.muted.label": "Audio off",
"common.close": "Fermer",
"common.error.generic": "erreur",
"common.error.title": "Erreur",
@@ -989,6 +1013,7 @@ enum L10n {
"broadcast.error.rtmp.url.invalid": "Ungültige RTMP-URL",
"broadcast.error.session.unavailable": "Sitzung nicht verfügbar",
"broadcast.hide.controls.cd": "Steuerung ausblenden",
"broadcast.mute.cd": "Audio stummschalten",
"broadcast.next.period.cd": "Nächste Periode",
"broadcast.pause.cd": "Livestream pausieren",
"broadcast.period.label.timed": "Halbzeit %1$d",
@@ -1010,6 +1035,9 @@ enum L10n {
"broadcast.side.away": "Gast",
"broadcast.side.home": "Heim",
"broadcast.snackbar.closed.remote": "Livestream von der Regie beendet",
"broadcast.snackbar.mute.error": "Audio: %1$@",
"broadcast.snackbar.muted": "Audio stummgeschaltet",
"broadcast.snackbar.muted.remote": "Audio von der Regie stummgeschaltet",
"broadcast.snackbar.pause.error": "Pause: %1$@",
"broadcast.snackbar.paused": "Livestream pausiert",
"broadcast.snackbar.paused.remote": "Von der Regie pausiert",
@@ -1018,6 +1046,8 @@ enum L10n {
"broadcast.snackbar.resume.rtmp.error": "RTMP-Fortsetzung: %1$@",
"broadcast.snackbar.resumed": "Livestream fortgesetzt",
"broadcast.snackbar.resumed.remote": "Von der Regie fortgesetzt",
"broadcast.snackbar.unmuted": "Audio wieder aktiv",
"broadcast.snackbar.unmuted.remote": "Audio von der Regie wieder aktiviert",
"broadcast.status.connecting": "VERBINDUNG…",
"broadcast.status.error.fallback": "FEHLER",
"broadcast.status.live": "LIVE",
@@ -1034,6 +1064,8 @@ enum L10n {
"broadcast.tooltip.add.point": "Punkt hinzufügen %1$@",
"broadcast.tooltip.plus.side": "+%1$d %2$@",
"broadcast.tooltip.remove.point": "Punkt entfernen %1$@",
"broadcast.unmute.cd": "Audio einschalten",
"broadcast.audio.muted.label": "Audio aus",
"common.close": "Schließen",
"common.error.generic": "Fehler",
"common.error.title": "Fehler",
@@ -1279,6 +1311,7 @@ enum L10n {
"broadcast.error.rtmp.url.invalid": "URL RTMP no válida",
"broadcast.error.session.unavailable": "Sesión no disponible",
"broadcast.hide.controls.cd": "Ocultar controles",
"broadcast.mute.cd": "Silenciar audio",
"broadcast.next.period.cd": "Periodo siguiente",
"broadcast.pause.cd": "Pausar directo",
"broadcast.period.label.timed": "Tiempo %1$d",
@@ -1300,6 +1333,9 @@ enum L10n {
"broadcast.side.away": "visitante",
"broadcast.side.home": "local",
"broadcast.snackbar.closed.remote": "Directo cerrado desde regie",
"broadcast.snackbar.mute.error": "Audio: %1$@",
"broadcast.snackbar.muted": "Audio silenciado",
"broadcast.snackbar.muted.remote": "Audio silenciado desde regie",
"broadcast.snackbar.pause.error": "Pausa: %1$@",
"broadcast.snackbar.paused": "Directo en pausa",
"broadcast.snackbar.paused.remote": "Pausado desde regie",
@@ -1308,6 +1344,8 @@ enum L10n {
"broadcast.snackbar.resume.rtmp.error": "Reanudación RTMP: %1$@",
"broadcast.snackbar.resumed": "Directo reanudado",
"broadcast.snackbar.resumed.remote": "Reanudado desde regie",
"broadcast.snackbar.unmuted": "Audio reactivado",
"broadcast.snackbar.unmuted.remote": "Audio reactivado desde regie",
"broadcast.status.connecting": "CONECTANDO…",
"broadcast.status.error.fallback": "ERROR",
"broadcast.status.live": "EN DIRECTO",
@@ -1324,6 +1362,8 @@ enum L10n {
"broadcast.tooltip.add.point": "Añadir punto %1$@",
"broadcast.tooltip.plus.side": "+%1$d %2$@",
"broadcast.tooltip.remove.point": "Quitar punto %1$@",
"broadcast.unmute.cd": "Reactivar audio",
"broadcast.audio.muted.label": "Audio off",
"common.close": "Cerrar",
"common.error.generic": "error",
"common.error.title": "Error",
@@ -203,6 +203,7 @@ struct StreamSessionDto: Decodable {
let targetFps: Int?
let qualityPreset: String?
let startedAt: String?
let audioMuted: Bool?
let score: ScoreStateDto?
func toDomain() -> StreamSession {
@@ -222,7 +223,8 @@ struct StreamSessionDto: Decodable {
targetFps: targetFps ?? 30,
qualityPreset: qualityPreset ?? "720p_30_2.5mbps",
startedAt: startedAt,
score: score?.toDomain()
score: score?.toDomain(),
audioMuted: audioMuted ?? false
)
}
}
@@ -414,6 +416,10 @@ struct CreateSessionRequest: Encodable {
let youtubeChannel: String?
}
struct AudioMuteRequest: Encodable {
let muted: Bool
}
struct ScoringRulesBody: Codable {
let setsToWin: Int?
let pointsPerSet: Int?
@@ -186,6 +186,10 @@ final class MatchLiveAPI: @unchecked Sendable {
try await patch("sessions/\(id)/resume", body: EmptyBody())
}
func setAudioMute(id: String, muted: Bool) async throws -> StreamSessionDto {
try await patch("sessions/\(id)/audio_mute", body: AudioMuteRequest(muted: muted))
}
func networkTest(sessionId: String, body: NetworkTestRequest) async throws -> NetworkTestResponse {
try await post("sessions/\(sessionId)/network_test", body: body)
}
@@ -8,6 +8,7 @@ final class SessionCableService: ObservableObject {
var onPauseStream: (() -> Void)?
var onResumeStream: (() -> Void)?
var onStopStream: (() -> Void)?
var onAudioMute: ((Bool) -> Void)?
private var client: ActionCableClient?
private var intentionalDisconnect = false
@@ -89,6 +90,9 @@ extension SessionCableService: ActionCableClient.Listener {
onScoreUpdate?(ScoreState.fromCablePayload(payload))
case "command":
let action = payload["action"] as? String ?? ""
if let muted = AudioMuteCommandLogic.muted(type: "command", action: action, event: nil, muted: payload["muted"]) {
onAudioMute?(muted)
}
switch action {
case "pause_stream": onPauseStream?()
case "resume_stream": onResumeStream?()
@@ -97,6 +101,9 @@ extension SessionCableService: ActionCableClient.Listener {
}
case "stream_event":
let event = payload["event"] as? String ?? ""
if let muted = AudioMuteCommandLogic.muted(type: "stream_event", action: nil, event: event, muted: payload["muted"]) {
onAudioMute?(muted)
}
switch event {
case "paused": onPauseStream?()
case "resumed": onResumeStream?()
@@ -109,3 +116,23 @@ extension SessionCableService: ActionCableClient.Listener {
}
}
}
enum AudioMuteCommandLogic {
static func muted(type: String, action: String?, event: String?, muted: Any?) -> Bool? {
switch type {
case "command":
switch action {
case "mute_audio": return true
case "unmute_audio": return false
default: return nil
}
case "stream_event":
guard event == "audio_muted" else { return nil }
if let value = muted as? Bool { return value }
if let value = muted as? String { return value == "true" }
return nil
default:
return nil
}
}
}
@@ -50,6 +50,10 @@ final class SessionRepository {
try await api.resumeSession(id: id).toDomain()
}
func setAudioMute(id: String, muted: Bool) async throws -> StreamSession {
try await api.setAudioMute(id: id, muted: muted).toDomain()
}
func submitNetworkTest(
sessionId: String,
downloadMbps: Double,
@@ -121,10 +121,33 @@ struct StreamSession: Identifiable, Equatable, Sendable {
let qualityPreset: String
let startedAt: String?
let score: ScoreState?
let audioMuted: Bool
var isLive: Bool { status == "live" || status == "reconnecting" }
var isPaused: Bool { status == "paused" }
func withAudioMuted(_ muted: Bool) -> StreamSession {
StreamSession(
id: id,
matchId: matchId,
status: status,
platform: platform,
rtmpIngestUrl: rtmpIngestUrl,
hlsPlaybackUrl: hlsPlaybackUrl,
watchPageUrl: watchPageUrl,
shareUrl: shareUrl,
youtubeWatchUrl: youtubeWatchUrl,
youtubeReady: youtubeReady,
privacyStatus: privacyStatus,
targetBitrate: targetBitrate,
targetFps: targetFps,
qualityPreset: qualityPreset,
startedAt: startedAt,
score: score,
audioMuted: muted
)
}
func watchShareUrl() -> String? {
if platform == "youtube", let url = youtubeWatchUrl, !url.isEmpty { return url }
if let url = shareUrl, !url.isEmpty { return url }
@@ -28,4 +28,5 @@ struct BroadcastMetrics: Sendable {
var fps: Int = 0
var connected: Bool = false
var lastError: String?
var audioMuted: Bool = false
}
@@ -18,6 +18,10 @@ final class LiveBroadcastCoordinator: ObservableObject {
engine.updateOverlay(state)
}
func setAudioMuted(_ muted: Bool) async {
await engine.setAudioMuted(muted)
}
func configureBackgroundAudio() {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
@@ -47,6 +47,7 @@ final class LiveBroadcastEngine: ObservableObject {
private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
private var orientationDebounceTask: Task<Void, Never>?
private var rtmpSession: (any Session)?
private var audioMuted = false
private static var rtmpFactoryRegistered = false
private static func ensureRTMPFactoryRegistered() async {
@@ -173,12 +174,21 @@ final class LiveBroadcastEngine: ObservableObject {
overlayRenderer.detach()
try? await mixer.stopRunning()
pipelineConfigured = false
audioMuted = false
}
func release() async {
await stopBroadcast()
}
/// Silenzia il microfono senza togliere la traccia AAC (YouTube/MediaMTX richiedono audio continuo).
func setAudioMuted(_ muted: Bool) async {
audioMuted = muted
await applyAudioMute()
metrics.audioMuted = muted
emitMetrics()
}
/// Adattamento termico: aggiorna profilo video senza interrompere la sessione RTMP attiva.
func applyThermalProfile(videoBitrate: Int, fps: Int) async {
guard var current = config else { return }
@@ -219,7 +229,8 @@ final class LiveBroadcastEngine: ObservableObject {
AudioMixerSettings(
sampleRate: BroadcastConfig.audioSampleRateHz,
channels: BroadcastConfig.audioChannels,
tracks: [0: AudioMixerTrackSettings(downmix: true, channelMap: [0])]
isMuted: audioMuted,
tracks: [0: AudioMixerTrackSettings(isMuted: audioMuted, downmix: true, channelMap: [0])]
)
)
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
@@ -324,7 +335,8 @@ final class LiveBroadcastEngine: ObservableObject {
bitrateKbps: kbps,
fps: self.config?.fps ?? self.metrics.fps,
connected: self.phase == .live,
lastError: self.metrics.lastError
lastError: self.metrics.lastError,
audioMuted: self.audioMuted
)
self.metricsListener?(self.metrics)
}
@@ -496,6 +508,16 @@ final class LiveBroadcastEngine: ObservableObject {
UIDevice.current.endGeneratingDeviceOrientationNotifications()
}
private func applyAudioMute() async {
guard pipelineConfigured else { return }
var settings = await mixer.audioMixerSettings
settings.isMuted = audioMuted
var track = settings.tracks[0] ?? AudioMixerTrackSettings(downmix: true, channelMap: [0])
track.isMuted = audioMuted
settings.tracks[0] = track
try? await mixer.setAudioMixerSettings(settings)
}
private func setPhase(_ newPhase: BroadcastPhase, error: String? = nil) {
phase = newPhase
metrics = BroadcastMetrics(
@@ -503,7 +525,8 @@ final class LiveBroadcastEngine: ObservableObject {
bitrateKbps: metrics.bitrateKbps,
fps: config?.fps ?? metrics.fps,
connected: newPhase == .live,
lastError: error
lastError: error,
audioMuted: audioMuted
)
metricsListener?(metrics)
}
@@ -31,6 +31,8 @@ struct BroadcastControlsOverlay: View {
var onCloseSet: (() -> Void)?
var onAdvancePeriod: (() -> Void)?
let onPauseOrResume: () -> Void
let onToggleAudioMute: () -> Void
let audioMuted: Bool
let onTerminate: () -> Void
let onShareLive: () -> Void
let onShareRegia: () -> Void
@@ -69,7 +71,8 @@ struct BroadcastControlsOverlay: View {
targetFps: targetFps,
bitrateKbps: bitrateKbps,
networkType: networkType,
deviceHealth: deviceHealth
deviceHealth: deviceHealth,
audioMuted: audioMuted
)
}
.padding(.trailing, 8)
@@ -142,6 +145,12 @@ struct BroadcastControlsOverlay: View {
action: onPauseOrResume,
highlighted: isPaused
)
SideIconButton(
systemName: audioMuted ? "speaker.slash.fill" : "speaker.wave.2.fill",
accessibilityLabel: audioMuted ? L10n.t("broadcast.unmute.cd") : L10n.t("broadcast.mute.cd"),
action: onToggleAudioMute,
highlighted: audioMuted
)
SideIconButton(
systemName: "stop.fill",
accessibilityLabel: L10n.t("broadcast.terminate.cd"),
@@ -195,6 +204,7 @@ private struct BroadcastTelemetryPanel: View {
let bitrateKbps: Int
let networkType: String
let deviceHealth: DeviceHealth
let audioMuted: Bool
var body: some View {
VStack(alignment: .trailing, spacing: 2) {
@@ -214,6 +224,11 @@ private struct BroadcastTelemetryPanel: View {
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
if audioMuted {
Text(L10n.t("broadcast.audio.muted.label"))
.font(.system(size: 11, weight: .bold))
.foregroundStyle(MatchColors.accentYellow)
}
ThermalIndicator(state: deviceHealth.thermalState)
}
.padding(.horizontal, 10)
@@ -27,6 +27,7 @@ struct BroadcastScreen: View {
@State private var logoReady = 0
@State private var pauseInFlight = false
@State private var resumeInFlight = false
@State private var muteInFlight = false
@State private var deviceHealth = DeviceTelemetry.snapshot()
@State private var snackbarMessage: String?
@State private var shareItem: ShareItem?
@@ -188,6 +189,7 @@ struct BroadcastScreen: View {
let metrics = broadcastCoordinator.metrics
let score = scoreController.score
let isPaused = session.isPaused || metrics.phase == .paused
let audioMuted = session.audioMuted || metrics.audioMuted
let statusText = broadcastStatusText(isPaused: isPaused, metrics: metrics)
let statusColor = broadcastStatusColor(isPaused: isPaused, metrics: metrics)
let boardType = match.boardType.isEmpty ? score.boardType : match.boardType
@@ -282,6 +284,10 @@ struct BroadcastScreen: View {
pauseStream()
}
},
onToggleAudioMute: {
Task { await setAudioMuted(!audioMuted, remote: false) }
},
audioMuted: audioMuted,
onTerminate: { Task { await stopStream() } },
onShareLive: { shareLiveLink(session: session, subject: shareSubject) },
onShareRegia: { shareRegiaLink(subject: shareSubject) },
@@ -384,6 +390,7 @@ struct BroadcastScreen: View {
config: config,
paused: loaded.isPaused
)
await container.broadcastCoordinator.setAudioMuted(loaded.audioMuted)
}
} catch {
guard generation == bootstrapGeneration else { return }
@@ -418,6 +425,12 @@ struct BroadcastScreen: View {
container.sessionCable.onStopStream = { [weak container] in
Task { @MainActor in await stopStream() }
}
container.sessionCable.onAudioMute = { muted in
Task { @MainActor in
guard !muteInFlight else { return }
await setAudioMuted(muted, remote: true)
}
}
container.sessionCable.connect(sessionId: sessionId, accessToken: token)
}
@@ -498,6 +511,7 @@ struct BroadcastScreen: View {
try await container.broadcastCoordinator.resumeBroadcast(config: config)
updated = try await container.sessionRepository.fetchSession(id: sessionId)
session = updated
await container.broadcastCoordinator.setAudioMuted(updated.audioMuted)
snackbarMessage = L10n.t("broadcast.snackbar.resumed")
} catch {
snackbarMessage = L10n.t("broadcast.snackbar.resume.error", UserFacingError.message(for: error) ?? L10n.t("common.error.generic"))
@@ -516,6 +530,31 @@ struct BroadcastScreen: View {
snackbarMessage = L10n.t("broadcast.snackbar.paused.remote")
}
private func setAudioMuted(_ muted: Bool, remote: Bool) async {
guard !muteInFlight else { return }
if (session?.audioMuted == true || broadcastCoordinator.metrics.audioMuted) == muted {
await container.broadcastCoordinator.setAudioMuted(muted)
return
}
muteInFlight = true
defer { muteInFlight = false }
do {
if remote {
session = session?.withAudioMuted(muted)
} else {
session = try await container.sessionRepository.setAudioMute(id: sessionId, muted: muted)
}
await container.broadcastCoordinator.setAudioMuted(muted)
snackbarMessage = L10n.t(
remote
? (muted ? "broadcast.snackbar.muted.remote" : "broadcast.snackbar.unmuted.remote")
: (muted ? "broadcast.snackbar.muted" : "broadcast.snackbar.unmuted")
)
} catch {
snackbarMessage = L10n.t("broadcast.snackbar.mute.error", UserFacingError.message(for: error) ?? L10n.t("common.error.generic"))
}
}
/// Ripresa dalla regia / echo cable: solo RTMP locale, senza PATCH resume.
private func applyRemoteResume(container: AppContainer) async {
guard !resumeInFlight else { return }
@@ -528,7 +567,8 @@ struct BroadcastScreen: View {
let config = broadcastConfig(for: current, rtmpUrl: url)
thermalManager.updateBaseline(config)
try await container.broadcastCoordinator.resumeBroadcast(config: config)
snackbarMessage = L10n.t("broadcast.snackbar.resumed")
await container.broadcastCoordinator.setAudioMuted(current.audioMuted)
snackbarMessage = L10n.t("broadcast.snackbar.resumed.remote")
} catch {
snackbarMessage = L10n.t("broadcast.snackbar.resume.rtmp.error", UserFacingError.message(for: error) ?? L10n.t("common.error.generic"))
}
@@ -567,6 +607,7 @@ struct BroadcastScreen: View {
container.sessionCable.onPauseStream = nil
container.sessionCable.onResumeStream = nil
container.sessionCable.onStopStream = nil
container.sessionCable.onAudioMute = nil
container.sessionCable.disconnect()
await container.broadcastCoordinator.stopBroadcast(sessionId: sessionId)
}
@@ -13,4 +13,12 @@ final class LiveBroadcastCoordinatorTests: XCTestCase {
func testShouldStopPreviousSessionTrueWhenSwitching() {
XCTAssertTrue(LiveBroadcastCoordinator.shouldStopPreviousSession(active: "session-a", incoming: "session-b"))
}
func testAudioMuteCommandFromCable() {
XCTAssertEqual(AudioMuteCommandLogic.muted(type: "command", action: "mute_audio", event: nil, muted: nil), true)
XCTAssertEqual(AudioMuteCommandLogic.muted(type: "command", action: "unmute_audio", event: nil, muted: nil), false)
XCTAssertNil(AudioMuteCommandLogic.muted(type: "command", action: "pause_stream", event: nil, muted: nil))
XCTAssertEqual(AudioMuteCommandLogic.muted(type: "stream_event", action: nil, event: "audio_muted", muted: true), true)
XCTAssertEqual(AudioMuteCommandLogic.muted(type: "stream_event", action: nil, event: "audio_muted", muted: false), false)
}
}