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:
@@ -184,6 +184,7 @@ data class StreamSessionDto(
|
||||
@Json(name = "target_fps") val targetFps: Int? = null,
|
||||
@Json(name = "quality_preset") val qualityPreset: String? = null,
|
||||
@Json(name = "started_at") val startedAt: String? = null,
|
||||
@Json(name = "audio_muted") val audioMuted: Boolean? = null,
|
||||
val score: ScoreStateDto? = null,
|
||||
) {
|
||||
fun toDomain() = StreamSession(
|
||||
@@ -203,6 +204,7 @@ data class StreamSessionDto(
|
||||
qualityPreset = qualityPreset ?: "720p_30_2.5mbps",
|
||||
startedAt = startedAt,
|
||||
score = score?.toDomain(),
|
||||
audioMuted = audioMuted ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -367,6 +369,10 @@ data class RegiaLinkResponse(
|
||||
@Json(name = "regia_url") val regiaUrl: String,
|
||||
)
|
||||
|
||||
data class AudioMuteRequest(
|
||||
val muted: Boolean,
|
||||
)
|
||||
|
||||
data class CreateMatchBody(
|
||||
@Json(name = "opponent_name") val opponentName: String,
|
||||
val sport: String = "volleyball",
|
||||
|
||||
+6
@@ -117,6 +117,12 @@ interface MatchLiveApi {
|
||||
@PATCH("sessions/{sessionId}/resume")
|
||||
suspend fun resumeSession(@Path("sessionId") sessionId: String): StreamSessionDto
|
||||
|
||||
@PATCH("sessions/{sessionId}/audio_mute")
|
||||
suspend fun setAudioMute(
|
||||
@Path("sessionId") sessionId: String,
|
||||
@Body body: AudioMuteRequest,
|
||||
): StreamSessionDto
|
||||
|
||||
@POST("sessions/{sessionId}/network_test")
|
||||
suspend fun networkTest(
|
||||
@Path("sessionId") sessionId: String,
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.matchlivetv.match_live_tv.data.cable
|
||||
|
||||
/**
|
||||
* Interpreta i payload ActionCable del mute audio.
|
||||
* Usato da app e test: lo stream resta in onda, cambia solo il microfono.
|
||||
*/
|
||||
object AudioMuteCommands {
|
||||
fun mutedFromPayload(
|
||||
type: String?,
|
||||
action: String? = null,
|
||||
event: String? = null,
|
||||
muted: Any? = null,
|
||||
): Boolean? = when (type) {
|
||||
"command" -> when (action) {
|
||||
"mute_audio" -> true
|
||||
"unmute_audio" -> false
|
||||
else -> null
|
||||
}
|
||||
"stream_event" -> if (event == "audio_muted") {
|
||||
muted == true || muted == "true"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
+22
-8
@@ -33,6 +33,7 @@ class SessionCableService(
|
||||
var onPauseStream: (() -> Unit)? = null
|
||||
var onResumeStream: (() -> Unit)? = null
|
||||
var onEndStream: (() -> Unit)? = null
|
||||
var onAudioMute: ((Boolean) -> Unit)? = null
|
||||
|
||||
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
|
||||
intentionalDisconnect = false
|
||||
@@ -105,15 +106,28 @@ class SessionCableService(
|
||||
val payload = unwrapCablePayload(data)
|
||||
when (payload["type"] as? String) {
|
||||
"score_update" -> onScoreUpdate?.invoke(parseScore(payload))
|
||||
"command" -> when (payload["action"] as? String) {
|
||||
"pause_stream" -> onPauseStream?.invoke()
|
||||
"resume_stream" -> onResumeStream?.invoke()
|
||||
"stop_stream" -> onEndStream?.invoke()
|
||||
"command" -> {
|
||||
AudioMuteCommands.mutedFromPayload(
|
||||
type = "command",
|
||||
action = payload["action"] as? String,
|
||||
)?.let { onAudioMute?.invoke(it) }
|
||||
when (payload["action"] as? String) {
|
||||
"pause_stream" -> onPauseStream?.invoke()
|
||||
"resume_stream" -> onResumeStream?.invoke()
|
||||
"stop_stream" -> onEndStream?.invoke()
|
||||
}
|
||||
}
|
||||
"stream_event" -> when (payload["event"] as? String) {
|
||||
"paused" -> onPauseStream?.invoke()
|
||||
"resumed" -> onResumeStream?.invoke()
|
||||
"ended" -> onEndStream?.invoke()
|
||||
"stream_event" -> {
|
||||
AudioMuteCommands.mutedFromPayload(
|
||||
type = "stream_event",
|
||||
event = payload["event"] as? String,
|
||||
muted = payload["muted"],
|
||||
)?.let { onAudioMute?.invoke(it) }
|
||||
when (payload["event"] as? String) {
|
||||
"paused" -> onPauseStream?.invoke()
|
||||
"resumed" -> onResumeStream?.invoke()
|
||||
"ended" -> onEndStream?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -38,6 +38,9 @@ class SessionRepository(
|
||||
suspend fun resumeSession(sessionId: String): StreamSession =
|
||||
api.resumeSession(sessionId).toDomain()
|
||||
|
||||
suspend fun setAudioMute(sessionId: String, muted: Boolean): StreamSession =
|
||||
api.setAudioMute(sessionId, com.matchlivetv.match_live_tv.data.api.AudioMuteRequest(muted)).toDomain()
|
||||
|
||||
suspend fun submitNetworkTest(
|
||||
sessionId: String,
|
||||
downloadMbps: Double,
|
||||
|
||||
@@ -128,6 +128,7 @@ data class StreamSession(
|
||||
val qualityPreset: String = "720p_30_2.5mbps",
|
||||
val startedAt: String? = null,
|
||||
val score: ScoreState? = null,
|
||||
val audioMuted: Boolean = false,
|
||||
) {
|
||||
val isLive: Boolean get() = status == "live" || status == "reconnecting"
|
||||
|
||||
|
||||
+1
@@ -39,4 +39,5 @@ data class BroadcastMetrics(
|
||||
val fps: Int = 0,
|
||||
val connected: Boolean = false,
|
||||
val lastError: String? = null,
|
||||
val audioMuted: Boolean = false,
|
||||
)
|
||||
|
||||
+4
@@ -33,6 +33,10 @@ class LiveBroadcastCoordinator(context: Context) {
|
||||
engine.updateOverlay(state)
|
||||
}
|
||||
|
||||
fun setAudioMuted(muted: Boolean) {
|
||||
engine.setAudioMuted(muted)
|
||||
}
|
||||
|
||||
fun startService() {
|
||||
val intent = LiveBroadcastService.startIntent(appContext)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
+32
@@ -8,6 +8,8 @@ import android.util.Log
|
||||
import android.view.Surface
|
||||
import android.view.WindowManager
|
||||
import com.pedro.common.ConnectChecker
|
||||
import com.pedro.encoder.input.sources.audio.MicrophoneSource
|
||||
import com.pedro.encoder.input.sources.audio.MixAudioSource
|
||||
import com.pedro.library.generic.GenericStream
|
||||
import com.pedro.library.util.SensorRotationManager
|
||||
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayRenderer
|
||||
@@ -42,6 +44,7 @@ class LiveBroadcastEngine(
|
||||
private val pausingIntentionally = AtomicBoolean(false)
|
||||
private val reconnectAttempts = AtomicInteger(0)
|
||||
private val overlayRenderer = OverlayRenderer(appContext)
|
||||
private val desiredAudioMuted = AtomicBoolean(false)
|
||||
|
||||
private val rebindRunnable = Runnable {
|
||||
reattachPreviewIfNeeded()
|
||||
@@ -227,6 +230,7 @@ class LiveBroadcastEngine(
|
||||
currentBitrateKbps = 0
|
||||
currentFps = 0
|
||||
reconnectAttempts.set(0)
|
||||
desiredAudioMuted.set(false)
|
||||
setPhase(BroadcastPhase.IDLE)
|
||||
}
|
||||
}
|
||||
@@ -264,6 +268,17 @@ class LiveBroadcastEngine(
|
||||
}
|
||||
}
|
||||
|
||||
/** Silenzia il microfono senza togliere la traccia AAC (YouTube/MediaMTX richiedono audio continuo). */
|
||||
fun setAudioMuted(muted: Boolean) {
|
||||
mainHandler.post {
|
||||
desiredAudioMuted.set(muted)
|
||||
applyAudioMuteInternal()
|
||||
emitMetrics()
|
||||
}
|
||||
}
|
||||
|
||||
fun isAudioMuted(): Boolean = desiredAudioMuted.get()
|
||||
|
||||
/** Adattamento termico: riduce bitrate/FPS senza fermare lo stream. */
|
||||
fun applyThermalProfile(videoBitrate: Int, fps: Int) {
|
||||
mainHandler.post {
|
||||
@@ -340,6 +355,7 @@ class LiveBroadcastEngine(
|
||||
applyInitialOrientation()
|
||||
attachOverlayFilter(cfg)
|
||||
startSensor()
|
||||
applyAudioMuteInternal()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -514,6 +530,7 @@ class LiveBroadcastEngine(
|
||||
},
|
||||
connected = phase == BroadcastPhase.LIVE,
|
||||
lastError = error,
|
||||
audioMuted = desiredAudioMuted.get(),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -579,6 +596,21 @@ class LiveBroadcastEngine(
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyAudioMuteInternal() {
|
||||
val muted = desiredAudioMuted.get()
|
||||
val source = genericStream?.audioSource ?: return
|
||||
runCatching {
|
||||
when (source) {
|
||||
is MicrophoneSource -> if (muted) source.mute() else source.unMute()
|
||||
is MixAudioSource -> if (muted) source.muteMicrophone() else source.unMuteMicrophone()
|
||||
else -> Log.w(TAG, "applyAudioMute: unsupported source ${source::class.java.simpleName}")
|
||||
}
|
||||
Log.i(TAG, "audio muted=$muted source=${source::class.java.simpleName}")
|
||||
}.onFailure {
|
||||
Log.w(TAG, "applyAudioMute failed: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun userFacingError(reason: String): String = when {
|
||||
reason.contains("broken pipe", ignoreCase = true) ->
|
||||
"Connessione persa — riprova a riavviare la diretta"
|
||||
|
||||
+24
@@ -32,6 +32,8 @@ import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material.icons.filled.Videocam
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -100,6 +102,8 @@ fun BroadcastControlsOverlay(
|
||||
onCloseSet: (() -> Unit)? = null,
|
||||
onAdvancePeriod: (() -> Unit)? = null,
|
||||
onPauseOrResume: () -> Unit,
|
||||
onToggleAudioMute: () -> Unit,
|
||||
audioMuted: Boolean,
|
||||
onTerminate: () -> Unit,
|
||||
onShareLive: () -> Unit,
|
||||
onShareRegia: () -> Unit,
|
||||
@@ -174,6 +178,7 @@ fun BroadcastControlsOverlay(
|
||||
bitrateKbps = bitrateKbps,
|
||||
networkType = networkType,
|
||||
deviceHealth = deviceHealth,
|
||||
audioMuted = audioMuted,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -227,6 +232,16 @@ fun BroadcastControlsOverlay(
|
||||
onClick = onPauseOrResume,
|
||||
highlighted = isPaused,
|
||||
)
|
||||
SideIconButton(
|
||||
icon = if (audioMuted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
|
||||
contentDescription = if (audioMuted) {
|
||||
stringResource(R.string.broadcast_unmute_cd)
|
||||
} else {
|
||||
stringResource(R.string.broadcast_mute_cd)
|
||||
},
|
||||
onClick = onToggleAudioMute,
|
||||
highlighted = audioMuted,
|
||||
)
|
||||
SideIconButton(
|
||||
icon = Icons.Default.Stop,
|
||||
contentDescription = stringResource(R.string.broadcast_terminate_cd),
|
||||
@@ -287,6 +302,7 @@ private fun BroadcastTelemetryPanel(
|
||||
bitrateKbps: Long,
|
||||
networkType: String,
|
||||
deviceHealth: DeviceHealthSnapshot,
|
||||
audioMuted: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val labelStyle = MaterialTheme.typography.labelSmall
|
||||
@@ -326,6 +342,14 @@ private fun BroadcastTelemetryPanel(
|
||||
style = labelStyle,
|
||||
color = MatchColors.TextSecondary,
|
||||
)
|
||||
if (audioMuted) {
|
||||
Text(
|
||||
stringResource(R.string.broadcast_audio_muted_label),
|
||||
style = labelStyle,
|
||||
color = MatchColors.AccentYellow,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
ThermalIndicator(state = deviceHealth.thermalState)
|
||||
}
|
||||
}
|
||||
|
||||
+48
@@ -78,6 +78,7 @@ fun BroadcastScreen(
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var pauseInFlight by remember { mutableStateOf(false) }
|
||||
var resumeInFlight by remember { mutableStateOf(false) }
|
||||
var muteInFlight by remember { mutableStateOf(false) }
|
||||
var controlsVisible by remember { mutableStateOf(true) }
|
||||
var logoReady by remember { mutableStateOf(0) }
|
||||
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) }
|
||||
@@ -145,6 +146,7 @@ fun BroadcastScreen(
|
||||
}
|
||||
updated = container.sessionRepository.fetchSession(sessionId)
|
||||
session = updated
|
||||
container.broadcastCoordinator.setAudioMuted(updated.audioMuted)
|
||||
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_resumed))
|
||||
} catch (e: Exception) {
|
||||
snackbar.showSnackbar(
|
||||
@@ -174,6 +176,7 @@ fun BroadcastScreen(
|
||||
thermalManager.updateBaseline(cfg)
|
||||
container.broadcastCoordinator.engine.resumeBroadcast(cfg)
|
||||
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_resumed_remote))
|
||||
container.broadcastCoordinator.setAudioMuted(current.audioMuted)
|
||||
} catch (e: Exception) {
|
||||
snackbar.showSnackbar(
|
||||
context.getString(R.string.broadcast_snackbar_resume_rtmp_error, e.message ?: genericError),
|
||||
@@ -191,6 +194,40 @@ fun BroadcastScreen(
|
||||
onFinished()
|
||||
}
|
||||
|
||||
suspend fun setAudioMuted(muted: Boolean, remote: Boolean) {
|
||||
if (muteInFlight) return
|
||||
if ((session?.audioMuted == true || metrics.audioMuted) == muted) {
|
||||
container.broadcastCoordinator.setAudioMuted(muted)
|
||||
return
|
||||
}
|
||||
muteInFlight = true
|
||||
try {
|
||||
if (!remote) {
|
||||
val updated = container.sessionRepository.setAudioMute(sessionId, muted)
|
||||
session = updated
|
||||
} else {
|
||||
session = session?.copy(audioMuted = muted)
|
||||
}
|
||||
container.broadcastCoordinator.setAudioMuted(muted)
|
||||
snackbar.showSnackbar(
|
||||
context.getString(
|
||||
when {
|
||||
remote && muted -> R.string.broadcast_snackbar_muted_remote
|
||||
remote && !muted -> R.string.broadcast_snackbar_unmuted_remote
|
||||
muted -> R.string.broadcast_snackbar_muted
|
||||
else -> R.string.broadcast_snackbar_unmuted
|
||||
},
|
||||
),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
snackbar.showSnackbar(
|
||||
context.getString(R.string.broadcast_snackbar_mute_error, e.message ?: genericError),
|
||||
)
|
||||
} finally {
|
||||
muteInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(sessionId, permissions.granted) {
|
||||
if (!permissions.granted) return@LaunchedEffect
|
||||
loading = true
|
||||
@@ -215,6 +252,9 @@ fun BroadcastScreen(
|
||||
container.sessionCable.onEndStream = {
|
||||
scope.launch { applyRemoteStop() }
|
||||
}
|
||||
container.sessionCable.onAudioMute = { muted ->
|
||||
if (!muteInFlight) scope.launch { setAudioMuted(muted, remote = true) }
|
||||
}
|
||||
container.sessionCable.connect(sessionId, token, deviceRole = "camera")
|
||||
}
|
||||
container.broadcastCoordinator.startService()
|
||||
@@ -225,8 +265,10 @@ fun BroadcastScreen(
|
||||
val cfg = broadcastConfig(loaded)
|
||||
thermalManager.updateBaseline(cfg)
|
||||
container.broadcastCoordinator.engine.startBroadcast(cfg)
|
||||
container.broadcastCoordinator.setAudioMuted(loaded.audioMuted)
|
||||
} else {
|
||||
thermalManager.updateBaseline(broadcastConfig(loaded))
|
||||
container.broadcastCoordinator.setAudioMuted(loaded.audioMuted)
|
||||
}
|
||||
}.onFailure {
|
||||
error = it.message ?: context.getString(R.string.broadcast_error_session_unavailable)
|
||||
@@ -364,6 +406,7 @@ fun BroadcastScreen(
|
||||
container.sessionCable.onPauseStream = null
|
||||
container.sessionCable.onResumeStream = null
|
||||
container.sessionCable.onEndStream = null
|
||||
container.sessionCable.onAudioMute = null
|
||||
container.sessionCable.disconnect()
|
||||
container.broadcastCoordinator.stopService()
|
||||
container.broadcastCoordinator.engine.stopBroadcast()
|
||||
@@ -371,6 +414,7 @@ fun BroadcastScreen(
|
||||
}
|
||||
|
||||
val isPaused = session?.isPaused == true || metrics.phase == BroadcastPhase.PAUSED
|
||||
val audioMuted = session?.audioMuted == true || metrics.audioMuted
|
||||
val statusText = when {
|
||||
isPaused -> stringResource(R.string.broadcast_status_paused)
|
||||
metrics.phase == BroadcastPhase.LIVE -> stringResource(R.string.broadcast_status_live)
|
||||
@@ -544,6 +588,10 @@ fun BroadcastScreen(
|
||||
if (isPaused) resumeStream() else pauseStream()
|
||||
}
|
||||
},
|
||||
onToggleAudioMute = {
|
||||
scope.launch { setAudioMuted(!audioMuted, remote = false) }
|
||||
},
|
||||
audioMuted = audioMuted,
|
||||
onTerminate = { scope.launch { stopStreamPermanently() } },
|
||||
onShareLive = {
|
||||
val url = shareUrl
|
||||
|
||||
@@ -140,6 +140,11 @@
|
||||
<string name="broadcast_snackbar_resumed_remote">Von der Regie fortgesetzt</string>
|
||||
<string name="broadcast_snackbar_resume_rtmp_error">RTMP-Fortsetzung: %1$s</string>
|
||||
<string name="broadcast_snackbar_closed_remote">Livestream von der Regie beendet</string>
|
||||
<string name="broadcast_snackbar_muted">Audio stummgeschaltet</string>
|
||||
<string name="broadcast_snackbar_unmuted">Audio wieder aktiv</string>
|
||||
<string name="broadcast_snackbar_muted_remote">Audio von der Regie stummgeschaltet</string>
|
||||
<string name="broadcast_snackbar_unmuted_remote">Audio von der Regie wieder aktiviert</string>
|
||||
<string name="broadcast_snackbar_mute_error">Audio: %1$s</string>
|
||||
<string name="broadcast_error_rtmp_missing">RTMP-URL fehlt</string>
|
||||
<string name="broadcast_error_session_unavailable">Sitzung nicht verfügbar</string>
|
||||
<string name="broadcast_status_paused">PAUSE</string>
|
||||
@@ -163,6 +168,9 @@
|
||||
<string name="broadcast_next_period_cd">Nächste Periode</string>
|
||||
<string name="broadcast_resume_cd">Livestream fortsetzen</string>
|
||||
<string name="broadcast_pause_cd">Livestream pausieren</string>
|
||||
<string name="broadcast_mute_cd">Audio stummschalten</string>
|
||||
<string name="broadcast_unmute_cd">Audio einschalten</string>
|
||||
<string name="broadcast_audio_muted_label">Audio aus</string>
|
||||
<string name="broadcast_terminate_cd">Livestream beenden</string>
|
||||
<string name="broadcast_team_home_label">HEIM</string>
|
||||
<string name="broadcast_team_away_label">GAST</string>
|
||||
|
||||
@@ -140,6 +140,11 @@
|
||||
<string name="broadcast_snackbar_resumed_remote">Resumed from control room</string>
|
||||
<string name="broadcast_snackbar_resume_rtmp_error">RTMP resume: %1$s</string>
|
||||
<string name="broadcast_snackbar_closed_remote">Live closed from control room</string>
|
||||
<string name="broadcast_snackbar_muted">Audio muted</string>
|
||||
<string name="broadcast_snackbar_unmuted">Audio restored</string>
|
||||
<string name="broadcast_snackbar_muted_remote">Audio muted from control room</string>
|
||||
<string name="broadcast_snackbar_unmuted_remote">Audio restored from control room</string>
|
||||
<string name="broadcast_snackbar_mute_error">Audio: %1$s</string>
|
||||
<string name="broadcast_error_rtmp_missing">Missing RTMP URL</string>
|
||||
<string name="broadcast_error_session_unavailable">Session not available</string>
|
||||
<string name="broadcast_status_paused">PAUSED</string>
|
||||
@@ -163,6 +168,9 @@
|
||||
<string name="broadcast_next_period_cd">Next period</string>
|
||||
<string name="broadcast_resume_cd">Resume live</string>
|
||||
<string name="broadcast_pause_cd">Pause live</string>
|
||||
<string name="broadcast_mute_cd">Mute audio</string>
|
||||
<string name="broadcast_unmute_cd">Unmute audio</string>
|
||||
<string name="broadcast_audio_muted_label">Audio off</string>
|
||||
<string name="broadcast_terminate_cd">End live</string>
|
||||
<string name="broadcast_team_home_label">HOME</string>
|
||||
<string name="broadcast_team_away_label">AWAY</string>
|
||||
|
||||
@@ -140,6 +140,11 @@
|
||||
<string name="broadcast_snackbar_resumed_remote">Reanudado desde regie</string>
|
||||
<string name="broadcast_snackbar_resume_rtmp_error">Reanudación RTMP: %1$s</string>
|
||||
<string name="broadcast_snackbar_closed_remote">Directo cerrado desde regie</string>
|
||||
<string name="broadcast_snackbar_muted">Audio silenciado</string>
|
||||
<string name="broadcast_snackbar_unmuted">Audio reactivado</string>
|
||||
<string name="broadcast_snackbar_muted_remote">Audio silenciado desde regie</string>
|
||||
<string name="broadcast_snackbar_unmuted_remote">Audio reactivado desde regie</string>
|
||||
<string name="broadcast_snackbar_mute_error">Audio: %1$s</string>
|
||||
<string name="broadcast_error_rtmp_missing">Falta la URL RTMP</string>
|
||||
<string name="broadcast_error_session_unavailable">Sesión no disponible</string>
|
||||
<string name="broadcast_status_paused">PAUSA</string>
|
||||
@@ -163,6 +168,9 @@
|
||||
<string name="broadcast_next_period_cd">Periodo siguiente</string>
|
||||
<string name="broadcast_resume_cd">Reanudar directo</string>
|
||||
<string name="broadcast_pause_cd">Pausar directo</string>
|
||||
<string name="broadcast_mute_cd">Silenciar audio</string>
|
||||
<string name="broadcast_unmute_cd">Reactivar audio</string>
|
||||
<string name="broadcast_audio_muted_label">Audio off</string>
|
||||
<string name="broadcast_terminate_cd">Terminar directo</string>
|
||||
<string name="broadcast_team_home_label">LOCAL</string>
|
||||
<string name="broadcast_team_away_label">VISITANTE</string>
|
||||
|
||||
@@ -140,6 +140,11 @@
|
||||
<string name="broadcast_snackbar_resumed_remote">Repris depuis la régie</string>
|
||||
<string name="broadcast_snackbar_resume_rtmp_error">Reprise RTMP : %1$s</string>
|
||||
<string name="broadcast_snackbar_closed_remote">Direct fermé depuis la régie</string>
|
||||
<string name="broadcast_snackbar_muted">Audio coupé</string>
|
||||
<string name="broadcast_snackbar_unmuted">Audio rétabli</string>
|
||||
<string name="broadcast_snackbar_muted_remote">Audio coupé depuis la régie</string>
|
||||
<string name="broadcast_snackbar_unmuted_remote">Audio rétabli depuis la régie</string>
|
||||
<string name="broadcast_snackbar_mute_error">Audio : %1$s</string>
|
||||
<string name="broadcast_error_rtmp_missing">URL RTMP manquante</string>
|
||||
<string name="broadcast_error_session_unavailable">Session non disponible</string>
|
||||
<string name="broadcast_status_paused">PAUSE</string>
|
||||
@@ -163,6 +168,9 @@
|
||||
<string name="broadcast_next_period_cd">Période suivante</string>
|
||||
<string name="broadcast_resume_cd">Reprendre le direct</string>
|
||||
<string name="broadcast_pause_cd">Mettre le direct en pause</string>
|
||||
<string name="broadcast_mute_cd">Couper l\'audio</string>
|
||||
<string name="broadcast_unmute_cd">Réactiver l\'audio</string>
|
||||
<string name="broadcast_audio_muted_label">Audio off</string>
|
||||
<string name="broadcast_terminate_cd">Terminer le direct</string>
|
||||
<string name="broadcast_team_home_label">DOMICILE</string>
|
||||
<string name="broadcast_team_away_label">EXTÉRIEUR</string>
|
||||
|
||||
@@ -140,6 +140,11 @@
|
||||
<string name="broadcast_snackbar_resumed_remote">Ripresa dalla regia</string>
|
||||
<string name="broadcast_snackbar_resume_rtmp_error">Ripresa RTMP: %1$s</string>
|
||||
<string name="broadcast_snackbar_closed_remote">Diretta chiusa dalla regia</string>
|
||||
<string name="broadcast_snackbar_muted">Audio silenziato</string>
|
||||
<string name="broadcast_snackbar_unmuted">Audio riattivato</string>
|
||||
<string name="broadcast_snackbar_muted_remote">Audio silenziato dalla regia</string>
|
||||
<string name="broadcast_snackbar_unmuted_remote">Audio riattivato dalla regia</string>
|
||||
<string name="broadcast_snackbar_mute_error">Audio: %1$s</string>
|
||||
<string name="broadcast_error_rtmp_missing">URL RTMP mancante</string>
|
||||
<string name="broadcast_error_session_unavailable">Sessione non disponibile</string>
|
||||
<string name="broadcast_status_paused">PAUSA</string>
|
||||
@@ -163,6 +168,9 @@
|
||||
<string name="broadcast_next_period_cd">Periodo successivo</string>
|
||||
<string name="broadcast_resume_cd">Riprendi diretta</string>
|
||||
<string name="broadcast_pause_cd">Pausa diretta</string>
|
||||
<string name="broadcast_mute_cd">Silenzia audio</string>
|
||||
<string name="broadcast_unmute_cd">Riattiva audio</string>
|
||||
<string name="broadcast_audio_muted_label">Audio off</string>
|
||||
<string name="broadcast_terminate_cd">Termina diretta</string>
|
||||
<string name="broadcast_team_home_label">CASA</string>
|
||||
<string name="broadcast_team_away_label">OSPITE</string>
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.matchlivetv.match_live_tv.data.cable
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class AudioMuteCommandsTest {
|
||||
@Test
|
||||
fun commandMuteAudio() {
|
||||
assertEquals(true, AudioMuteCommands.mutedFromPayload(type = "command", action = "mute_audio"))
|
||||
assertEquals(false, AudioMuteCommands.mutedFromPayload(type = "command", action = "unmute_audio"))
|
||||
assertNull(AudioMuteCommands.mutedFromPayload(type = "command", action = "pause_stream"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamEventAudioMuted() {
|
||||
assertEquals(true, AudioMuteCommands.mutedFromPayload(type = "stream_event", event = "audio_muted", muted = true))
|
||||
assertEquals(false, AudioMuteCommands.mutedFromPayload(type = "stream_event", event = "audio_muted", muted = false))
|
||||
assertNull(AudioMuteCommands.mutedFromPayload(type = "stream_event", event = "paused"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user