Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fecccaafd |
@@ -55,6 +55,10 @@ class SessionChannel < ApplicationCable::Channel
|
|||||||
Sessions::Pause.new(session).call
|
Sessions::Pause.new(session).call
|
||||||
when "resume_stream"
|
when "resume_stream"
|
||||||
Sessions::Resume.new(session).call
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ module Api
|
|||||||
render json: session_json(@session)
|
render json: session_json(@session)
|
||||||
end
|
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
|
def score
|
||||||
Scoring::SyncState.new(session: @session, payload: score_sync_params).call
|
Scoring::SyncState.new(session: @session, payload: score_sync_params).call
|
||||||
render json: session_json(@session, detail: true)
|
render json: session_json(@session, detail: true)
|
||||||
@@ -189,7 +195,8 @@ module Api
|
|||||||
quality_preset: session.quality_preset,
|
quality_preset: session.quality_preset,
|
||||||
target_bitrate: session.target_bitrate,
|
target_bitrate: session.target_bitrate,
|
||||||
started_at: session.started_at,
|
started_at: session.started_at,
|
||||||
disconnection_count: session.disconnection_count
|
disconnection_count: session.disconnection_count,
|
||||||
|
audio_muted: session.audio_muted
|
||||||
}
|
}
|
||||||
if detail
|
if detail
|
||||||
data[:score] = session.score_state&.as_cable_payload
|
data[:score] = session.score_state&.as_cable_payload
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ module Public
|
|||||||
layout "regia"
|
layout "regia"
|
||||||
|
|
||||||
protect_from_forgery with: :null_session
|
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
|
before_action :set_session_from_token
|
||||||
|
|
||||||
@@ -61,6 +61,12 @@ module Public
|
|||||||
render json: status_payload
|
render json: status_payload
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def set_session_from_token
|
def set_session_from_token
|
||||||
@@ -78,6 +84,7 @@ module Public
|
|||||||
{
|
{
|
||||||
status: @session.status,
|
status: @session.status,
|
||||||
paused: @session.paused?,
|
paused: @session.paused?,
|
||||||
|
audio_muted: @session.audio_muted,
|
||||||
stream_closed: closed,
|
stream_closed: closed,
|
||||||
live: !closed && (@session.live? || @session.paused? || publisher_online),
|
live: !closed && (@session.live? || @session.paused? || publisher_online),
|
||||||
on_air: playable || (!closed && @session.status.in?(%w[connecting live reconnecting paused])),
|
on_air: playable || (!closed && @session.status.in?(%w[connecting live reconnecting paused])),
|
||||||
|
|||||||
@@ -44,9 +44,12 @@ module Public
|
|||||||
partialsPrefix: t("regia.board.partials_prefix"),
|
partialsPrefix: t("regia.board.partials_prefix"),
|
||||||
resumed: t("regia.js.resumed"),
|
resumed: t("regia.js.resumed"),
|
||||||
pausedCover: t("regia.js.paused_cover"),
|
pausedCover: t("regia.js.paused_cover"),
|
||||||
|
muted: t("regia.js.muted"),
|
||||||
|
unmuted: t("regia.js.unmuted"),
|
||||||
closed: t("regia.js.closed"),
|
closed: t("regia.js.closed"),
|
||||||
resumeError: t("regia.js.resume_error"),
|
resumeError: t("regia.js.resume_error"),
|
||||||
pauseError: t("regia.js.pause_error"),
|
pauseError: t("regia.js.pause_error"),
|
||||||
|
muteError: t("regia.js.mute_error"),
|
||||||
closeError: t("regia.js.close_error"),
|
closeError: t("regia.js.close_error"),
|
||||||
closeConfirm: t("regia.js.close_confirm"),
|
closeConfirm: t("regia.js.close_confirm"),
|
||||||
linkUnavailable: t("regia.js.link_unavailable"),
|
linkUnavailable: t("regia.js.link_unavailable"),
|
||||||
@@ -58,6 +61,8 @@ module Public
|
|||||||
streamEnded: t("regia.preview_ended"),
|
streamEnded: t("regia.preview_ended"),
|
||||||
resumeLabel: t("regia.resume"),
|
resumeLabel: t("regia.resume"),
|
||||||
pauseLabel: t("regia.pause"),
|
pauseLabel: t("regia.pause"),
|
||||||
|
muteLabel: t("regia.mute"),
|
||||||
|
unmuteLabel: t("regia.unmute"),
|
||||||
endedBadge: t("regia.status.ended"),
|
endedBadge: t("regia.status.ended"),
|
||||||
pausedBadge: t("regia.status.paused"),
|
pausedBadge: t("regia.status.paused"),
|
||||||
liveBadge: t("regia.status.live"),
|
liveBadge: t("regia.status.live"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class StreamEvent < ApplicationRecord
|
class StreamEvent < ApplicationRecord
|
||||||
EVENT_TYPES = %w[
|
EVENT_TYPES = %w[
|
||||||
connected disconnected reconnected quality_changed error paused resumed
|
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
|
].freeze
|
||||||
|
|
||||||
belongs_to :stream_session
|
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
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
data-score-url="<%= j public_regia_score_path(params[:token]) %>"
|
data-score-url="<%= j public_regia_score_path(params[:token]) %>"
|
||||||
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
||||||
data-resume-url="<%= j public_regia_resume_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-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
|
||||||
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
||||||
data-hls-url="<%= j @session.hls_playback_url %>"
|
data-hls-url="<%= j @session.hls_playback_url %>"
|
||||||
@@ -26,7 +27,8 @@
|
|||||||
data-live-share-title="<%= j @live_share_title %>"
|
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-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-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">
|
<header class="regia-header">
|
||||||
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
||||||
@@ -77,6 +79,7 @@
|
|||||||
|
|
||||||
<% unless @stream_closed %>
|
<% 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" 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>
|
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop"><%= t("regia.stop") %></button>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,4 +97,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/regia.js?v=8"></script>
|
<script src="/regia.js?v=9"></script>
|
||||||
|
|||||||
@@ -441,6 +441,8 @@ de:
|
|||||||
preview_waiting: Vorschau wartet auf Signal…
|
preview_waiting: Vorschau wartet auf Signal…
|
||||||
pause: Pausieren
|
pause: Pausieren
|
||||||
resume: Livestream fortsetzen
|
resume: Livestream fortsetzen
|
||||||
|
mute: Audio stummschalten
|
||||||
|
unmute: Audio einschalten
|
||||||
stop: Livestream beenden
|
stop: Livestream beenden
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Satz gewonnen
|
set_won_title: Satz gewonnen
|
||||||
@@ -476,9 +478,12 @@ de:
|
|||||||
unknown_period: "—"
|
unknown_period: "—"
|
||||||
resumed: Übertragung fortgesetzt
|
resumed: Übertragung fortgesetzt
|
||||||
paused_cover: "Übertragung pausiert — Deckbild auf Sendung"
|
paused_cover: "Übertragung pausiert — Deckbild auf Sendung"
|
||||||
|
muted: "Audio stumm — YouTube empfängt das Mikrofon nicht"
|
||||||
|
unmuted: "Mikrofon-Audio wieder aktiv"
|
||||||
closed: Übertragung beendet
|
closed: Übertragung beendet
|
||||||
resume_error: Fehler beim Fortsetzen der Übertragung
|
resume_error: Fehler beim Fortsetzen der Übertragung
|
||||||
pause_error: Fehler beim Pausieren der Übertragung
|
pause_error: Fehler beim Pausieren der Übertragung
|
||||||
|
mute_error: Fehler beim Stummschalten
|
||||||
close_error: Fehler beim Beenden
|
close_error: Fehler beim Beenden
|
||||||
close_confirm: Übertragung endgültig beenden?
|
close_confirm: Übertragung endgültig beenden?
|
||||||
link_unavailable: Link nicht verfügbar
|
link_unavailable: Link nicht verfügbar
|
||||||
|
|||||||
@@ -436,6 +436,8 @@ en:
|
|||||||
preview_waiting: Waiting for signal…
|
preview_waiting: Waiting for signal…
|
||||||
pause: Pause stream
|
pause: Pause stream
|
||||||
resume: Resume stream
|
resume: Resume stream
|
||||||
|
mute: Mute audio
|
||||||
|
unmute: Unmute audio
|
||||||
stop: End stream
|
stop: End stream
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set won
|
set_won_title: Set won
|
||||||
@@ -471,9 +473,12 @@ en:
|
|||||||
unknown_period: "—"
|
unknown_period: "—"
|
||||||
resumed: Stream resumed
|
resumed: Stream resumed
|
||||||
paused_cover: "Stream paused — cover on air"
|
paused_cover: "Stream paused — cover on air"
|
||||||
|
muted: "Audio muted — YouTube is not receiving the microphone"
|
||||||
|
unmuted: "Microphone audio restored"
|
||||||
closed: Stream closed
|
closed: Stream closed
|
||||||
resume_error: Error resuming the stream
|
resume_error: Error resuming the stream
|
||||||
pause_error: Error pausing the stream
|
pause_error: Error pausing the stream
|
||||||
|
mute_error: Error muting audio
|
||||||
close_error: Error closing
|
close_error: Error closing
|
||||||
close_confirm: Permanently close the live stream?
|
close_confirm: Permanently close the live stream?
|
||||||
link_unavailable: Link not available
|
link_unavailable: Link not available
|
||||||
|
|||||||
@@ -441,6 +441,8 @@ es:
|
|||||||
preview_waiting: Vista previa a la espera de señal…
|
preview_waiting: Vista previa a la espera de señal…
|
||||||
pause: Pausar directo
|
pause: Pausar directo
|
||||||
resume: Reanudar directo
|
resume: Reanudar directo
|
||||||
|
mute: Silenciar audio
|
||||||
|
unmute: Reactivar audio
|
||||||
stop: Finalizar directo
|
stop: Finalizar directo
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set ganado
|
set_won_title: Set ganado
|
||||||
@@ -476,9 +478,12 @@ es:
|
|||||||
unknown_period: "—"
|
unknown_period: "—"
|
||||||
resumed: Retransmisión reanudada
|
resumed: Retransmisión reanudada
|
||||||
paused_cover: "Retransmisión en pausa — cortinilla al aire"
|
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
|
closed: Retransmisión finalizada
|
||||||
resume_error: Error al reanudar la retransmisión
|
resume_error: Error al reanudar la retransmisión
|
||||||
pause_error: Error al pausar la retransmisión
|
pause_error: Error al pausar la retransmisión
|
||||||
|
mute_error: Error al silenciar el audio
|
||||||
close_error: Error al cerrar
|
close_error: Error al cerrar
|
||||||
close_confirm: "¿Cerrar definitivamente la retransmisión?"
|
close_confirm: "¿Cerrar definitivamente la retransmisión?"
|
||||||
link_unavailable: Enlace no disponible
|
link_unavailable: Enlace no disponible
|
||||||
|
|||||||
@@ -441,6 +441,8 @@ fr:
|
|||||||
preview_waiting: Aperçu en attente du signal…
|
preview_waiting: Aperçu en attente du signal…
|
||||||
pause: Mettre en pause
|
pause: Mettre en pause
|
||||||
resume: Reprendre le direct
|
resume: Reprendre le direct
|
||||||
|
mute: Couper l'audio
|
||||||
|
unmute: Réactiver l'audio
|
||||||
stop: Terminer le direct
|
stop: Terminer le direct
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set gagné
|
set_won_title: Set gagné
|
||||||
@@ -476,9 +478,12 @@ fr:
|
|||||||
unknown_period: "—"
|
unknown_period: "—"
|
||||||
resumed: Direct repris
|
resumed: Direct repris
|
||||||
paused_cover: "Direct en pause — habillage à l'antenne"
|
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é
|
closed: Direct terminé
|
||||||
resume_error: Erreur lors de la reprise du direct
|
resume_error: Erreur lors de la reprise du direct
|
||||||
pause_error: Erreur lors de la mise en pause 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_error: Erreur de clôture
|
||||||
close_confirm: Clôturer définitivement le direct ?
|
close_confirm: Clôturer définitivement le direct ?
|
||||||
link_unavailable: Lien non disponible
|
link_unavailable: Lien non disponible
|
||||||
|
|||||||
@@ -436,6 +436,8 @@ it:
|
|||||||
preview_waiting: Anteprima in attesa del segnale…
|
preview_waiting: Anteprima in attesa del segnale…
|
||||||
pause: Metti in pausa
|
pause: Metti in pausa
|
||||||
resume: Riprendi diretta
|
resume: Riprendi diretta
|
||||||
|
mute: Silenzia audio
|
||||||
|
unmute: Riattiva audio
|
||||||
stop: Chiudi diretta
|
stop: Chiudi diretta
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set vinto
|
set_won_title: Set vinto
|
||||||
@@ -471,9 +473,12 @@ it:
|
|||||||
unknown_period: "—"
|
unknown_period: "—"
|
||||||
resumed: Diretta ripresa
|
resumed: Diretta ripresa
|
||||||
paused_cover: "Diretta in pausa — copertina in onda"
|
paused_cover: "Diretta in pausa — copertina in onda"
|
||||||
|
muted: "Audio silenziato — YouTube non riceve il microfono"
|
||||||
|
unmuted: "Audio microfono riattivato"
|
||||||
closed: Diretta chiusa
|
closed: Diretta chiusa
|
||||||
resume_error: Errore ripresa diretta
|
resume_error: Errore ripresa diretta
|
||||||
pause_error: Errore pausa diretta
|
pause_error: Errore pausa diretta
|
||||||
|
mute_error: Errore silenziamento audio
|
||||||
close_error: Errore chiusura
|
close_error: Errore chiusura
|
||||||
close_confirm: Chiudere definitivamente la diretta?
|
close_confirm: Chiudere definitivamente la diretta?
|
||||||
link_unavailable: Link non disponibile
|
link_unavailable: Link non disponibile
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ Rails.application.routes.draw do
|
|||||||
patch :stop
|
patch :stop
|
||||||
patch :pause
|
patch :pause
|
||||||
patch :resume
|
patch :resume
|
||||||
|
patch :audio_mute
|
||||||
patch :score
|
patch :score
|
||||||
post :score_action
|
post :score_action
|
||||||
get :events
|
get :events
|
||||||
@@ -142,6 +143,7 @@ Rails.application.routes.draw do
|
|||||||
patch "regia/:token/score", to: "public/regia#score", as: :public_regia_score
|
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/pause", to: "public/regia#pause", as: :public_regia_pause
|
||||||
post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume
|
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
|
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
|
||||||
|
|
||||||
root to: "public/pages#home"
|
root to: "public/pages#home"
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddAudioMutedToStreamSessions < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :stream_sessions, :audio_muted, :boolean, default: false, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
Generated
+2
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
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.datetime "updated_at", null: false
|
||||||
t.string "regia_token_digest"
|
t.string "regia_token_digest"
|
||||||
t.datetime "regia_token_expires_at"
|
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 ["match_id"], name: "index_stream_sessions_on_match_id"
|
||||||
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
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
|
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
||||||
|
|||||||
@@ -165,6 +165,10 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
.regia-btn--outline.is-muted {
|
||||||
|
border-color: var(--regia-yellow);
|
||||||
|
color: var(--regia-yellow);
|
||||||
|
}
|
||||||
.regia-btn--danger {
|
.regia-btn--danger {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--regia-red);
|
border: 1px solid var(--regia-red);
|
||||||
|
|||||||
@@ -25,9 +25,12 @@
|
|||||||
partialsPrefix: "Parziali:",
|
partialsPrefix: "Parziali:",
|
||||||
resumed: "Diretta ripresa",
|
resumed: "Diretta ripresa",
|
||||||
pausedCover: "Diretta in pausa — copertina in onda",
|
pausedCover: "Diretta in pausa — copertina in onda",
|
||||||
|
muted: "Audio silenziato — YouTube non riceve il microfono",
|
||||||
|
unmuted: "Audio microfono riattivato",
|
||||||
closed: "Diretta chiusa",
|
closed: "Diretta chiusa",
|
||||||
resumeError: "Errore ripresa diretta",
|
resumeError: "Errore ripresa diretta",
|
||||||
pauseError: "Errore pausa diretta",
|
pauseError: "Errore pausa diretta",
|
||||||
|
muteError: "Errore silenziamento audio",
|
||||||
closeError: "Errore chiusura",
|
closeError: "Errore chiusura",
|
||||||
closeConfirm: "Chiudere definitivamente la diretta?",
|
closeConfirm: "Chiudere definitivamente la diretta?",
|
||||||
linkUnavailable: "Link non disponibile",
|
linkUnavailable: "Link non disponibile",
|
||||||
@@ -39,6 +42,8 @@
|
|||||||
streamEnded: "Diretta terminata",
|
streamEnded: "Diretta terminata",
|
||||||
resumeLabel: "Riprendi diretta",
|
resumeLabel: "Riprendi diretta",
|
||||||
pauseLabel: "Metti in pausa",
|
pauseLabel: "Metti in pausa",
|
||||||
|
muteLabel: "Silenzia audio",
|
||||||
|
unmuteLabel: "Riattiva audio",
|
||||||
endedBadge: "Terminata",
|
endedBadge: "Terminata",
|
||||||
pausedBadge: "In pausa",
|
pausedBadge: "In pausa",
|
||||||
liveBadge: "In onda",
|
liveBadge: "In onda",
|
||||||
@@ -76,6 +81,7 @@
|
|||||||
scoreUrl: root.dataset.scoreUrl,
|
scoreUrl: root.dataset.scoreUrl,
|
||||||
pauseUrl: root.dataset.pauseUrl,
|
pauseUrl: root.dataset.pauseUrl,
|
||||||
resumeUrl: root.dataset.resumeUrl,
|
resumeUrl: root.dataset.resumeUrl,
|
||||||
|
audioMuteUrl: root.dataset.audioMuteUrl,
|
||||||
stopUrl: root.dataset.stopUrl,
|
stopUrl: root.dataset.stopUrl,
|
||||||
cableUrl: root.dataset.cableUrl,
|
cableUrl: root.dataset.cableUrl,
|
||||||
hlsUrl: root.dataset.hlsUrl,
|
hlsUrl: root.dataset.hlsUrl,
|
||||||
@@ -103,6 +109,7 @@
|
|||||||
preview: document.getElementById("regia-preview"),
|
preview: document.getElementById("regia-preview"),
|
||||||
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
||||||
btnPause: document.getElementById("btn-pause"),
|
btnPause: document.getElementById("btn-pause"),
|
||||||
|
btnAudioMute: document.getElementById("btn-audio-mute"),
|
||||||
subtitle: document.getElementById("regia-subtitle")
|
subtitle: document.getElementById("regia-subtitle")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,6 +118,7 @@
|
|||||||
let previewStarted = false;
|
let previewStarted = false;
|
||||||
let wasPausedPreview = false;
|
let wasPausedPreview = false;
|
||||||
let streamPaused = root.dataset.initialPaused === "true";
|
let streamPaused = root.dataset.initialPaused === "true";
|
||||||
|
let audioMuted = root.dataset.initialAudioMuted === "true";
|
||||||
|
|
||||||
function hlsUrlFresh() {
|
function hlsUrlFresh() {
|
||||||
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
|
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
|
||||||
@@ -246,8 +254,18 @@
|
|||||||
els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel;
|
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) {
|
function setBadge(data) {
|
||||||
syncPauseButton(data);
|
syncPauseButton(data);
|
||||||
|
syncMuteButton(data);
|
||||||
if (data.stream_closed) {
|
if (data.stream_closed) {
|
||||||
els.badge.textContent = I18N.endedBadge;
|
els.badge.textContent = I18N.endedBadge;
|
||||||
els.badge.className = "regia-badge regia-badge--ended";
|
els.badge.className = "regia-badge regia-badge--ended";
|
||||||
@@ -458,6 +476,24 @@
|
|||||||
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
|
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() {
|
async function stopStream() {
|
||||||
const ok = confirm(I18N.closeConfirm);
|
const ok = confirm(I18N.closeConfirm);
|
||||||
if (!ok) return null;
|
if (!ok) return null;
|
||||||
@@ -471,6 +507,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
|
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)));
|
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
|
||||||
|
|
||||||
async function shareLink(url, title, text) {
|
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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
require "rails_helper"
|
require "rails_helper"
|
||||||
|
|
||||||
RSpec.describe "Public regia", type: :request do
|
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!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||||
let!(:match) { team.matches.create!(opponent_name: "Opp", 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")
|
expect(session.reload.status).to eq("connecting")
|
||||||
end
|
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
|
it "emette token valido anche oltre le 8 ore dalla partenza" do
|
||||||
session.update!(started_at: 9.hours.ago)
|
session.update!(started_at: 9.hours.ago)
|
||||||
token = Sessions::RegiaAccess.new(session).issue_token!
|
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Sessions::SetAudioMute do
|
||||||
|
let!(:user) { User.create!(email: "mute@test.it", name: "U", password: "Password123", role: "coach") }
|
||||||
|
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
|
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||||
|
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
||||||
|
let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live") }
|
||||||
|
|
||||||
|
it "attiva il mute e persiste lo stato" do
|
||||||
|
described_class.new(session, muted: true).call
|
||||||
|
expect(session.reload.audio_muted).to eq(true)
|
||||||
|
expect(session.stream_events.order(:occurred_at).last.event_type).to eq("audio_muted")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "disattiva il mute" do
|
||||||
|
session.update!(audio_muted: true)
|
||||||
|
described_class.new(session, muted: false).call
|
||||||
|
expect(session.reload.audio_muted).to eq(false)
|
||||||
|
expect(session.stream_events.order(:occurred_at).last.event_type).to eq("audio_unmuted")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "è idempotente se lo stato è già quello richiesto" do
|
||||||
|
described_class.new(session, muted: false).call
|
||||||
|
expect(session.stream_events.count).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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/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) |
|
| [`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`) |
|
| [`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 |
|
| [`REPLAY_MODULE.md`](REPLAY_MODULE.md) | Garage, retention, YouTube VOD |
|
||||||
| [`OPS_MONITORING.md`](OPS_MONITORING.md) | ntfy, variabili ops, troubleshooting |
|
| [`OPS_MONITORING.md`](OPS_MONITORING.md) | ntfy, variabili ops, troubleshooting |
|
||||||
| [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md) | Tabelloni e overlay (app nativa) |
|
| [`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 | Replay: redirect 302 a `/media/` → Garage (fuori da Puma) |
|
||||||
| 2026-06 | HLS live: edge → MediaMTX (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 | 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-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-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 |
|
| 2026-08 | `YoutubeRelay` = remux copy video+audio (niente ricodifica AAC); profilo app/slate AAC 48k mono |
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare un’app **allineata ad Android**.
|
Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare un’app **allineata ad Android**.
|
||||||
|
|
||||||
**Aggiornato:** 8 agosto 2026
|
**Aggiornato:** 12 agosto 2026
|
||||||
**Riferimento Android (produzione / telefono / Play):** `2.0.10-native` (`versionCode` **31**), API `https://www.matchlivetv.it`
|
**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)
|
**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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# iOS — mute audio in diretta (allineamento da Mac)
|
||||||
|
|
||||||
|
Documento operativo per **Cursor su Mac**: verificare e, se serve, ritoccare l’app iOS dopo il ramo `feature/stream-audio-mute`.
|
||||||
|
|
||||||
|
**Aggiornato:** 2026-08-12
|
||||||
|
**Branch:** `feature/stream-audio-mute` (parte da `main`)
|
||||||
|
**Android di riferimento:** mute microfono in overlay + sync regia
|
||||||
|
**API produzione:** `https://www.matchlivetv.it`
|
||||||
|
|
||||||
|
Il codice iOS è **già nel branch** (engine, overlay, API, Cable, L10n). Su Mac va **compilato, testato su device** e, se HaishinKit 2.2.5 rifiuta un parametro, adattato come sotto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Perché esiste
|
||||||
|
|
||||||
|
In palestra la musica di sottofondo finisce nel microfono del telefono. Il relay YouTube fa `-c:a copy`, quindi YouTube sente quella musica e può reclamare il copyright.
|
||||||
|
|
||||||
|
Il mute **non ferma** lo stream: continua video + overlay + AAC, ma i sample audio sono silenzio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contratto API / Cable (già in backend)
|
||||||
|
|
||||||
|
| Pezzo | Dettaglio |
|
||||||
|
|-------|-----------|
|
||||||
|
| Campo sessione | `audio_muted` (boolean, default `false`) in JSON sessione e `GET/POST /regia/:token/status.json` |
|
||||||
|
| REST app | `PATCH /api/v1/sessions/:id/audio_mute` body `{ "muted": true \| false }` |
|
||||||
|
| REST regia | `POST /regia/:token/audio_mute` body `{ "muted": true \| false }` (senza body: toggle) |
|
||||||
|
| Cable command | `{ "type": "command", "action": "mute_audio" \| "unmute_audio", "muted": true \| false }` |
|
||||||
|
| Cable event | `{ "type": "stream_event", "event": "audio_muted", "muted": true \| false }` |
|
||||||
|
|
||||||
|
Idempotente: se lo stato è già quello richiesto, il backend non reinvia eventi.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File iOS già modificati
|
||||||
|
|
||||||
|
```
|
||||||
|
native/ios/MatchLiveTv/Streaming/BroadcastModels.swift # BroadcastMetrics.audioMuted
|
||||||
|
native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift # setAudioMuted + AudioMixerSettings.isMuted
|
||||||
|
native/ios/MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift # setAudioMuted
|
||||||
|
native/ios/MatchLiveTv/Domain/Models.swift # StreamSession.audioMuted + withAudioMuted
|
||||||
|
native/ios/MatchLiveTv/Data/API/ApiDtos.swift # audioMuted + AudioMuteRequest
|
||||||
|
native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift # setAudioMute
|
||||||
|
native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift
|
||||||
|
native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift # onAudioMute + AudioMuteCommandLogic
|
||||||
|
native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift
|
||||||
|
native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift
|
||||||
|
native/ios/MatchLiveTv/Core/AppLanguage.swift # IT/EN/FR/DE/ES
|
||||||
|
native/ios/MatchLiveTvTests/LiveBroadcastCoordinatorTests.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Engine HaishinKit 2.2.5 (punto da verificare su Mac)
|
||||||
|
|
||||||
|
Implementazione attesa in `LiveBroadcastEngine`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func setAudioMuted(_ muted: Bool) async {
|
||||||
|
audioMuted = muted
|
||||||
|
await applyAudioMute()
|
||||||
|
metrics.audioMuted = muted
|
||||||
|
emitMetrics()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyAudioMute() async {
|
||||||
|
guard pipelineConfigured else { return }
|
||||||
|
var settings = await mixer.audioMixerSettings
|
||||||
|
settings.isMuted = audioMuted
|
||||||
|
var track = settings.tracks[0] ?? AudioMixerTrackSettings(downmix: true, channelMap: [0])
|
||||||
|
track.isMuted = audioMuted
|
||||||
|
settings.tracks[0] = track
|
||||||
|
try? await mixer.setAudioMixerSettings(settings)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `configurePipeline` i settings iniziali passano `isMuted: audioMuted` sia sul mixer sia sulla track 0.
|
||||||
|
|
||||||
|
**Se il build fallisce:**
|
||||||
|
|
||||||
|
1. `AudioMixerSettings` potrebbe non avere `isMuted` nel memberwise usato oggi — in quel caso muta solo `settings.tracks[0].isMuted`.
|
||||||
|
2. `setAudioMixerSettings` è `async throws` nel codice attuale; se la signature è sync, togli `try await`.
|
||||||
|
3. Non staccare il microfono (`attachAudio(nil)`): YouTube/MediaMTX vogliono AAC continuo. Mute = silenzio, non assenza di traccia.
|
||||||
|
|
||||||
|
Dopo `prepareBroadcast` / `resumeBroadcast` la schermata richiama `setAudioMuted(session.audioMuted)` per riallineare lo stato persistito.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI overlay
|
||||||
|
|
||||||
|
Toolbar destra (come Android), sotto pausa:
|
||||||
|
|
||||||
|
| Stato | SF Symbol | Chiave L10n |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| Audio on | `speaker.wave.2.fill` | `broadcast.mute.cd` |
|
||||||
|
| Audio off | `speaker.slash.fill` | `broadcast.unmute.cd` |
|
||||||
|
|
||||||
|
Bottone `highlighted` quando mutato (stesso verde della pausa). Pannello telemetria: riga `broadcast.audio.muted.label` se mutato.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chiavi L10n (già aggiunte, 5 lingue)
|
||||||
|
|
||||||
|
| Android | iOS |
|
||||||
|
|---------|-----|
|
||||||
|
| `broadcast_mute_cd` | `broadcast.mute.cd` |
|
||||||
|
| `broadcast_unmute_cd` | `broadcast.unmute.cd` |
|
||||||
|
| `broadcast_audio_muted_label` | `broadcast.audio.muted.label` |
|
||||||
|
| `broadcast_snackbar_muted` | `broadcast.snackbar.muted` |
|
||||||
|
| `broadcast_snackbar_unmuted` | `broadcast.snackbar.unmuted` |
|
||||||
|
| `broadcast_snackbar_muted_remote` | `broadcast.snackbar.muted.remote` |
|
||||||
|
| `broadcast_snackbar_unmuted_remote` | `broadcast.snackbar.unmuted.remote` |
|
||||||
|
| `broadcast_snackbar_mute_error` | `broadcast.snackbar.mute.error` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test su Mac (acceptance)
|
||||||
|
|
||||||
|
1. `cd native/ios && python3 generate_xcodeproj.py`
|
||||||
|
2. Build simulatore / device.
|
||||||
|
3. Avvia una diretta (anche verso ambiente di staging).
|
||||||
|
4. Overlay: tap mute → icona speaker.slash, snackbar «Audio silenziato», telemetria «Audio off».
|
||||||
|
5. Apri la **regia** sullo stesso match → il pulsante deve dire «Riattiva audio».
|
||||||
|
6. Dalla regia riattiva → il telefono mostra speaker.wave e snackbar «dalla regia».
|
||||||
|
7. Mute, metti in **pausa**, riprendi: l’audio resta mutato (stato persistito).
|
||||||
|
8. YouTube / player HLS: video continua, audio silenzioso (non assente).
|
||||||
|
9. Unit test: `LiveBroadcastCoordinatorTests.testAudioMuteCommandFromCable`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd native/ios
|
||||||
|
python3 generate_xcodeproj.py
|
||||||
|
xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \
|
||||||
|
-destination 'generic/platform=iOS Simulator' \
|
||||||
|
-derivedDataPath build/DerivedData \
|
||||||
|
ONLY_ACTIVE_ARCH=YES ARCHS=arm64 EXCLUDED_ARCHS=x86_64 \
|
||||||
|
build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Criterio “fatto”
|
||||||
|
|
||||||
|
- [ ] Build iOS senza errori HaishinKit
|
||||||
|
- [ ] Mute/unmute locale in overlay
|
||||||
|
- [ ] Sync bidirezionale con la regia via Cable + REST
|
||||||
|
- [ ] Mute sopravvive a pausa/ripresa
|
||||||
|
- [ ] AAC silenzioso in onda (niente drop della traccia audio)
|
||||||
|
- [ ] Stringhe IT/EN/FR/DE/ES visibili in overlay
|
||||||
+14
-1
@@ -1,6 +1,6 @@
|
|||||||
# Diretta live — architettura MediaMTX
|
# Diretta live — architettura MediaMTX
|
||||||
|
|
||||||
**Ultimo aggiornamento:** 2026-07-29
|
**Ultimo aggiornamento:** 2026-08-12
|
||||||
|
|
||||||
## Idea
|
## Idea
|
||||||
|
|
||||||
@@ -106,6 +106,19 @@ L’app mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (o `
|
|||||||
|
|
||||||
`GET /live/:id/status.json` resta per messaggi sotto il player e recovery HLS; `Cache-Control: no-store`.
|
`GET /live/:id/status.json` resta per messaggi sotto il player e recovery HLS; `Cache-Control: no-store`.
|
||||||
|
|
||||||
|
### Mute audio (copyright palestra / YouTube)
|
||||||
|
|
||||||
|
Se in palestra c’è musica di sottofondo, YouTube può reclamare il copyright sul flusso inoltrato (`-c:a copy`). L’operatore può **silenziare il microfono** senza interrompere video, overlay, HLS o relay:
|
||||||
|
|
||||||
|
1. Lo stato `audio_muted` è persistito su `stream_sessions` (default `false`).
|
||||||
|
2. API `PATCH /api/v1/sessions/:id/audio_mute` `{ "muted": true|false }` e `POST /regia/:token/audio_mute`.
|
||||||
|
3. Action Cable: `{ type: "command", action: "mute_audio"|"unmute_audio" }` e `{ type: "stream_event", event: "audio_muted", muted: true|false }`.
|
||||||
|
4. L’app **non toglie la traccia AAC**: azzera i sample PCM (silenzio). YouTube e MediaMTX continuano a ricevere audio 48 kHz mono.
|
||||||
|
|
||||||
|
Pulsante mute: overlay diretta (Android/iOS, accanto a pausa) e pagina regia.
|
||||||
|
|
||||||
|
Dettaglio implementazione iOS: [`IOS_STREAM_AUDIO_MUTE.md`](IOS_STREAM_AUDIO_MUTE.md).
|
||||||
|
|
||||||
## Rigenerare la slate
|
## Rigenerare la slate
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ data class StreamSessionDto(
|
|||||||
@Json(name = "target_fps") val targetFps: Int? = null,
|
@Json(name = "target_fps") val targetFps: Int? = null,
|
||||||
@Json(name = "quality_preset") val qualityPreset: String? = null,
|
@Json(name = "quality_preset") val qualityPreset: String? = null,
|
||||||
@Json(name = "started_at") val startedAt: String? = null,
|
@Json(name = "started_at") val startedAt: String? = null,
|
||||||
|
@Json(name = "audio_muted") val audioMuted: Boolean? = null,
|
||||||
val score: ScoreStateDto? = null,
|
val score: ScoreStateDto? = null,
|
||||||
) {
|
) {
|
||||||
fun toDomain() = StreamSession(
|
fun toDomain() = StreamSession(
|
||||||
@@ -203,6 +204,7 @@ data class StreamSessionDto(
|
|||||||
qualityPreset = qualityPreset ?: "720p_30_2.5mbps",
|
qualityPreset = qualityPreset ?: "720p_30_2.5mbps",
|
||||||
startedAt = startedAt,
|
startedAt = startedAt,
|
||||||
score = score?.toDomain(),
|
score = score?.toDomain(),
|
||||||
|
audioMuted = audioMuted ?: false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +369,10 @@ data class RegiaLinkResponse(
|
|||||||
@Json(name = "regia_url") val regiaUrl: String,
|
@Json(name = "regia_url") val regiaUrl: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class AudioMuteRequest(
|
||||||
|
val muted: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
data class CreateMatchBody(
|
data class CreateMatchBody(
|
||||||
@Json(name = "opponent_name") val opponentName: String,
|
@Json(name = "opponent_name") val opponentName: String,
|
||||||
val sport: String = "volleyball",
|
val sport: String = "volleyball",
|
||||||
|
|||||||
+6
@@ -117,6 +117,12 @@ interface MatchLiveApi {
|
|||||||
@PATCH("sessions/{sessionId}/resume")
|
@PATCH("sessions/{sessionId}/resume")
|
||||||
suspend fun resumeSession(@Path("sessionId") sessionId: String): StreamSessionDto
|
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")
|
@POST("sessions/{sessionId}/network_test")
|
||||||
suspend fun networkTest(
|
suspend fun networkTest(
|
||||||
@Path("sessionId") sessionId: String,
|
@Path("sessionId") sessionId: String,
|
||||||
|
|||||||
+26
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-2
@@ -33,6 +33,7 @@ class SessionCableService(
|
|||||||
var onPauseStream: (() -> Unit)? = null
|
var onPauseStream: (() -> Unit)? = null
|
||||||
var onResumeStream: (() -> Unit)? = null
|
var onResumeStream: (() -> Unit)? = null
|
||||||
var onEndStream: (() -> Unit)? = null
|
var onEndStream: (() -> Unit)? = null
|
||||||
|
var onAudioMute: ((Boolean) -> Unit)? = null
|
||||||
|
|
||||||
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
|
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
|
||||||
intentionalDisconnect = false
|
intentionalDisconnect = false
|
||||||
@@ -105,18 +106,31 @@ class SessionCableService(
|
|||||||
val payload = unwrapCablePayload(data)
|
val payload = unwrapCablePayload(data)
|
||||||
when (payload["type"] as? String) {
|
when (payload["type"] as? String) {
|
||||||
"score_update" -> onScoreUpdate?.invoke(parseScore(payload))
|
"score_update" -> onScoreUpdate?.invoke(parseScore(payload))
|
||||||
"command" -> when (payload["action"] as? String) {
|
"command" -> {
|
||||||
|
AudioMuteCommands.mutedFromPayload(
|
||||||
|
type = "command",
|
||||||
|
action = payload["action"] as? String,
|
||||||
|
)?.let { onAudioMute?.invoke(it) }
|
||||||
|
when (payload["action"] as? String) {
|
||||||
"pause_stream" -> onPauseStream?.invoke()
|
"pause_stream" -> onPauseStream?.invoke()
|
||||||
"resume_stream" -> onResumeStream?.invoke()
|
"resume_stream" -> onResumeStream?.invoke()
|
||||||
"stop_stream" -> onEndStream?.invoke()
|
"stop_stream" -> onEndStream?.invoke()
|
||||||
}
|
}
|
||||||
"stream_event" -> when (payload["event"] as? String) {
|
}
|
||||||
|
"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()
|
"paused" -> onPauseStream?.invoke()
|
||||||
"resumed" -> onResumeStream?.invoke()
|
"resumed" -> onResumeStream?.invoke()
|
||||||
"ended" -> onEndStream?.invoke()
|
"ended" -> onEndStream?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Compat: messaggi legacy `{ method: receive_message, data: {...} }`. */
|
/** Compat: messaggi legacy `{ method: receive_message, data: {...} }`. */
|
||||||
private fun unwrapCablePayload(raw: Map<String, Any?>): Map<String, Any?> {
|
private fun unwrapCablePayload(raw: Map<String, Any?>): Map<String, Any?> {
|
||||||
|
|||||||
+3
@@ -38,6 +38,9 @@ class SessionRepository(
|
|||||||
suspend fun resumeSession(sessionId: String): StreamSession =
|
suspend fun resumeSession(sessionId: String): StreamSession =
|
||||||
api.resumeSession(sessionId).toDomain()
|
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(
|
suspend fun submitNetworkTest(
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
downloadMbps: Double,
|
downloadMbps: Double,
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ data class StreamSession(
|
|||||||
val qualityPreset: String = "720p_30_2.5mbps",
|
val qualityPreset: String = "720p_30_2.5mbps",
|
||||||
val startedAt: String? = null,
|
val startedAt: String? = null,
|
||||||
val score: ScoreState? = null,
|
val score: ScoreState? = null,
|
||||||
|
val audioMuted: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val isLive: Boolean get() = status == "live" || status == "reconnecting"
|
val isLive: Boolean get() = status == "live" || status == "reconnecting"
|
||||||
|
|
||||||
|
|||||||
+1
@@ -39,4 +39,5 @@ data class BroadcastMetrics(
|
|||||||
val fps: Int = 0,
|
val fps: Int = 0,
|
||||||
val connected: Boolean = false,
|
val connected: Boolean = false,
|
||||||
val lastError: String? = null,
|
val lastError: String? = null,
|
||||||
|
val audioMuted: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
+4
@@ -33,6 +33,10 @@ class LiveBroadcastCoordinator(context: Context) {
|
|||||||
engine.updateOverlay(state)
|
engine.updateOverlay(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAudioMuted(muted: Boolean) {
|
||||||
|
engine.setAudioMuted(muted)
|
||||||
|
}
|
||||||
|
|
||||||
fun startService() {
|
fun startService() {
|
||||||
val intent = LiveBroadcastService.startIntent(appContext)
|
val intent = LiveBroadcastService.startIntent(appContext)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
|||||||
+32
@@ -8,6 +8,8 @@ import android.util.Log
|
|||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import com.pedro.common.ConnectChecker
|
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.generic.GenericStream
|
||||||
import com.pedro.library.util.SensorRotationManager
|
import com.pedro.library.util.SensorRotationManager
|
||||||
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayRenderer
|
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayRenderer
|
||||||
@@ -42,6 +44,7 @@ class LiveBroadcastEngine(
|
|||||||
private val pausingIntentionally = AtomicBoolean(false)
|
private val pausingIntentionally = AtomicBoolean(false)
|
||||||
private val reconnectAttempts = AtomicInteger(0)
|
private val reconnectAttempts = AtomicInteger(0)
|
||||||
private val overlayRenderer = OverlayRenderer(appContext)
|
private val overlayRenderer = OverlayRenderer(appContext)
|
||||||
|
private val desiredAudioMuted = AtomicBoolean(false)
|
||||||
|
|
||||||
private val rebindRunnable = Runnable {
|
private val rebindRunnable = Runnable {
|
||||||
reattachPreviewIfNeeded()
|
reattachPreviewIfNeeded()
|
||||||
@@ -227,6 +230,7 @@ class LiveBroadcastEngine(
|
|||||||
currentBitrateKbps = 0
|
currentBitrateKbps = 0
|
||||||
currentFps = 0
|
currentFps = 0
|
||||||
reconnectAttempts.set(0)
|
reconnectAttempts.set(0)
|
||||||
|
desiredAudioMuted.set(false)
|
||||||
setPhase(BroadcastPhase.IDLE)
|
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. */
|
/** Adattamento termico: riduce bitrate/FPS senza fermare lo stream. */
|
||||||
fun applyThermalProfile(videoBitrate: Int, fps: Int) {
|
fun applyThermalProfile(videoBitrate: Int, fps: Int) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
@@ -340,6 +355,7 @@ class LiveBroadcastEngine(
|
|||||||
applyInitialOrientation()
|
applyInitialOrientation()
|
||||||
attachOverlayFilter(cfg)
|
attachOverlayFilter(cfg)
|
||||||
startSensor()
|
startSensor()
|
||||||
|
applyAudioMuteInternal()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,6 +530,7 @@ class LiveBroadcastEngine(
|
|||||||
},
|
},
|
||||||
connected = phase == BroadcastPhase.LIVE,
|
connected = phase == BroadcastPhase.LIVE,
|
||||||
lastError = error,
|
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 {
|
private fun userFacingError(reason: String): String = when {
|
||||||
reason.contains("broken pipe", ignoreCase = true) ->
|
reason.contains("broken pipe", ignoreCase = true) ->
|
||||||
"Connessione persa — riprova a riavviare la diretta"
|
"Connessione persa — riprova a riavviare la diretta"
|
||||||
|
|||||||
+24
@@ -32,6 +32,8 @@ import androidx.compose.material.icons.filled.Stop
|
|||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material.icons.filled.Videocam
|
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.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -100,6 +102,8 @@ fun BroadcastControlsOverlay(
|
|||||||
onCloseSet: (() -> Unit)? = null,
|
onCloseSet: (() -> Unit)? = null,
|
||||||
onAdvancePeriod: (() -> Unit)? = null,
|
onAdvancePeriod: (() -> Unit)? = null,
|
||||||
onPauseOrResume: () -> Unit,
|
onPauseOrResume: () -> Unit,
|
||||||
|
onToggleAudioMute: () -> Unit,
|
||||||
|
audioMuted: Boolean,
|
||||||
onTerminate: () -> Unit,
|
onTerminate: () -> Unit,
|
||||||
onShareLive: () -> Unit,
|
onShareLive: () -> Unit,
|
||||||
onShareRegia: () -> Unit,
|
onShareRegia: () -> Unit,
|
||||||
@@ -174,6 +178,7 @@ fun BroadcastControlsOverlay(
|
|||||||
bitrateKbps = bitrateKbps,
|
bitrateKbps = bitrateKbps,
|
||||||
networkType = networkType,
|
networkType = networkType,
|
||||||
deviceHealth = deviceHealth,
|
deviceHealth = deviceHealth,
|
||||||
|
audioMuted = audioMuted,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +232,16 @@ fun BroadcastControlsOverlay(
|
|||||||
onClick = onPauseOrResume,
|
onClick = onPauseOrResume,
|
||||||
highlighted = isPaused,
|
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(
|
SideIconButton(
|
||||||
icon = Icons.Default.Stop,
|
icon = Icons.Default.Stop,
|
||||||
contentDescription = stringResource(R.string.broadcast_terminate_cd),
|
contentDescription = stringResource(R.string.broadcast_terminate_cd),
|
||||||
@@ -287,6 +302,7 @@ private fun BroadcastTelemetryPanel(
|
|||||||
bitrateKbps: Long,
|
bitrateKbps: Long,
|
||||||
networkType: String,
|
networkType: String,
|
||||||
deviceHealth: DeviceHealthSnapshot,
|
deviceHealth: DeviceHealthSnapshot,
|
||||||
|
audioMuted: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val labelStyle = MaterialTheme.typography.labelSmall
|
val labelStyle = MaterialTheme.typography.labelSmall
|
||||||
@@ -326,6 +342,14 @@ private fun BroadcastTelemetryPanel(
|
|||||||
style = labelStyle,
|
style = labelStyle,
|
||||||
color = MatchColors.TextSecondary,
|
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)
|
ThermalIndicator(state = deviceHealth.thermalState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
@@ -78,6 +78,7 @@ fun BroadcastScreen(
|
|||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var pauseInFlight by remember { mutableStateOf(false) }
|
var pauseInFlight by remember { mutableStateOf(false) }
|
||||||
var resumeInFlight by remember { mutableStateOf(false) }
|
var resumeInFlight by remember { mutableStateOf(false) }
|
||||||
|
var muteInFlight by remember { mutableStateOf(false) }
|
||||||
var controlsVisible by remember { mutableStateOf(true) }
|
var controlsVisible by remember { mutableStateOf(true) }
|
||||||
var logoReady by remember { mutableStateOf(0) }
|
var logoReady by remember { mutableStateOf(0) }
|
||||||
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) }
|
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) }
|
||||||
@@ -145,6 +146,7 @@ fun BroadcastScreen(
|
|||||||
}
|
}
|
||||||
updated = container.sessionRepository.fetchSession(sessionId)
|
updated = container.sessionRepository.fetchSession(sessionId)
|
||||||
session = updated
|
session = updated
|
||||||
|
container.broadcastCoordinator.setAudioMuted(updated.audioMuted)
|
||||||
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_resumed))
|
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_resumed))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
snackbar.showSnackbar(
|
snackbar.showSnackbar(
|
||||||
@@ -174,6 +176,7 @@ fun BroadcastScreen(
|
|||||||
thermalManager.updateBaseline(cfg)
|
thermalManager.updateBaseline(cfg)
|
||||||
container.broadcastCoordinator.engine.resumeBroadcast(cfg)
|
container.broadcastCoordinator.engine.resumeBroadcast(cfg)
|
||||||
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_resumed_remote))
|
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_resumed_remote))
|
||||||
|
container.broadcastCoordinator.setAudioMuted(current.audioMuted)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
snackbar.showSnackbar(
|
snackbar.showSnackbar(
|
||||||
context.getString(R.string.broadcast_snackbar_resume_rtmp_error, e.message ?: genericError),
|
context.getString(R.string.broadcast_snackbar_resume_rtmp_error, e.message ?: genericError),
|
||||||
@@ -191,6 +194,40 @@ fun BroadcastScreen(
|
|||||||
onFinished()
|
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) {
|
LaunchedEffect(sessionId, permissions.granted) {
|
||||||
if (!permissions.granted) return@LaunchedEffect
|
if (!permissions.granted) return@LaunchedEffect
|
||||||
loading = true
|
loading = true
|
||||||
@@ -215,6 +252,9 @@ fun BroadcastScreen(
|
|||||||
container.sessionCable.onEndStream = {
|
container.sessionCable.onEndStream = {
|
||||||
scope.launch { applyRemoteStop() }
|
scope.launch { applyRemoteStop() }
|
||||||
}
|
}
|
||||||
|
container.sessionCable.onAudioMute = { muted ->
|
||||||
|
if (!muteInFlight) scope.launch { setAudioMuted(muted, remote = true) }
|
||||||
|
}
|
||||||
container.sessionCable.connect(sessionId, token, deviceRole = "camera")
|
container.sessionCable.connect(sessionId, token, deviceRole = "camera")
|
||||||
}
|
}
|
||||||
container.broadcastCoordinator.startService()
|
container.broadcastCoordinator.startService()
|
||||||
@@ -225,8 +265,10 @@ fun BroadcastScreen(
|
|||||||
val cfg = broadcastConfig(loaded)
|
val cfg = broadcastConfig(loaded)
|
||||||
thermalManager.updateBaseline(cfg)
|
thermalManager.updateBaseline(cfg)
|
||||||
container.broadcastCoordinator.engine.startBroadcast(cfg)
|
container.broadcastCoordinator.engine.startBroadcast(cfg)
|
||||||
|
container.broadcastCoordinator.setAudioMuted(loaded.audioMuted)
|
||||||
} else {
|
} else {
|
||||||
thermalManager.updateBaseline(broadcastConfig(loaded))
|
thermalManager.updateBaseline(broadcastConfig(loaded))
|
||||||
|
container.broadcastCoordinator.setAudioMuted(loaded.audioMuted)
|
||||||
}
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
error = it.message ?: context.getString(R.string.broadcast_error_session_unavailable)
|
error = it.message ?: context.getString(R.string.broadcast_error_session_unavailable)
|
||||||
@@ -364,6 +406,7 @@ fun BroadcastScreen(
|
|||||||
container.sessionCable.onPauseStream = null
|
container.sessionCable.onPauseStream = null
|
||||||
container.sessionCable.onResumeStream = null
|
container.sessionCable.onResumeStream = null
|
||||||
container.sessionCable.onEndStream = null
|
container.sessionCable.onEndStream = null
|
||||||
|
container.sessionCable.onAudioMute = null
|
||||||
container.sessionCable.disconnect()
|
container.sessionCable.disconnect()
|
||||||
container.broadcastCoordinator.stopService()
|
container.broadcastCoordinator.stopService()
|
||||||
container.broadcastCoordinator.engine.stopBroadcast()
|
container.broadcastCoordinator.engine.stopBroadcast()
|
||||||
@@ -371,6 +414,7 @@ fun BroadcastScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val isPaused = session?.isPaused == true || metrics.phase == BroadcastPhase.PAUSED
|
val isPaused = session?.isPaused == true || metrics.phase == BroadcastPhase.PAUSED
|
||||||
|
val audioMuted = session?.audioMuted == true || metrics.audioMuted
|
||||||
val statusText = when {
|
val statusText = when {
|
||||||
isPaused -> stringResource(R.string.broadcast_status_paused)
|
isPaused -> stringResource(R.string.broadcast_status_paused)
|
||||||
metrics.phase == BroadcastPhase.LIVE -> stringResource(R.string.broadcast_status_live)
|
metrics.phase == BroadcastPhase.LIVE -> stringResource(R.string.broadcast_status_live)
|
||||||
@@ -544,6 +588,10 @@ fun BroadcastScreen(
|
|||||||
if (isPaused) resumeStream() else pauseStream()
|
if (isPaused) resumeStream() else pauseStream()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onToggleAudioMute = {
|
||||||
|
scope.launch { setAudioMuted(!audioMuted, remote = false) }
|
||||||
|
},
|
||||||
|
audioMuted = audioMuted,
|
||||||
onTerminate = { scope.launch { stopStreamPermanently() } },
|
onTerminate = { scope.launch { stopStreamPermanently() } },
|
||||||
onShareLive = {
|
onShareLive = {
|
||||||
val url = shareUrl
|
val url = shareUrl
|
||||||
|
|||||||
@@ -140,6 +140,11 @@
|
|||||||
<string name="broadcast_snackbar_resumed_remote">Von der Regie fortgesetzt</string>
|
<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_resume_rtmp_error">RTMP-Fortsetzung: %1$s</string>
|
||||||
<string name="broadcast_snackbar_closed_remote">Livestream von der Regie beendet</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_rtmp_missing">RTMP-URL fehlt</string>
|
||||||
<string name="broadcast_error_session_unavailable">Sitzung nicht verfügbar</string>
|
<string name="broadcast_error_session_unavailable">Sitzung nicht verfügbar</string>
|
||||||
<string name="broadcast_status_paused">PAUSE</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_next_period_cd">Nächste Periode</string>
|
||||||
<string name="broadcast_resume_cd">Livestream fortsetzen</string>
|
<string name="broadcast_resume_cd">Livestream fortsetzen</string>
|
||||||
<string name="broadcast_pause_cd">Livestream pausieren</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_terminate_cd">Livestream beenden</string>
|
||||||
<string name="broadcast_team_home_label">HEIM</string>
|
<string name="broadcast_team_home_label">HEIM</string>
|
||||||
<string name="broadcast_team_away_label">GAST</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_resumed_remote">Resumed from control room</string>
|
||||||
<string name="broadcast_snackbar_resume_rtmp_error">RTMP resume: %1$s</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_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_rtmp_missing">Missing RTMP URL</string>
|
||||||
<string name="broadcast_error_session_unavailable">Session not available</string>
|
<string name="broadcast_error_session_unavailable">Session not available</string>
|
||||||
<string name="broadcast_status_paused">PAUSED</string>
|
<string name="broadcast_status_paused">PAUSED</string>
|
||||||
@@ -163,6 +168,9 @@
|
|||||||
<string name="broadcast_next_period_cd">Next period</string>
|
<string name="broadcast_next_period_cd">Next period</string>
|
||||||
<string name="broadcast_resume_cd">Resume live</string>
|
<string name="broadcast_resume_cd">Resume live</string>
|
||||||
<string name="broadcast_pause_cd">Pause 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_terminate_cd">End live</string>
|
||||||
<string name="broadcast_team_home_label">HOME</string>
|
<string name="broadcast_team_home_label">HOME</string>
|
||||||
<string name="broadcast_team_away_label">AWAY</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_resumed_remote">Reanudado desde regie</string>
|
||||||
<string name="broadcast_snackbar_resume_rtmp_error">Reanudación RTMP: %1$s</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_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_rtmp_missing">Falta la URL RTMP</string>
|
||||||
<string name="broadcast_error_session_unavailable">Sesión no disponible</string>
|
<string name="broadcast_error_session_unavailable">Sesión no disponible</string>
|
||||||
<string name="broadcast_status_paused">PAUSA</string>
|
<string name="broadcast_status_paused">PAUSA</string>
|
||||||
@@ -163,6 +168,9 @@
|
|||||||
<string name="broadcast_next_period_cd">Periodo siguiente</string>
|
<string name="broadcast_next_period_cd">Periodo siguiente</string>
|
||||||
<string name="broadcast_resume_cd">Reanudar directo</string>
|
<string name="broadcast_resume_cd">Reanudar directo</string>
|
||||||
<string name="broadcast_pause_cd">Pausar 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_terminate_cd">Terminar directo</string>
|
||||||
<string name="broadcast_team_home_label">LOCAL</string>
|
<string name="broadcast_team_home_label">LOCAL</string>
|
||||||
<string name="broadcast_team_away_label">VISITANTE</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_resumed_remote">Repris depuis la régie</string>
|
||||||
<string name="broadcast_snackbar_resume_rtmp_error">Reprise RTMP : %1$s</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_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_rtmp_missing">URL RTMP manquante</string>
|
||||||
<string name="broadcast_error_session_unavailable">Session non disponible</string>
|
<string name="broadcast_error_session_unavailable">Session non disponible</string>
|
||||||
<string name="broadcast_status_paused">PAUSE</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_next_period_cd">Période suivante</string>
|
||||||
<string name="broadcast_resume_cd">Reprendre le direct</string>
|
<string name="broadcast_resume_cd">Reprendre le direct</string>
|
||||||
<string name="broadcast_pause_cd">Mettre le direct en pause</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_terminate_cd">Terminer le direct</string>
|
||||||
<string name="broadcast_team_home_label">DOMICILE</string>
|
<string name="broadcast_team_home_label">DOMICILE</string>
|
||||||
<string name="broadcast_team_away_label">EXTÉRIEUR</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_resumed_remote">Ripresa dalla regia</string>
|
||||||
<string name="broadcast_snackbar_resume_rtmp_error">Ripresa RTMP: %1$s</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_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_rtmp_missing">URL RTMP mancante</string>
|
||||||
<string name="broadcast_error_session_unavailable">Sessione non disponibile</string>
|
<string name="broadcast_error_session_unavailable">Sessione non disponibile</string>
|
||||||
<string name="broadcast_status_paused">PAUSA</string>
|
<string name="broadcast_status_paused">PAUSA</string>
|
||||||
@@ -163,6 +168,9 @@
|
|||||||
<string name="broadcast_next_period_cd">Periodo successivo</string>
|
<string name="broadcast_next_period_cd">Periodo successivo</string>
|
||||||
<string name="broadcast_resume_cd">Riprendi diretta</string>
|
<string name="broadcast_resume_cd">Riprendi diretta</string>
|
||||||
<string name="broadcast_pause_cd">Pausa 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_terminate_cd">Termina diretta</string>
|
||||||
<string name="broadcast_team_home_label">CASA</string>
|
<string name="broadcast_team_home_label">CASA</string>
|
||||||
<string name="broadcast_team_away_label">OSPITE</string>
|
<string name="broadcast_team_away_label">OSPITE</string>
|
||||||
|
|||||||
+21
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,7 @@ enum L10n {
|
|||||||
"broadcast.error.rtmp.url.invalid": "URL RTMP non valido",
|
"broadcast.error.rtmp.url.invalid": "URL RTMP non valido",
|
||||||
"broadcast.error.session.unavailable": "Sessione non disponibile",
|
"broadcast.error.session.unavailable": "Sessione non disponibile",
|
||||||
"broadcast.hide.controls.cd": "Nascondi controlli",
|
"broadcast.hide.controls.cd": "Nascondi controlli",
|
||||||
|
"broadcast.mute.cd": "Silenzia audio",
|
||||||
"broadcast.next.period.cd": "Periodo successivo",
|
"broadcast.next.period.cd": "Periodo successivo",
|
||||||
"broadcast.pause.cd": "Pausa diretta",
|
"broadcast.pause.cd": "Pausa diretta",
|
||||||
"broadcast.period.label.timed": "%1$d° tempo",
|
"broadcast.period.label.timed": "%1$d° tempo",
|
||||||
@@ -140,6 +141,9 @@ enum L10n {
|
|||||||
"broadcast.side.away": "ospite",
|
"broadcast.side.away": "ospite",
|
||||||
"broadcast.side.home": "casa",
|
"broadcast.side.home": "casa",
|
||||||
"broadcast.snackbar.closed.remote": "Diretta chiusa dalla regia",
|
"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.pause.error": "Pausa: %1$@",
|
||||||
"broadcast.snackbar.paused": "Diretta in pausa",
|
"broadcast.snackbar.paused": "Diretta in pausa",
|
||||||
"broadcast.snackbar.paused.remote": "Pausa dalla regia",
|
"broadcast.snackbar.paused.remote": "Pausa dalla regia",
|
||||||
@@ -148,6 +152,8 @@ enum L10n {
|
|||||||
"broadcast.snackbar.resume.rtmp.error": "Ripresa RTMP: %1$@",
|
"broadcast.snackbar.resume.rtmp.error": "Ripresa RTMP: %1$@",
|
||||||
"broadcast.snackbar.resumed": "Diretta ripresa",
|
"broadcast.snackbar.resumed": "Diretta ripresa",
|
||||||
"broadcast.snackbar.resumed.remote": "Ripresa dalla regia",
|
"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.connecting": "CONNESSIONE…",
|
||||||
"broadcast.status.error.fallback": "ERRORE",
|
"broadcast.status.error.fallback": "ERRORE",
|
||||||
"broadcast.status.live": "IN DIRETTA",
|
"broadcast.status.live": "IN DIRETTA",
|
||||||
@@ -164,6 +170,8 @@ enum L10n {
|
|||||||
"broadcast.tooltip.add.point": "Aggiungi punto %1$@",
|
"broadcast.tooltip.add.point": "Aggiungi punto %1$@",
|
||||||
"broadcast.tooltip.plus.side": "+%1$d %2$@",
|
"broadcast.tooltip.plus.side": "+%1$d %2$@",
|
||||||
"broadcast.tooltip.remove.point": "Togli punto %1$@",
|
"broadcast.tooltip.remove.point": "Togli punto %1$@",
|
||||||
|
"broadcast.unmute.cd": "Riattiva audio",
|
||||||
|
"broadcast.audio.muted.label": "Audio off",
|
||||||
"common.close": "Chiudi",
|
"common.close": "Chiudi",
|
||||||
"common.error.generic": "errore",
|
"common.error.generic": "errore",
|
||||||
"common.error.title": "Errore",
|
"common.error.title": "Errore",
|
||||||
@@ -409,6 +417,7 @@ enum L10n {
|
|||||||
"broadcast.error.rtmp.url.invalid": "Invalid RTMP URL",
|
"broadcast.error.rtmp.url.invalid": "Invalid RTMP URL",
|
||||||
"broadcast.error.session.unavailable": "Session not available",
|
"broadcast.error.session.unavailable": "Session not available",
|
||||||
"broadcast.hide.controls.cd": "Hide controls",
|
"broadcast.hide.controls.cd": "Hide controls",
|
||||||
|
"broadcast.mute.cd": "Mute audio",
|
||||||
"broadcast.next.period.cd": "Next period",
|
"broadcast.next.period.cd": "Next period",
|
||||||
"broadcast.pause.cd": "Pause live",
|
"broadcast.pause.cd": "Pause live",
|
||||||
"broadcast.period.label.timed": "Period %1$d",
|
"broadcast.period.label.timed": "Period %1$d",
|
||||||
@@ -430,6 +439,9 @@ enum L10n {
|
|||||||
"broadcast.side.away": "away",
|
"broadcast.side.away": "away",
|
||||||
"broadcast.side.home": "home",
|
"broadcast.side.home": "home",
|
||||||
"broadcast.snackbar.closed.remote": "Live closed from control room",
|
"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.pause.error": "Pause: %1$@",
|
||||||
"broadcast.snackbar.paused": "Live paused",
|
"broadcast.snackbar.paused": "Live paused",
|
||||||
"broadcast.snackbar.paused.remote": "Paused from control room",
|
"broadcast.snackbar.paused.remote": "Paused from control room",
|
||||||
@@ -438,6 +450,8 @@ enum L10n {
|
|||||||
"broadcast.snackbar.resume.rtmp.error": "RTMP resume: %1$@",
|
"broadcast.snackbar.resume.rtmp.error": "RTMP resume: %1$@",
|
||||||
"broadcast.snackbar.resumed": "Live resumed",
|
"broadcast.snackbar.resumed": "Live resumed",
|
||||||
"broadcast.snackbar.resumed.remote": "Resumed from control room",
|
"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.connecting": "CONNECTING…",
|
||||||
"broadcast.status.error.fallback": "ERROR",
|
"broadcast.status.error.fallback": "ERROR",
|
||||||
"broadcast.status.live": "LIVE",
|
"broadcast.status.live": "LIVE",
|
||||||
@@ -454,6 +468,8 @@ enum L10n {
|
|||||||
"broadcast.tooltip.add.point": "Add point %1$@",
|
"broadcast.tooltip.add.point": "Add point %1$@",
|
||||||
"broadcast.tooltip.plus.side": "+%1$d %2$@",
|
"broadcast.tooltip.plus.side": "+%1$d %2$@",
|
||||||
"broadcast.tooltip.remove.point": "Remove point %1$@",
|
"broadcast.tooltip.remove.point": "Remove point %1$@",
|
||||||
|
"broadcast.unmute.cd": "Unmute audio",
|
||||||
|
"broadcast.audio.muted.label": "Audio off",
|
||||||
"common.close": "Close",
|
"common.close": "Close",
|
||||||
"common.error.generic": "error",
|
"common.error.generic": "error",
|
||||||
"common.error.title": "Error",
|
"common.error.title": "Error",
|
||||||
@@ -699,6 +715,7 @@ enum L10n {
|
|||||||
"broadcast.error.rtmp.url.invalid": "URL RTMP non valide",
|
"broadcast.error.rtmp.url.invalid": "URL RTMP non valide",
|
||||||
"broadcast.error.session.unavailable": "Session non disponible",
|
"broadcast.error.session.unavailable": "Session non disponible",
|
||||||
"broadcast.hide.controls.cd": "Masquer les commandes",
|
"broadcast.hide.controls.cd": "Masquer les commandes",
|
||||||
|
"broadcast.mute.cd": "Couper l'audio",
|
||||||
"broadcast.next.period.cd": "Période suivante",
|
"broadcast.next.period.cd": "Période suivante",
|
||||||
"broadcast.pause.cd": "Mettre le direct en pause",
|
"broadcast.pause.cd": "Mettre le direct en pause",
|
||||||
"broadcast.period.label.timed": "Période %1$d",
|
"broadcast.period.label.timed": "Période %1$d",
|
||||||
@@ -720,6 +737,9 @@ enum L10n {
|
|||||||
"broadcast.side.away": "extérieur",
|
"broadcast.side.away": "extérieur",
|
||||||
"broadcast.side.home": "domicile",
|
"broadcast.side.home": "domicile",
|
||||||
"broadcast.snackbar.closed.remote": "Direct fermé depuis la régie",
|
"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.pause.error": "Pause : %1$@",
|
||||||
"broadcast.snackbar.paused": "Direct en pause",
|
"broadcast.snackbar.paused": "Direct en pause",
|
||||||
"broadcast.snackbar.paused.remote": "Mis en pause depuis la régie",
|
"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.resume.rtmp.error": "Reprise RTMP : %1$@",
|
||||||
"broadcast.snackbar.resumed": "Direct repris",
|
"broadcast.snackbar.resumed": "Direct repris",
|
||||||
"broadcast.snackbar.resumed.remote": "Repris depuis la régie",
|
"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.connecting": "CONNEXION…",
|
||||||
"broadcast.status.error.fallback": "ERREUR",
|
"broadcast.status.error.fallback": "ERREUR",
|
||||||
"broadcast.status.live": "EN DIRECT",
|
"broadcast.status.live": "EN DIRECT",
|
||||||
@@ -744,6 +766,8 @@ enum L10n {
|
|||||||
"broadcast.tooltip.add.point": "Ajouter un point %1$@",
|
"broadcast.tooltip.add.point": "Ajouter un point %1$@",
|
||||||
"broadcast.tooltip.plus.side": "+%1$d %2$@",
|
"broadcast.tooltip.plus.side": "+%1$d %2$@",
|
||||||
"broadcast.tooltip.remove.point": "Retirer un point %1$@",
|
"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.close": "Fermer",
|
||||||
"common.error.generic": "erreur",
|
"common.error.generic": "erreur",
|
||||||
"common.error.title": "Erreur",
|
"common.error.title": "Erreur",
|
||||||
@@ -989,6 +1013,7 @@ enum L10n {
|
|||||||
"broadcast.error.rtmp.url.invalid": "Ungültige RTMP-URL",
|
"broadcast.error.rtmp.url.invalid": "Ungültige RTMP-URL",
|
||||||
"broadcast.error.session.unavailable": "Sitzung nicht verfügbar",
|
"broadcast.error.session.unavailable": "Sitzung nicht verfügbar",
|
||||||
"broadcast.hide.controls.cd": "Steuerung ausblenden",
|
"broadcast.hide.controls.cd": "Steuerung ausblenden",
|
||||||
|
"broadcast.mute.cd": "Audio stummschalten",
|
||||||
"broadcast.next.period.cd": "Nächste Periode",
|
"broadcast.next.period.cd": "Nächste Periode",
|
||||||
"broadcast.pause.cd": "Livestream pausieren",
|
"broadcast.pause.cd": "Livestream pausieren",
|
||||||
"broadcast.period.label.timed": "Halbzeit %1$d",
|
"broadcast.period.label.timed": "Halbzeit %1$d",
|
||||||
@@ -1010,6 +1035,9 @@ enum L10n {
|
|||||||
"broadcast.side.away": "Gast",
|
"broadcast.side.away": "Gast",
|
||||||
"broadcast.side.home": "Heim",
|
"broadcast.side.home": "Heim",
|
||||||
"broadcast.snackbar.closed.remote": "Livestream von der Regie beendet",
|
"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.pause.error": "Pause: %1$@",
|
||||||
"broadcast.snackbar.paused": "Livestream pausiert",
|
"broadcast.snackbar.paused": "Livestream pausiert",
|
||||||
"broadcast.snackbar.paused.remote": "Von der Regie 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.resume.rtmp.error": "RTMP-Fortsetzung: %1$@",
|
||||||
"broadcast.snackbar.resumed": "Livestream fortgesetzt",
|
"broadcast.snackbar.resumed": "Livestream fortgesetzt",
|
||||||
"broadcast.snackbar.resumed.remote": "Von der Regie 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.connecting": "VERBINDUNG…",
|
||||||
"broadcast.status.error.fallback": "FEHLER",
|
"broadcast.status.error.fallback": "FEHLER",
|
||||||
"broadcast.status.live": "LIVE",
|
"broadcast.status.live": "LIVE",
|
||||||
@@ -1034,6 +1064,8 @@ enum L10n {
|
|||||||
"broadcast.tooltip.add.point": "Punkt hinzufügen %1$@",
|
"broadcast.tooltip.add.point": "Punkt hinzufügen %1$@",
|
||||||
"broadcast.tooltip.plus.side": "+%1$d %2$@",
|
"broadcast.tooltip.plus.side": "+%1$d %2$@",
|
||||||
"broadcast.tooltip.remove.point": "Punkt entfernen %1$@",
|
"broadcast.tooltip.remove.point": "Punkt entfernen %1$@",
|
||||||
|
"broadcast.unmute.cd": "Audio einschalten",
|
||||||
|
"broadcast.audio.muted.label": "Audio aus",
|
||||||
"common.close": "Schließen",
|
"common.close": "Schließen",
|
||||||
"common.error.generic": "Fehler",
|
"common.error.generic": "Fehler",
|
||||||
"common.error.title": "Fehler",
|
"common.error.title": "Fehler",
|
||||||
@@ -1279,6 +1311,7 @@ enum L10n {
|
|||||||
"broadcast.error.rtmp.url.invalid": "URL RTMP no válida",
|
"broadcast.error.rtmp.url.invalid": "URL RTMP no válida",
|
||||||
"broadcast.error.session.unavailable": "Sesión no disponible",
|
"broadcast.error.session.unavailable": "Sesión no disponible",
|
||||||
"broadcast.hide.controls.cd": "Ocultar controles",
|
"broadcast.hide.controls.cd": "Ocultar controles",
|
||||||
|
"broadcast.mute.cd": "Silenciar audio",
|
||||||
"broadcast.next.period.cd": "Periodo siguiente",
|
"broadcast.next.period.cd": "Periodo siguiente",
|
||||||
"broadcast.pause.cd": "Pausar directo",
|
"broadcast.pause.cd": "Pausar directo",
|
||||||
"broadcast.period.label.timed": "Tiempo %1$d",
|
"broadcast.period.label.timed": "Tiempo %1$d",
|
||||||
@@ -1300,6 +1333,9 @@ enum L10n {
|
|||||||
"broadcast.side.away": "visitante",
|
"broadcast.side.away": "visitante",
|
||||||
"broadcast.side.home": "local",
|
"broadcast.side.home": "local",
|
||||||
"broadcast.snackbar.closed.remote": "Directo cerrado desde regie",
|
"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.pause.error": "Pausa: %1$@",
|
||||||
"broadcast.snackbar.paused": "Directo en pausa",
|
"broadcast.snackbar.paused": "Directo en pausa",
|
||||||
"broadcast.snackbar.paused.remote": "Pausado desde regie",
|
"broadcast.snackbar.paused.remote": "Pausado desde regie",
|
||||||
@@ -1308,6 +1344,8 @@ enum L10n {
|
|||||||
"broadcast.snackbar.resume.rtmp.error": "Reanudación RTMP: %1$@",
|
"broadcast.snackbar.resume.rtmp.error": "Reanudación RTMP: %1$@",
|
||||||
"broadcast.snackbar.resumed": "Directo reanudado",
|
"broadcast.snackbar.resumed": "Directo reanudado",
|
||||||
"broadcast.snackbar.resumed.remote": "Reanudado desde regie",
|
"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.connecting": "CONECTANDO…",
|
||||||
"broadcast.status.error.fallback": "ERROR",
|
"broadcast.status.error.fallback": "ERROR",
|
||||||
"broadcast.status.live": "EN DIRECTO",
|
"broadcast.status.live": "EN DIRECTO",
|
||||||
@@ -1324,6 +1362,8 @@ enum L10n {
|
|||||||
"broadcast.tooltip.add.point": "Añadir punto %1$@",
|
"broadcast.tooltip.add.point": "Añadir punto %1$@",
|
||||||
"broadcast.tooltip.plus.side": "+%1$d %2$@",
|
"broadcast.tooltip.plus.side": "+%1$d %2$@",
|
||||||
"broadcast.tooltip.remove.point": "Quitar punto %1$@",
|
"broadcast.tooltip.remove.point": "Quitar punto %1$@",
|
||||||
|
"broadcast.unmute.cd": "Reactivar audio",
|
||||||
|
"broadcast.audio.muted.label": "Audio off",
|
||||||
"common.close": "Cerrar",
|
"common.close": "Cerrar",
|
||||||
"common.error.generic": "error",
|
"common.error.generic": "error",
|
||||||
"common.error.title": "Error",
|
"common.error.title": "Error",
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ struct StreamSessionDto: Decodable {
|
|||||||
let targetFps: Int?
|
let targetFps: Int?
|
||||||
let qualityPreset: String?
|
let qualityPreset: String?
|
||||||
let startedAt: String?
|
let startedAt: String?
|
||||||
|
let audioMuted: Bool?
|
||||||
let score: ScoreStateDto?
|
let score: ScoreStateDto?
|
||||||
|
|
||||||
func toDomain() -> StreamSession {
|
func toDomain() -> StreamSession {
|
||||||
@@ -222,7 +223,8 @@ struct StreamSessionDto: Decodable {
|
|||||||
targetFps: targetFps ?? 30,
|
targetFps: targetFps ?? 30,
|
||||||
qualityPreset: qualityPreset ?? "720p_30_2.5mbps",
|
qualityPreset: qualityPreset ?? "720p_30_2.5mbps",
|
||||||
startedAt: startedAt,
|
startedAt: startedAt,
|
||||||
score: score?.toDomain()
|
score: score?.toDomain(),
|
||||||
|
audioMuted: audioMuted ?? false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -414,6 +416,10 @@ struct CreateSessionRequest: Encodable {
|
|||||||
let youtubeChannel: String?
|
let youtubeChannel: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AudioMuteRequest: Encodable {
|
||||||
|
let muted: Bool
|
||||||
|
}
|
||||||
|
|
||||||
struct ScoringRulesBody: Codable {
|
struct ScoringRulesBody: Codable {
|
||||||
let setsToWin: Int?
|
let setsToWin: Int?
|
||||||
let pointsPerSet: Int?
|
let pointsPerSet: Int?
|
||||||
|
|||||||
@@ -186,6 +186,10 @@ final class MatchLiveAPI: @unchecked Sendable {
|
|||||||
try await patch("sessions/\(id)/resume", body: EmptyBody())
|
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 {
|
func networkTest(sessionId: String, body: NetworkTestRequest) async throws -> NetworkTestResponse {
|
||||||
try await post("sessions/\(sessionId)/network_test", body: body)
|
try await post("sessions/\(sessionId)/network_test", body: body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ final class SessionCableService: ObservableObject {
|
|||||||
var onPauseStream: (() -> Void)?
|
var onPauseStream: (() -> Void)?
|
||||||
var onResumeStream: (() -> Void)?
|
var onResumeStream: (() -> Void)?
|
||||||
var onStopStream: (() -> Void)?
|
var onStopStream: (() -> Void)?
|
||||||
|
var onAudioMute: ((Bool) -> Void)?
|
||||||
|
|
||||||
private var client: ActionCableClient?
|
private var client: ActionCableClient?
|
||||||
private var intentionalDisconnect = false
|
private var intentionalDisconnect = false
|
||||||
@@ -89,6 +90,9 @@ extension SessionCableService: ActionCableClient.Listener {
|
|||||||
onScoreUpdate?(ScoreState.fromCablePayload(payload))
|
onScoreUpdate?(ScoreState.fromCablePayload(payload))
|
||||||
case "command":
|
case "command":
|
||||||
let action = payload["action"] as? String ?? ""
|
let action = payload["action"] as? String ?? ""
|
||||||
|
if let muted = AudioMuteCommandLogic.muted(type: "command", action: action, event: nil, muted: payload["muted"]) {
|
||||||
|
onAudioMute?(muted)
|
||||||
|
}
|
||||||
switch action {
|
switch action {
|
||||||
case "pause_stream": onPauseStream?()
|
case "pause_stream": onPauseStream?()
|
||||||
case "resume_stream": onResumeStream?()
|
case "resume_stream": onResumeStream?()
|
||||||
@@ -97,6 +101,9 @@ extension SessionCableService: ActionCableClient.Listener {
|
|||||||
}
|
}
|
||||||
case "stream_event":
|
case "stream_event":
|
||||||
let event = payload["event"] as? String ?? ""
|
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 {
|
switch event {
|
||||||
case "paused": onPauseStream?()
|
case "paused": onPauseStream?()
|
||||||
case "resumed": onResumeStream?()
|
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()
|
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(
|
func submitNetworkTest(
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
downloadMbps: Double,
|
downloadMbps: Double,
|
||||||
|
|||||||
@@ -121,10 +121,33 @@ struct StreamSession: Identifiable, Equatable, Sendable {
|
|||||||
let qualityPreset: String
|
let qualityPreset: String
|
||||||
let startedAt: String?
|
let startedAt: String?
|
||||||
let score: ScoreState?
|
let score: ScoreState?
|
||||||
|
let audioMuted: Bool
|
||||||
|
|
||||||
var isLive: Bool { status == "live" || status == "reconnecting" }
|
var isLive: Bool { status == "live" || status == "reconnecting" }
|
||||||
var isPaused: Bool { status == "paused" }
|
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? {
|
func watchShareUrl() -> String? {
|
||||||
if platform == "youtube", let url = youtubeWatchUrl, !url.isEmpty { return url }
|
if platform == "youtube", let url = youtubeWatchUrl, !url.isEmpty { return url }
|
||||||
if let url = shareUrl, !url.isEmpty { return url }
|
if let url = shareUrl, !url.isEmpty { return url }
|
||||||
|
|||||||
@@ -28,4 +28,5 @@ struct BroadcastMetrics: Sendable {
|
|||||||
var fps: Int = 0
|
var fps: Int = 0
|
||||||
var connected: Bool = false
|
var connected: Bool = false
|
||||||
var lastError: String?
|
var lastError: String?
|
||||||
|
var audioMuted: Bool = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ final class LiveBroadcastCoordinator: ObservableObject {
|
|||||||
engine.updateOverlay(state)
|
engine.updateOverlay(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setAudioMuted(_ muted: Bool) async {
|
||||||
|
await engine.setAudioMuted(muted)
|
||||||
|
}
|
||||||
|
|
||||||
func configureBackgroundAudio() {
|
func configureBackgroundAudio() {
|
||||||
let session = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
try? session.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
|
try? session.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
|
private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
|
||||||
private var orientationDebounceTask: Task<Void, Never>?
|
private var orientationDebounceTask: Task<Void, Never>?
|
||||||
private var rtmpSession: (any Session)?
|
private var rtmpSession: (any Session)?
|
||||||
|
private var audioMuted = false
|
||||||
private static var rtmpFactoryRegistered = false
|
private static var rtmpFactoryRegistered = false
|
||||||
|
|
||||||
private static func ensureRTMPFactoryRegistered() async {
|
private static func ensureRTMPFactoryRegistered() async {
|
||||||
@@ -173,12 +174,21 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
overlayRenderer.detach()
|
overlayRenderer.detach()
|
||||||
try? await mixer.stopRunning()
|
try? await mixer.stopRunning()
|
||||||
pipelineConfigured = false
|
pipelineConfigured = false
|
||||||
|
audioMuted = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func release() async {
|
func release() async {
|
||||||
await stopBroadcast()
|
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.
|
/// Adattamento termico: aggiorna profilo video senza interrompere la sessione RTMP attiva.
|
||||||
func applyThermalProfile(videoBitrate: Int, fps: Int) async {
|
func applyThermalProfile(videoBitrate: Int, fps: Int) async {
|
||||||
guard var current = config else { return }
|
guard var current = config else { return }
|
||||||
@@ -219,7 +229,8 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
AudioMixerSettings(
|
AudioMixerSettings(
|
||||||
sampleRate: BroadcastConfig.audioSampleRateHz,
|
sampleRate: BroadcastConfig.audioSampleRateHz,
|
||||||
channels: BroadcastConfig.audioChannels,
|
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) {
|
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
|
||||||
@@ -324,7 +335,8 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
bitrateKbps: kbps,
|
bitrateKbps: kbps,
|
||||||
fps: self.config?.fps ?? self.metrics.fps,
|
fps: self.config?.fps ?? self.metrics.fps,
|
||||||
connected: self.phase == .live,
|
connected: self.phase == .live,
|
||||||
lastError: self.metrics.lastError
|
lastError: self.metrics.lastError,
|
||||||
|
audioMuted: self.audioMuted
|
||||||
)
|
)
|
||||||
self.metricsListener?(self.metrics)
|
self.metricsListener?(self.metrics)
|
||||||
}
|
}
|
||||||
@@ -496,6 +508,16 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
UIDevice.current.endGeneratingDeviceOrientationNotifications()
|
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) {
|
private func setPhase(_ newPhase: BroadcastPhase, error: String? = nil) {
|
||||||
phase = newPhase
|
phase = newPhase
|
||||||
metrics = BroadcastMetrics(
|
metrics = BroadcastMetrics(
|
||||||
@@ -503,7 +525,8 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
bitrateKbps: metrics.bitrateKbps,
|
bitrateKbps: metrics.bitrateKbps,
|
||||||
fps: config?.fps ?? metrics.fps,
|
fps: config?.fps ?? metrics.fps,
|
||||||
connected: newPhase == .live,
|
connected: newPhase == .live,
|
||||||
lastError: error
|
lastError: error,
|
||||||
|
audioMuted: audioMuted
|
||||||
)
|
)
|
||||||
metricsListener?(metrics)
|
metricsListener?(metrics)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ struct BroadcastControlsOverlay: View {
|
|||||||
var onCloseSet: (() -> Void)?
|
var onCloseSet: (() -> Void)?
|
||||||
var onAdvancePeriod: (() -> Void)?
|
var onAdvancePeriod: (() -> Void)?
|
||||||
let onPauseOrResume: () -> Void
|
let onPauseOrResume: () -> Void
|
||||||
|
let onToggleAudioMute: () -> Void
|
||||||
|
let audioMuted: Bool
|
||||||
let onTerminate: () -> Void
|
let onTerminate: () -> Void
|
||||||
let onShareLive: () -> Void
|
let onShareLive: () -> Void
|
||||||
let onShareRegia: () -> Void
|
let onShareRegia: () -> Void
|
||||||
@@ -69,7 +71,8 @@ struct BroadcastControlsOverlay: View {
|
|||||||
targetFps: targetFps,
|
targetFps: targetFps,
|
||||||
bitrateKbps: bitrateKbps,
|
bitrateKbps: bitrateKbps,
|
||||||
networkType: networkType,
|
networkType: networkType,
|
||||||
deviceHealth: deviceHealth
|
deviceHealth: deviceHealth,
|
||||||
|
audioMuted: audioMuted
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(.trailing, 8)
|
.padding(.trailing, 8)
|
||||||
@@ -142,6 +145,12 @@ struct BroadcastControlsOverlay: View {
|
|||||||
action: onPauseOrResume,
|
action: onPauseOrResume,
|
||||||
highlighted: isPaused
|
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(
|
SideIconButton(
|
||||||
systemName: "stop.fill",
|
systemName: "stop.fill",
|
||||||
accessibilityLabel: L10n.t("broadcast.terminate.cd"),
|
accessibilityLabel: L10n.t("broadcast.terminate.cd"),
|
||||||
@@ -195,6 +204,7 @@ private struct BroadcastTelemetryPanel: View {
|
|||||||
let bitrateKbps: Int
|
let bitrateKbps: Int
|
||||||
let networkType: String
|
let networkType: String
|
||||||
let deviceHealth: DeviceHealth
|
let deviceHealth: DeviceHealth
|
||||||
|
let audioMuted: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
@@ -214,6 +224,11 @@ private struct BroadcastTelemetryPanel: View {
|
|||||||
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
|
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 11, weight: .medium))
|
||||||
.foregroundStyle(MatchColors.textSecondary)
|
.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)
|
ThermalIndicator(state: deviceHealth.thermalState)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ struct BroadcastScreen: View {
|
|||||||
@State private var logoReady = 0
|
@State private var logoReady = 0
|
||||||
@State private var pauseInFlight = false
|
@State private var pauseInFlight = false
|
||||||
@State private var resumeInFlight = false
|
@State private var resumeInFlight = false
|
||||||
|
@State private var muteInFlight = false
|
||||||
@State private var deviceHealth = DeviceTelemetry.snapshot()
|
@State private var deviceHealth = DeviceTelemetry.snapshot()
|
||||||
@State private var snackbarMessage: String?
|
@State private var snackbarMessage: String?
|
||||||
@State private var shareItem: ShareItem?
|
@State private var shareItem: ShareItem?
|
||||||
@@ -188,6 +189,7 @@ struct BroadcastScreen: View {
|
|||||||
let metrics = broadcastCoordinator.metrics
|
let metrics = broadcastCoordinator.metrics
|
||||||
let score = scoreController.score
|
let score = scoreController.score
|
||||||
let isPaused = session.isPaused || metrics.phase == .paused
|
let isPaused = session.isPaused || metrics.phase == .paused
|
||||||
|
let audioMuted = session.audioMuted || metrics.audioMuted
|
||||||
let statusText = broadcastStatusText(isPaused: isPaused, metrics: metrics)
|
let statusText = broadcastStatusText(isPaused: isPaused, metrics: metrics)
|
||||||
let statusColor = broadcastStatusColor(isPaused: isPaused, metrics: metrics)
|
let statusColor = broadcastStatusColor(isPaused: isPaused, metrics: metrics)
|
||||||
let boardType = match.boardType.isEmpty ? score.boardType : match.boardType
|
let boardType = match.boardType.isEmpty ? score.boardType : match.boardType
|
||||||
@@ -282,6 +284,10 @@ struct BroadcastScreen: View {
|
|||||||
pauseStream()
|
pauseStream()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onToggleAudioMute: {
|
||||||
|
Task { await setAudioMuted(!audioMuted, remote: false) }
|
||||||
|
},
|
||||||
|
audioMuted: audioMuted,
|
||||||
onTerminate: { Task { await stopStream() } },
|
onTerminate: { Task { await stopStream() } },
|
||||||
onShareLive: { shareLiveLink(session: session, subject: shareSubject) },
|
onShareLive: { shareLiveLink(session: session, subject: shareSubject) },
|
||||||
onShareRegia: { shareRegiaLink(subject: shareSubject) },
|
onShareRegia: { shareRegiaLink(subject: shareSubject) },
|
||||||
@@ -384,6 +390,7 @@ struct BroadcastScreen: View {
|
|||||||
config: config,
|
config: config,
|
||||||
paused: loaded.isPaused
|
paused: loaded.isPaused
|
||||||
)
|
)
|
||||||
|
await container.broadcastCoordinator.setAudioMuted(loaded.audioMuted)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
guard generation == bootstrapGeneration else { return }
|
guard generation == bootstrapGeneration else { return }
|
||||||
@@ -418,6 +425,12 @@ struct BroadcastScreen: View {
|
|||||||
container.sessionCable.onStopStream = { [weak container] in
|
container.sessionCable.onStopStream = { [weak container] in
|
||||||
Task { @MainActor in await stopStream() }
|
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)
|
container.sessionCable.connect(sessionId: sessionId, accessToken: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,6 +511,7 @@ struct BroadcastScreen: View {
|
|||||||
try await container.broadcastCoordinator.resumeBroadcast(config: config)
|
try await container.broadcastCoordinator.resumeBroadcast(config: config)
|
||||||
updated = try await container.sessionRepository.fetchSession(id: sessionId)
|
updated = try await container.sessionRepository.fetchSession(id: sessionId)
|
||||||
session = updated
|
session = updated
|
||||||
|
await container.broadcastCoordinator.setAudioMuted(updated.audioMuted)
|
||||||
snackbarMessage = L10n.t("broadcast.snackbar.resumed")
|
snackbarMessage = L10n.t("broadcast.snackbar.resumed")
|
||||||
} catch {
|
} catch {
|
||||||
snackbarMessage = L10n.t("broadcast.snackbar.resume.error", UserFacingError.message(for: error) ?? L10n.t("common.error.generic"))
|
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")
|
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.
|
/// Ripresa dalla regia / echo cable: solo RTMP locale, senza PATCH resume.
|
||||||
private func applyRemoteResume(container: AppContainer) async {
|
private func applyRemoteResume(container: AppContainer) async {
|
||||||
guard !resumeInFlight else { return }
|
guard !resumeInFlight else { return }
|
||||||
@@ -528,7 +567,8 @@ struct BroadcastScreen: View {
|
|||||||
let config = broadcastConfig(for: current, rtmpUrl: url)
|
let config = broadcastConfig(for: current, rtmpUrl: url)
|
||||||
thermalManager.updateBaseline(config)
|
thermalManager.updateBaseline(config)
|
||||||
try await container.broadcastCoordinator.resumeBroadcast(config: 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 {
|
} catch {
|
||||||
snackbarMessage = L10n.t("broadcast.snackbar.resume.rtmp.error", UserFacingError.message(for: error) ?? L10n.t("common.error.generic"))
|
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.onPauseStream = nil
|
||||||
container.sessionCable.onResumeStream = nil
|
container.sessionCable.onResumeStream = nil
|
||||||
container.sessionCable.onStopStream = nil
|
container.sessionCable.onStopStream = nil
|
||||||
|
container.sessionCable.onAudioMute = nil
|
||||||
container.sessionCable.disconnect()
|
container.sessionCable.disconnect()
|
||||||
await container.broadcastCoordinator.stopBroadcast(sessionId: sessionId)
|
await container.broadcastCoordinator.stopBroadcast(sessionId: sessionId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,12 @@ final class LiveBroadcastCoordinatorTests: XCTestCase {
|
|||||||
func testShouldStopPreviousSessionTrueWhenSwitching() {
|
func testShouldStopPreviousSessionTrueWhenSwitching() {
|
||||||
XCTAssertTrue(LiveBroadcastCoordinator.shouldStopPreviousSession(active: "session-a", incoming: "session-b"))
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user