Merge branch 'feature/stream-audio-mute' into collaudo

Integra il mute del microfono in diretta per il collaudo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-13 20:10:27 +02:00
co-authored by Cursor
53 changed files with 827 additions and 25 deletions
+4
View File
@@ -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
@@ -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)
@@ -190,7 +196,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
@@ -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])),
@@ -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"),
+1 -1
View File
@@ -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
@@ -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
+5 -2
View File
@@ -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 %>">
<header class="regia-header">
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
@@ -77,6 +79,7 @@
<% unless @stream_closed %>
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause"><%= @session.paused? ? t("regia.resume") : t("regia.pause") %></button>
<button type="button" class="regia-btn regia-btn--outline<%= @session.audio_muted ? ' is-muted' : '' %>" id="btn-audio-mute"><%= @session.audio_muted ? t("regia.unmute") : t("regia.mute") %></button>
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop"><%= t("regia.stop") %></button>
<% end %>
</div>
@@ -94,4 +97,4 @@
</div>
</div>
<script src="/regia.js?v=8"></script>
<script src="/regia.js?v=9"></script>
+5
View File
@@ -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
+5
View File
@@ -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
+5
View File
@@ -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
+5
View File
@@ -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
+5
View File
@@ -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
+2
View File
@@ -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
@@ -151,6 +152,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"
@@ -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
+2 -1
View File
@@ -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_08_09_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"
@@ -304,6 +304,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_09_120000) do
t.string "regia_token_digest"
t.datetime "regia_token_expires_at"
t.uuid "stream_node_id"
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
+4
View File
@@ -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);
+37
View File
@@ -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) {
@@ -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
+24 -1
View File
@@ -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!
@@ -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