Rendi affidabile pausa con slate e conferma i controlli laterali.

Evita buchi HLS/YouTube in pausa (path slate ricreata, reload player, no patch recording inutili) e richiede conferma su tasti laterali app/regia.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-20 12:44:23 +02:00
co-authored by Cursor
parent e09b3ffcf8
commit 0702cb699c
37 changed files with 1186 additions and 83 deletions
@@ -52,6 +52,18 @@ module Public
muteError: t("regia.js.mute_error"),
closeError: t("regia.js.close_error"),
closeConfirm: t("regia.js.close_confirm"),
pauseConfirmTitle: t("regia.js.pause_confirm_title"),
pauseConfirmBody: t("regia.js.pause_confirm_body"),
resumeConfirmTitle: t("regia.js.resume_confirm_title"),
resumeConfirmBody: t("regia.js.resume_confirm_body"),
muteConfirmTitle: t("regia.js.mute_confirm_title"),
muteConfirmBody: t("regia.js.mute_confirm_body"),
unmuteConfirmTitle: t("regia.js.unmute_confirm_title"),
unmuteConfirmBody: t("regia.js.unmute_confirm_body"),
advancePeriodTitle: t("regia.js.advance_period_title"),
advancePeriodBody: t("regia.js.advance_period_body"),
confirmAction: t("regia.modal.confirm_action"),
stopConfirmTitle: t("regia.js.stop_confirm_title"),
linkUnavailable: t("regia.js.link_unavailable"),
linkCopied: t("regia.js.link_copied"),
copyPrompt: t("regia.js.copy_prompt"),
+42 -1
View File
@@ -74,7 +74,48 @@ module Mediamtx
set_always_available(session, enabled: true)
end
# Slate alwaysAvailable: copertina sullo stesso path quando il telefono è offline.
# Garantisce path config + slate: se MediaMTX ha perso la config (restart → all_others),
# in pausa non resterebbe nessuno stream verso HLS/YouTube.
def ensure_path_with_slate!(session)
path = session.mediamtx_path_name
response = @conn.get("/v3/config/paths/get/#{CGI.escape(path)}")
missing = response.status == 404 ||
(response.body.is_a?(Hash) && response.body["error"].present?)
if missing
forget_always_available(path)
begin
create_path(session)
rescue Error
# Path creato in parallelo o già presente: forza solo la slate.
forget_always_available(path)
set_always_available(session, enabled: true)
end
return true
end
conf = response.body.is_a?(Hash) ? response.body : {}
desired = slate_file_path(session)
if conf["alwaysAvailable"] == true && conf["alwaysAvailableFile"].to_s == desired.to_s
remember_always_available(path, enabled: true)
return true
end
forget_always_available(path)
set_always_available(session, enabled: true)
end
# Spegnere record solo se attivo: ogni PATCH path ricarica MediaMTX e distrugge i muxer HLS.
def disable_recording_if_active!(session)
path = session.mediamtx_path_name
response = @conn.get("/v3/config/paths/get/#{CGI.escape(path)}")
return true unless response.success?
return true unless response.body.is_a?(Hash) && response.body["record"] == true
set_path_recording(session, enabled: false)
end
# Slate alwaysAvailable: copertina sullo stesso path quando il telefono è offline.
# Resta accesa anche con publisher in onda (MediaMTX usa il publisher se presente).
def set_always_available(session, enabled:)
path = session.mediamtx_path_name
@@ -79,9 +79,8 @@ module Mediamtx
end
def restore_slate_path!(session)
return if session.platform == "matchlivetv"
Client.for_session(session).set_always_available(session, enabled: true)
# Anche su matchlivetv: dopo restart MediaMTX la path può cadere su all_others senza slate.
Client.for_session(session).ensure_path_with_slate!(session)
rescue Client::Error => e
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
end
@@ -146,6 +145,14 @@ module Mediamtx
nil
end
def self.remember_recording_off!(session_id)
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
redis.set("mediamtx:recording:#{session_id}", "0", ex: 48.hours.to_i)
redis.set(format("mediamtx:recording:patched_at:%s", session_id), Time.current.to_i, ex: 48.hours.to_i)
rescue Redis::BaseError
nil
end
def recording_state_key(session_id)
"mediamtx:recording:#{session_id}"
end
+5 -3
View File
@@ -7,11 +7,13 @@ module Sessions
def call
cancel_timeout_job
@session.pause! if @session.may_pause?
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
begin
mtx = Mediamtx::Client.for_session(@session)
mtx.set_path_recording(@session, enabled: false)
mtx.set_always_available(@session, enabled: true)
# Ricrea/riallinea slate prima di spegnere l'RTMP del telefono.
mtx.ensure_path_with_slate!(@session)
# PATCH recording solo se era attivo (evita kill muxer HLS a ogni pausa).
mtx.disable_recording_if_active!(@session)
Mediamtx::PublisherSync.remember_recording_off!(@session.id)
rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}")
end
@@ -4,10 +4,15 @@ module Streams
ENV.fetch("MEDIAMTX_SLATES_CUSTOM_DIR", "/slates/custom")
end
# ActiveStorage checksum is base64 e può contenere "/", "+", "=" — non usabile come path.
def self.safe_digest(checksum)
checksum.to_s.tr("+/", "-_").delete("=")
end
def self.filename_for(record)
return nil unless record.cover_slate.attached?
digest = record.cover_slate.blob.checksum
digest = safe_digest(record.cover_slate.blob.checksum)
prefix = record.class.name.underscore
"#{prefix}-#{record.id}-#{digest}.mp4"
end
@@ -31,8 +31,11 @@ module Streams
end
host_path = result.slate_local_path
filename = File.basename(host_path)
mediamtx_path = "#{CoverSlatePaths.slates_custom_root}/#{filename}"
filename = CoverSlatePaths.filename_for(result.record)
raise Error, "slate filename missing" if filename.blank?
# Path completo (non File.basename): il checksum AS può contenere "/" e spezzerebbe il path.
mediamtx_path = CoverSlatePaths.expected_path(result.record)
if cloud_node?
push_to_agent!(filename, result.record, host_path)
+2 -1
View File
@@ -219,7 +219,8 @@
}
function onEnterPause() {
nudgePlayback();
// Camera → slate cambia risoluzione/codec: senza reload HLS.js resta spesso bloccato.
hardReloadPlayer();
}
function hardReloadPlayer() {
+1 -1
View File
@@ -165,4 +165,4 @@
</div>
</div>
<script src="/regia.js?v=19"></script>
<script src="/regia.js?v=20"></script>
+1 -1
View File
@@ -21,7 +21,7 @@ module MatchLiveTv
end
def hls_public_url
ENV.fetch("HLS_PUBLIC_URL", "http://localhost:8888")
ENV.fetch("HLS_PUBLIC_URL") { "#{app_public_url.chomp('/')}/hls" }
end
def mediamtx_hls_url
+1 -1
View File
@@ -57,7 +57,7 @@ de:
username_label: Benutzername
password_label: Passwort
submit: Anmelden
initial_credentials_html: "Anfangs-Zugangsdaten: <code>admin</code> / <code>admin</code>. Ändere das Passwort nach der ersten Anmeldung."
initial_credentials_html: "Anfangs-Zugangsdaten: <code>admin</code> / <code>AdminPass123</code>. Ändere das Passwort nach der ersten Anmeldung."
passwords:
edit:
title: Passwort ändern
+1 -1
View File
@@ -57,7 +57,7 @@ en:
username_label: Username
password_label: Password
submit: Log in
initial_credentials_html: "Initial credentials: <code>admin</code> / <code>admin</code>. Change the password after the first login."
initial_credentials_html: "Initial credentials: <code>admin</code> / <code>AdminPass123</code>. Change the password after the first login."
passwords:
edit:
title: Change password
+1 -1
View File
@@ -57,7 +57,7 @@ es:
username_label: Usuario
password_label: Contraseña
submit: Acceder
initial_credentials_html: "Credenciales iniciales: <code>admin</code> / <code>admin</code>. Cambia la contraseña después del primer acceso."
initial_credentials_html: "Credenciales iniciales: <code>admin</code> / <code>AdminPass123</code>. Cambia la contraseña después del primer acceso."
passwords:
edit:
title: Cambiar contraseña
+1 -1
View File
@@ -57,7 +57,7 @@ fr:
username_label: Nom d'utilisateur
password_label: Mot de passe
submit: Se connecter
initial_credentials_html: "Identifiants initiaux : <code>admin</code> / <code>admin</code>. Changez le mot de passe après la première connexion."
initial_credentials_html: "Identifiants initiaux : <code>admin</code> / <code>AdminPass123</code>. Changez le mot de passe après la première connexion."
passwords:
edit:
title: Changer le mot de passe
+1 -1
View File
@@ -61,7 +61,7 @@ it:
username_label: Username
password_label: Password
submit: Accedi
initial_credentials_html: "Credenziali iniziali: <code>admin</code> / <code>admin</code>. Cambia la password dopo il primo accesso."
initial_credentials_html: "Credenziali iniziali: <code>admin</code> / <code>AdminPass123</code>. Cambia la password dopo il primo accesso."
passwords:
edit:
title: Cambia password
+12
View File
@@ -491,6 +491,7 @@ de:
modal:
set_won_title: Satz gewonnen
confirm: Satz beenden
confirm_action: Bestätigen
cancel: Abbrechen
board:
next_period: Nächstes Viertel
@@ -530,6 +531,17 @@ de:
mute_error: Fehler beim Stummschalten
close_error: Fehler beim Beenden
close_confirm: Übertragung endgültig beenden?
pause_confirm_title: Livestream pausieren?
pause_confirm_body: Das Cover bleibt on air, bis du fortsetzt.
resume_confirm_title: Livestream fortsetzen?
resume_confirm_body: Die Kamera geht wieder on air statt des Covers.
mute_confirm_title: Audio stummschalten?
mute_confirm_body: Das Mikrofon wird nicht mehr an den Stream gesendet, bis du es wieder aktivierst.
unmute_confirm_title: Audio wieder aktivieren?
unmute_confirm_body: Das Mikrofon ist wieder aktiv im Stream.
advance_period_title: Zum nächsten Abschnitt wechseln?
advance_period_body: Die Anzeigetafel wechselt zum nächsten Abschnitt und der aktuelle Stand wird gespeichert.
stop_confirm_title: Livestream beenden?
link_unavailable: Link nicht verfügbar
link_copied: Link kopiert
copy_prompt: "Link kopieren:"
+12
View File
@@ -494,6 +494,7 @@ en:
modal:
set_won_title: Set won
confirm: Close set
confirm_action: Confirm
cancel: Cancel
board:
next_period: Next quarter
@@ -533,6 +534,17 @@ en:
mute_error: Error muting audio
close_error: Error closing
close_confirm: Permanently close the live stream?
pause_confirm_title: Pause the live stream?
pause_confirm_body: The cover slate will stay on air until you resume.
resume_confirm_title: Resume the live stream?
resume_confirm_body: The camera will go back on air instead of the cover slate.
mute_confirm_title: Mute audio?
mute_confirm_body: The microphone will not be sent to the stream until you unmute.
unmute_confirm_title: Unmute audio?
unmute_confirm_body: The microphone will be active on the stream again.
advance_period_title: Advance to the next period?
advance_period_body: The scoreboard will move to the next period and the current period score will be saved.
stop_confirm_title: End the live stream?
link_unavailable: Link not available
link_copied: Link copied
copy_prompt: "Copy the link:"
+12
View File
@@ -491,6 +491,7 @@ es:
modal:
set_won_title: Set ganado
confirm: Cerrar set
confirm_action: Confirmar
cancel: Cancelar
board:
next_period: Siguiente cuarto
@@ -530,6 +531,17 @@ es:
mute_error: Error al silenciar el audio
close_error: Error al cerrar
close_confirm: "¿Cerrar definitivamente la retransmisión?"
pause_confirm_title: "¿Pausar el directo?"
pause_confirm_body: La portada seguirá en antena hasta que lo reanudes.
resume_confirm_title: "¿Reanudar el directo?"
resume_confirm_body: La cámara volverá a antena en lugar de la portada.
mute_confirm_title: "¿Silenciar el audio?"
mute_confirm_body: El micrófono no se enviará al stream hasta que lo reactives.
unmute_confirm_title: "¿Reactivar el audio?"
unmute_confirm_body: El micrófono volverá a estar activo en el stream.
advance_period_title: "¿Pasar al periodo siguiente?"
advance_period_body: El marcador pasará al siguiente periodo y se guardará el marcador del periodo actual.
stop_confirm_title: "¿Terminar el directo?"
link_unavailable: Enlace no disponible
link_copied: Enlace copiado
copy_prompt: "Copia el enlace:"
+12
View File
@@ -491,6 +491,7 @@ fr:
modal:
set_won_title: Set gagné
confirm: Terminer le set
confirm_action: Confirmer
cancel: Annuler
board:
next_period: Quart-temps suivant
@@ -530,6 +531,17 @@ fr:
mute_error: Erreur lors de la coupure audio
close_error: Erreur de clôture
close_confirm: Clôturer définitivement le direct ?
pause_confirm_title: Mettre le direct en pause ?
pause_confirm_body: La couverture restera à l'antenne jusqu'à la reprise.
resume_confirm_title: Reprendre le direct ?
resume_confirm_body: La caméra reviendra à l'antenne à la place de la couverture.
mute_confirm_title: Couper l'audio ?
mute_confirm_body: Le micro ne sera plus envoyé au flux tant que vous ne le réactivez pas.
unmute_confirm_title: Réactiver l'audio ?
unmute_confirm_body: Le micro sera à nouveau actif sur le flux.
advance_period_title: Passer à la période suivante ?
advance_period_body: Le tableau passera à la période suivante et le score de la période en cours sera enregistré.
stop_confirm_title: Terminer le direct ?
link_unavailable: Lien non disponible
link_copied: Lien copié
copy_prompt: "Copiez le lien :"
+12
View File
@@ -515,6 +515,7 @@ it:
modal:
set_won_title: Set vinto
confirm: Chiudi set
confirm_action: Conferma
cancel: Annulla
board:
next_period: Prossimo quarto
@@ -554,6 +555,17 @@ it:
mute_error: Errore silenziamento audio
close_error: Errore chiusura
close_confirm: Chiudere definitivamente la diretta?
pause_confirm_title: Mettere in pausa?
pause_confirm_body: La diretta passerà alla copertina finché non la riprendi.
resume_confirm_title: Riprendere la diretta?
resume_confirm_body: La telecamera tornerà in onda al posto della copertina.
mute_confirm_title: Silenziare l'audio?
mute_confirm_body: Il microfono non verrà inviato allo stream finché non lo riattivi.
unmute_confirm_title: Riattivare l'audio?
unmute_confirm_body: Il microfono tornerà attivo sullo stream.
advance_period_title: Passare al periodo successivo?
advance_period_body: Il tabellone passerà al periodo successivo e il punteggio del periodo corrente sarà salvato.
stop_confirm_title: Terminare la diretta?
link_unavailable: Link non disponibile
link_copied: Link copiato
copy_prompt: "Copia il link:"
+72 -13
View File
@@ -33,6 +33,18 @@
muteError: "Errore silenziamento audio",
closeError: "Errore chiusura",
closeConfirm: "Chiudere definitivamente la diretta?",
pauseConfirmTitle: "Mettere in pausa?",
pauseConfirmBody: "La diretta passerà alla copertina finché non la riprendi.",
resumeConfirmTitle: "Riprendere la diretta?",
resumeConfirmBody: "La telecamera tornerà in onda al posto della copertina.",
muteConfirmTitle: "Silenziare l'audio?",
muteConfirmBody: "Il microfono non verrà inviato allo stream finché non lo riattivi.",
unmuteConfirmTitle: "Riattivare l'audio?",
unmuteConfirmBody: "Il microfono tornerà attivo sullo stream.",
advancePeriodTitle: "Passare al periodo successivo?",
advancePeriodBody: "Il tabellone passerà al periodo successivo e il punteggio del periodo corrente sarà salvato.",
confirmAction: "Conferma",
stopConfirmTitle: "Terminare la diretta?",
linkUnavailable: "Link non disponibile",
linkCopied: "Link copiato",
copyPrompt: "Copia il link:",
@@ -389,13 +401,18 @@
if (cfg.board === "volley" || cfg.board === "racket") {
if (data.set_won && action !== "close_set") {
const winner = data.winner === "home" ? cfg.homeName : cfg.awayName;
openModal(I18N.setWonTitle, fillTemplate(I18N.setWonBodyTemplate, { winner }), () => postScore("close_set"));
openModal(I18N.setWonTitle, fillTemplate(I18N.setWonBodyTemplate, { winner }), () => postScore("close_set"), I18N.closeSetTitle);
}
}
if (data.match_won) {
const winner = data.winner === "home" ? cfg.homeName : cfg.awayName;
openModal(I18N.matchWonTitle, fillTemplate(I18N.matchWonBodyTemplate, { winner }), () => stopStream());
}
if (data.match_won) {
const winner = data.winner === "home" ? cfg.homeName : cfg.awayName;
openModal(
I18N.matchWonTitle,
fillTemplate(I18N.matchWonBodyTemplate, { winner }),
() => performStopStream(),
I18N.confirmAction
);
}
} catch (e) {
if (prevHome != null && els.homePoints) els.homePoints.textContent = prevHome;
const awayEl = document.getElementById("away-points");
@@ -406,9 +423,12 @@
}
}
function openModal(title, body, onConfirm) {
function openModal(title, body, onConfirm, confirmLabel) {
els.modalTitle.textContent = title;
els.modalBody.textContent = body;
if (els.modalConfirm) {
els.modalConfirm.textContent = confirmLabel || I18N.confirmAction;
}
els.modal.classList.add("open");
pendingAction = onConfirm;
}
@@ -440,7 +460,9 @@
btn.addEventListener("click", () => {
const action = btn.dataset.action;
if (action === "close_set") {
openModal(I18N.closeSetTitle, I18N.closeSetConfirm, () => postScore("close_set"));
openModal(I18N.closeSetTitle, I18N.closeSetConfirm, () => postScore("close_set"), I18N.closeSetTitle);
} else if (action === "advance_period") {
openModal(I18N.advancePeriodTitle, I18N.advancePeriodBody, () => handleAction("advance_period"));
} else {
handleAction(action);
}
@@ -689,9 +711,8 @@
toast(audioMuted ? I18N.muted : I18N.unmuted);
}
async function stopStream() {
const ok = confirm(I18N.closeConfirm);
if (!ok) return null;
async function performStopStream() {
if (!cfg.stopUrl) return null;
const res = await fetch(cfg.stopUrl, { method: "POST", headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(I18N.closeError);
const data = await res.json();
@@ -701,9 +722,47 @@
return data;
}
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)));
function requestStopStream() {
openModal(I18N.stopConfirmTitle, I18N.closeConfirm, () => performStopStream(), I18N.confirmAction);
}
function requestPauseToggle() {
if (streamPaused) {
openModal(I18N.resumeConfirmTitle, I18N.resumeConfirmBody, async () => {
await togglePauseStream();
return null;
});
} else {
openModal(I18N.pauseConfirmTitle, I18N.pauseConfirmBody, async () => {
await togglePauseStream();
return null;
});
}
}
function requestMuteToggle() {
if (audioMuted) {
openModal(I18N.unmuteConfirmTitle, I18N.unmuteConfirmBody, async () => {
await toggleMuteAudio();
return null;
});
} else {
openModal(I18N.muteConfirmTitle, I18N.muteConfirmBody, async () => {
await toggleMuteAudio();
return null;
});
}
}
async function stopStream() {
// Compatibilità: conferma via modal (non window.confirm).
requestStopStream();
return null;
}
els.btnPause?.addEventListener("click", () => requestPauseToggle());
els.btnAudioMute?.addEventListener("click", () => requestMuteToggle());
document.getElementById("btn-stop")?.addEventListener("click", () => requestStopStream());
let minQualityBusy = false;
@@ -27,6 +27,8 @@ RSpec.describe Mediamtx::PublisherSync do
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(true)
allow_any_instance_of(described_class).to receive(:redis).and_return(redis)
allow(client).to receive(:set_path_recording)
allow(client).to receive(:ensure_path_with_slate!)
allow(client).to receive(:set_always_available)
end
it "abilita la registrazione quando il publisher è online e la sessione è live" do
@@ -43,7 +43,7 @@ RSpec.describe Streams::SlateDistributor do
it "usa path custom su nodo home quando la slate è su disco" do
attach_ready_cover(club)
custom = "/slates/custom/#{File.basename(Streams::CoverSlatePaths.expected_path(club))}"
custom = Streams::CoverSlatePaths.expected_path(club)
expect(described_class.slate_path_for_session(session)).to eq(custom)
end
@@ -72,9 +72,8 @@ RSpec.describe Streams::SlateDistributor do
allow(http).to receive(:read_timeout=)
allow(http).to receive(:request).and_return(response)
filename = File.basename(Streams::CoverSlatePaths.expected_path(club))
path = described_class.slate_path_for_session(session)
expect(path).to eq("/slates/custom/#{filename}")
expect(path).to eq(Streams::CoverSlatePaths.expected_path(club))
expect(http).to have_received(:request)
end
end