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
@@ -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",
@@ -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,
@@ -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
}
}
@@ -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()
}
}
}
}
@@ -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"
@@ -39,4 +39,5 @@ data class BroadcastMetrics(
val fps: Int = 0,
val connected: Boolean = false,
val lastError: String? = null,
val audioMuted: Boolean = false,
)
@@ -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) {
@@ -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"
@@ -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)
}
}
@@ -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>
@@ -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"))
}
}