Compare commits

...
Author SHA1 Message Date
eminuxandCursor 1fecccaafd Aggiunge il mute del microfono in diretta per evitare claim YouTube sulla musica in palestra.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 22:38:05 +02:00
eminuxandCursor bc2257efd2 Aggiunge la pagina pubblica /support compatibile con App Store Review.
Fornisce assistenza multilingua senza CTA commerciali e riusa l’email di supporto configurabile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 19:37:20 +02:00
Emiliano FrascaroandCursor 4af4fa68ac Prepara iOS build 32 per App Store: icona opaca e orientamenti iPad.
Rimuove l'alpha dall'AppIcon e dichiara tutte le orientazioni richieste dal multitasking iPad, così l'upload ASC non fallisce più.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 22:58:27 +02:00
81 changed files with 1986 additions and 317 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)
@@ -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
@@ -11,17 +11,8 @@ module Public
load_club_billing_context_for_pricing
end
private
def load_club_billing_context_for_pricing
return unless logged_in?
@club = current_user.primary_club
return unless @club
@subscription = @club.subscription
@team = @club.teams.order(:name).first
@entitlements = @team&.entitlements
def support
@app_store_review_chrome = true
end
def privacy
@@ -38,5 +29,18 @@ module Public
def pallavolo
end
private
def load_club_billing_context_for_pricing
return unless logged_in?
@club = current_user.primary_club
return unless @club
@subscription = @club.subscription
@team = @club.teams.order(:name).first
@entitlements = @team&.entitlements
end
end
end
@@ -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])),
@@ -11,6 +11,7 @@ module Public
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
]
@@ -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
+2 -2
View File
@@ -12,13 +12,13 @@
</head>
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
<%= render "shared/cookie_banner" %>
<%= render "shared/marketing_nav" %>
<%= render(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %>
<main>
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
<%= yield %>
</main>
<%= render "shared/marketing_footer" %>
<%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %>
<script src="/branding-form.js?v=1" defer></script>
<script src="/roster-form.js?v=1" defer></script>
<script src="/password-toggle.js?v=2" defer></script>
@@ -0,0 +1,38 @@
<% content_for :title, t("legal.support.title") %>
<% content_for :meta_description, t("legal.support.meta_description") %>
<% content_for :canonical_url, seo_absolute_url(public_support_path) %>
<div class="wrap legal-doc">
<h1><%= t("legal.support.h1") %></h1>
<p><%= t("legal.support.intro") %></p>
<p>
<%= t("legal.support.email_label") %>
<a href="mailto:<%= MatchLiveTv.support_email %>"><%= MatchLiveTv.support_email %></a>
</p>
<section>
<h2><%= t("legal.support.access_title") %></h2>
<p><%= t("legal.support.access_body") %></p>
</section>
<section>
<h2><%= t("legal.support.live_title") %></h2>
<p><%= t("legal.support.live_body") %></p>
</section>
<section>
<h2><%= t("legal.support.team_title") %></h2>
<p><%= t("legal.support.team_body") %></p>
</section>
<section>
<h2><%= t("legal.support.privacy_title") %></h2>
<p>
<%= raw t(
"legal.support.privacy_body_html",
privacy_link: link_to(t("legal.support.privacy_link_text"), public_privacy_path)
) %>
</p>
</section>
</div>
+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>
@@ -4,6 +4,7 @@
<strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %>
</div>
<div>
<%= link_to t("common.support"), public_support_path %> ·
<%= link_to t("common.pricing"), public_prezzi_path %> ·
<%= link_to t("footer.live"), public_live_index_path %> ·
<%= link_to t("common.faq"), public_faq_path %> ·
@@ -0,0 +1,19 @@
<%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %>
<footer class="site-footer">
<div class="wrap">
<div>
<strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %>
</div>
<div>
<%= link_to t("common.support"), public_support_path %> ·
<%= link_to t("common.privacy"), public_privacy_path %> ·
<%= link_to t("common.cookies"), public_cookies_path %> ·
<%= link_to t("common.terms"), public_termini_path %>
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
</div>
<div class="site-footer__legal">
<p><%= t("footer.copyright") %></p>
<p><%= t("footer.responsibility") %></p>
</div>
</div>
</footer>
@@ -0,0 +1,86 @@
<%# Chrome minimale per App Store Review: branding, lingua, Privacy e Supporto — senza CTA commerciali. %>
<div class="site-chrome">
<div class="site-masthead">
<div class="wrap mast-inner">
<%= link_to public_support_path, class: "mast-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
<img class="mast-brand-logo" src="/logo.png?v=3" alt="Match Live TV" width="40" height="40" decoding="async">
<span class="brand" aria-hidden="true">Match <span>Live TV</span></span>
<% end %>
<div class="mast-tools">
<button type="button" class="nav-toggle" aria-label="<%= t('nav.open_menu') %>" aria-expanded="false" aria-controls="site-nav">
<span class="nav-toggle-bar" aria-hidden="true"></span>
<span class="nav-toggle-bar" aria-hidden="true"></span>
<span class="nav-toggle-bar" aria-hidden="true"></span>
</button>
</div>
</div>
</div>
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
<div class="nav-panel">
<div class="nav-mobile-head">
<%= link_to public_support_path, class: "nav-mobile-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
<img class="nav-mobile-brand-logo" src="/logo.png?v=3" alt="" width="40" height="40" decoding="async">
<span class="brand">Match <span>Live TV</span></span>
<% end %>
<div class="nav-lang">
<%= render "shared/language_switcher" %>
</div>
</div>
<%= link_to t("common.support"), public_support_path, class: "nav-active" %>
<%= link_to t("common.privacy"), public_privacy_path, class: (request.path == "/privacy" ? "nav-active" : nil) %>
</div>
</nav>
</div>
<div class="nav-backdrop" id="nav-backdrop" aria-hidden="true"></div>
<script>
(function () {
var chrome = document.querySelector(".site-chrome");
var toggle = document.querySelector(".nav-toggle");
var backdrop = document.getElementById("nav-backdrop");
var nav = document.getElementById("site-nav");
if (!chrome || !toggle || !nav) return;
var openLabel = <%= raw t("nav.open_menu").to_json %>;
var closeLabel = <%= raw t("nav.close_menu").to_json %>;
function setOpen(open) {
chrome.classList.toggle("nav-open", open);
document.body.classList.toggle("nav-menu-open", open);
toggle.setAttribute("aria-expanded", open ? "true" : "false");
toggle.setAttribute("aria-label", open ? closeLabel : openLabel);
nav.setAttribute("aria-hidden", open ? "false" : "true");
if (backdrop) backdrop.setAttribute("aria-hidden", open ? "false" : "true");
}
function closeMenu() { setOpen(false); }
toggle.addEventListener("click", function (e) {
e.stopPropagation();
document.body.classList.contains("nav-menu-open") ? closeMenu() : setOpen(true);
});
if (backdrop) backdrop.addEventListener("click", closeMenu);
function shouldCloseNavOnControl(el) {
if (!el.closest("[data-lang-switcher]")) return true;
return el.classList.contains("lang-switcher__option");
}
nav.querySelectorAll("a, button").forEach(function (el) {
el.addEventListener("click", function () {
if (shouldCloseNavOnControl(el)) closeMenu();
});
});
window.addEventListener("resize", function () {
if (window.matchMedia("(min-width: 900px)").matches) closeMenu();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") closeMenu();
});
})();
</script>
@@ -81,6 +81,12 @@ module MatchLiveTv
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
end
# Email di supporto (App Store Support URL e contatti assistenza).
# Preferisce SUPPORT_CONTACT_EMAIL; default = contatto commerciale già usato nel sito.
def support_email
ENV["SUPPORT_CONTACT_EMAIL"].presence || "info@matchlivetv.it"
end
def privacy_controller_address
ENV.fetch("PRIVACY_CONTROLLER_ADDRESS", "Via Guido De Ruggiero, 89 - 20142 - Milano (MI)")
end
+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
+15
View File
@@ -195,3 +195,18 @@ de:
s6_body_html: "Um die von der DSGVO vorgesehenen Rechte auszuüben (Zugang, Löschung, Widerspruch, Widerruf der Einwilligung), schreiben Sie an %{email_link}. Details finden Sie im %{privacy_doc_link}."
s6_privacy_doc_link_text: Datenschutzdokument
manage_button: Cookie-Einstellungen verwalten
support:
title: Match Live TV Support
meta_description: "Match Live TV Hilfe: Zugangsprobleme, Livestreams, Teamverwaltung und Support-Kontaktdaten."
h1: Match Live TV Support
intro: "Brauchen Sie Hilfe mit Match Live TV? Bei Problemen mit dem Zugang, der Einrichtung von Livestreams, der Spielverwaltung oder der Nutzung der App können Sie unseren Support kontaktieren."
email_label: "Support-E-Mail:"
access_title: Zugangsprobleme
access_body: "Wenn Sie sich nicht anmelden können, prüfen Sie die Zugangsdaten, die Sie von Ihrem Sportverein erhalten haben. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support."
live_title: Probleme während eines Livestreams
live_body: "Prüfen Sie Ihre Internetverbindung und versuchen Sie erneut, die Übertragung in der App zu starten. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support und geben Sie Gerät, App-Version und eine kurze Beschreibung des Problems an."
team_title: Teamverwaltung
team_body: "Konten und Übertragungsberechtigungen werden vom Sportverein verwaltet."
privacy_title: Datenschutz
privacy_body_html: "Informationen zur Verarbeitung personenbezogener Daten finden Sie auf der Seite %{privacy_link}."
privacy_link_text: Datenschutz
+15
View File
@@ -195,3 +195,18 @@ en:
s6_body_html: "To exercise the rights provided by the GDPR (access, erasure, objection, withdrawal of consent) write to %{email_link}. Details are in the %{privacy_doc_link}."
s6_privacy_doc_link_text: privacy document
manage_button: Manage cookie preferences
support:
title: Match Live TV Support
meta_description: "Match Live TV help: access issues, live streaming, team management and support contact details."
h1: Match Live TV Support
intro: "Need help with Match Live TV? For access issues, live stream setup, match management or app usage, you can contact our support team."
email_label: "Support email:"
access_title: Access issues
access_body: "If you cannot sign in, check the credentials provided by your sports club. If the problem persists, contact support."
live_title: Issues during a live stream
live_body: "Check your Internet connection and try starting the broadcast from the app again. If the problem persists, contact support and include your device, app version and a short description of the issue."
team_title: Team management
team_body: "Accounts and broadcasting permissions are managed by the sports club."
privacy_title: Privacy
privacy_body_html: "For information on personal data processing, see the %{privacy_link} page."
privacy_link_text: Privacy
+15
View File
@@ -195,3 +195,18 @@ es:
s6_body_html: "Para ejercer los derechos previstos por el RGPD (acceso, supresión, oposición, revocación del consentimiento) escribe a %{email_link}. Más detalles en el %{privacy_doc_link}."
s6_privacy_doc_link_text: documento de privacidad
manage_button: Gestionar preferencias de cookies
support:
title: Soporte Match Live TV
meta_description: "Ayuda de Match Live TV: problemas de acceso, directos, gestión del equipo y datos de contacto del soporte."
h1: Soporte Match Live TV
intro: "¿Necesitas ayuda con Match Live TV? Para problemas de acceso, configuración de directos, gestión de partidos o uso de la app puedes contactar con nuestro soporte."
email_label: "Correo de soporte:"
access_title: Problemas de acceso
access_body: "Si no puedes acceder, verifica las credenciales recibidas de tu club deportivo. Si el problema continúa, contacta con el soporte."
live_title: Problemas durante un directo
live_body: "Verifica la conexión a Internet e intenta de nuevo iniciar la transmisión desde la app. Si el problema continúa, contacta con el soporte indicando el dispositivo, la versión de la app y una breve descripción del problema."
team_title: Gestión del equipo
team_body: "Las cuentas y los permisos de transmisión los gestiona el club deportivo."
privacy_title: Privacidad
privacy_body_html: "Para información sobre el tratamiento de datos personales, consulta la página %{privacy_link}."
privacy_link_text: Privacidad
+15
View File
@@ -195,3 +195,18 @@ fr:
s6_body_html: "Pour exercer les droits prévus par le RGPD (accès, effacement, opposition, retrait du consentement), écrivez à %{email_link}. Détails dans le %{privacy_doc_link}."
s6_privacy_doc_link_text: document de confidentialité
manage_button: Gérer les préférences de cookies
support:
title: Support Match Live TV
meta_description: "Assistance Match Live TV : problèmes d'accès, directs, gestion d'équipe et coordonnées du support."
h1: Support Match Live TV
intro: "Besoin d'aide avec Match Live TV ? Pour les problèmes d'accès, la configuration des directs, la gestion des matchs ou l'utilisation de l'application, vous pouvez contacter notre support."
email_label: "E-mail du support :"
access_title: Problèmes d'accès
access_body: "Si vous ne parvenez pas à vous connecter, vérifiez les identifiants fournis par votre club sportif. Si le problème persiste, contactez le support."
live_title: Problèmes pendant un direct
live_body: "Vérifiez votre connexion Internet et réessayez de démarrer la diffusion depuis l'application. Si le problème persiste, contactez le support en indiquant l'appareil, la version de l'application et une brève description du problème."
team_title: Gestion de l'équipe
team_body: "Les comptes et les autorisations de diffusion sont gérés par le club sportif."
privacy_title: Confidentialité
privacy_body_html: "Pour les informations sur le traitement des données personnelles, consultez la page %{privacy_link}."
privacy_link_text: Confidentialité
+15
View File
@@ -195,3 +195,18 @@ it:
s6_body_html: "Per esercitare i diritti previsti dal GDPR (accesso, cancellazione, opposizione, revoca consenso) scrivi a %{email_link}. Dettagli nel %{privacy_doc_link}."
s6_privacy_doc_link_text: documento privacy
manage_button: Gestisci preferenze cookie
support:
title: Supporto Match Live TV
meta_description: "Assistenza Match Live TV: problemi di accesso, dirette live, gestione squadra e contatti del supporto."
h1: Supporto Match Live TV
intro: "Hai bisogno di assistenza con Match Live TV? Per problemi di accesso, configurazione delle dirette, gestione delle partite o utilizzo dellapp puoi contattare il nostro supporto."
email_label: "Email di supporto:"
access_title: Problemi di accesso
access_body: "Se non riesci ad accedere, verifica le credenziali ricevute dalla tua società sportiva. Se il problema persiste, contatta il supporto."
live_title: Problemi durante una diretta
live_body: "Verifica la connessione Internet e riprova ad avviare la trasmissione dallapp. Se il problema persiste, contatta il supporto indicando dispositivo, versione dellapp e una breve descrizione del problema."
team_title: Gestione della squadra
team_body: "Gli account e le autorizzazioni per la trasmissione sono gestiti dalla società sportiva."
privacy_title: Privacy
privacy_body_html: "Per informazioni sul trattamento dei dati personali consulta la pagina %{privacy_link}."
privacy_link_text: Privacy
+1
View File
@@ -133,6 +133,7 @@ de:
save_password: Passwort aktualisieren
common:
privacy: Datenschutz
support: Support
cookies: Cookies
terms: AGB
pricing: Preise
+1
View File
@@ -133,6 +133,7 @@ en:
save_password: Update password
common:
privacy: Privacy
support: Support
cookies: Cookies
terms: Terms
pricing: Pricing
+1
View File
@@ -133,6 +133,7 @@ es:
save_password: Actualizar contraseña
common:
privacy: Privacidad
support: Soporte
cookies: Cookies
terms: Términos
pricing: Precios
+1
View File
@@ -133,6 +133,7 @@ fr:
save_password: Mettre à jour le mot de passe
common:
privacy: Confidentialité
support: Support
cookies: Cookies
terms: Conditions
pricing: Tarifs
+1
View File
@@ -133,6 +133,7 @@ it:
save_password: Aggiorna password
common:
privacy: Privacy
support: Supporto
cookies: Cookie
terms: Termini
pricing: Prezzi
+3
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
@@ -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"
@@ -154,6 +156,7 @@ Rails.application.routes.draw do
get "prezzi", to: "pages#pricing", as: :prezzi
get "pricing", to: redirect("/prezzi")
get "privacy", to: "pages#privacy", as: :privacy
get "support", to: "pages#support", as: :support
get "cookie", to: "pages#cookies", as: :cookies
get "cookies", to: redirect("/cookie")
get "termini", to: "pages#terms", as: :termini
@@ -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_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
+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,57 @@
require "rails_helper"
RSpec.describe "Public support page", type: :request do
it "è pubblica, indicizzabile e mostra l'email di supporto configurata" do
get public_support_path
expect(response).to have_http_status(:ok)
expect(response.body).to include(MatchLiveTv.support_email)
expect(response.body).to include("mailto:#{MatchLiveTv.support_email}")
expect(response.body).to include(I18n.t("legal.support.h1", locale: :it))
expect(response.body).to include('rel="canonical"')
expect(response.body).to include(public_support_path)
expect(response.body).to include(public_privacy_path)
end
it "non espone CTA o link commerciali (App Store Review)" do
get public_support_path
body = response.body
expect(body).not_to include(public_prezzi_path)
expect(body).not_to include(public_signup_path)
expect(body).not_to include('href="/prezzi"')
expect(body).not_to include('href="/signup"')
expect(body).not_to include(I18n.t("nav.signup", locale: :it))
expect(body).not_to include(I18n.t("nav.pricing", locale: :it))
expect(body).not_to include(I18n.t("common.pricing", locale: :it))
expect(body).not_to match(/\bPremium Light\b/i)
expect(body).not_to match(/\bPremium Full\b/i)
expect(body).not_to match(/\bpiano Free\b/i)
expect(body).not_to match(/\bAbbonati\b/i)
expect(body).not_to match(/\bAcquista\b/i)
end
{
"it" => "Supporto Match Live TV",
"en" => "Match Live TV Support",
"fr" => "Support Match Live TV",
"de" => "Match Live TV Support",
"es" => "Soporte Match Live TV"
}.each do |locale, heading|
it "renderizza correttamente in #{locale} senza translation missing" do
cookies[:mltv_locale] = locale
get public_support_path
expect(response).to have_http_status(:ok)
expect(response.body).to include(heading)
expect(response.body).not_to include("translation missing")
end
end
it "include /support nella sitemap" do
get "/sitemap.xml"
expect(response).to have_http_status(:ok)
expect(response.body).to include("#{MatchLiveTv.app_public_url.chomp('/')}/support")
end
end
@@ -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
+3 -2
View File
@@ -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 |
+3 -1
View File
@@ -2,10 +2,12 @@
Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare unapp **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.
---
+149
View File
@@ -0,0 +1,149 @@
# iOS — mute audio in diretta (allineamento da Mac)
Documento operativo per **Cursor su Mac**: verificare e, se serve, ritoccare lapp 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: laudio 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
+14 -1
View File
@@ -1,6 +1,6 @@
# Diretta live — architettura MediaMTX
**Ultimo aggiornamento:** 2026-07-29
**Ultimo aggiornamento:** 2026-08-12
## Idea
@@ -106,6 +106,19 @@ Lapp 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`). Loperatore 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. Lapp **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
+2
View File
@@ -15,6 +15,8 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
# Opzionale: email dedicata di supporto (default = info@matchlivetv.it)
# SUPPORT_CONTACT_EMAIL=info@matchlivetv.it
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
PASSWORD_RESET_EXPIRY_HOURS=2
+2
View File
@@ -39,6 +39,8 @@ RAILS_LOG_LEVEL=info
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
# Opzionale: email di supporto App Store (default = info@matchlivetv.it)
# SUPPORT_CONTACT_EMAIL=info@matchlivetv.it
PRIVACY_CONTROLLER_VAT=
# Email transazionali (reset password, inviti)
+1
View File
@@ -73,6 +73,7 @@ services:
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
SUPPORT_CONTACT_EMAIL: ${SUPPORT_CONTACT_EMAIL:-}
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-}
+1
View File
@@ -71,6 +71,7 @@ services:
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
SUPPORT_CONTACT_EMAIL: ${SUPPORT_CONTACT_EMAIL:-}
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlivetv.it>}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
@@ -184,6 +184,7 @@ data class StreamSessionDto(
@Json(name = "target_fps") val targetFps: Int? = null,
@Json(name = "quality_preset") val qualityPreset: String? = null,
@Json(name = "started_at") val startedAt: String? = null,
@Json(name = "audio_muted") val audioMuted: Boolean? = null,
val score: ScoreStateDto? = null,
) {
fun toDomain() = StreamSession(
@@ -203,6 +204,7 @@ data class StreamSessionDto(
qualityPreset = qualityPreset ?: "720p_30_2.5mbps",
startedAt = startedAt,
score = score?.toDomain(),
audioMuted = audioMuted ?: false,
)
}
@@ -367,6 +369,10 @@ data class RegiaLinkResponse(
@Json(name = "regia_url") val regiaUrl: String,
)
data class AudioMuteRequest(
val muted: Boolean,
)
data class CreateMatchBody(
@Json(name = "opponent_name") val opponentName: String,
val sport: String = "volleyball",
@@ -117,6 +117,12 @@ interface MatchLiveApi {
@PATCH("sessions/{sessionId}/resume")
suspend fun resumeSession(@Path("sessionId") sessionId: String): StreamSessionDto
@PATCH("sessions/{sessionId}/audio_mute")
suspend fun setAudioMute(
@Path("sessionId") sessionId: String,
@Body body: AudioMuteRequest,
): StreamSessionDto
@POST("sessions/{sessionId}/network_test")
suspend fun networkTest(
@Path("sessionId") sessionId: String,
@@ -0,0 +1,26 @@
package com.matchlivetv.match_live_tv.data.cable
/**
* Interpreta i payload ActionCable del mute audio.
* Usato da app e test: lo stream resta in onda, cambia solo il microfono.
*/
object AudioMuteCommands {
fun mutedFromPayload(
type: String?,
action: String? = null,
event: String? = null,
muted: Any? = null,
): Boolean? = when (type) {
"command" -> when (action) {
"mute_audio" -> true
"unmute_audio" -> false
else -> null
}
"stream_event" -> if (event == "audio_muted") {
muted == true || muted == "true"
} else {
null
}
else -> null
}
}
@@ -33,6 +33,7 @@ class SessionCableService(
var onPauseStream: (() -> Unit)? = null
var onResumeStream: (() -> Unit)? = null
var onEndStream: (() -> Unit)? = null
var onAudioMute: ((Boolean) -> Unit)? = null
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
intentionalDisconnect = false
@@ -105,15 +106,28 @@ class SessionCableService(
val payload = unwrapCablePayload(data)
when (payload["type"] as? String) {
"score_update" -> onScoreUpdate?.invoke(parseScore(payload))
"command" -> when (payload["action"] as? String) {
"pause_stream" -> onPauseStream?.invoke()
"resume_stream" -> onResumeStream?.invoke()
"stop_stream" -> onEndStream?.invoke()
"command" -> {
AudioMuteCommands.mutedFromPayload(
type = "command",
action = payload["action"] as? String,
)?.let { onAudioMute?.invoke(it) }
when (payload["action"] as? String) {
"pause_stream" -> onPauseStream?.invoke()
"resume_stream" -> onResumeStream?.invoke()
"stop_stream" -> onEndStream?.invoke()
}
}
"stream_event" -> when (payload["event"] as? String) {
"paused" -> onPauseStream?.invoke()
"resumed" -> onResumeStream?.invoke()
"ended" -> onEndStream?.invoke()
"stream_event" -> {
AudioMuteCommands.mutedFromPayload(
type = "stream_event",
event = payload["event"] as? String,
muted = payload["muted"],
)?.let { onAudioMute?.invoke(it) }
when (payload["event"] as? String) {
"paused" -> onPauseStream?.invoke()
"resumed" -> onResumeStream?.invoke()
"ended" -> onEndStream?.invoke()
}
}
}
}
@@ -38,6 +38,9 @@ class SessionRepository(
suspend fun resumeSession(sessionId: String): StreamSession =
api.resumeSession(sessionId).toDomain()
suspend fun setAudioMute(sessionId: String, muted: Boolean): StreamSession =
api.setAudioMute(sessionId, com.matchlivetv.match_live_tv.data.api.AudioMuteRequest(muted)).toDomain()
suspend fun submitNetworkTest(
sessionId: String,
downloadMbps: Double,
@@ -128,6 +128,7 @@ data class StreamSession(
val qualityPreset: String = "720p_30_2.5mbps",
val startedAt: String? = null,
val score: ScoreState? = null,
val audioMuted: Boolean = false,
) {
val isLive: Boolean get() = status == "live" || status == "reconnecting"
@@ -39,4 +39,5 @@ data class BroadcastMetrics(
val fps: Int = 0,
val connected: Boolean = false,
val lastError: String? = null,
val audioMuted: Boolean = false,
)
@@ -33,6 +33,10 @@ class LiveBroadcastCoordinator(context: Context) {
engine.updateOverlay(state)
}
fun setAudioMuted(muted: Boolean) {
engine.setAudioMuted(muted)
}
fun startService() {
val intent = LiveBroadcastService.startIntent(appContext)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -8,6 +8,8 @@ import android.util.Log
import android.view.Surface
import android.view.WindowManager
import com.pedro.common.ConnectChecker
import com.pedro.encoder.input.sources.audio.MicrophoneSource
import com.pedro.encoder.input.sources.audio.MixAudioSource
import com.pedro.library.generic.GenericStream
import com.pedro.library.util.SensorRotationManager
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayRenderer
@@ -42,6 +44,7 @@ class LiveBroadcastEngine(
private val pausingIntentionally = AtomicBoolean(false)
private val reconnectAttempts = AtomicInteger(0)
private val overlayRenderer = OverlayRenderer(appContext)
private val desiredAudioMuted = AtomicBoolean(false)
private val rebindRunnable = Runnable {
reattachPreviewIfNeeded()
@@ -227,6 +230,7 @@ class LiveBroadcastEngine(
currentBitrateKbps = 0
currentFps = 0
reconnectAttempts.set(0)
desiredAudioMuted.set(false)
setPhase(BroadcastPhase.IDLE)
}
}
@@ -264,6 +268,17 @@ class LiveBroadcastEngine(
}
}
/** Silenzia il microfono senza togliere la traccia AAC (YouTube/MediaMTX richiedono audio continuo). */
fun setAudioMuted(muted: Boolean) {
mainHandler.post {
desiredAudioMuted.set(muted)
applyAudioMuteInternal()
emitMetrics()
}
}
fun isAudioMuted(): Boolean = desiredAudioMuted.get()
/** Adattamento termico: riduce bitrate/FPS senza fermare lo stream. */
fun applyThermalProfile(videoBitrate: Int, fps: Int) {
mainHandler.post {
@@ -340,6 +355,7 @@ class LiveBroadcastEngine(
applyInitialOrientation()
attachOverlayFilter(cfg)
startSensor()
applyAudioMuteInternal()
return true
}
@@ -514,6 +530,7 @@ class LiveBroadcastEngine(
},
connected = phase == BroadcastPhase.LIVE,
lastError = error,
audioMuted = desiredAudioMuted.get(),
),
)
}
@@ -579,6 +596,21 @@ class LiveBroadcastEngine(
}
}
private fun applyAudioMuteInternal() {
val muted = desiredAudioMuted.get()
val source = genericStream?.audioSource ?: return
runCatching {
when (source) {
is MicrophoneSource -> if (muted) source.mute() else source.unMute()
is MixAudioSource -> if (muted) source.muteMicrophone() else source.unMuteMicrophone()
else -> Log.w(TAG, "applyAudioMute: unsupported source ${source::class.java.simpleName}")
}
Log.i(TAG, "audio muted=$muted source=${source::class.java.simpleName}")
}.onFailure {
Log.w(TAG, "applyAudioMute failed: ${it.message}")
}
}
private fun userFacingError(reason: String): String = when {
reason.contains("broken pipe", ignoreCase = true) ->
"Connessione persa — riprova a riavviare la diretta"
@@ -32,6 +32,8 @@ import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -100,6 +102,8 @@ fun BroadcastControlsOverlay(
onCloseSet: (() -> Unit)? = null,
onAdvancePeriod: (() -> Unit)? = null,
onPauseOrResume: () -> Unit,
onToggleAudioMute: () -> Unit,
audioMuted: Boolean,
onTerminate: () -> Unit,
onShareLive: () -> Unit,
onShareRegia: () -> Unit,
@@ -174,6 +178,7 @@ fun BroadcastControlsOverlay(
bitrateKbps = bitrateKbps,
networkType = networkType,
deviceHealth = deviceHealth,
audioMuted = audioMuted,
)
}
@@ -227,6 +232,16 @@ fun BroadcastControlsOverlay(
onClick = onPauseOrResume,
highlighted = isPaused,
)
SideIconButton(
icon = if (audioMuted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = if (audioMuted) {
stringResource(R.string.broadcast_unmute_cd)
} else {
stringResource(R.string.broadcast_mute_cd)
},
onClick = onToggleAudioMute,
highlighted = audioMuted,
)
SideIconButton(
icon = Icons.Default.Stop,
contentDescription = stringResource(R.string.broadcast_terminate_cd),
@@ -287,6 +302,7 @@ private fun BroadcastTelemetryPanel(
bitrateKbps: Long,
networkType: String,
deviceHealth: DeviceHealthSnapshot,
audioMuted: Boolean,
modifier: Modifier = Modifier,
) {
val labelStyle = MaterialTheme.typography.labelSmall
@@ -326,6 +342,14 @@ private fun BroadcastTelemetryPanel(
style = labelStyle,
color = MatchColors.TextSecondary,
)
if (audioMuted) {
Text(
stringResource(R.string.broadcast_audio_muted_label),
style = labelStyle,
color = MatchColors.AccentYellow,
fontWeight = FontWeight.Bold,
)
}
ThermalIndicator(state = deviceHealth.thermalState)
}
}
@@ -78,6 +78,7 @@ fun BroadcastScreen(
var loading by remember { mutableStateOf(true) }
var pauseInFlight by remember { mutableStateOf(false) }
var resumeInFlight by remember { mutableStateOf(false) }
var muteInFlight by remember { mutableStateOf(false) }
var controlsVisible by remember { mutableStateOf(true) }
var logoReady by remember { mutableStateOf(0) }
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) }
@@ -145,6 +146,7 @@ fun BroadcastScreen(
}
updated = container.sessionRepository.fetchSession(sessionId)
session = updated
container.broadcastCoordinator.setAudioMuted(updated.audioMuted)
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_resumed))
} catch (e: Exception) {
snackbar.showSnackbar(
@@ -174,6 +176,7 @@ fun BroadcastScreen(
thermalManager.updateBaseline(cfg)
container.broadcastCoordinator.engine.resumeBroadcast(cfg)
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_resumed_remote))
container.broadcastCoordinator.setAudioMuted(current.audioMuted)
} catch (e: Exception) {
snackbar.showSnackbar(
context.getString(R.string.broadcast_snackbar_resume_rtmp_error, e.message ?: genericError),
@@ -191,6 +194,40 @@ fun BroadcastScreen(
onFinished()
}
suspend fun setAudioMuted(muted: Boolean, remote: Boolean) {
if (muteInFlight) return
if ((session?.audioMuted == true || metrics.audioMuted) == muted) {
container.broadcastCoordinator.setAudioMuted(muted)
return
}
muteInFlight = true
try {
if (!remote) {
val updated = container.sessionRepository.setAudioMute(sessionId, muted)
session = updated
} else {
session = session?.copy(audioMuted = muted)
}
container.broadcastCoordinator.setAudioMuted(muted)
snackbar.showSnackbar(
context.getString(
when {
remote && muted -> R.string.broadcast_snackbar_muted_remote
remote && !muted -> R.string.broadcast_snackbar_unmuted_remote
muted -> R.string.broadcast_snackbar_muted
else -> R.string.broadcast_snackbar_unmuted
},
),
)
} catch (e: Exception) {
snackbar.showSnackbar(
context.getString(R.string.broadcast_snackbar_mute_error, e.message ?: genericError),
)
} finally {
muteInFlight = false
}
}
LaunchedEffect(sessionId, permissions.granted) {
if (!permissions.granted) return@LaunchedEffect
loading = true
@@ -215,6 +252,9 @@ fun BroadcastScreen(
container.sessionCable.onEndStream = {
scope.launch { applyRemoteStop() }
}
container.sessionCable.onAudioMute = { muted ->
if (!muteInFlight) scope.launch { setAudioMuted(muted, remote = true) }
}
container.sessionCable.connect(sessionId, token, deviceRole = "camera")
}
container.broadcastCoordinator.startService()
@@ -225,8 +265,10 @@ fun BroadcastScreen(
val cfg = broadcastConfig(loaded)
thermalManager.updateBaseline(cfg)
container.broadcastCoordinator.engine.startBroadcast(cfg)
container.broadcastCoordinator.setAudioMuted(loaded.audioMuted)
} else {
thermalManager.updateBaseline(broadcastConfig(loaded))
container.broadcastCoordinator.setAudioMuted(loaded.audioMuted)
}
}.onFailure {
error = it.message ?: context.getString(R.string.broadcast_error_session_unavailable)
@@ -364,6 +406,7 @@ fun BroadcastScreen(
container.sessionCable.onPauseStream = null
container.sessionCable.onResumeStream = null
container.sessionCable.onEndStream = null
container.sessionCable.onAudioMute = null
container.sessionCable.disconnect()
container.broadcastCoordinator.stopService()
container.broadcastCoordinator.engine.stopBroadcast()
@@ -371,6 +414,7 @@ fun BroadcastScreen(
}
val isPaused = session?.isPaused == true || metrics.phase == BroadcastPhase.PAUSED
val audioMuted = session?.audioMuted == true || metrics.audioMuted
val statusText = when {
isPaused -> stringResource(R.string.broadcast_status_paused)
metrics.phase == BroadcastPhase.LIVE -> stringResource(R.string.broadcast_status_live)
@@ -544,6 +588,10 @@ fun BroadcastScreen(
if (isPaused) resumeStream() else pauseStream()
}
},
onToggleAudioMute = {
scope.launch { setAudioMuted(!audioMuted, remote = false) }
},
audioMuted = audioMuted,
onTerminate = { scope.launch { stopStreamPermanently() } },
onShareLive = {
val url = shareUrl
@@ -140,6 +140,11 @@
<string name="broadcast_snackbar_resumed_remote">Von der Regie fortgesetzt</string>
<string name="broadcast_snackbar_resume_rtmp_error">RTMP-Fortsetzung: %1$s</string>
<string name="broadcast_snackbar_closed_remote">Livestream von der Regie beendet</string>
<string name="broadcast_snackbar_muted">Audio stummgeschaltet</string>
<string name="broadcast_snackbar_unmuted">Audio wieder aktiv</string>
<string name="broadcast_snackbar_muted_remote">Audio von der Regie stummgeschaltet</string>
<string name="broadcast_snackbar_unmuted_remote">Audio von der Regie wieder aktiviert</string>
<string name="broadcast_snackbar_mute_error">Audio: %1$s</string>
<string name="broadcast_error_rtmp_missing">RTMP-URL fehlt</string>
<string name="broadcast_error_session_unavailable">Sitzung nicht verfügbar</string>
<string name="broadcast_status_paused">PAUSE</string>
@@ -163,6 +168,9 @@
<string name="broadcast_next_period_cd">Nächste Periode</string>
<string name="broadcast_resume_cd">Livestream fortsetzen</string>
<string name="broadcast_pause_cd">Livestream pausieren</string>
<string name="broadcast_mute_cd">Audio stummschalten</string>
<string name="broadcast_unmute_cd">Audio einschalten</string>
<string name="broadcast_audio_muted_label">Audio aus</string>
<string name="broadcast_terminate_cd">Livestream beenden</string>
<string name="broadcast_team_home_label">HEIM</string>
<string name="broadcast_team_away_label">GAST</string>
@@ -140,6 +140,11 @@
<string name="broadcast_snackbar_resumed_remote">Resumed from control room</string>
<string name="broadcast_snackbar_resume_rtmp_error">RTMP resume: %1$s</string>
<string name="broadcast_snackbar_closed_remote">Live closed from control room</string>
<string name="broadcast_snackbar_muted">Audio muted</string>
<string name="broadcast_snackbar_unmuted">Audio restored</string>
<string name="broadcast_snackbar_muted_remote">Audio muted from control room</string>
<string name="broadcast_snackbar_unmuted_remote">Audio restored from control room</string>
<string name="broadcast_snackbar_mute_error">Audio: %1$s</string>
<string name="broadcast_error_rtmp_missing">Missing RTMP URL</string>
<string name="broadcast_error_session_unavailable">Session not available</string>
<string name="broadcast_status_paused">PAUSED</string>
@@ -163,6 +168,9 @@
<string name="broadcast_next_period_cd">Next period</string>
<string name="broadcast_resume_cd">Resume live</string>
<string name="broadcast_pause_cd">Pause live</string>
<string name="broadcast_mute_cd">Mute audio</string>
<string name="broadcast_unmute_cd">Unmute audio</string>
<string name="broadcast_audio_muted_label">Audio off</string>
<string name="broadcast_terminate_cd">End live</string>
<string name="broadcast_team_home_label">HOME</string>
<string name="broadcast_team_away_label">AWAY</string>
@@ -140,6 +140,11 @@
<string name="broadcast_snackbar_resumed_remote">Reanudado desde regie</string>
<string name="broadcast_snackbar_resume_rtmp_error">Reanudación RTMP: %1$s</string>
<string name="broadcast_snackbar_closed_remote">Directo cerrado desde regie</string>
<string name="broadcast_snackbar_muted">Audio silenciado</string>
<string name="broadcast_snackbar_unmuted">Audio reactivado</string>
<string name="broadcast_snackbar_muted_remote">Audio silenciado desde regie</string>
<string name="broadcast_snackbar_unmuted_remote">Audio reactivado desde regie</string>
<string name="broadcast_snackbar_mute_error">Audio: %1$s</string>
<string name="broadcast_error_rtmp_missing">Falta la URL RTMP</string>
<string name="broadcast_error_session_unavailable">Sesión no disponible</string>
<string name="broadcast_status_paused">PAUSA</string>
@@ -163,6 +168,9 @@
<string name="broadcast_next_period_cd">Periodo siguiente</string>
<string name="broadcast_resume_cd">Reanudar directo</string>
<string name="broadcast_pause_cd">Pausar directo</string>
<string name="broadcast_mute_cd">Silenciar audio</string>
<string name="broadcast_unmute_cd">Reactivar audio</string>
<string name="broadcast_audio_muted_label">Audio off</string>
<string name="broadcast_terminate_cd">Terminar directo</string>
<string name="broadcast_team_home_label">LOCAL</string>
<string name="broadcast_team_away_label">VISITANTE</string>
@@ -140,6 +140,11 @@
<string name="broadcast_snackbar_resumed_remote">Repris depuis la régie</string>
<string name="broadcast_snackbar_resume_rtmp_error">Reprise RTMP : %1$s</string>
<string name="broadcast_snackbar_closed_remote">Direct fermé depuis la régie</string>
<string name="broadcast_snackbar_muted">Audio coupé</string>
<string name="broadcast_snackbar_unmuted">Audio rétabli</string>
<string name="broadcast_snackbar_muted_remote">Audio coupé depuis la régie</string>
<string name="broadcast_snackbar_unmuted_remote">Audio rétabli depuis la régie</string>
<string name="broadcast_snackbar_mute_error">Audio : %1$s</string>
<string name="broadcast_error_rtmp_missing">URL RTMP manquante</string>
<string name="broadcast_error_session_unavailable">Session non disponible</string>
<string name="broadcast_status_paused">PAUSE</string>
@@ -163,6 +168,9 @@
<string name="broadcast_next_period_cd">Période suivante</string>
<string name="broadcast_resume_cd">Reprendre le direct</string>
<string name="broadcast_pause_cd">Mettre le direct en pause</string>
<string name="broadcast_mute_cd">Couper l\'audio</string>
<string name="broadcast_unmute_cd">Réactiver l\'audio</string>
<string name="broadcast_audio_muted_label">Audio off</string>
<string name="broadcast_terminate_cd">Terminer le direct</string>
<string name="broadcast_team_home_label">DOMICILE</string>
<string name="broadcast_team_away_label">EXTÉRIEUR</string>
@@ -140,6 +140,11 @@
<string name="broadcast_snackbar_resumed_remote">Ripresa dalla regia</string>
<string name="broadcast_snackbar_resume_rtmp_error">Ripresa RTMP: %1$s</string>
<string name="broadcast_snackbar_closed_remote">Diretta chiusa dalla regia</string>
<string name="broadcast_snackbar_muted">Audio silenziato</string>
<string name="broadcast_snackbar_unmuted">Audio riattivato</string>
<string name="broadcast_snackbar_muted_remote">Audio silenziato dalla regia</string>
<string name="broadcast_snackbar_unmuted_remote">Audio riattivato dalla regia</string>
<string name="broadcast_snackbar_mute_error">Audio: %1$s</string>
<string name="broadcast_error_rtmp_missing">URL RTMP mancante</string>
<string name="broadcast_error_session_unavailable">Sessione non disponibile</string>
<string name="broadcast_status_paused">PAUSA</string>
@@ -163,6 +168,9 @@
<string name="broadcast_next_period_cd">Periodo successivo</string>
<string name="broadcast_resume_cd">Riprendi diretta</string>
<string name="broadcast_pause_cd">Pausa diretta</string>
<string name="broadcast_mute_cd">Silenzia audio</string>
<string name="broadcast_unmute_cd">Riattiva audio</string>
<string name="broadcast_audio_muted_label">Audio off</string>
<string name="broadcast_terminate_cd">Termina diretta</string>
<string name="broadcast_team_home_label">CASA</string>
<string name="broadcast_team_away_label">OSPITE</string>
@@ -0,0 +1,21 @@
package com.matchlivetv.match_live_tv.data.cable
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AudioMuteCommandsTest {
@Test
fun commandMuteAudio() {
assertEquals(true, AudioMuteCommands.mutedFromPayload(type = "command", action = "mute_audio"))
assertEquals(false, AudioMuteCommands.mutedFromPayload(type = "command", action = "unmute_audio"))
assertNull(AudioMuteCommands.mutedFromPayload(type = "command", action = "pause_stream"))
}
@Test
fun streamEventAudioMuted() {
assertEquals(true, AudioMuteCommands.mutedFromPayload(type = "stream_event", event = "audio_muted", muted = true))
assertEquals(false, AudioMuteCommands.mutedFromPayload(type = "stream_event", event = "audio_muted", muted = false))
assertNull(AudioMuteCommands.mutedFromPayload(type = "stream_event", event = "paused"))
}
}
File diff suppressed because it is too large Load Diff
@@ -1,32 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion="1600" version="1.7">
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
<Scheme
LastUpgradeVersion = "2660"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F54F6C97361C4E8A96AEDD11" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F54F6C97361C4E8A96AEDD11"
BuildableName = "MatchLiveTv.app"
BlueprintName = "MatchLiveTv"
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="20C695DBFB4042879308B0F5" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "20C695DBFB4042879308B0F5"
BuildableName = "MatchLiveTvTests.xctest"
BlueprintName = "MatchLiveTvTests"
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference skipped="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="20C695DBFB4042879308B0F5" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "20C695DBFB4042879308B0F5"
BuildableName = "MatchLiveTvTests.xctest"
BlueprintName = "MatchLiveTvTests"
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F54F6C97361C4E8A96AEDD11" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F54F6C97361C4E8A96AEDD11"
BuildableName = "MatchLiveTv.app"
BlueprintName = "MatchLiveTv"
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F54F6C97361C4E8A96AEDD11" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F54F6C97361C4E8A96AEDD11"
BuildableName = "MatchLiveTv.app"
BlueprintName = "MatchLiveTv"
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration="Debug"/>
<ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -119,6 +119,7 @@ enum L10n {
"broadcast.error.rtmp.url.invalid": "URL RTMP non valido",
"broadcast.error.session.unavailable": "Sessione non disponibile",
"broadcast.hide.controls.cd": "Nascondi controlli",
"broadcast.mute.cd": "Silenzia audio",
"broadcast.next.period.cd": "Periodo successivo",
"broadcast.pause.cd": "Pausa diretta",
"broadcast.period.label.timed": "%1$d° tempo",
@@ -140,6 +141,9 @@ enum L10n {
"broadcast.side.away": "ospite",
"broadcast.side.home": "casa",
"broadcast.snackbar.closed.remote": "Diretta chiusa dalla regia",
"broadcast.snackbar.mute.error": "Audio: %1$@",
"broadcast.snackbar.muted": "Audio silenziato",
"broadcast.snackbar.muted.remote": "Audio silenziato dalla regia",
"broadcast.snackbar.pause.error": "Pausa: %1$@",
"broadcast.snackbar.paused": "Diretta in pausa",
"broadcast.snackbar.paused.remote": "Pausa dalla regia",
@@ -148,6 +152,8 @@ enum L10n {
"broadcast.snackbar.resume.rtmp.error": "Ripresa RTMP: %1$@",
"broadcast.snackbar.resumed": "Diretta ripresa",
"broadcast.snackbar.resumed.remote": "Ripresa dalla regia",
"broadcast.snackbar.unmuted": "Audio riattivato",
"broadcast.snackbar.unmuted.remote": "Audio riattivato dalla regia",
"broadcast.status.connecting": "CONNESSIONE…",
"broadcast.status.error.fallback": "ERRORE",
"broadcast.status.live": "IN DIRETTA",
@@ -164,6 +170,8 @@ enum L10n {
"broadcast.tooltip.add.point": "Aggiungi punto %1$@",
"broadcast.tooltip.plus.side": "+%1$d %2$@",
"broadcast.tooltip.remove.point": "Togli punto %1$@",
"broadcast.unmute.cd": "Riattiva audio",
"broadcast.audio.muted.label": "Audio off",
"common.close": "Chiudi",
"common.error.generic": "errore",
"common.error.title": "Errore",
@@ -409,6 +417,7 @@ enum L10n {
"broadcast.error.rtmp.url.invalid": "Invalid RTMP URL",
"broadcast.error.session.unavailable": "Session not available",
"broadcast.hide.controls.cd": "Hide controls",
"broadcast.mute.cd": "Mute audio",
"broadcast.next.period.cd": "Next period",
"broadcast.pause.cd": "Pause live",
"broadcast.period.label.timed": "Period %1$d",
@@ -430,6 +439,9 @@ enum L10n {
"broadcast.side.away": "away",
"broadcast.side.home": "home",
"broadcast.snackbar.closed.remote": "Live closed from control room",
"broadcast.snackbar.mute.error": "Audio: %1$@",
"broadcast.snackbar.muted": "Audio muted",
"broadcast.snackbar.muted.remote": "Audio muted from control room",
"broadcast.snackbar.pause.error": "Pause: %1$@",
"broadcast.snackbar.paused": "Live paused",
"broadcast.snackbar.paused.remote": "Paused from control room",
@@ -438,6 +450,8 @@ enum L10n {
"broadcast.snackbar.resume.rtmp.error": "RTMP resume: %1$@",
"broadcast.snackbar.resumed": "Live resumed",
"broadcast.snackbar.resumed.remote": "Resumed from control room",
"broadcast.snackbar.unmuted": "Audio restored",
"broadcast.snackbar.unmuted.remote": "Audio restored from control room",
"broadcast.status.connecting": "CONNECTING…",
"broadcast.status.error.fallback": "ERROR",
"broadcast.status.live": "LIVE",
@@ -454,6 +468,8 @@ enum L10n {
"broadcast.tooltip.add.point": "Add point %1$@",
"broadcast.tooltip.plus.side": "+%1$d %2$@",
"broadcast.tooltip.remove.point": "Remove point %1$@",
"broadcast.unmute.cd": "Unmute audio",
"broadcast.audio.muted.label": "Audio off",
"common.close": "Close",
"common.error.generic": "error",
"common.error.title": "Error",
@@ -699,6 +715,7 @@ enum L10n {
"broadcast.error.rtmp.url.invalid": "URL RTMP non valide",
"broadcast.error.session.unavailable": "Session non disponible",
"broadcast.hide.controls.cd": "Masquer les commandes",
"broadcast.mute.cd": "Couper l'audio",
"broadcast.next.period.cd": "Période suivante",
"broadcast.pause.cd": "Mettre le direct en pause",
"broadcast.period.label.timed": "Période %1$d",
@@ -720,6 +737,9 @@ enum L10n {
"broadcast.side.away": "extérieur",
"broadcast.side.home": "domicile",
"broadcast.snackbar.closed.remote": "Direct fermé depuis la régie",
"broadcast.snackbar.mute.error": "Audio : %1$@",
"broadcast.snackbar.muted": "Audio coupé",
"broadcast.snackbar.muted.remote": "Audio coupé depuis la régie",
"broadcast.snackbar.pause.error": "Pause : %1$@",
"broadcast.snackbar.paused": "Direct en pause",
"broadcast.snackbar.paused.remote": "Mis en pause depuis la régie",
@@ -728,6 +748,8 @@ enum L10n {
"broadcast.snackbar.resume.rtmp.error": "Reprise RTMP : %1$@",
"broadcast.snackbar.resumed": "Direct repris",
"broadcast.snackbar.resumed.remote": "Repris depuis la régie",
"broadcast.snackbar.unmuted": "Audio rétabli",
"broadcast.snackbar.unmuted.remote": "Audio rétabli depuis la régie",
"broadcast.status.connecting": "CONNEXION…",
"broadcast.status.error.fallback": "ERREUR",
"broadcast.status.live": "EN DIRECT",
@@ -744,6 +766,8 @@ enum L10n {
"broadcast.tooltip.add.point": "Ajouter un point %1$@",
"broadcast.tooltip.plus.side": "+%1$d %2$@",
"broadcast.tooltip.remove.point": "Retirer un point %1$@",
"broadcast.unmute.cd": "Réactiver l'audio",
"broadcast.audio.muted.label": "Audio off",
"common.close": "Fermer",
"common.error.generic": "erreur",
"common.error.title": "Erreur",
@@ -989,6 +1013,7 @@ enum L10n {
"broadcast.error.rtmp.url.invalid": "Ungültige RTMP-URL",
"broadcast.error.session.unavailable": "Sitzung nicht verfügbar",
"broadcast.hide.controls.cd": "Steuerung ausblenden",
"broadcast.mute.cd": "Audio stummschalten",
"broadcast.next.period.cd": "Nächste Periode",
"broadcast.pause.cd": "Livestream pausieren",
"broadcast.period.label.timed": "Halbzeit %1$d",
@@ -1010,6 +1035,9 @@ enum L10n {
"broadcast.side.away": "Gast",
"broadcast.side.home": "Heim",
"broadcast.snackbar.closed.remote": "Livestream von der Regie beendet",
"broadcast.snackbar.mute.error": "Audio: %1$@",
"broadcast.snackbar.muted": "Audio stummgeschaltet",
"broadcast.snackbar.muted.remote": "Audio von der Regie stummgeschaltet",
"broadcast.snackbar.pause.error": "Pause: %1$@",
"broadcast.snackbar.paused": "Livestream pausiert",
"broadcast.snackbar.paused.remote": "Von der Regie pausiert",
@@ -1018,6 +1046,8 @@ enum L10n {
"broadcast.snackbar.resume.rtmp.error": "RTMP-Fortsetzung: %1$@",
"broadcast.snackbar.resumed": "Livestream fortgesetzt",
"broadcast.snackbar.resumed.remote": "Von der Regie fortgesetzt",
"broadcast.snackbar.unmuted": "Audio wieder aktiv",
"broadcast.snackbar.unmuted.remote": "Audio von der Regie wieder aktiviert",
"broadcast.status.connecting": "VERBINDUNG…",
"broadcast.status.error.fallback": "FEHLER",
"broadcast.status.live": "LIVE",
@@ -1034,6 +1064,8 @@ enum L10n {
"broadcast.tooltip.add.point": "Punkt hinzufügen %1$@",
"broadcast.tooltip.plus.side": "+%1$d %2$@",
"broadcast.tooltip.remove.point": "Punkt entfernen %1$@",
"broadcast.unmute.cd": "Audio einschalten",
"broadcast.audio.muted.label": "Audio aus",
"common.close": "Schließen",
"common.error.generic": "Fehler",
"common.error.title": "Fehler",
@@ -1279,6 +1311,7 @@ enum L10n {
"broadcast.error.rtmp.url.invalid": "URL RTMP no válida",
"broadcast.error.session.unavailable": "Sesión no disponible",
"broadcast.hide.controls.cd": "Ocultar controles",
"broadcast.mute.cd": "Silenciar audio",
"broadcast.next.period.cd": "Periodo siguiente",
"broadcast.pause.cd": "Pausar directo",
"broadcast.period.label.timed": "Tiempo %1$d",
@@ -1300,6 +1333,9 @@ enum L10n {
"broadcast.side.away": "visitante",
"broadcast.side.home": "local",
"broadcast.snackbar.closed.remote": "Directo cerrado desde regie",
"broadcast.snackbar.mute.error": "Audio: %1$@",
"broadcast.snackbar.muted": "Audio silenciado",
"broadcast.snackbar.muted.remote": "Audio silenciado desde regie",
"broadcast.snackbar.pause.error": "Pausa: %1$@",
"broadcast.snackbar.paused": "Directo en pausa",
"broadcast.snackbar.paused.remote": "Pausado desde regie",
@@ -1308,6 +1344,8 @@ enum L10n {
"broadcast.snackbar.resume.rtmp.error": "Reanudación RTMP: %1$@",
"broadcast.snackbar.resumed": "Directo reanudado",
"broadcast.snackbar.resumed.remote": "Reanudado desde regie",
"broadcast.snackbar.unmuted": "Audio reactivado",
"broadcast.snackbar.unmuted.remote": "Audio reactivado desde regie",
"broadcast.status.connecting": "CONECTANDO…",
"broadcast.status.error.fallback": "ERROR",
"broadcast.status.live": "EN DIRECTO",
@@ -1324,6 +1362,8 @@ enum L10n {
"broadcast.tooltip.add.point": "Añadir punto %1$@",
"broadcast.tooltip.plus.side": "+%1$d %2$@",
"broadcast.tooltip.remove.point": "Quitar punto %1$@",
"broadcast.unmute.cd": "Reactivar audio",
"broadcast.audio.muted.label": "Audio off",
"common.close": "Cerrar",
"common.error.generic": "error",
"common.error.title": "Error",
@@ -203,6 +203,7 @@ struct StreamSessionDto: Decodable {
let targetFps: Int?
let qualityPreset: String?
let startedAt: String?
let audioMuted: Bool?
let score: ScoreStateDto?
func toDomain() -> StreamSession {
@@ -222,7 +223,8 @@ struct StreamSessionDto: Decodable {
targetFps: targetFps ?? 30,
qualityPreset: qualityPreset ?? "720p_30_2.5mbps",
startedAt: startedAt,
score: score?.toDomain()
score: score?.toDomain(),
audioMuted: audioMuted ?? false
)
}
}
@@ -414,6 +416,10 @@ struct CreateSessionRequest: Encodable {
let youtubeChannel: String?
}
struct AudioMuteRequest: Encodable {
let muted: Bool
}
struct ScoringRulesBody: Codable {
let setsToWin: Int?
let pointsPerSet: Int?
@@ -186,6 +186,10 @@ final class MatchLiveAPI: @unchecked Sendable {
try await patch("sessions/\(id)/resume", body: EmptyBody())
}
func setAudioMute(id: String, muted: Bool) async throws -> StreamSessionDto {
try await patch("sessions/\(id)/audio_mute", body: AudioMuteRequest(muted: muted))
}
func networkTest(sessionId: String, body: NetworkTestRequest) async throws -> NetworkTestResponse {
try await post("sessions/\(sessionId)/network_test", body: body)
}
@@ -8,6 +8,7 @@ final class SessionCableService: ObservableObject {
var onPauseStream: (() -> Void)?
var onResumeStream: (() -> Void)?
var onStopStream: (() -> Void)?
var onAudioMute: ((Bool) -> Void)?
private var client: ActionCableClient?
private var intentionalDisconnect = false
@@ -89,6 +90,9 @@ extension SessionCableService: ActionCableClient.Listener {
onScoreUpdate?(ScoreState.fromCablePayload(payload))
case "command":
let action = payload["action"] as? String ?? ""
if let muted = AudioMuteCommandLogic.muted(type: "command", action: action, event: nil, muted: payload["muted"]) {
onAudioMute?(muted)
}
switch action {
case "pause_stream": onPauseStream?()
case "resume_stream": onResumeStream?()
@@ -97,6 +101,9 @@ extension SessionCableService: ActionCableClient.Listener {
}
case "stream_event":
let event = payload["event"] as? String ?? ""
if let muted = AudioMuteCommandLogic.muted(type: "stream_event", action: nil, event: event, muted: payload["muted"]) {
onAudioMute?(muted)
}
switch event {
case "paused": onPauseStream?()
case "resumed": onResumeStream?()
@@ -109,3 +116,23 @@ extension SessionCableService: ActionCableClient.Listener {
}
}
}
enum AudioMuteCommandLogic {
static func muted(type: String, action: String?, event: String?, muted: Any?) -> Bool? {
switch type {
case "command":
switch action {
case "mute_audio": return true
case "unmute_audio": return false
default: return nil
}
case "stream_event":
guard event == "audio_muted" else { return nil }
if let value = muted as? Bool { return value }
if let value = muted as? String { return value == "true" }
return nil
default:
return nil
}
}
}
@@ -50,6 +50,10 @@ final class SessionRepository {
try await api.resumeSession(id: id).toDomain()
}
func setAudioMute(id: String, muted: Bool) async throws -> StreamSession {
try await api.setAudioMute(id: id, muted: muted).toDomain()
}
func submitNetworkTest(
sessionId: String,
downloadMbps: Double,
@@ -121,10 +121,33 @@ struct StreamSession: Identifiable, Equatable, Sendable {
let qualityPreset: String
let startedAt: String?
let score: ScoreState?
let audioMuted: Bool
var isLive: Bool { status == "live" || status == "reconnecting" }
var isPaused: Bool { status == "paused" }
func withAudioMuted(_ muted: Bool) -> StreamSession {
StreamSession(
id: id,
matchId: matchId,
status: status,
platform: platform,
rtmpIngestUrl: rtmpIngestUrl,
hlsPlaybackUrl: hlsPlaybackUrl,
watchPageUrl: watchPageUrl,
shareUrl: shareUrl,
youtubeWatchUrl: youtubeWatchUrl,
youtubeReady: youtubeReady,
privacyStatus: privacyStatus,
targetBitrate: targetBitrate,
targetFps: targetFps,
qualityPreset: qualityPreset,
startedAt: startedAt,
score: score,
audioMuted: muted
)
}
func watchShareUrl() -> String? {
if platform == "youtube", let url = youtubeWatchUrl, !url.isEmpty { return url }
if let url = shareUrl, !url.isEmpty { return url }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 216 KiB

+9 -1
View File
@@ -32,7 +32,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>31</string>
<string>32</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
@@ -55,6 +55,14 @@
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
@@ -28,4 +28,5 @@ struct BroadcastMetrics: Sendable {
var fps: Int = 0
var connected: Bool = false
var lastError: String?
var audioMuted: Bool = false
}
@@ -18,6 +18,10 @@ final class LiveBroadcastCoordinator: ObservableObject {
engine.updateOverlay(state)
}
func setAudioMuted(_ muted: Bool) async {
await engine.setAudioMuted(muted)
}
func configureBackgroundAudio() {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
@@ -47,6 +47,7 @@ final class LiveBroadcastEngine: ObservableObject {
private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
private var orientationDebounceTask: Task<Void, Never>?
private var rtmpSession: (any Session)?
private var audioMuted = false
private static var rtmpFactoryRegistered = false
private static func ensureRTMPFactoryRegistered() async {
@@ -173,12 +174,21 @@ final class LiveBroadcastEngine: ObservableObject {
overlayRenderer.detach()
try? await mixer.stopRunning()
pipelineConfigured = false
audioMuted = false
}
func release() async {
await stopBroadcast()
}
/// Silenzia il microfono senza togliere la traccia AAC (YouTube/MediaMTX richiedono audio continuo).
func setAudioMuted(_ muted: Bool) async {
audioMuted = muted
await applyAudioMute()
metrics.audioMuted = muted
emitMetrics()
}
/// Adattamento termico: aggiorna profilo video senza interrompere la sessione RTMP attiva.
func applyThermalProfile(videoBitrate: Int, fps: Int) async {
guard var current = config else { return }
@@ -219,7 +229,8 @@ final class LiveBroadcastEngine: ObservableObject {
AudioMixerSettings(
sampleRate: BroadcastConfig.audioSampleRateHz,
channels: BroadcastConfig.audioChannels,
tracks: [0: AudioMixerTrackSettings(downmix: true, channelMap: [0])]
isMuted: audioMuted,
tracks: [0: AudioMixerTrackSettings(isMuted: audioMuted, downmix: true, channelMap: [0])]
)
)
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
@@ -324,7 +335,8 @@ final class LiveBroadcastEngine: ObservableObject {
bitrateKbps: kbps,
fps: self.config?.fps ?? self.metrics.fps,
connected: self.phase == .live,
lastError: self.metrics.lastError
lastError: self.metrics.lastError,
audioMuted: self.audioMuted
)
self.metricsListener?(self.metrics)
}
@@ -496,6 +508,16 @@ final class LiveBroadcastEngine: ObservableObject {
UIDevice.current.endGeneratingDeviceOrientationNotifications()
}
private func applyAudioMute() async {
guard pipelineConfigured else { return }
var settings = await mixer.audioMixerSettings
settings.isMuted = audioMuted
var track = settings.tracks[0] ?? AudioMixerTrackSettings(downmix: true, channelMap: [0])
track.isMuted = audioMuted
settings.tracks[0] = track
try? await mixer.setAudioMixerSettings(settings)
}
private func setPhase(_ newPhase: BroadcastPhase, error: String? = nil) {
phase = newPhase
metrics = BroadcastMetrics(
@@ -503,7 +525,8 @@ final class LiveBroadcastEngine: ObservableObject {
bitrateKbps: metrics.bitrateKbps,
fps: config?.fps ?? metrics.fps,
connected: newPhase == .live,
lastError: error
lastError: error,
audioMuted: audioMuted
)
metricsListener?(metrics)
}
@@ -31,6 +31,8 @@ struct BroadcastControlsOverlay: View {
var onCloseSet: (() -> Void)?
var onAdvancePeriod: (() -> Void)?
let onPauseOrResume: () -> Void
let onToggleAudioMute: () -> Void
let audioMuted: Bool
let onTerminate: () -> Void
let onShareLive: () -> Void
let onShareRegia: () -> Void
@@ -69,7 +71,8 @@ struct BroadcastControlsOverlay: View {
targetFps: targetFps,
bitrateKbps: bitrateKbps,
networkType: networkType,
deviceHealth: deviceHealth
deviceHealth: deviceHealth,
audioMuted: audioMuted
)
}
.padding(.trailing, 8)
@@ -142,6 +145,12 @@ struct BroadcastControlsOverlay: View {
action: onPauseOrResume,
highlighted: isPaused
)
SideIconButton(
systemName: audioMuted ? "speaker.slash.fill" : "speaker.wave.2.fill",
accessibilityLabel: audioMuted ? L10n.t("broadcast.unmute.cd") : L10n.t("broadcast.mute.cd"),
action: onToggleAudioMute,
highlighted: audioMuted
)
SideIconButton(
systemName: "stop.fill",
accessibilityLabel: L10n.t("broadcast.terminate.cd"),
@@ -195,6 +204,7 @@ private struct BroadcastTelemetryPanel: View {
let bitrateKbps: Int
let networkType: String
let deviceHealth: DeviceHealth
let audioMuted: Bool
var body: some View {
VStack(alignment: .trailing, spacing: 2) {
@@ -214,6 +224,11 @@ private struct BroadcastTelemetryPanel: View {
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
if audioMuted {
Text(L10n.t("broadcast.audio.muted.label"))
.font(.system(size: 11, weight: .bold))
.foregroundStyle(MatchColors.accentYellow)
}
ThermalIndicator(state: deviceHealth.thermalState)
}
.padding(.horizontal, 10)
@@ -27,6 +27,7 @@ struct BroadcastScreen: View {
@State private var logoReady = 0
@State private var pauseInFlight = false
@State private var resumeInFlight = false
@State private var muteInFlight = false
@State private var deviceHealth = DeviceTelemetry.snapshot()
@State private var snackbarMessage: String?
@State private var shareItem: ShareItem?
@@ -188,6 +189,7 @@ struct BroadcastScreen: View {
let metrics = broadcastCoordinator.metrics
let score = scoreController.score
let isPaused = session.isPaused || metrics.phase == .paused
let audioMuted = session.audioMuted || metrics.audioMuted
let statusText = broadcastStatusText(isPaused: isPaused, metrics: metrics)
let statusColor = broadcastStatusColor(isPaused: isPaused, metrics: metrics)
let boardType = match.boardType.isEmpty ? score.boardType : match.boardType
@@ -282,6 +284,10 @@ struct BroadcastScreen: View {
pauseStream()
}
},
onToggleAudioMute: {
Task { await setAudioMuted(!audioMuted, remote: false) }
},
audioMuted: audioMuted,
onTerminate: { Task { await stopStream() } },
onShareLive: { shareLiveLink(session: session, subject: shareSubject) },
onShareRegia: { shareRegiaLink(subject: shareSubject) },
@@ -384,6 +390,7 @@ struct BroadcastScreen: View {
config: config,
paused: loaded.isPaused
)
await container.broadcastCoordinator.setAudioMuted(loaded.audioMuted)
}
} catch {
guard generation == bootstrapGeneration else { return }
@@ -418,6 +425,12 @@ struct BroadcastScreen: View {
container.sessionCable.onStopStream = { [weak container] in
Task { @MainActor in await stopStream() }
}
container.sessionCable.onAudioMute = { muted in
Task { @MainActor in
guard !muteInFlight else { return }
await setAudioMuted(muted, remote: true)
}
}
container.sessionCable.connect(sessionId: sessionId, accessToken: token)
}
@@ -498,6 +511,7 @@ struct BroadcastScreen: View {
try await container.broadcastCoordinator.resumeBroadcast(config: config)
updated = try await container.sessionRepository.fetchSession(id: sessionId)
session = updated
await container.broadcastCoordinator.setAudioMuted(updated.audioMuted)
snackbarMessage = L10n.t("broadcast.snackbar.resumed")
} catch {
snackbarMessage = L10n.t("broadcast.snackbar.resume.error", UserFacingError.message(for: error) ?? L10n.t("common.error.generic"))
@@ -516,6 +530,31 @@ struct BroadcastScreen: View {
snackbarMessage = L10n.t("broadcast.snackbar.paused.remote")
}
private func setAudioMuted(_ muted: Bool, remote: Bool) async {
guard !muteInFlight else { return }
if (session?.audioMuted == true || broadcastCoordinator.metrics.audioMuted) == muted {
await container.broadcastCoordinator.setAudioMuted(muted)
return
}
muteInFlight = true
defer { muteInFlight = false }
do {
if remote {
session = session?.withAudioMuted(muted)
} else {
session = try await container.sessionRepository.setAudioMute(id: sessionId, muted: muted)
}
await container.broadcastCoordinator.setAudioMuted(muted)
snackbarMessage = L10n.t(
remote
? (muted ? "broadcast.snackbar.muted.remote" : "broadcast.snackbar.unmuted.remote")
: (muted ? "broadcast.snackbar.muted" : "broadcast.snackbar.unmuted")
)
} catch {
snackbarMessage = L10n.t("broadcast.snackbar.mute.error", UserFacingError.message(for: error) ?? L10n.t("common.error.generic"))
}
}
/// Ripresa dalla regia / echo cable: solo RTMP locale, senza PATCH resume.
private func applyRemoteResume(container: AppContainer) async {
guard !resumeInFlight else { return }
@@ -528,7 +567,8 @@ struct BroadcastScreen: View {
let config = broadcastConfig(for: current, rtmpUrl: url)
thermalManager.updateBaseline(config)
try await container.broadcastCoordinator.resumeBroadcast(config: config)
snackbarMessage = L10n.t("broadcast.snackbar.resumed")
await container.broadcastCoordinator.setAudioMuted(current.audioMuted)
snackbarMessage = L10n.t("broadcast.snackbar.resumed.remote")
} catch {
snackbarMessage = L10n.t("broadcast.snackbar.resume.rtmp.error", UserFacingError.message(for: error) ?? L10n.t("common.error.generic"))
}
@@ -567,6 +607,7 @@ struct BroadcastScreen: View {
container.sessionCable.onPauseStream = nil
container.sessionCable.onResumeStream = nil
container.sessionCable.onStopStream = nil
container.sessionCable.onAudioMute = nil
container.sessionCable.disconnect()
await container.broadcastCoordinator.stopBroadcast(sessionId: sessionId)
}
@@ -13,4 +13,12 @@ final class LiveBroadcastCoordinatorTests: XCTestCase {
func testShouldStopPreviousSessionTrueWhenSwitching() {
XCTAssertTrue(LiveBroadcastCoordinator.shouldStopPreviousSession(active: "session-a", incoming: "session-b"))
}
func testAudioMuteCommandFromCable() {
XCTAssertEqual(AudioMuteCommandLogic.muted(type: "command", action: "mute_audio", event: nil, muted: nil), true)
XCTAssertEqual(AudioMuteCommandLogic.muted(type: "command", action: "unmute_audio", event: nil, muted: nil), false)
XCTAssertNil(AudioMuteCommandLogic.muted(type: "command", action: "pause_stream", event: nil, muted: nil))
XCTAssertEqual(AudioMuteCommandLogic.muted(type: "stream_event", action: nil, event: "audio_muted", muted: true), true)
XCTAssertEqual(AudioMuteCommandLogic.muted(type: "stream_event", action: nil, event: "audio_muted", muted: false), false)
}
}
+2 -2
View File
@@ -96,7 +96,7 @@ lines += [
app_settings = """
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31;
CURRENT_PROJECT_VERSION = 32;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
@@ -122,7 +122,7 @@ app_debug_settings = app_settings + """
test_settings = """
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31;
CURRENT_PROJECT_VERSION = 32;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 2.0.10;