From 1fecccaafd3e78be1cf71437e5acb36c0c46cc60 Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Wed, 12 Aug 2026 22:38:05 +0200 Subject: [PATCH] Aggiunge il mute del microfono in diretta per evitare claim YouTube sulla musica in palestra. Co-authored-by: Cursor --- backend/app/channels/session_channel.rb | 4 + .../api/v1/stream_sessions_controller.rb | 9 +- .../controllers/public/regia_controller.rb | 9 +- backend/app/helpers/public/regia_helper.rb | 5 + backend/app/models/stream_event.rb | 2 +- .../app/services/sessions/set_audio_mute.rb | 28 ++++ backend/app/views/public/regia/show.html.erb | 7 +- backend/config/locales/app.de.yml | 5 + backend/config/locales/app.en.yml | 5 + backend/config/locales/app.es.yml | 5 + backend/config/locales/app.fr.yml | 5 + backend/config/locales/app.it.yml | 5 + backend/config/routes.rb | 2 + ...0000_add_audio_muted_to_stream_sessions.rb | 5 + backend/db/schema.rb | 3 +- backend/public/regia.css | 4 + backend/public/regia.js | 37 +++++ .../api/v1/stream_sessions_audio_mute_spec.rb | 37 +++++ backend/spec/requests/public/regia_spec.rb | 25 ++- .../services/sessions/set_audio_mute_spec.rb | 27 ++++ docs/ARCHITECTURE.md | 5 +- docs/IOS_ANDROID_I18N_GAP.md | 4 +- docs/IOS_STREAM_AUDIO_MUTE.md | 149 ++++++++++++++++++ docs/LIVE_STREAMING.md | 15 +- .../match_live_tv/data/api/ApiDtos.kt | 6 + .../match_live_tv/data/api/MatchLiveApi.kt | 6 + .../data/cable/AudioMuteCommands.kt | 26 +++ .../data/cable/SessionCableService.kt | 30 +++- .../data/repository/SessionRepository.kt | 3 + .../match_live_tv/domain/Models.kt | 1 + .../streaming/BroadcastModels.kt | 1 + .../streaming/LiveBroadcastCoordinator.kt | 4 + .../streaming/LiveBroadcastEngine.kt | 32 ++++ .../ui/broadcast/BroadcastControlsOverlay.kt | 24 +++ .../ui/broadcast/BroadcastScreen.kt | 48 ++++++ .../app/src/main/res/values-de/strings.xml | 8 + .../app/src/main/res/values-en/strings.xml | 8 + .../app/src/main/res/values-es/strings.xml | 8 + .../app/src/main/res/values-fr/strings.xml | 8 + .../app/src/main/res/values/strings.xml | 8 + .../data/cable/AudioMuteCommandsTest.kt | 21 +++ native/ios/MatchLiveTv/Core/AppLanguage.swift | 40 +++++ native/ios/MatchLiveTv/Data/API/ApiDtos.swift | 8 +- .../MatchLiveTv/Data/API/MatchLiveAPI.swift | 4 + .../Data/Cable/SessionCableService.swift | 27 ++++ .../Data/Repository/SessionRepository.swift | 4 + native/ios/MatchLiveTv/Domain/Models.swift | 23 +++ .../Streaming/BroadcastModels.swift | 1 + .../Streaming/LiveBroadcastCoordinator.swift | 4 + .../Streaming/LiveBroadcastEngine.swift | 29 +++- .../Broadcast/BroadcastControlsOverlay.swift | 17 +- .../UI/Broadcast/BroadcastScreen.swift | 43 ++++- .../LiveBroadcastCoordinatorTests.swift | 8 + 53 files changed, 827 insertions(+), 25 deletions(-) create mode 100644 backend/app/services/sessions/set_audio_mute.rb create mode 100644 backend/db/migrate/20260812220000_add_audio_muted_to_stream_sessions.rb create mode 100644 backend/spec/requests/api/v1/stream_sessions_audio_mute_spec.rb create mode 100644 backend/spec/services/sessions/set_audio_mute_spec.rb create mode 100644 docs/IOS_STREAM_AUDIO_MUTE.md create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/AudioMuteCommands.kt create mode 100644 native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/data/cable/AudioMuteCommandsTest.kt diff --git a/backend/app/channels/session_channel.rb b/backend/app/channels/session_channel.rb index d50def1..d1f2dd0 100644 --- a/backend/app/channels/session_channel.rb +++ b/backend/app/channels/session_channel.rb @@ -55,6 +55,10 @@ class SessionChannel < ApplicationCable::Channel Sessions::Pause.new(session).call when "resume_stream" Sessions::Resume.new(session).call + when "mute_audio" + Sessions::SetAudioMute.new(session, muted: true).call + when "unmute_audio" + Sessions::SetAudioMute.new(session, muted: false).call end end diff --git a/backend/app/controllers/api/v1/stream_sessions_controller.rb b/backend/app/controllers/api/v1/stream_sessions_controller.rb index 4ca1845..f164b40 100644 --- a/backend/app/controllers/api/v1/stream_sessions_controller.rb +++ b/backend/app/controllers/api/v1/stream_sessions_controller.rb @@ -34,6 +34,12 @@ module Api render json: session_json(@session) end + def audio_mute + muted = params.key?(:muted) ? params[:muted] : true + Sessions::SetAudioMute.new(@session, muted: muted).call + render json: session_json(@session) + end + def score Scoring::SyncState.new(session: @session, payload: score_sync_params).call render json: session_json(@session, detail: true) @@ -189,7 +195,8 @@ module Api quality_preset: session.quality_preset, target_bitrate: session.target_bitrate, started_at: session.started_at, - disconnection_count: session.disconnection_count + disconnection_count: session.disconnection_count, + audio_muted: session.audio_muted } if detail data[:score] = session.score_state&.as_cable_payload diff --git a/backend/app/controllers/public/regia_controller.rb b/backend/app/controllers/public/regia_controller.rb index 83c8404..767ad00 100644 --- a/backend/app/controllers/public/regia_controller.rb +++ b/backend/app/controllers/public/regia_controller.rb @@ -7,7 +7,7 @@ module Public layout "regia" protect_from_forgery with: :null_session - skip_before_action :verify_authenticity_token, only: %i[score pause resume stop] + skip_before_action :verify_authenticity_token, only: %i[score pause resume stop audio_mute] before_action :set_session_from_token @@ -61,6 +61,12 @@ module Public render json: status_payload end + def audio_mute + muted = params.key?(:muted) ? params[:muted] : !@session.audio_muted + Sessions::SetAudioMute.new(@session, muted: muted).call + render json: status_payload + end + private def set_session_from_token @@ -78,6 +84,7 @@ module Public { status: @session.status, paused: @session.paused?, + audio_muted: @session.audio_muted, stream_closed: closed, live: !closed && (@session.live? || @session.paused? || publisher_online), on_air: playable || (!closed && @session.status.in?(%w[connecting live reconnecting paused])), diff --git a/backend/app/helpers/public/regia_helper.rb b/backend/app/helpers/public/regia_helper.rb index 6a6a64d..f1d3848 100644 --- a/backend/app/helpers/public/regia_helper.rb +++ b/backend/app/helpers/public/regia_helper.rb @@ -44,9 +44,12 @@ module Public partialsPrefix: t("regia.board.partials_prefix"), resumed: t("regia.js.resumed"), pausedCover: t("regia.js.paused_cover"), + muted: t("regia.js.muted"), + unmuted: t("regia.js.unmuted"), closed: t("regia.js.closed"), resumeError: t("regia.js.resume_error"), pauseError: t("regia.js.pause_error"), + muteError: t("regia.js.mute_error"), closeError: t("regia.js.close_error"), closeConfirm: t("regia.js.close_confirm"), linkUnavailable: t("regia.js.link_unavailable"), @@ -58,6 +61,8 @@ module Public streamEnded: t("regia.preview_ended"), resumeLabel: t("regia.resume"), pauseLabel: t("regia.pause"), + muteLabel: t("regia.mute"), + unmuteLabel: t("regia.unmute"), endedBadge: t("regia.status.ended"), pausedBadge: t("regia.status.paused"), liveBadge: t("regia.status.live"), diff --git a/backend/app/models/stream_event.rb b/backend/app/models/stream_event.rb index c6b2bfb..cc1cdec 100644 --- a/backend/app/models/stream_event.rb +++ b/backend/app/models/stream_event.rb @@ -1,7 +1,7 @@ class StreamEvent < ApplicationRecord EVENT_TYPES = %w[ connected disconnected reconnected quality_changed error paused resumed - started ended network_test pairing youtube_ready + started ended network_test pairing youtube_ready audio_muted audio_unmuted ].freeze belongs_to :stream_session diff --git a/backend/app/services/sessions/set_audio_mute.rb b/backend/app/services/sessions/set_audio_mute.rb new file mode 100644 index 0000000..4e265db --- /dev/null +++ b/backend/app/services/sessions/set_audio_mute.rb @@ -0,0 +1,28 @@ +module Sessions + # Silenzia o riattiva il microfono della camera in onda. + # Lo stream continua a inviare AAC silenzioso (YouTube/MediaMTX richiedono la traccia audio). + class SetAudioMute + def initialize(session, muted:) + @session = session + @muted = ActiveModel::Type::Boolean.new.cast(muted) + end + + def call + return @session if @session.audio_muted == @muted + + @session.update!(audio_muted: @muted) + action = @muted ? "mute_audio" : "unmute_audio" + event_type = @muted ? "audio_muted" : "audio_unmuted" + log_event(event_type) + SessionChannel.broadcast_message(@session, { type: "command", action: action, muted: @muted }) + SessionChannel.broadcast_message(@session, { type: "stream_event", event: "audio_muted", muted: @muted }) + @session + end + + private + + def log_event(type) + @session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: { muted: @muted }) + end + end +end diff --git a/backend/app/views/public/regia/show.html.erb b/backend/app/views/public/regia/show.html.erb index 19c6add..efb7e1c 100644 --- a/backend/app/views/public/regia/show.html.erb +++ b/backend/app/views/public/regia/show.html.erb @@ -15,6 +15,7 @@ data-score-url="<%= j public_regia_score_path(params[:token]) %>" data-pause-url="<%= j public_regia_pause_path(params[:token]) %>" data-resume-url="<%= j public_regia_resume_path(params[:token]) %>" + data-audio-mute-url="<%= j public_regia_audio_mute_path(params[:token]) %>" data-stop-url="<%= j public_regia_stop_path(params[:token]) %>" data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>" data-hls-url="<%= j @session.hls_playback_url %>" @@ -26,7 +27,8 @@ data-live-share-title="<%= j @live_share_title %>" data-points-target="<%= @match.effective_board_type.in?(%w[volley racket]) ? Scoring::Rules.from_match(@match).points_target(@score.current_set) : 0 %>" data-stream-closed="<%= @stream_closed %>" - data-initial-paused="<%= @session.paused? %>"> + data-initial-paused="<%= @session.paused? %>" + data-initial-audio-muted="<%= @session.audio_muted %>">

<%= @team.name %> vs <%= @match.opponent_name %>

@@ -77,6 +79,7 @@ <% unless @stream_closed %> + <% end %> @@ -94,4 +97,4 @@ - + diff --git a/backend/config/locales/app.de.yml b/backend/config/locales/app.de.yml index 63e5db2..a0003d4 100644 --- a/backend/config/locales/app.de.yml +++ b/backend/config/locales/app.de.yml @@ -441,6 +441,8 @@ de: preview_waiting: Vorschau wartet auf Signal… pause: Pausieren resume: Livestream fortsetzen + mute: Audio stummschalten + unmute: Audio einschalten stop: Livestream beenden modal: set_won_title: Satz gewonnen @@ -476,9 +478,12 @@ de: unknown_period: "—" resumed: Übertragung fortgesetzt paused_cover: "Übertragung pausiert — Deckbild auf Sendung" + muted: "Audio stumm — YouTube empfängt das Mikrofon nicht" + unmuted: "Mikrofon-Audio wieder aktiv" closed: Übertragung beendet resume_error: Fehler beim Fortsetzen der Übertragung pause_error: Fehler beim Pausieren der Übertragung + mute_error: Fehler beim Stummschalten close_error: Fehler beim Beenden close_confirm: Übertragung endgültig beenden? link_unavailable: Link nicht verfügbar diff --git a/backend/config/locales/app.en.yml b/backend/config/locales/app.en.yml index 1150d77..5955ad3 100644 --- a/backend/config/locales/app.en.yml +++ b/backend/config/locales/app.en.yml @@ -436,6 +436,8 @@ en: preview_waiting: Waiting for signal… pause: Pause stream resume: Resume stream + mute: Mute audio + unmute: Unmute audio stop: End stream modal: set_won_title: Set won @@ -471,9 +473,12 @@ en: unknown_period: "—" resumed: Stream resumed paused_cover: "Stream paused — cover on air" + muted: "Audio muted — YouTube is not receiving the microphone" + unmuted: "Microphone audio restored" closed: Stream closed resume_error: Error resuming the stream pause_error: Error pausing the stream + mute_error: Error muting audio close_error: Error closing close_confirm: Permanently close the live stream? link_unavailable: Link not available diff --git a/backend/config/locales/app.es.yml b/backend/config/locales/app.es.yml index 08c1647..e3798b7 100644 --- a/backend/config/locales/app.es.yml +++ b/backend/config/locales/app.es.yml @@ -441,6 +441,8 @@ es: preview_waiting: Vista previa a la espera de señal… pause: Pausar directo resume: Reanudar directo + mute: Silenciar audio + unmute: Reactivar audio stop: Finalizar directo modal: set_won_title: Set ganado @@ -476,9 +478,12 @@ es: unknown_period: "—" resumed: Retransmisión reanudada paused_cover: "Retransmisión en pausa — cortinilla al aire" + muted: "Audio silenciado — YouTube no recibe el micrófono" + unmuted: "Audio del micrófono reactivado" closed: Retransmisión finalizada resume_error: Error al reanudar la retransmisión pause_error: Error al pausar la retransmisión + mute_error: Error al silenciar el audio close_error: Error al cerrar close_confirm: "¿Cerrar definitivamente la retransmisión?" link_unavailable: Enlace no disponible diff --git a/backend/config/locales/app.fr.yml b/backend/config/locales/app.fr.yml index 60f7f8c..9433766 100644 --- a/backend/config/locales/app.fr.yml +++ b/backend/config/locales/app.fr.yml @@ -441,6 +441,8 @@ fr: preview_waiting: Aperçu en attente du signal… pause: Mettre en pause resume: Reprendre le direct + mute: Couper l'audio + unmute: Réactiver l'audio stop: Terminer le direct modal: set_won_title: Set gagné @@ -476,9 +478,12 @@ fr: unknown_period: "—" resumed: Direct repris paused_cover: "Direct en pause — habillage à l'antenne" + muted: "Audio coupé — YouTube ne reçoit pas le micro" + unmuted: "Audio du micro rétabli" closed: Direct terminé resume_error: Erreur lors de la reprise du direct pause_error: Erreur lors de la mise en pause du direct + mute_error: Erreur lors de la coupure audio close_error: Erreur de clôture close_confirm: Clôturer définitivement le direct ? link_unavailable: Lien non disponible diff --git a/backend/config/locales/app.it.yml b/backend/config/locales/app.it.yml index 7cbb6bf..bbbcf1b 100644 --- a/backend/config/locales/app.it.yml +++ b/backend/config/locales/app.it.yml @@ -436,6 +436,8 @@ it: preview_waiting: Anteprima in attesa del segnale… pause: Metti in pausa resume: Riprendi diretta + mute: Silenzia audio + unmute: Riattiva audio stop: Chiudi diretta modal: set_won_title: Set vinto @@ -471,9 +473,12 @@ it: unknown_period: "—" resumed: Diretta ripresa paused_cover: "Diretta in pausa — copertina in onda" + muted: "Audio silenziato — YouTube non riceve il microfono" + unmuted: "Audio microfono riattivato" closed: Diretta chiusa resume_error: Errore ripresa diretta pause_error: Errore pausa diretta + mute_error: Errore silenziamento audio close_error: Errore chiusura close_confirm: Chiudere definitivamente la diretta? link_unavailable: Link non disponibile diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 3107a01..bbe890c 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -47,6 +47,7 @@ Rails.application.routes.draw do patch :stop patch :pause patch :resume + patch :audio_mute patch :score post :score_action get :events @@ -142,6 +143,7 @@ Rails.application.routes.draw do patch "regia/:token/score", to: "public/regia#score", as: :public_regia_score post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume + post "regia/:token/audio_mute", to: "public/regia#audio_mute", as: :public_regia_audio_mute post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop root to: "public/pages#home" diff --git a/backend/db/migrate/20260812220000_add_audio_muted_to_stream_sessions.rb b/backend/db/migrate/20260812220000_add_audio_muted_to_stream_sessions.rb new file mode 100644 index 0000000..ac89a73 --- /dev/null +++ b/backend/db/migrate/20260812220000_add_audio_muted_to_stream_sessions.rb @@ -0,0 +1,5 @@ +class AddAudioMutedToStreamSessions < ActiveRecord::Migration[7.2] + def change + add_column :stream_sessions, :audio_muted, :boolean, default: false, null: false + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 1e8e2e6..dfa1a4e 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do +ActiveRecord::Schema[7.2].define(version: 2026_08_12_220000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -280,6 +280,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do t.datetime "updated_at", null: false t.string "regia_token_digest" t.datetime "regia_token_expires_at" + t.boolean "audio_muted", default: false, null: false t.index ["match_id"], name: "index_stream_sessions_on_match_id" t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true diff --git a/backend/public/regia.css b/backend/public/regia.css index 1b4ff89..bf3d22a 100644 --- a/backend/public/regia.css +++ b/backend/public/regia.css @@ -165,6 +165,10 @@ width: 100%; margin-top: 8px; } +.regia-btn--outline.is-muted { + border-color: var(--regia-yellow); + color: var(--regia-yellow); +} .regia-btn--danger { background: transparent; border: 1px solid var(--regia-red); diff --git a/backend/public/regia.js b/backend/public/regia.js index 97f0c63..e79f9c9 100644 --- a/backend/public/regia.js +++ b/backend/public/regia.js @@ -25,9 +25,12 @@ partialsPrefix: "Parziali:", resumed: "Diretta ripresa", pausedCover: "Diretta in pausa — copertina in onda", + muted: "Audio silenziato — YouTube non riceve il microfono", + unmuted: "Audio microfono riattivato", closed: "Diretta chiusa", resumeError: "Errore ripresa diretta", pauseError: "Errore pausa diretta", + muteError: "Errore silenziamento audio", closeError: "Errore chiusura", closeConfirm: "Chiudere definitivamente la diretta?", linkUnavailable: "Link non disponibile", @@ -39,6 +42,8 @@ streamEnded: "Diretta terminata", resumeLabel: "Riprendi diretta", pauseLabel: "Metti in pausa", + muteLabel: "Silenzia audio", + unmuteLabel: "Riattiva audio", endedBadge: "Terminata", pausedBadge: "In pausa", liveBadge: "In onda", @@ -76,6 +81,7 @@ scoreUrl: root.dataset.scoreUrl, pauseUrl: root.dataset.pauseUrl, resumeUrl: root.dataset.resumeUrl, + audioMuteUrl: root.dataset.audioMuteUrl, stopUrl: root.dataset.stopUrl, cableUrl: root.dataset.cableUrl, hlsUrl: root.dataset.hlsUrl, @@ -103,6 +109,7 @@ preview: document.getElementById("regia-preview"), previewPlaceholder: document.getElementById("regia-preview-placeholder"), btnPause: document.getElementById("btn-pause"), + btnAudioMute: document.getElementById("btn-audio-mute"), subtitle: document.getElementById("regia-subtitle") }; @@ -111,6 +118,7 @@ let previewStarted = false; let wasPausedPreview = false; let streamPaused = root.dataset.initialPaused === "true"; + let audioMuted = root.dataset.initialAudioMuted === "true"; function hlsUrlFresh() { const sep = cfg.hlsUrl.includes("?") ? "&" : "?"; @@ -246,8 +254,18 @@ els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel; } + function syncMuteButton(data) { + if (!els.btnAudioMute) return; + if (typeof data.audio_muted === "boolean") { + audioMuted = data.audio_muted; + } + els.btnAudioMute.textContent = audioMuted ? I18N.unmuteLabel : I18N.muteLabel; + els.btnAudioMute.classList.toggle("is-muted", audioMuted); + } + function setBadge(data) { syncPauseButton(data); + syncMuteButton(data); if (data.stream_closed) { els.badge.textContent = I18N.endedBadge; els.badge.className = "regia-badge regia-badge--ended"; @@ -458,6 +476,24 @@ toast(wasPaused ? I18N.resumed : I18N.pausedCover); } + async function toggleMuteAudio() { + if (!cfg.audioMuteUrl) return; + const nextMuted = !audioMuted; + const res = await fetch(cfg.audioMuteUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ muted: nextMuted }) + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || I18N.muteError); + } + const data = await res.json(); + applyScorePayload(data); + setBadge(data); + toast(audioMuted ? I18N.muted : I18N.unmuted); + } + async function stopStream() { const ok = confirm(I18N.closeConfirm); if (!ok) return null; @@ -471,6 +507,7 @@ } els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message))); + els.btnAudioMute?.addEventListener("click", () => toggleMuteAudio().catch((e) => toast(e.message))); document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message))); async function shareLink(url, title, text) { diff --git a/backend/spec/requests/api/v1/stream_sessions_audio_mute_spec.rb b/backend/spec/requests/api/v1/stream_sessions_audio_mute_spec.rb new file mode 100644 index 0000000..6288eb4 --- /dev/null +++ b/backend/spec/requests/api/v1/stream_sessions_audio_mute_spec.rb @@ -0,0 +1,37 @@ +require "rails_helper" + +RSpec.describe "Api::V1 stream session audio mute", type: :request do + let!(:user) { User.create!(email: "mute-api@test.it", name: "U", password: "Password123", role: "coach") } + let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:owner) { club.club_memberships.create!(user: user, role: "owner") } + let!(:team) { club.teams.create!(name: "T", sport: "volleyball") } + let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") } + let!(:session) { StreamSession.create!(match: match, user: user, platform: "youtube", status: "live") } + + def auth_headers + post "/api/v1/auth/login", params: { email: user.email, password: "Password123" } + token = response.parsed_body["access_token"] + { "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" } + end + + it "include audio_muted nello show della sessione" do + get "/api/v1/sessions/#{session.id}", headers: auth_headers + expect(response).to have_http_status(:ok) + expect(response.parsed_body["audio_muted"]).to eq(false) + end + + it "silenzia e riattiva l'audio via PATCH" do + patch "/api/v1/sessions/#{session.id}/audio_mute", + params: { muted: true }.to_json, + headers: auth_headers + expect(response).to have_http_status(:ok) + expect(response.parsed_body["audio_muted"]).to eq(true) + expect(session.reload.audio_muted).to eq(true) + + patch "/api/v1/sessions/#{session.id}/audio_mute", + params: { muted: false }.to_json, + headers: auth_headers + expect(response).to have_http_status(:ok) + expect(response.parsed_body["audio_muted"]).to eq(false) + end +end diff --git a/backend/spec/requests/public/regia_spec.rb b/backend/spec/requests/public/regia_spec.rb index 5f85e17..c35cadb 100644 --- a/backend/spec/requests/public/regia_spec.rb +++ b/backend/spec/requests/public/regia_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe "Public regia", type: :request do - let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "password123", role: "coach") } + let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "Password123", role: "coach") } let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:team) { club.teams.create!(name: "T", sport: "volleyball") } let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") } @@ -148,6 +148,29 @@ RSpec.describe "Public regia", type: :request do expect(session.reload.status).to eq("connecting") end + it "silenzia e riattiva l'audio della diretta" do + token = Sessions::RegiaAccess.new(session).issue_token! + expect(session.audio_muted).to eq(false) + + post public_regia_audio_mute_path(token), params: { muted: true }, as: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body["audio_muted"]).to eq(true) + expect(session.reload.audio_muted).to eq(true) + + post public_regia_audio_mute_path(token), params: { muted: false }, as: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body["audio_muted"]).to eq(false) + expect(session.reload.audio_muted).to eq(false) + end + + it "mostra il pulsante mute sulla pagina regia" do + token = Sessions::RegiaAccess.new(session).issue_token! + get public_regia_path(token) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Silenzia audio") + expect(response.body).to include("btn-audio-mute") + end + it "emette token valido anche oltre le 8 ore dalla partenza" do session.update!(started_at: 9.hours.ago) token = Sessions::RegiaAccess.new(session).issue_token! diff --git a/backend/spec/services/sessions/set_audio_mute_spec.rb b/backend/spec/services/sessions/set_audio_mute_spec.rb new file mode 100644 index 0000000..17d1eb4 --- /dev/null +++ b/backend/spec/services/sessions/set_audio_mute_spec.rb @@ -0,0 +1,27 @@ +require "rails_helper" + +RSpec.describe Sessions::SetAudioMute do + let!(:user) { User.create!(email: "mute@test.it", name: "U", password: "Password123", role: "coach") } + let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:team) { club.teams.create!(name: "T", sport: "volleyball") } + let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") } + let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live") } + + it "attiva il mute e persiste lo stato" do + described_class.new(session, muted: true).call + expect(session.reload.audio_muted).to eq(true) + expect(session.stream_events.order(:occurred_at).last.event_type).to eq("audio_muted") + end + + it "disattiva il mute" do + session.update!(audio_muted: true) + described_class.new(session, muted: false).call + expect(session.reload.audio_muted).to eq(false) + expect(session.stream_events.order(:occurred_at).last.event_type).to eq("audio_unmuted") + end + + it "è idempotente se lo stato è già quello richiesto" do + described_class.new(session, muted: false).call + expect(session.stream_events.count).to eq(0) + end +end diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0ec450b..17d8cf3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -399,7 +399,8 @@ cd infra && cp .env.example .env && docker compose up -d --build | [`infrastructure/SERVER_DEPLOYMENT.md`](infrastructure/SERVER_DEPLOYMENT.md) | Bootstrap server, NPM, cron, backup | | [`infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md`](infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md) | Piano overflow relay YouTube su Hetzner Cloud (picchi) | | [`ANDROID_APP_LINKS.md`](ANDROID_APP_LINKS.md) | Digital Asset Links / deep link Play (`assetlinks.json`) | -| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pausa, HLS, ruolo ffmpeg | +| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pausa, mute audio, HLS, ruolo ffmpeg | +| [`IOS_STREAM_AUDIO_MUTE.md`](IOS_STREAM_AUDIO_MUTE.md) | Mute microfono iOS (allineamento Mac) | | [`REPLAY_MODULE.md`](REPLAY_MODULE.md) | Garage, retention, YouTube VOD | | [`OPS_MONITORING.md`](OPS_MONITORING.md) | ntfy, variabili ops, troubleshooting | | [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md) | Tabelloni e overlay (app nativa) | @@ -416,7 +417,7 @@ cd infra && cp .env.example .env && docker compose up -d --build | 2026-06 | Replay: redirect 302 a `/media/` → Garage (fuori da Puma) | | 2026-06 | HLS live: edge → MediaMTX (fuori da Puma) | | 2026-06 | Ops: check `http_rails` + `http_public` separati, latenza p95 `UpLatencyTracker` | -| 2026-06 | `RAILS_MAX_THREADS=5`; relay YouTube da MediaMTX (non via Puma) | +| 2026-08 | Mute microfono in diretta (`audio_muted`) per evitare claim YouTube sulla musica in palestra | | 2026-06 | Cleanup path MediaMTX orfani (`Mediamtx::CleanupOrphanPaths`); delete path sempre a fine diretta | | 2026-07 | Rimosso overlay server (`OverlayRelay`); tabellone bruciato in app; HLS su path camera (non `*_air`); `YoutubeRelay` = copy video + AAC | | 2026-08 | `YoutubeRelay` = remux copy video+audio (niente ricodifica AAC); profilo app/slate AAC 48k mono | diff --git a/docs/IOS_ANDROID_I18N_GAP.md b/docs/IOS_ANDROID_I18N_GAP.md index 97f835b..27c0a16 100644 --- a/docs/IOS_ANDROID_I18N_GAP.md +++ b/docs/IOS_ANDROID_I18N_GAP.md @@ -2,10 +2,12 @@ Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare un’app **allineata ad Android**. -**Aggiornato:** 8 agosto 2026 +**Aggiornato:** 12 agosto 2026 **Riferimento Android (produzione / telefono / Play):** `2.0.10-native` (`versionCode` **31**), API `https://www.matchlivetv.it` **Stato iOS attuale:** marketing `2.0.10`, build `31` — i18n **allineato** (Login, hub, sheet/dialog, wizard, broadcast, account/forgot) +**Nuovo (branch `feature/stream-audio-mute`):** mute microfono in diretta. Codice iOS già nel branch; verifica su Mac in [`IOS_STREAM_AUDIO_MUTE.md`](IOS_STREAM_AUDIO_MUTE.md). + Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog, wizard, broadcast, account) + bump versione, **senza** i bug di sessione/crash già risolti su Android. --- diff --git a/docs/IOS_STREAM_AUDIO_MUTE.md b/docs/IOS_STREAM_AUDIO_MUTE.md new file mode 100644 index 0000000..45bbc7c --- /dev/null +++ b/docs/IOS_STREAM_AUDIO_MUTE.md @@ -0,0 +1,149 @@ +# iOS — mute audio in diretta (allineamento da Mac) + +Documento operativo per **Cursor su Mac**: verificare e, se serve, ritoccare l’app iOS dopo il ramo `feature/stream-audio-mute`. + +**Aggiornato:** 2026-08-12 +**Branch:** `feature/stream-audio-mute` (parte da `main`) +**Android di riferimento:** mute microfono in overlay + sync regia +**API produzione:** `https://www.matchlivetv.it` + +Il codice iOS è **già nel branch** (engine, overlay, API, Cable, L10n). Su Mac va **compilato, testato su device** e, se HaishinKit 2.2.5 rifiuta un parametro, adattato come sotto. + +--- + +## Perché esiste + +In palestra la musica di sottofondo finisce nel microfono del telefono. Il relay YouTube fa `-c:a copy`, quindi YouTube sente quella musica e può reclamare il copyright. + +Il mute **non ferma** lo stream: continua video + overlay + AAC, ma i sample audio sono silenzio. + +--- + +## Contratto API / Cable (già in backend) + +| Pezzo | Dettaglio | +|-------|-----------| +| Campo sessione | `audio_muted` (boolean, default `false`) in JSON sessione e `GET/POST /regia/:token/status.json` | +| REST app | `PATCH /api/v1/sessions/:id/audio_mute` body `{ "muted": true \| false }` | +| REST regia | `POST /regia/:token/audio_mute` body `{ "muted": true \| false }` (senza body: toggle) | +| Cable command | `{ "type": "command", "action": "mute_audio" \| "unmute_audio", "muted": true \| false }` | +| Cable event | `{ "type": "stream_event", "event": "audio_muted", "muted": true \| false }` | + +Idempotente: se lo stato è già quello richiesto, il backend non reinvia eventi. + +--- + +## File iOS già modificati + +``` +native/ios/MatchLiveTv/Streaming/BroadcastModels.swift # BroadcastMetrics.audioMuted +native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift # setAudioMuted + AudioMixerSettings.isMuted +native/ios/MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift # setAudioMuted +native/ios/MatchLiveTv/Domain/Models.swift # StreamSession.audioMuted + withAudioMuted +native/ios/MatchLiveTv/Data/API/ApiDtos.swift # audioMuted + AudioMuteRequest +native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift # setAudioMute +native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift +native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift # onAudioMute + AudioMuteCommandLogic +native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift +native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift +native/ios/MatchLiveTv/Core/AppLanguage.swift # IT/EN/FR/DE/ES +native/ios/MatchLiveTvTests/LiveBroadcastCoordinatorTests.swift +``` + +--- + +## Engine HaishinKit 2.2.5 (punto da verificare su Mac) + +Implementazione attesa in `LiveBroadcastEngine`: + +```swift +func setAudioMuted(_ muted: Bool) async { + audioMuted = muted + await applyAudioMute() + metrics.audioMuted = muted + emitMetrics() +} + +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) +} +``` + +In `configurePipeline` i settings iniziali passano `isMuted: audioMuted` sia sul mixer sia sulla track 0. + +**Se il build fallisce:** + +1. `AudioMixerSettings` potrebbe non avere `isMuted` nel memberwise usato oggi — in quel caso muta solo `settings.tracks[0].isMuted`. +2. `setAudioMixerSettings` è `async throws` nel codice attuale; se la signature è sync, togli `try await`. +3. Non staccare il microfono (`attachAudio(nil)`): YouTube/MediaMTX vogliono AAC continuo. Mute = silenzio, non assenza di traccia. + +Dopo `prepareBroadcast` / `resumeBroadcast` la schermata richiama `setAudioMuted(session.audioMuted)` per riallineare lo stato persistito. + +--- + +## UI overlay + +Toolbar destra (come Android), sotto pausa: + +| Stato | SF Symbol | Chiave L10n | +|-------|-----------|-------------| +| Audio on | `speaker.wave.2.fill` | `broadcast.mute.cd` | +| Audio off | `speaker.slash.fill` | `broadcast.unmute.cd` | + +Bottone `highlighted` quando mutato (stesso verde della pausa). Pannello telemetria: riga `broadcast.audio.muted.label` se mutato. + +--- + +## Chiavi L10n (già aggiunte, 5 lingue) + +| Android | iOS | +|---------|-----| +| `broadcast_mute_cd` | `broadcast.mute.cd` | +| `broadcast_unmute_cd` | `broadcast.unmute.cd` | +| `broadcast_audio_muted_label` | `broadcast.audio.muted.label` | +| `broadcast_snackbar_muted` | `broadcast.snackbar.muted` | +| `broadcast_snackbar_unmuted` | `broadcast.snackbar.unmuted` | +| `broadcast_snackbar_muted_remote` | `broadcast.snackbar.muted.remote` | +| `broadcast_snackbar_unmuted_remote` | `broadcast.snackbar.unmuted.remote` | +| `broadcast_snackbar_mute_error` | `broadcast.snackbar.mute.error` | + +--- + +## Test su Mac (acceptance) + +1. `cd native/ios && python3 generate_xcodeproj.py` +2. Build simulatore / device. +3. Avvia una diretta (anche verso ambiente di staging). +4. Overlay: tap mute → icona speaker.slash, snackbar «Audio silenziato», telemetria «Audio off». +5. Apri la **regia** sullo stesso match → il pulsante deve dire «Riattiva audio». +6. Dalla regia riattiva → il telefono mostra speaker.wave e snackbar «dalla regia». +7. Mute, metti in **pausa**, riprendi: l’audio resta mutato (stato persistito). +8. YouTube / player HLS: video continua, audio silenzioso (non assente). +9. Unit test: `LiveBroadcastCoordinatorTests.testAudioMuteCommandFromCable`. + +```bash +cd native/ios +python3 generate_xcodeproj.py +xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath build/DerivedData \ + ONLY_ACTIVE_ARCH=YES ARCHS=arm64 EXCLUDED_ARCHS=x86_64 \ + build +``` + +--- + +## Criterio “fatto” + +- [ ] Build iOS senza errori HaishinKit +- [ ] Mute/unmute locale in overlay +- [ ] Sync bidirezionale con la regia via Cable + REST +- [ ] Mute sopravvive a pausa/ripresa +- [ ] AAC silenzioso in onda (niente drop della traccia audio) +- [ ] Stringhe IT/EN/FR/DE/ES visibili in overlay diff --git a/docs/LIVE_STREAMING.md b/docs/LIVE_STREAMING.md index 7f5a292..878bb8f 100644 --- a/docs/LIVE_STREAMING.md +++ b/docs/LIVE_STREAMING.md @@ -1,6 +1,6 @@ # Diretta live — architettura MediaMTX -**Ultimo aggiornamento:** 2026-07-29 +**Ultimo aggiornamento:** 2026-08-12 ## Idea @@ -106,6 +106,19 @@ L’app mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (o ` `GET /live/:id/status.json` resta per messaggi sotto il player e recovery HLS; `Cache-Control: no-store`. +### Mute audio (copyright palestra / YouTube) + +Se in palestra c’è musica di sottofondo, YouTube può reclamare il copyright sul flusso inoltrato (`-c:a copy`). L’operatore può **silenziare il microfono** senza interrompere video, overlay, HLS o relay: + +1. Lo stato `audio_muted` è persistito su `stream_sessions` (default `false`). +2. API `PATCH /api/v1/sessions/:id/audio_mute` `{ "muted": true|false }` e `POST /regia/:token/audio_mute`. +3. Action Cable: `{ type: "command", action: "mute_audio"|"unmute_audio" }` e `{ type: "stream_event", event: "audio_muted", muted: true|false }`. +4. L’app **non toglie la traccia AAC**: azzera i sample PCM (silenzio). YouTube e MediaMTX continuano a ricevere audio 48 kHz mono. + +Pulsante mute: overlay diretta (Android/iOS, accanto a pausa) e pagina regia. + +Dettaglio implementazione iOS: [`IOS_STREAM_AUDIO_MUTE.md`](IOS_STREAM_AUDIO_MUTE.md). + ## Rigenerare la slate ```bash diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt index aaa2863..a023ed1 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt @@ -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", diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt index b620567..32e0f65 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt @@ -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, diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/AudioMuteCommands.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/AudioMuteCommands.kt new file mode 100644 index 0000000..76eaa55 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/AudioMuteCommands.kt @@ -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 + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt index 8d5bc14..042f1b1 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt @@ -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() + } } } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt index 659350a..c592bce 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt @@ -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, diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt index bea0ffe..5589543 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt @@ -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" diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt index 8455ffc..070341c 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt @@ -39,4 +39,5 @@ data class BroadcastMetrics( val fps: Int = 0, val connected: Boolean = false, val lastError: String? = null, + val audioMuted: Boolean = false, ) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt index 59bf562..34c57c4 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt @@ -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) { diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt index 52f8629..dd6e468 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt @@ -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" diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt index 59bed6f..430c7a5 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt @@ -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) } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt index a49fe63..42b84cc 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt @@ -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 diff --git a/native/android/app/src/main/res/values-de/strings.xml b/native/android/app/src/main/res/values-de/strings.xml index 250597d..0bae7ed 100644 --- a/native/android/app/src/main/res/values-de/strings.xml +++ b/native/android/app/src/main/res/values-de/strings.xml @@ -140,6 +140,11 @@ Von der Regie fortgesetzt RTMP-Fortsetzung: %1$s Livestream von der Regie beendet + Audio stummgeschaltet + Audio wieder aktiv + Audio von der Regie stummgeschaltet + Audio von der Regie wieder aktiviert + Audio: %1$s RTMP-URL fehlt Sitzung nicht verfügbar PAUSE @@ -163,6 +168,9 @@ Nächste Periode Livestream fortsetzen Livestream pausieren + Audio stummschalten + Audio einschalten + Audio aus Livestream beenden HEIM GAST diff --git a/native/android/app/src/main/res/values-en/strings.xml b/native/android/app/src/main/res/values-en/strings.xml index 4e2faa4..950f43a 100644 --- a/native/android/app/src/main/res/values-en/strings.xml +++ b/native/android/app/src/main/res/values-en/strings.xml @@ -140,6 +140,11 @@ Resumed from control room RTMP resume: %1$s Live closed from control room + Audio muted + Audio restored + Audio muted from control room + Audio restored from control room + Audio: %1$s Missing RTMP URL Session not available PAUSED @@ -163,6 +168,9 @@ Next period Resume live Pause live + Mute audio + Unmute audio + Audio off End live HOME AWAY diff --git a/native/android/app/src/main/res/values-es/strings.xml b/native/android/app/src/main/res/values-es/strings.xml index 5371de8..0cdf2e7 100644 --- a/native/android/app/src/main/res/values-es/strings.xml +++ b/native/android/app/src/main/res/values-es/strings.xml @@ -140,6 +140,11 @@ Reanudado desde regie Reanudación RTMP: %1$s Directo cerrado desde regie + Audio silenciado + Audio reactivado + Audio silenciado desde regie + Audio reactivado desde regie + Audio: %1$s Falta la URL RTMP Sesión no disponible PAUSA @@ -163,6 +168,9 @@ Periodo siguiente Reanudar directo Pausar directo + Silenciar audio + Reactivar audio + Audio off Terminar directo LOCAL VISITANTE diff --git a/native/android/app/src/main/res/values-fr/strings.xml b/native/android/app/src/main/res/values-fr/strings.xml index fad9546..f6ed743 100644 --- a/native/android/app/src/main/res/values-fr/strings.xml +++ b/native/android/app/src/main/res/values-fr/strings.xml @@ -140,6 +140,11 @@ Repris depuis la régie Reprise RTMP : %1$s Direct fermé depuis la régie + Audio coupé + Audio rétabli + Audio coupé depuis la régie + Audio rétabli depuis la régie + Audio : %1$s URL RTMP manquante Session non disponible PAUSE @@ -163,6 +168,9 @@ Période suivante Reprendre le direct Mettre le direct en pause + Couper l\'audio + Réactiver l\'audio + Audio off Terminer le direct DOMICILE EXTÉRIEUR diff --git a/native/android/app/src/main/res/values/strings.xml b/native/android/app/src/main/res/values/strings.xml index d726872..c974217 100644 --- a/native/android/app/src/main/res/values/strings.xml +++ b/native/android/app/src/main/res/values/strings.xml @@ -140,6 +140,11 @@ Ripresa dalla regia Ripresa RTMP: %1$s Diretta chiusa dalla regia + Audio silenziato + Audio riattivato + Audio silenziato dalla regia + Audio riattivato dalla regia + Audio: %1$s URL RTMP mancante Sessione non disponibile PAUSA @@ -163,6 +168,9 @@ Periodo successivo Riprendi diretta Pausa diretta + Silenzia audio + Riattiva audio + Audio off Termina diretta CASA OSPITE diff --git a/native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/data/cable/AudioMuteCommandsTest.kt b/native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/data/cable/AudioMuteCommandsTest.kt new file mode 100644 index 0000000..e3d6a58 --- /dev/null +++ b/native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/data/cable/AudioMuteCommandsTest.kt @@ -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")) + } +} diff --git a/native/ios/MatchLiveTv/Core/AppLanguage.swift b/native/ios/MatchLiveTv/Core/AppLanguage.swift index ad8317d..73ae7d5 100644 --- a/native/ios/MatchLiveTv/Core/AppLanguage.swift +++ b/native/ios/MatchLiveTv/Core/AppLanguage.swift @@ -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", diff --git a/native/ios/MatchLiveTv/Data/API/ApiDtos.swift b/native/ios/MatchLiveTv/Data/API/ApiDtos.swift index 26d5c77..c2a7823 100644 --- a/native/ios/MatchLiveTv/Data/API/ApiDtos.swift +++ b/native/ios/MatchLiveTv/Data/API/ApiDtos.swift @@ -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? diff --git a/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift b/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift index fa2f3d7..6e5fd49 100644 --- a/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift +++ b/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift @@ -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) } diff --git a/native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift b/native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift index a79de5b..bd8e707 100644 --- a/native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift +++ b/native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift @@ -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 + } + } +} diff --git a/native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift b/native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift index 3419585..a30a138 100644 --- a/native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift +++ b/native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift @@ -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, diff --git a/native/ios/MatchLiveTv/Domain/Models.swift b/native/ios/MatchLiveTv/Domain/Models.swift index 1997a6b..f097dab 100644 --- a/native/ios/MatchLiveTv/Domain/Models.swift +++ b/native/ios/MatchLiveTv/Domain/Models.swift @@ -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 } diff --git a/native/ios/MatchLiveTv/Streaming/BroadcastModels.swift b/native/ios/MatchLiveTv/Streaming/BroadcastModels.swift index 488c4cd..158f17f 100644 --- a/native/ios/MatchLiveTv/Streaming/BroadcastModels.swift +++ b/native/ios/MatchLiveTv/Streaming/BroadcastModels.swift @@ -28,4 +28,5 @@ struct BroadcastMetrics: Sendable { var fps: Int = 0 var connected: Bool = false var lastError: String? + var audioMuted: Bool = false } diff --git a/native/ios/MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift b/native/ios/MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift index 5fcc2fc..291b6de 100644 --- a/native/ios/MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift +++ b/native/ios/MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift @@ -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]) diff --git a/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift b/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift index 3e6f18d..030bcaf 100644 --- a/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift +++ b/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift @@ -47,6 +47,7 @@ final class LiveBroadcastEngine: ObservableObject { private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation? private var orientationDebounceTask: Task? 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) } diff --git a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift index 084ac75..def77bf 100644 --- a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift +++ b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift @@ -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) diff --git a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift index f15ad7a..8c12c97 100644 --- a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift +++ b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift @@ -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) } diff --git a/native/ios/MatchLiveTvTests/LiveBroadcastCoordinatorTests.swift b/native/ios/MatchLiveTvTests/LiveBroadcastCoordinatorTests.swift index 768387f..1af4975 100644 --- a/native/ios/MatchLiveTvTests/LiveBroadcastCoordinatorTests.swift +++ b/native/ios/MatchLiveTvTests/LiveBroadcastCoordinatorTests.swift @@ -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) + } }