Compare commits

..
79 changed files with 929 additions and 2725 deletions
+4 -2
View File
@@ -55,8 +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 "set_min_quality" when "mute_audio"
Sessions::SetMinQuality.new(session, preset: data["min_quality_preset"]).call 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)
@@ -68,7 +74,6 @@ module Api
current_bitrate: params[:current_bitrate], current_bitrate: params[:current_bitrate],
target_bitrate: params[:target_bitrate], target_bitrate: params[:target_bitrate],
fps: params[:fps], fps: params[:fps],
thermal_state: sanitized_thermal_state,
last_seen_at: Time.current last_seen_at: Time.current
) )
sync_publisher_when_streaming!(params[:fps].to_f) sync_publisher_when_streaming!(params[:fps].to_f)
@@ -142,13 +147,6 @@ module Api
} }
end end
def min_quality
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
render json: session_json(@session)
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def youtube_stats def youtube_stats
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present? count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
Youtube::BroadcastService.new(@session.match.team) Youtube::BroadcastService.new(@session.match.team)
@@ -168,11 +166,6 @@ module Api
.find(params[:id]) .find(params[:id])
end end
def sanitized_thermal_state
value = params[:thermal_state].to_s.downcase.presence
value if value.in?(%w[nominal fair serious critical])
end
def session_params def session_params
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel) params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
end end
@@ -200,10 +193,10 @@ module Api
youtube_ready: session.youtube_ready?, youtube_ready: session.youtube_ready?,
privacy_status: session.privacy_status, privacy_status: session.privacy_status,
quality_preset: session.quality_preset, quality_preset: session.quality_preset,
min_quality_preset: session.min_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 min_quality] 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,20 +61,14 @@ module Public
render json: status_payload render json: status_payload
end end
def min_quality def audio_mute
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call muted = params.key?(:muted) ? params[:muted] : !@session.audio_muted
Sessions::SetAudioMute.new(@session, muted: muted).call
render json: status_payload render json: status_payload
rescue ArgumentError => e
render json: status_payload.merge(error: e.message), status: :unprocessable_entity
end end
private private
def camera_telemetry_payload(publisher_online)
cam = @session.device_states.find_by(device_role: "camera")
(cam&.as_regia_payload || {}).merge(publisher_online: publisher_online)
end
def set_session_from_token def set_session_from_token
@session = Sessions::RegiaAccess.find_session_by_token(params[:token]) @session = Sessions::RegiaAccess.find_session_by_token(params[:token])
return if @session return if @session
@@ -90,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])),
@@ -98,10 +93,7 @@ module Public
home_name: @session.match.team.name, home_name: @session.match.team.name,
away_name: @session.match.opponent_name, away_name: @session.match.opponent_name,
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1), points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
score: score&.as_cable_payload, score: score&.as_cable_payload
min_quality_preset: @session.min_quality_preset,
min_quality_options: Sessions::SelectQuality.allowed_min_ids(@session.quality_preset),
telemetry: camera_telemetry_payload(publisher_online)
} }
end end
end end
+6 -20
View File
@@ -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"),
@@ -54,34 +57,17 @@ module Public
copyPrompt: t("regia.js.copy_prompt"), copyPrompt: t("regia.js.copy_prompt"),
shareRegiaText: t("regia.js.share_regia_text"), shareRegiaText: t("regia.js.share_regia_text"),
shareLiveText: t("regia.js.share_live_text"), shareLiveText: t("regia.js.share_live_text"),
minQualityUpdated: t("regia.js.min_quality_updated"),
minQualityError: t("regia.js.min_quality_error"),
minQualityAuto: t("regia.min_quality.auto"),
previewWaiting: t("regia.preview_waiting"), previewWaiting: t("regia.preview_waiting"),
streamEnded: t("regia.preview_ended"), streamEnded: t("regia.preview_ended"),
previewHide: t("regia.preview_hide"),
previewShow: t("regia.preview_show"),
previewOffHint: t("regia.preview_off_hint"),
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"),
waitingBadge: t("regia.status.waiting"), waitingBadge: t("regia.status.waiting"),
subtitlePrefix: t("regia.subtitle_prefix"), subtitlePrefix: t("regia.subtitle_prefix")
telemetryWaiting: t("regia.telemetry.waiting"),
telemetryStale: t("regia.telemetry.stale"),
telemetryJustNow: t("regia.telemetry.just_now"),
telemetrySecondsAgo: t("regia.telemetry.seconds_ago"),
telemetryPublisherOn: t("regia.telemetry.publisher_on"),
telemetryPublisherOff: t("regia.telemetry.publisher_off"),
telemetryThermalNominal: t("regia.telemetry.thermal_nominal"),
telemetryThermalFair: t("regia.telemetry.thermal_fair"),
telemetryThermalSerious: t("regia.telemetry.thermal_serious"),
telemetryThermalCritical: t("regia.telemetry.thermal_critical"),
telemetryBitrateMax: t("regia.telemetry.bitrate_max"),
telemetryBitrateTitle: t("regia.telemetry.bitrate_title"),
telemetryBitrateTap: t("regia.telemetry.bitrate_tap")
}.to_json }.to_json
end end
-13
View File
@@ -16,17 +16,4 @@ class DeviceState < ApplicationRecord
status: stream_session.status status: stream_session.status
} }
end end
def as_regia_payload
{
battery: battery_level,
network: network_type,
signal: signal_strength,
bitrate: current_bitrate,
target_bitrate: target_bitrate,
fps: fps,
thermal_state: thermal_state,
last_seen_at: last_seen_at&.iso8601
}
end
end end
+1 -1
View File
@@ -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
-1
View File
@@ -14,7 +14,6 @@ class StreamSession < ApplicationRecord
validates :platform, inclusion: { in: PLATFORMS } validates :platform, inclusion: { in: PLATFORMS }
validates :status, inclusion: { in: STATUSES } validates :status, inclusion: { in: STATUSES }
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES } validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
validates :min_quality_preset, inclusion: { in: Sessions::SelectQuality::MIN_QUALITY_IDS }
before_validation :normalize_privacy_status before_validation :normalize_privacy_status
before_validation :ensure_publish_token, on: :create before_validation :ensure_publish_token, on: :create
+3 -4
View File
@@ -9,12 +9,11 @@ module Sessions
@session.pause! if @session.may_pause? @session.pause! if @session.may_pause?
Mediamtx::PublisherSync.forget_recording_state!(@session.id) Mediamtx::PublisherSync.forget_recording_state!(@session.id)
begin begin
mtx = Mediamtx::Client.new Mediamtx::Client.new.set_path_recording(@session, enabled: false)
mtx.set_path_recording(@session, enabled: false)
mtx.set_always_available(@session, enabled: true)
rescue Mediamtx::Client::Error => e rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}") Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}")
end end
Mediamtx::Client.new.set_always_available(@session, enabled: true)
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app. # Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
log_event("paused") log_event("paused")
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" }) SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
@@ -3,8 +3,6 @@ module Sessions
class SelectQuality class SelectQuality
UPLOAD_HEADROOM = 0.8 UPLOAD_HEADROOM = 0.8
UPLOAD_HEADROOM_1080P = 1.0 UPLOAD_HEADROOM_1080P = 1.0
AUTO_ID = "auto"
AUTO_FLOOR_BITRATE = 800_000
PRESETS = [ PRESETS = [
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 }, { id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
@@ -13,32 +11,6 @@ module Sessions
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 } { id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
].freeze ].freeze
MIN_QUALITY_IDS = ([AUTO_ID] + PRESETS.map { |preset| preset[:id] }).freeze
def self.floor_bitrate(preset_id)
return AUTO_FLOOR_BITRATE if preset_id.blank? || preset_id == AUTO_ID
PRESETS.find { |preset| preset[:id] == preset_id }&.fetch(:target_bitrate) || AUTO_FLOOR_BITRATE
end
def self.allowed_min_ids(session_preset)
ids = [AUTO_ID] + PRESETS.reject { |preset| preset[:id].start_with?("1080p") }.reverse.map { |preset| preset[:id] }
ids << "1080p_30_4.5mbps" if session_preset.to_s.start_with?("1080p")
ids
end
def self.display_name(preset_id)
return AUTO_ID if preset_id.blank? || preset_id == AUTO_ID
preset = PRESETS.find { |item| item[:id] == preset_id }
return preset_id unless preset
resolution = preset[:id].start_with?("1080p") ? "1080p" : "720p"
mbps = preset[:target_bitrate] / 1_000_000.0
mbps_text = (mbps == mbps.to_i) ? mbps.to_i.to_s : format("%.1f", mbps)
"#{resolution} · #{mbps_text} Mbps"
end
def self.call(upload_mbps:) def self.call(upload_mbps:)
new(upload_mbps: upload_mbps).call new(upload_mbps: upload_mbps).call
end end
@@ -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
@@ -1,39 +0,0 @@
# frozen_string_literal: true
module Sessions
class SetMinQuality
def initialize(session, preset:)
@session = session
@preset = preset.to_s.strip
end
def call
id = normalize
unless SelectQuality.allowed_min_ids(@session.quality_preset).include?(id)
raise ArgumentError, "Qualità minima non valida"
end
return @session if @session.min_quality_preset == id
@session.update!(min_quality_preset: id)
@session.stream_events.create!(
event_type: "quality_changed",
occurred_at: Time.current,
metadata: { "min_quality_preset" => id }
)
SessionChannel.broadcast_message(
@session,
{ type: "command", action: "set_min_quality", min_quality_preset: id }
)
@session
end
private
def normalize
return SelectQuality::AUTO_ID if @preset.blank? || @preset == SelectQuality::AUTO_ID
@preset
end
end
end
+1 -1
View File
@@ -7,7 +7,7 @@
<meta name="theme-color" content="#0a0a0e"> <meta name="theme-color" content="#0a0a0e">
<title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title> <title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title>
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<link rel="stylesheet" href="/regia.css?v=11"> <link rel="stylesheet" href="/regia.css?v=1">
<%= yield :head %> <%= yield :head %>
</head> </head>
<body class="regia-body"> <body class="regia-body">
@@ -21,4 +21,4 @@
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px"></button> <button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px"></button>
</div> </div>
</div> </div>
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set"><%= t("regia.board.close_set") %></button> <button type="button" class="regia-btn regia-btn--yellow" data-action="close_set" style="margin-top:12px"><%= t("regia.board.close_set") %></button>
+37 -99
View File
@@ -15,9 +15,8 @@
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-min-quality-url="<%= j public_regia_min_quality_path(params[:token]) %>"
data-min-quality="<%= j @session.min_quality_preset %>"
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 %>"
data-home-name="<%= j @team.name %>" data-home-name="<%= j @team.name %>"
@@ -28,121 +27,60 @@
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">
<div class="regia-header__text"> <h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1> <p id="regia-subtitle"><%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %></p>
<p id="regia-subtitle"><%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %></p>
</div>
<span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>"> <span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>">
<%= @stream_closed ? t("regia.status.ended") : (@session.paused? ? t("regia.status.paused") : (@on_air ? t("regia.status.live") : t("regia.status.waiting"))) %> <%= @stream_closed ? t("regia.status.ended") : (@session.paused? ? t("regia.status.paused") : (@on_air ? t("regia.status.live") : t("regia.status.waiting"))) %>
</span> </span>
</header> </header>
<nav class="regia-toolbar" aria-label="<%= t("regia.links_nav") %>"> <% if @live_share_url.present? %>
<% if @live_share_url.present? %> <div class="regia-share-group">
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.live_share_label") %>"> <p class="regia-share-label"><%= t("regia.live_share_label") %></p>
<span class="regia-tool-group__label"><%= t("regia.live_share_label") %></span> <div class="regia-share">
<div class="regia-tool-group__btns"> <button type="button" class="regia-btn regia-btn--outline" id="btn-share-live" style="margin:0">
<button type="button" class="regia-icon-btn" id="btn-share-live" aria-label="<%= t("regia.share_live") %>" title="<%= t("regia.share_live") %>"> <%= t("regia.share_live") %>
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
</button>
<button type="button" class="regia-icon-btn" id="btn-copy-live" aria-label="<%= t("regia.copy_live") %>" title="<%= t("regia.copy_live") %>">
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
</div>
<% end %>
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.regia_share_label") %>">
<span class="regia-tool-group__label"><%= t("regia.regia_share_label") %></span>
<div class="regia-tool-group__btns">
<button type="button" class="regia-icon-btn" id="btn-share-regia" aria-label="<%= t("regia.share_regia") %>" title="<%= t("regia.share_regia") %>">
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
</button> </button>
<button type="button" class="regia-icon-btn" id="btn-copy-regia" aria-label="<%= t("regia.copy_regia") %>" title="<%= t("regia.copy_regia") %>"> <button type="button" class="regia-btn regia-btn--outline" id="btn-copy-live" style="margin:0">
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> <%= t("regia.copy_link") %>
</button> </button>
</div> </div>
</div> </div>
<% unless @stream_closed %> <% end %>
<div class="regia-tool-group regia-tool-group--stream" role="group" aria-label="<%= t("regia.stream_controls_label") %>">
<span class="regia-tool-group__label" aria-hidden="true">&nbsp;</span>
<div class="regia-tool-group__btns">
<button type="button" class="regia-icon-btn" id="btn-toggle-preview" aria-label="<%= t("regia.preview_hide") %>" title="<%= t("regia.preview_hide") %>" aria-pressed="true">
<svg class="icon-fill icon-eye" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
<svg class="icon-fill icon-eye-off" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78 3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>
</button>
<button type="button" class="regia-icon-btn<%= " is-paused" if @session.paused? %>" id="btn-pause" aria-label="<%= @session.paused? ? t("regia.resume") : t("regia.pause") %>" title="<%= @session.paused? ? t("regia.resume") : t("regia.pause") %>">
<svg class="icon-fill icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
<svg class="icon-fill icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
</button>
<button type="button" class="regia-icon-btn regia-icon-btn--danger" id="btn-stop" aria-label="<%= t("regia.stop") %>" title="<%= t("regia.stop") %>">
<svg class="icon-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6h12v12H6z"/></svg>
</button>
</div>
</div>
<% end %>
</nav>
<section class="regia-preview-wrap" id="regia-preview-wrap"> <div class="regia-share-group">
<div class="regia-preview" id="regia-preview-box"> <p class="regia-share-label"><%= t("regia.regia_share_label") %></p>
<% unless @stream_closed %> <div class="regia-share">
<video id="regia-preview" playsinline muted autoplay></video> <button type="button" class="regia-btn regia-btn--outline" id="btn-share-regia" style="margin:0">
<% end %> <%= t("regia.share_regia") %>
<div id="regia-preview-placeholder" class="regia-preview__placeholder"> </button>
<%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %> <button type="button" class="regia-btn regia-btn--outline" id="btn-copy-regia" style="margin:0">
</div> <%= t("regia.copy_link") %>
</button>
</div> </div>
</section> </div>
<div class="regia-preview">
<% unless @stream_closed %>
<video id="regia-preview" playsinline muted autoplay></video>
<% end %>
<div id="regia-preview-placeholder" class="regia-preview__placeholder">
<%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %>
</div>
</div>
<section class="regia-scoreboard"> <section class="regia-scoreboard">
<%= render regia_board_partial(@match) %> <%= render regia_board_partial(@match) %>
</section> </section>
<% unless @stream_closed %> <% unless @stream_closed %>
<section class="regia-telemetry" id="regia-telemetry" aria-label="<%= t("regia.telemetry.label") %>"> <button type="button" class="regia-btn regia-btn--outline" id="btn-pause"><%= @session.paused? ? t("regia.resume") : t("regia.pause") %></button>
<div class="regia-telemetry__row"> <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>
<span class="regia-chip" id="tel-battery" title="<%= t("regia.telemetry.battery") %>"> <button type="button" class="regia-btn regia-btn--danger" id="btn-stop"><%= t("regia.stop") %></button>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 4h-1V3h-6v1H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H8V6h8v14z"/></svg>
<span class="regia-chip__label"><%= t("regia.telemetry.battery") %></span>
<span class="regia-chip__value"></span>
</span>
<span class="regia-chip" id="tel-network" title="<%= t("regia.telemetry.network") %>">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/></svg>
<span class="regia-chip__label"><%= t("regia.telemetry.network") %></span>
<span class="regia-chip__value"></span>
</span>
<button type="button" class="regia-chip regia-chip--action" id="tel-bitrate" aria-expanded="false" aria-controls="regia-quality" title="<%= t("regia.telemetry.bitrate_tap") %>">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 13h2v-2H3v2zm4 4h2v-6H7v6zm4 4h2V7h-2v14zm4-8h2V9h-2v4zm4-4h2V5h-2v4z"/></svg>
<span class="regia-chip__label"><%= t("regia.telemetry.bitrate") %></span>
<span class="regia-chip__value"></span>
<span class="regia-chip__hint" hidden></span>
</button>
<span class="regia-chip" id="tel-fps" title="<%= t("regia.telemetry.fps") %>">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
<span class="regia-chip__label"><%= t("regia.telemetry.fps") %></span>
<span class="regia-chip__value"></span>
</span>
<span class="regia-chip" id="tel-thermal" title="<%= t("regia.telemetry.thermal") %>">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 13V5c0-1.66-1.34-3-3-3S9 3.34 9 5v8c-1.21.91-2 2.37-2 4 0 2.76 2.24 5 5 5s5-2.24 5-5c0-1.63-.79-3.09-2-4zm-3 8c-1.66 0-3-1.34-3-3 0-1.19.7-2.23 1.71-2.72L11 14.73V5c0-.55.45-1 1-1s1 .45 1 1v9.73l.29.18C14.3 15.77 15 16.81 15 18c0 1.66-1.34 3-3 3z"/></svg>
<span class="regia-chip__label"><%= t("regia.telemetry.thermal") %></span>
<span class="regia-chip__value"></span>
</span>
</div>
<div class="regia-quality" id="regia-quality" hidden>
<p class="regia-quality__hint"><%= t("regia.min_quality.recommend") %></p>
<label for="min-quality"><%= t("regia.min_quality.label") %></label>
<select id="min-quality" title="<%= t("regia.min_quality.hint") %>">
<% Sessions::SelectQuality.allowed_min_ids(@session.quality_preset).each do |id| %>
<option value="<%= id %>" <%= "selected" if @session.min_quality_preset == id %>>
<%= id == Sessions::SelectQuality::AUTO_ID ? t("regia.min_quality.auto") : Sessions::SelectQuality.display_name(id) %>
</option>
<% end %>
</select>
</div>
<p class="regia-telemetry__meta" id="tel-meta"><%= t("regia.telemetry.waiting") %></p>
</section>
<% end %> <% end %>
</div> </div>
@@ -159,4 +97,4 @@
</div> </div>
</div> </div>
<script src="/regia.js?v=18"></script> <script src="/regia.js?v=9"></script>
+2 -2
View File
@@ -62,8 +62,8 @@ Rails.application.configure do
# Annotate rendered view with file names. # Annotate rendered view with file names.
config.action_view.annotate_rendered_view_with_filenames = true config.action_view.annotate_rendered_view_with_filenames = true
# Permette Action Cable dall'emulatore Android (10.0.2.2) e da altri host locali. # Uncomment if you wish to allow Action Cable access from any origin.
config.action_cable.disable_request_forgery_protection = true # config.action_cable.disable_request_forgery_protection = true
# Raise error when a before_action's only/except options reference missing actions. # Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true config.action_controller.raise_on_missing_callback_actions = true
+7 -39
View File
@@ -432,51 +432,18 @@ de:
paused: Pausiert paused: Pausiert
live: Live live: Live
waiting: Wartet waiting: Wartet
live_share_label: Live live_share_label: "Live-Link (Zuschauer)"
share_live: Livestream teilen share_live: Livestream teilen
share_action: Teilen
copy_link: Link kopieren copy_link: Link kopieren
copy_action: Kopieren regia_share_label: "Regie-Link (Punktestand)"
copy_live: Live kopieren
copy_regia: Regie kopieren
links_nav: Schnellaktionen
regia_share_label: Regie
stream_controls_label: Live
share_regia: Regie-Link teilen share_regia: Regie-Link teilen
preview_ended: Livestream beendet preview_ended: Livestream beendet
preview_waiting: Vorschau wartet auf Signal… preview_waiting: Vorschau wartet auf Signal…
preview_hide: Vorschau ausblenden
preview_show: Vorschau anzeigen
preview_off_hint: Vorschau aus · kein Video-Download
pause: Pausieren pause: Pausieren
resume: Livestream fortsetzen resume: Livestream fortsetzen
mute: Audio stummschalten
unmute: Audio einschalten
stop: Livestream beenden stop: Livestream beenden
min_quality:
label: Minimale Übertragungsqualität
summary: Qualität
hint: Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.
recommend: "Automatisch wird empfohlen: passt sich an Netz und Telefon an, ohne das Maximum zu erzwingen."
auto: Automatisch
telemetry:
label: Telefon
waiting: Warte auf das Telefon…
stale: Telefon antwortet nicht
just_now: jetzt
seconds_ago: "vor %{n}s"
publisher_on: RTMP ok
publisher_off: RTMP fehlt
battery: Akku
network: Netz
bitrate: Bitrate
bitrate_max: "max %{n}"
bitrate_title: "%{current} Mbps aktuell · max. %{target} Mbps"
bitrate_tap: Tippen, um die Mindestqualität festzulegen
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Warm
thermal_serious: Heiß
thermal_critical: Kritisch
modal: modal:
set_won_title: Satz gewonnen set_won_title: Satz gewonnen
confirm: Satz beenden confirm: Satz beenden
@@ -511,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
@@ -521,8 +491,6 @@ de:
copy_prompt: "Link kopieren:" copy_prompt: "Link kopieren:"
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:" share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
share_live_text: "Sieh dir die Übertragung auf Match Live TV an:" share_live_text: "Sieh dir die Übertragung auf Match Live TV an:"
min_quality_updated: Mindestqualität aktualisiert
min_quality_error: Fehler beim Aktualisieren der Mindestqualität
recordings: recordings:
archive: archive:
back_prefix: "←" back_prefix: "←"
+7 -39
View File
@@ -427,51 +427,18 @@ en:
paused: Paused paused: Paused
live: Live live: Live
waiting: Waiting waiting: Waiting
live_share_label: Live live_share_label: "Live link (viewers)"
share_live: Share live link share_live: Share live link
share_action: Share
copy_link: Copy link copy_link: Copy link
copy_action: Copy regia_share_label: "Control room link (score)"
copy_live: Copy live
copy_regia: Copy room
links_nav: Quick actions
regia_share_label: Control
stream_controls_label: Live
share_regia: Share control room link share_regia: Share control room link
preview_ended: Stream ended preview_ended: Stream ended
preview_waiting: Waiting for signal… preview_waiting: Waiting for signal…
preview_hide: Hide preview
preview_show: Show preview
preview_off_hint: Preview off · no video download
pause: Pause stream pause: Pause stream
resume: Resume stream resume: Resume stream
mute: Mute audio
unmute: Unmute audio
stop: End stream stop: End stream
min_quality:
label: Minimum streaming quality
summary: Quality
hint: The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.
recommend: "Automatic is recommended: it adapts to the network and the phone, without forcing maximum quality."
auto: Automatic
telemetry:
label: Phone
waiting: Waiting for the phone…
stale: Phone not responding
just_now: now
seconds_ago: "%{n}s ago"
publisher_on: RTMP ok
publisher_off: RTMP down
battery: Battery
network: Network
bitrate: Bitrate
bitrate_max: "max %{n}"
bitrate_title: "%{current} Mbps current · max %{target} Mbps"
bitrate_tap: Tap to set the minimum quality
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Warm
thermal_serious: Hot
thermal_critical: Critical
modal: modal:
set_won_title: Set won set_won_title: Set won
confirm: Close set confirm: Close set
@@ -506,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
@@ -516,8 +486,6 @@ en:
copy_prompt: "Copy the link:" copy_prompt: "Copy the link:"
share_regia_text: "Open this link to manage the live score:" share_regia_text: "Open this link to manage the live score:"
share_live_text: "Watch the live stream on Match Live TV:" share_live_text: "Watch the live stream on Match Live TV:"
min_quality_updated: Minimum quality updated
min_quality_error: Error updating minimum quality
recordings: recordings:
archive: archive:
back_prefix: "←" back_prefix: "←"
+7 -39
View File
@@ -432,51 +432,18 @@ es:
paused: En pausa paused: En pausa
live: En directo live: En directo
waiting: En espera waiting: En espera
live_share_label: Directo live_share_label: "Enlace del directo (espectadores)"
share_live: Compartir directo share_live: Compartir directo
share_action: Compartir
copy_link: Copiar enlace copy_link: Copiar enlace
copy_action: Copiar regia_share_label: "Enlace de control (marcador)"
copy_live: Copiar directo
copy_regia: Copiar control
links_nav: Acciones rápidas
regia_share_label: Control
stream_controls_label: Live
share_regia: Compartir enlace de control share_regia: Compartir enlace de control
preview_ended: Directo finalizado preview_ended: Directo finalizado
preview_waiting: Vista previa a la espera de señal… preview_waiting: Vista previa a la espera de señal…
preview_hide: Ocultar vista previa
preview_show: Mostrar vista previa
preview_off_hint: Vista previa apagada · sin descarga de vídeo
pause: Pausar directo pause: Pausar directo
resume: Reanudar directo resume: Reanudar directo
mute: Silenciar audio
unmute: Reactivar audio
stop: Finalizar directo stop: Finalizar directo
min_quality:
label: Calidad mínima de transmisión
summary: Calidad
hint: El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.
recommend: "Se recomienda Automática: se adapta a la red y al teléfono, sin forzar el máximo."
auto: Automática
telemetry:
label: Teléfono
waiting: Esperando al teléfono…
stale: El teléfono no responde
just_now: ahora
seconds_ago: "hace %{n}s"
publisher_on: RTMP ok
publisher_off: RTMP ausente
battery: Batería
network: Red
bitrate: Bitrate
bitrate_max: "máx %{n}"
bitrate_title: "%{current} Mbps actuales · máximo %{target} Mbps"
bitrate_tap: Toca para definir la calidad mínima
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Caliente
thermal_serious: Muy caliente
thermal_critical: Crítica
modal: modal:
set_won_title: Set ganado set_won_title: Set ganado
confirm: Cerrar set confirm: Cerrar set
@@ -511,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
@@ -521,8 +491,6 @@ es:
copy_prompt: "Copia el enlace:" copy_prompt: "Copia el enlace:"
share_regia_text: "Abre este enlace para gestionar el marcador en directo:" share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
share_live_text: "Mira la retransmisión en Match Live TV:" share_live_text: "Mira la retransmisión en Match Live TV:"
min_quality_updated: Calidad mínima actualizada
min_quality_error: Error al actualizar la calidad mínima
recordings: recordings:
archive: archive:
back_prefix: "←" back_prefix: "←"
+7 -39
View File
@@ -432,51 +432,18 @@ fr:
paused: En pause paused: En pause
live: En direct live: En direct
waiting: En attente waiting: En attente
live_share_label: Direct live_share_label: "Lien direct (spectateurs)"
share_live: Partager le direct share_live: Partager le direct
share_action: Partager
copy_link: Copier le lien copy_link: Copier le lien
copy_action: Copier regia_share_label: "Lien régie (score)"
copy_live: Copier live
copy_regia: Copier régie
links_nav: Actions rapides
regia_share_label: Régie
stream_controls_label: Live
share_regia: Partager le lien régie share_regia: Partager le lien régie
preview_ended: Direct terminé preview_ended: Direct terminé
preview_waiting: Aperçu en attente du signal… preview_waiting: Aperçu en attente du signal…
preview_hide: Masquer l'aperçu
preview_show: Afficher l'aperçu
preview_off_hint: Aperçu désactivé · aucun téléchargement vidéo
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
min_quality:
label: Qualité minimale de transmission
summary: Qualité
hint: L'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.
recommend: "Automatique est conseillé : s'adapte au réseau et au téléphone, sans forcer le maximum."
auto: Automatique
telemetry:
label: Téléphone
waiting: En attente du téléphone…
stale: Téléphone sans réponse
just_now: maintenant
seconds_ago: "il y a %{n}s"
publisher_on: RTMP ok
publisher_off: RTMP absent
battery: Batterie
network: Réseau
bitrate: Débit
bitrate_max: "max %{n}"
bitrate_title: "%{current} Mbps actuels · max %{target} Mbps"
bitrate_tap: Touchez pour définir la qualité minimale
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Chaude
thermal_serious: Très chaude
thermal_critical: Critique
modal: modal:
set_won_title: Set gagné set_won_title: Set gagné
confirm: Terminer le set confirm: Terminer le set
@@ -511,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
@@ -521,8 +491,6 @@ fr:
copy_prompt: "Copiez le lien :" copy_prompt: "Copiez le lien :"
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :" share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
share_live_text: "Regardez le direct sur Match Live TV :" share_live_text: "Regardez le direct sur Match Live TV :"
min_quality_updated: Qualité minimale mise à jour
min_quality_error: Erreur de mise à jour de la qualité minimale
recordings: recordings:
archive: archive:
back_prefix: "←" back_prefix: "←"
+7 -39
View File
@@ -427,51 +427,18 @@ it:
paused: In pausa paused: In pausa
live: In onda live: In onda
waiting: In attesa waiting: In attesa
live_share_label: Diretta live_share_label: "Link diretta (spettatori)"
share_live: Condividi diretta share_live: Condividi diretta
share_action: Condividi
copy_link: Copia link copy_link: Copia link
copy_action: Copia regia_share_label: "Link regia (punteggio)"
copy_live: Copia live
copy_regia: Copia regia
links_nav: Azioni rapide
regia_share_label: Regia
stream_controls_label: Live
share_regia: Condividi link regia share_regia: Condividi link regia
preview_ended: Diretta terminata preview_ended: Diretta terminata
preview_waiting: Anteprima in attesa del segnale… preview_waiting: Anteprima in attesa del segnale…
preview_hide: Nascondi anteprima
preview_show: Mostra anteprima
preview_off_hint: Anteprima spenta · nessun download video
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
min_quality:
label: Qualità minima di trasmissione
summary: Qualità
hint: L'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.
recommend: "Consigliata Automatica: si adatta alla rete e al telefono, senza forzare il massimo."
auto: Automatica
telemetry:
label: Telefono
waiting: In attesa del telefono…
stale: Telefono non risponde
just_now: ora
seconds_ago: "%{n}s fa"
publisher_on: RTMP ok
publisher_off: RTMP assente
battery: Batteria
network: Rete
bitrate: Bitrate
bitrate_max: "max %{n}"
bitrate_title: "%{current} Mbps attuali · massimo %{target} Mbps"
bitrate_tap: Tocca per impostare la qualità minima
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Calda
thermal_serious: Molto calda
thermal_critical: Critica
modal: modal:
set_won_title: Set vinto set_won_title: Set vinto
confirm: Chiudi set confirm: Chiudi set
@@ -506,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
@@ -516,8 +486,6 @@ it:
copy_prompt: "Copia il link:" copy_prompt: "Copia il link:"
share_regia_text: "Apri questo link per gestire il punteggio della diretta:" share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
share_live_text: "Guarda la diretta su Match Live TV:" share_live_text: "Guarda la diretta su Match Live TV:"
min_quality_updated: Qualità minima aggiornata
min_quality_error: Errore aggiornamento qualità minima
recordings: recordings:
archive: archive:
back_prefix: "←" back_prefix: "←"
+2 -2
View File
@@ -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
@@ -55,7 +56,6 @@ Rails.application.routes.draw do
post :claim_pairing post :claim_pairing
post :regia_link post :regia_link
post :network_test post :network_test
patch :min_quality
get :youtube_stats get :youtube_stats
end end
end end
@@ -143,8 +143,8 @@ 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
patch "regia/:token/min_quality", to: "public/regia#min_quality", as: :public_regia_min_quality
root to: "public/pages#home" root to: "public/pages#home"
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml } get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
@@ -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
@@ -1,7 +0,0 @@
# frozen_string_literal: true
class AddMinQualityPresetToStreamSessions < ActiveRecord::Migration[7.2]
def change
add_column :stream_sessions, :min_quality_preset, :string, default: "auto", null: false
end
end
@@ -1,5 +0,0 @@
class AddThermalStateToDeviceStates < ActiveRecord::Migration[7.2]
def change
add_column :device_states, :thermal_state, :string
end
end
+2 -29
View File
@@ -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_08_13_184100) 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"
@@ -138,7 +138,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
t.datetime "last_seen_at" t.datetime "last_seen_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "thermal_state"
t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true
t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id" t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id"
end end
@@ -256,29 +255,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id" t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id"
end end
create_table "stream_nodes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "slug", null: false
t.string "hostname", null: false
t.string "role", default: "home", null: false
t.string "status", default: "ready", null: false
t.string "rtmp_base_url", null: false
t.string "hls_base_url", null: false
t.string "api_base_url", null: false
t.string "internal_rtmp_url"
t.string "internal_hls_url"
t.integer "max_publishers", default: 6, null: false
t.integer "max_relays", default: 6, null: false
t.string "provider", default: "local", null: false
t.string "provider_instance_id"
t.datetime "last_health_at"
t.jsonb "metadata", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["role"], name: "index_stream_nodes_on_role"
t.index ["slug"], name: "index_stream_nodes_on_slug", unique: true
t.index ["status"], name: "index_stream_nodes_on_status"
end
create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "match_id", null: false t.uuid "match_id", null: false
t.uuid "user_id", null: false t.uuid "user_id", null: false
@@ -304,13 +280,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) 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.uuid "stream_node_id" t.boolean "audio_muted", default: false, null: false
t.string "min_quality_preset", default: "auto", 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
t.index ["status"], name: "index_stream_sessions_on_status" t.index ["status"], name: "index_stream_sessions_on_status"
t.index ["stream_node_id"], name: "index_stream_sessions_on_stream_node_id"
t.index ["user_id"], name: "index_stream_sessions_on_user_id" t.index ["user_id"], name: "index_stream_sessions_on_user_id"
end end
@@ -438,7 +412,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
add_foreign_key "score_states", "stream_sessions" add_foreign_key "score_states", "stream_sessions"
add_foreign_key "stream_events", "stream_sessions" add_foreign_key "stream_events", "stream_sessions"
add_foreign_key "stream_sessions", "matches" add_foreign_key "stream_sessions", "matches"
add_foreign_key "stream_sessions", "stream_nodes"
add_foreign_key "stream_sessions", "users" add_foreign_key "stream_sessions", "users"
add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id" add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
add_foreign_key "subscriptions", "clubs" add_foreign_key "subscriptions", "clubs"
+45 -277
View File
@@ -4,8 +4,6 @@
--regia-surface: #14141c; --regia-surface: #14141c;
--regia-red: #e53935; --regia-red: #e53935;
--regia-yellow: #f9a825; --regia-yellow: #f9a825;
--regia-green: #43a047;
--regia-orange: #fb8c00;
--regia-text: #f5f5f5; --regia-text: #f5f5f5;
--regia-muted: #9ca3af; --regia-muted: #9ca3af;
--regia-safe-bottom: env(safe-area-inset-bottom, 0px); --regia-safe-bottom: env(safe-area-inset-bottom, 0px);
@@ -24,166 +22,37 @@
.regia-page { .regia-page {
max-width: 480px; max-width: 480px;
margin: 0 auto; margin: 0 auto;
padding: 8px 12px calc(12px + var(--regia-safe-bottom)); padding: 12px 16px calc(24px + var(--regia-safe-bottom));
}
.regia-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.regia-header__text {
min-width: 0;
flex: 1;
} }
.regia-header h1 { .regia-header h1 {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1.1rem;
font-weight: 700; font-weight: 700;
line-height: 1.25;
} }
.regia-header p { .regia-header p {
margin: 2px 0 0; margin: 4px 0 0;
font-size: 0.78rem; font-size: 0.85rem;
color: var(--regia-muted); color: var(--regia-muted);
} }
.regia-badge { .regia-badge {
display: inline-block; display: inline-block;
margin-top: 0; margin-top: 8px;
padding: 3px 8px; padding: 4px 10px;
border-radius: 999px; border-radius: 999px;
font-size: 0.65rem; font-size: 0.7rem;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
flex-shrink: 0;
} }
.regia-badge--live { background: #2e7d32; color: #fff; } .regia-badge--live { background: #2e7d32; color: #fff; }
.regia-badge--wait { background: #444; color: #ddd; } .regia-badge--wait { background: #444; color: #ddd; }
.regia-badge--ended { background: #374151; color: #ddd; } .regia-badge--ended { background: #374151; color: #ddd; }
.regia-toolbar {
margin: 8px 0 6px;
display: flex;
align-items: flex-end;
gap: 8px;
}
.regia-tool-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
min-width: 0;
}
.regia-tool-group__label {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--regia-muted);
line-height: 1;
white-space: nowrap;
}
.regia-tool-group__btns {
display: flex;
gap: 3px;
}
.regia-tool-group--stream {
margin-left: auto;
padding-left: 8px;
border-left: 1px solid #333;
}
.regia-icon-btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 1px solid #333;
border-radius: 10px;
background: #16161f;
color: var(--regia-text);
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.regia-icon-btn svg {
width: 20px;
height: 20px;
pointer-events: none;
}
.regia-icon-btn svg.icon-fill {
fill: currentColor;
}
.regia-icon-btn svg.icon-outline {
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.regia-icon-btn .icon-eye-off,
.regia-icon-btn .icon-play {
display: none;
}
.regia-icon-btn.is-off .icon-eye {
display: none;
}
.regia-icon-btn.is-off .icon-eye-off {
display: block;
}
.regia-icon-btn.is-paused .icon-pause {
display: none;
}
.regia-icon-btn.is-paused .icon-play {
display: block;
}
.regia-icon-btn:active { transform: scale(0.96); }
.regia-icon-btn.is-off {
color: var(--regia-yellow);
border-color: #5a4a1a;
}
.regia-icon-btn--danger {
color: var(--regia-red);
border-color: #7a2a28;
}
.regia-preview-wrap {
margin: 6px 0 10px;
}
.regia-preview-wrap.is-off {
margin: 0;
height: 0;
overflow: hidden;
}
.regia-preview { .regia-preview {
margin: 0; margin: 14px 0;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: #000; background: #000;
@@ -221,29 +90,29 @@
.regia-scoreboard { .regia-scoreboard {
background: var(--regia-surface); background: var(--regia-surface);
border-radius: 12px; border-radius: 12px;
padding: 10px 12px; padding: 16px;
margin-bottom: 8px; margin-bottom: 12px;
} }
.regia-sets { .regia-sets {
text-align: center; text-align: center;
font-size: 0.78rem; font-size: 0.82rem;
color: var(--regia-muted); color: var(--regia-muted);
margin: 0 0 4px; margin: 0 0 8px;
} }
.regia-score-line { .regia-score-line {
text-align: center; text-align: center;
font-size: 1.2rem; font-size: 1.5rem;
font-weight: 800; font-weight: 800;
margin: 0; margin: 0;
} }
.regia-partials { .regia-partials {
text-align: center; text-align: center;
font-size: 0.75rem; font-size: 0.78rem;
color: var(--regia-muted); color: var(--regia-muted);
margin: 4px 0 0; margin: 8px 0 0;
} }
.regia-team-row { .regia-team-row {
@@ -251,7 +120,7 @@
grid-template-columns: 1fr auto 1fr; grid-template-columns: 1fr auto 1fr;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
margin-top: 8px; margin-top: 16px;
} }
.regia-team-name { .regia-team-name {
@@ -262,7 +131,7 @@
} }
.regia-points { .regia-points {
font-size: 1.55rem; font-size: 2rem;
font-weight: 800; font-weight: 800;
text-align: center; text-align: center;
} }
@@ -277,7 +146,7 @@
.regia-btn { .regia-btn {
border: none; border: none;
border-radius: 10px; border-radius: 10px;
padding: 10px 8px; padding: 16px 12px;
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
@@ -287,8 +156,8 @@
.regia-btn:active { transform: scale(0.98); } .regia-btn:active { transform: scale(0.98); }
.regia-btn--point { background: var(--regia-red); color: #fff; } .regia-btn--point { background: var(--regia-red); color: #fff; }
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 8px; } .regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 12px; }
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 8px; padding: 12px; } .regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 10px; }
.regia-btn--outline { .regia-btn--outline {
background: transparent; background: transparent;
border: 1px solid #444; border: 1px solid #444;
@@ -296,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);
@@ -304,6 +177,26 @@
margin-top: 8px; margin-top: 8px;
} }
.regia-share-group {
margin: 12px 0;
}
.regia-share-label {
margin: 0 0 6px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--regia-muted);
}
.regia-share {
display: flex;
gap: 8px;
}
.regia-share .regia-btn { flex: 1; font-size: 0.9rem; padding: 12px; }
.regia-toast { .regia-toast {
position: fixed; position: fixed;
bottom: calc(16px + var(--regia-safe-bottom)); bottom: calc(16px + var(--regia-safe-bottom));
@@ -351,7 +244,7 @@
text-align: center; text-align: center;
color: var(--regia-muted); color: var(--regia-muted);
font-size: 0.72rem; font-size: 0.72rem;
padding-top: 8px; padding-top: 24px;
} }
.regia-clock-line { .regia-clock-line {
@@ -366,128 +259,3 @@
gap: 8px; gap: 8px;
margin-top: 12px; margin-top: 12px;
} }
.regia-quality {
margin: 6px 0 0;
padding: 8px 10px 10px;
background: var(--regia-surface);
border-radius: 10px;
}
.regia-quality[hidden] { display: none !important; }
.regia-quality__hint {
margin: 0 0 8px;
font-size: 0.68rem;
line-height: 1.35;
color: var(--regia-muted);
}
.regia-quality label {
display: block;
font-size: 0.68rem;
color: var(--regia-muted);
margin: 0 0 4px;
}
.regia-quality select {
width: 100%;
background: #0a0a0e;
color: var(--regia-text);
border: 1px solid #2a2a35;
border-radius: 8px;
padding: 8px 10px;
font-size: 0.82rem;
}
.regia-telemetry {
margin-top: 8px;
}
.regia-telemetry__row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 4px;
}
.regia-chip {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 0;
text-align: center;
padding: 6px 2px 5px;
border-radius: 8px;
background: var(--regia-surface);
color: var(--regia-text);
border: 1px solid transparent;
}
.regia-chip--action {
appearance: none;
-webkit-appearance: none;
font: inherit;
cursor: pointer;
width: 100%;
}
.regia-chip--action:focus-visible {
outline: 2px solid var(--regia-yellow);
outline-offset: 1px;
}
.regia-chip--action.is-open {
border-color: #3a3a48;
}
.regia-chip svg {
width: 14px;
height: 14px;
fill: currentColor;
}
.regia-chip__label {
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--regia-muted);
line-height: 1;
white-space: nowrap;
}
.regia-chip__value {
font-size: 0.72rem;
font-weight: 800;
line-height: 1.15;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.regia-chip__hint {
font-size: 0.55rem;
font-weight: 600;
color: var(--regia-muted);
line-height: 1;
white-space: nowrap;
}
.regia-chip__hint[hidden] { display: none; }
.regia-chip.is-ok { color: var(--regia-green); }
.regia-chip.is-warn { color: var(--regia-orange); }
.regia-chip.is-bad { color: var(--regia-red); }
.regia-telemetry__meta {
margin: 4px 0 0;
font-size: 0.65rem;
color: var(--regia-muted);
text-align: center;
}
.regia-telemetry.is-stale .regia-telemetry__meta {
color: var(--regia-yellow);
}
+42 -262
View File
@@ -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",
@@ -37,32 +40,15 @@
shareLiveText: "Guarda la diretta su Match Live TV:", shareLiveText: "Guarda la diretta su Match Live TV:",
previewWaiting: "Anteprima in attesa del segnale…", previewWaiting: "Anteprima in attesa del segnale…",
streamEnded: "Diretta terminata", streamEnded: "Diretta terminata",
previewHide: "Nascondi anteprima",
previewShow: "Mostra anteprima",
previewOffHint: "Anteprima spenta · nessun download video",
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",
waitingBadge: "In attesa", waitingBadge: "In attesa",
subtitlePrefix: "Regia ·", subtitlePrefix: "Regia ·"
minQualityUpdated: "Qualità minima aggiornata",
minQualityError: "Errore aggiornamento qualità minima",
minQualityAuto: "Automatica",
telemetryWaiting: "In attesa del telefono…",
telemetryStale: "Telefono non risponde",
telemetryJustNow: "ora",
telemetrySecondsAgo: "%{n}s fa",
telemetryPublisherOn: "RTMP ok",
telemetryPublisherOff: "RTMP assente",
telemetryThermalNominal: "OK",
telemetryThermalFair: "Calda",
telemetryThermalSerious: "Calda",
telemetryThermalCritical: "Critica",
telemetryBitrateMax: "max %{n}",
telemetryBitrateTitle: "%{current} Mbps attuali · massimo %{target} Mbps",
telemetryBitrateTap: "Tocca per impostare la qualità minima"
}; };
function loadI18n() { function loadI18n() {
@@ -95,8 +81,8 @@
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,
minQualityUrl: root.dataset.minQualityUrl,
cableUrl: root.dataset.cableUrl, cableUrl: root.dataset.cableUrl,
hlsUrl: root.dataset.hlsUrl, hlsUrl: root.dataset.hlsUrl,
homeName: root.dataset.homeName, homeName: root.dataset.homeName,
@@ -122,28 +108,17 @@
modalCancel: document.getElementById("modal-cancel"), modalCancel: document.getElementById("modal-cancel"),
preview: document.getElementById("regia-preview"), preview: document.getElementById("regia-preview"),
previewPlaceholder: document.getElementById("regia-preview-placeholder"), previewPlaceholder: document.getElementById("regia-preview-placeholder"),
previewWrap: document.getElementById("regia-preview-wrap"),
btnTogglePreview: document.getElementById("btn-toggle-preview"),
btnPause: document.getElementById("btn-pause"), btnPause: document.getElementById("btn-pause"),
minQuality: document.getElementById("min-quality"), btnAudioMute: document.getElementById("btn-audio-mute"),
subtitle: document.getElementById("regia-subtitle"), subtitle: document.getElementById("regia-subtitle")
telemetry: document.getElementById("regia-telemetry"),
telBattery: document.getElementById("tel-battery"),
telNetwork: document.getElementById("tel-network"),
telBitrate: document.getElementById("tel-bitrate"),
telFps: document.getElementById("tel-fps"),
telThermal: document.getElementById("tel-thermal"),
telMeta: document.getElementById("tel-meta"),
qualityPanel: document.getElementById("regia-quality")
}; };
let pendingAction = null; let pendingAction = null;
let hls = null; let hls = null;
let previewStarted = false; let previewStarted = false;
let previewWanted = true;
let wasPausedPreview = false; let wasPausedPreview = false;
let streamPaused = root.dataset.initialPaused === "true"; let streamPaused = root.dataset.initialPaused === "true";
const PREVIEW_PREF_KEY = "mltv-regia-preview"; let audioMuted = root.dataset.initialAudioMuted === "true";
function hlsUrlFresh() { function hlsUrlFresh() {
const sep = cfg.hlsUrl.includes("?") ? "&" : "?"; const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
@@ -172,43 +147,8 @@
} }
} }
function readPreviewPref() {
try {
return localStorage.getItem(PREVIEW_PREF_KEY) !== "0";
} catch (_) {
return true;
}
}
function writePreviewPref(on) {
try {
localStorage.setItem(PREVIEW_PREF_KEY, on ? "1" : "0");
} catch (_) {}
}
function syncPreviewToggleUi() {
if (!els.previewWrap || !els.btnTogglePreview) return;
const label = previewWanted ? I18N.previewHide : I18N.previewShow;
els.previewWrap.classList.toggle("is-off", !previewWanted);
els.btnTogglePreview.classList.toggle("is-off", !previewWanted);
els.btnTogglePreview.setAttribute("aria-pressed", previewWanted ? "true" : "false");
els.btnTogglePreview.setAttribute("aria-label", label);
els.btnTogglePreview.title = previewWanted ? I18N.previewHide : I18N.previewOffHint;
}
function setPreviewWanted(on) {
previewWanted = !!on;
writePreviewPref(previewWanted);
syncPreviewToggleUi();
if (previewWanted) {
startPreview();
} else {
destroyPreview();
}
}
function refreshPreviewForCover() { function refreshPreviewForCover() {
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed || !previewWanted) return; if (!els.preview || !cfg.hlsUrl || cfg.streamClosed) return;
destroyPreview(); destroyPreview();
previewStarted = false; previewStarted = false;
startPreview(); startPreview();
@@ -311,14 +251,21 @@
if (!els.btnPause) return; if (!els.btnPause) return;
const paused = !!(data.paused || data.status === "paused"); const paused = !!(data.paused || data.status === "paused");
streamPaused = paused; streamPaused = paused;
const label = paused ? I18N.resumeLabel : I18N.pauseLabel; els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel;
els.btnPause.classList.toggle("is-paused", paused); }
els.btnPause.setAttribute("aria-label", label);
els.btnPause.title = label; 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";
@@ -435,11 +382,6 @@
} }
const paused = data.paused || data.status === "paused"; const paused = data.paused || data.status === "paused";
if (!previewWanted) {
destroyPreview();
wasPausedPreview = paused;
return;
}
if (data.on_air) { if (data.on_air) {
if (!previewStarted) { if (!previewStarted) {
startPreview(); startPreview();
@@ -458,119 +400,6 @@
wasPausedPreview = paused; wasPausedPreview = paused;
} }
function mbpsNumber(bps) {
const n = Number(bps);
if (!n || n <= 0) return null;
const mbps = n >= 10_000 ? n / 1e6 : n / 1e3;
return mbps >= 10 ? mbps.toFixed(0) : mbps.toFixed(1);
}
function formatAge(iso) {
if (!iso) return null;
const then = Date.parse(iso);
if (!then) return null;
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
if (secs < 8) return I18N.telemetryJustNow;
return fillTemplate(I18N.telemetrySecondsAgo, { n: secs });
}
function setChip(el, text, level, hint) {
if (!el) return;
const valueEl = el.querySelector(".regia-chip__value") || el;
valueEl.textContent = text;
el.classList.toggle("is-ok", level === "ok");
el.classList.toggle("is-warn", level === "warn");
el.classList.toggle("is-bad", level === "bad");
const hintEl = el.querySelector(".regia-chip__hint");
if (hintEl) {
hintEl.textContent = hint || "";
hintEl.hidden = !hint;
}
}
function formatThermal(state) {
const key = String(state || "").toLowerCase();
if (key === "nominal") return { text: I18N.telemetryThermalNominal, level: "ok" };
if (key === "fair") return { text: I18N.telemetryThermalFair, level: "warn" };
if (key === "serious") return { text: I18N.telemetryThermalSerious, level: "bad" };
if (key === "critical") return { text: I18N.telemetryThermalCritical, level: "bad" };
return { text: "—", level: null };
}
function bitrateLevel(current, target, paused) {
if (paused || !current || current <= 0) return null;
if (target && target > 0) {
const ratio = current / target;
if (ratio >= 0.7) return "ok";
if (ratio >= 0.4) return "warn";
return "bad";
}
const mbps = current >= 10_000 ? current / 1e6 : current / 1e3;
if (mbps >= 2.5) return "ok";
if (mbps >= 1.5) return "warn";
return "bad";
}
function bitrateTitle(currentMbps, targetMbps) {
const tap = I18N.telemetryBitrateTap;
if (currentMbps && targetMbps) {
return `${fillTemplate(I18N.telemetryBitrateTitle, { current: currentMbps, target: targetMbps })} · ${tap}`;
}
if (currentMbps) return `${currentMbps} Mbps · ${tap}`;
return tap;
}
function syncTelemetry(data) {
if (!els.telemetry) return;
const tel = data.telemetry || {};
const battery = tel.battery;
const fps = tel.fps;
const bitrate = tel.bitrate;
const target = tel.target_bitrate || tel.targetBitrate;
const lastSeen = tel.last_seen_at || tel.lastSeenAt;
const ageSecs = lastSeen ? Math.max(0, Math.round((Date.now() - Date.parse(lastSeen)) / 1000)) : null;
const stale = ageSecs == null || ageSecs > 25;
const publishing = !!tel.publisher_online || !!tel.publisherOnline;
let battLevel = null;
let battText = "—";
if (battery != null && battery !== "") {
battText = `${battery}%`;
if (battery <= 15) battLevel = "bad";
else if (battery <= 25) battLevel = "warn";
}
setChip(els.telBattery, battText, battLevel);
setChip(els.telNetwork, tel.network || "—");
const currentMbps = mbpsNumber(bitrate);
const targetMbps = mbpsNumber(target);
const rateText = currentMbps || "—";
const rateHint = currentMbps && targetMbps
? fillTemplate(I18N.telemetryBitrateMax, { n: targetMbps })
: null;
if (els.telBitrate) els.telBitrate.title = bitrateTitle(currentMbps, targetMbps);
setChip(els.telBitrate, rateText, bitrateLevel(Number(bitrate), Number(target), data.paused), rateHint);
let fpsLevel = null;
if (fps != null && fps !== "" && Number(fps) > 0 && Number(fps) < 20) fpsLevel = "warn";
setChip(els.telFps, fps && Number(fps) > 0 ? String(fps) : "—", fpsLevel);
const thermal = formatThermal(tel.thermal_state || tel.thermalState);
setChip(els.telThermal, thermal.text, thermal.level);
els.telemetry.classList.toggle("is-stale", stale && !data.paused && !data.stream_closed);
if (!els.telMeta) return;
if (stale && !lastSeen) {
els.telMeta.textContent = I18N.telemetryWaiting;
return;
}
const parts = [publishing ? I18N.telemetryPublisherOn : I18N.telemetryPublisherOff];
const age = formatAge(lastSeen);
if (age) parts.push(age);
if (stale && !data.paused && !data.stream_closed) parts.push(I18N.telemetryStale);
els.telMeta.textContent = parts.join(" · ");
}
async function pollStatus() { async function pollStatus() {
try { try {
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } }); const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
@@ -578,8 +407,6 @@
const data = await res.json(); const data = await res.json();
applyScorePayload(data); applyScorePayload(data);
setBadge(data); setBadge(data);
syncMinQuality(data);
syncTelemetry(data);
syncPreviewFromStatus(data); syncPreviewFromStatus(data);
} catch (_) {} } catch (_) {}
} }
@@ -592,7 +419,7 @@
} }
function startPreview() { function startPreview() {
if (cfg.streamClosed || !previewWanted || !els.preview || !cfg.hlsUrl || previewStarted) return; if (cfg.streamClosed || !els.preview || !cfg.hlsUrl || previewStarted) return;
previewStarted = true; previewStarted = true;
const url = hlsUrlFresh(); const url = hlsUrlFresh();
@@ -645,12 +472,28 @@
const wasPaused = streamPaused; const wasPaused = streamPaused;
applyScorePayload(data); applyScorePayload(data);
setBadge(data); setBadge(data);
syncMinQuality(data);
syncTelemetry(data);
syncPreviewFromStatus(data); syncPreviewFromStatus(data);
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;
@@ -664,72 +507,9 @@
} }
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)));
let minQualityBusy = false;
function setQualityPanelOpen(open) {
if (!els.qualityPanel || !els.telBitrate) return;
els.qualityPanel.hidden = !open;
els.telBitrate.setAttribute("aria-expanded", open ? "true" : "false");
els.telBitrate.classList.toggle("is-open", open);
if (open && els.minQuality) {
window.requestAnimationFrame(() => els.minQuality.focus());
}
}
function syncMinQuality(data) {
if (!els.minQuality || minQualityBusy) return;
const value = data.min_quality_preset || "auto";
if (els.minQuality.value !== value) els.minQuality.value = value;
}
async function setMinQuality(preset) {
if (!cfg.minQualityUrl) return;
minQualityBusy = true;
try {
const res = await fetch(cfg.minQualityUrl, {
method: "PATCH",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ min_quality_preset: preset })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || I18N.minQualityError);
minQualityBusy = false;
syncMinQuality(data);
toast(I18N.minQualityUpdated);
} catch (e) {
toast(e.message || I18N.minQualityError);
} finally {
minQualityBusy = false;
}
}
els.minQuality?.addEventListener("change", () => {
setMinQuality(els.minQuality.value);
});
els.telBitrate?.addEventListener("click", (e) => {
e.stopPropagation();
const opening = !!(els.qualityPanel && els.qualityPanel.hidden);
setQualityPanelOpen(opening);
});
document.addEventListener("click", (e) => {
if (!els.qualityPanel || els.qualityPanel.hidden) return;
if (els.qualityPanel.contains(e.target) || els.telBitrate?.contains(e.target)) return;
setQualityPanelOpen(false);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") setQualityPanelOpen(false);
});
els.btnTogglePreview?.addEventListener("click", () => setPreviewWanted(!previewWanted));
previewWanted = readPreviewPref();
syncPreviewToggleUi();
if (!previewWanted) destroyPreview();
async function shareLink(url, title, text) { async function shareLink(url, title, text) {
if (!url) { if (!url) {
toast(I18N.linkUnavailable); toast(I18N.linkUnavailable);
@@ -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
+23 -46
View File
@@ -12,18 +12,7 @@ RSpec.describe "Public regia", type: :request do
get public_regia_path(token) get public_regia_path(token)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.body).to include("Condividi diretta") expect(response.body).to include("Condividi diretta")
expect(response.body).to include("Copia live")
expect(response.body).to include("Condividi link regia") expect(response.body).to include("Condividi link regia")
expect(response.body).to include("Copia regia")
expect(response.body).to include(">Diretta<")
expect(response.body).to include(">Regia<")
expect(response.body).to include("regia-tool-group")
expect(response.body).to include("Nascondi anteprima")
expect(response.body).to include('id="btn-pause"')
expect(response.body).to include('id="btn-stop"')
expect(response.body).to include("Qualità minima di trasmissione")
expect(response.body).to include('id="tel-bitrate"')
expect(response.body).to include('aria-controls="regia-quality"')
expect(response.body).to include("/live/#{session.id}") expect(response.body).to include("/live/#{session.id}")
expect(response.body).not_to include("Aruba") expect(response.body).not_to include("Aruba")
end end
@@ -159,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!
@@ -167,39 +179,4 @@ RSpec.describe "Public regia", type: :request do
get public_regia_path(token) get public_regia_path(token)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
end end
it "aggiorna la qualità minima di trasmissione" do
token = Sessions::RegiaAccess.new(session).issue_token!
patch public_regia_min_quality_path(token), params: { min_quality_preset: "720p_30_1.5mbps" }
expect(response).to have_http_status(:ok)
expect(session.reload.min_quality_preset).to eq("720p_30_1.5mbps")
json = JSON.parse(response.body)
expect(json["min_quality_preset"]).to eq("720p_30_1.5mbps")
end
it "espone la telemetria della camera nello status" do
session.device_states.create!(
device_role: "camera",
battery_level: 81,
network_type: "WiFi",
current_bitrate: 2_400_000,
target_bitrate: 4_500_000,
fps: 30,
thermal_state: "nominal",
last_seen_at: Time.current
)
token = Sessions::RegiaAccess.new(session).issue_token!
get public_regia_path(token)
expect(response.body).to include('id="regia-telemetry"')
get public_regia_status_path(token)
expect(response).to have_http_status(:ok)
tel = response.parsed_body["telemetry"]
expect(tel["battery"]).to eq(81)
expect(tel["network"]).to eq("WiFi")
expect(tel["fps"]).to eq(30)
expect(tel["bitrate"]).to eq(2_400_000)
expect(tel["thermal_state"]).to eq("nominal")
expect(tel).to have_key("publisher_online")
end
end end
@@ -11,11 +11,4 @@ RSpec.describe Sessions::SelectQuality do
expect(preset[:id]).to eq("1080p_30_4.5mbps") expect(preset[:id]).to eq("1080p_30_4.5mbps")
end end
it "offre 1080p come qualità minima solo se la sessione è 1080p" do
expect(described_class.allowed_min_ids("720p_30_2.5mbps")).to eq(
%w[auto 720p_30_1.5mbps 720p_30_2.5mbps 720p_30_4mbps]
)
expect(described_class.allowed_min_ids("1080p_30_4.5mbps")).to include("1080p_30_4.5mbps")
end
end end
@@ -0,0 +1,27 @@
require "rails_helper"
RSpec.describe Sessions::SetAudioMute do
let!(:user) { User.create!(email: "mute@test.it", name: "U", password: "Password123", role: "coach") }
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live") }
it "attiva il mute e persiste lo stato" do
described_class.new(session, muted: true).call
expect(session.reload.audio_muted).to eq(true)
expect(session.stream_events.order(:occurred_at).last.event_type).to eq("audio_muted")
end
it "disattiva il mute" do
session.update!(audio_muted: true)
described_class.new(session, muted: false).call
expect(session.reload.audio_muted).to eq(false)
expect(session.stream_events.order(:occurred_at).last.event_type).to eq("audio_unmuted")
end
it "è idempotente se lo stato è già quello richiesto" do
described_class.new(session, muted: false).call
expect(session.stream_events.count).to eq(0)
end
end
@@ -1,41 +0,0 @@
require "rails_helper"
RSpec.describe Sessions::SetMinQuality do
let!(:user) { User.create!(email: "abr@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) do
StreamSession.create!(
match: match,
user: user,
platform: "matchlivetv",
status: "live",
quality_preset: "720p_30_2.5mbps"
)
end
it "salva il pavimento e notifica i client" do
expect(SessionChannel).to receive(:broadcast_message).with(
session,
hash_including(action: "set_min_quality", min_quality_preset: "720p_30_1.5mbps")
)
described_class.new(session, preset: "720p_30_1.5mbps").call
expect(session.reload.min_quality_preset).to eq("720p_30_1.5mbps")
expect(session.stream_events.where(event_type: "quality_changed")).to exist
end
it "rifiuta 1080p se la sessione è 720p" do
expect {
described_class.new(session, preset: "1080p_30_4.5mbps").call
}.to raise_error(ArgumentError)
end
it "accetta auto come qualità minima" do
session.update!(min_quality_preset: "720p_30_1.5mbps")
described_class.new(session, preset: "auto").call
expect(session.reload.min_quality_preset).to eq("auto")
end
end
+3 -2
View File
@@ -399,7 +399,8 @@ cd infra && cp .env.example .env && docker compose up -d --build
| [`infrastructure/SERVER_DEPLOYMENT.md`](infrastructure/SERVER_DEPLOYMENT.md) | Bootstrap server, NPM, cron, backup | | [`infrastructure/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 |
+3 -1
View File
@@ -2,10 +2,12 @@
Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare unapp **allineata ad Android**. Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare unapp **allineata ad Android**.
**Aggiornato:** 8 agosto 2026 **Aggiornato:** 12 agosto 2026
**Riferimento Android (produzione / telefono / Play):** `2.0.10-native` (`versionCode` **31**), API `https://www.matchlivetv.it` **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.
--- ---
+149
View File
@@ -0,0 +1,149 @@
# iOS — mute audio in diretta (allineamento da Mac)
Documento operativo per **Cursor su Mac**: verificare e, se serve, ritoccare lapp iOS dopo il ramo `feature/stream-audio-mute`.
**Aggiornato:** 2026-08-12
**Branch:** `feature/stream-audio-mute` (parte da `main`)
**Android di riferimento:** mute microfono in overlay + sync regia
**API produzione:** `https://www.matchlivetv.it`
Il codice iOS è **già nel branch** (engine, overlay, API, Cable, L10n). Su Mac va **compilato, testato su device** e, se HaishinKit 2.2.5 rifiuta un parametro, adattato come sotto.
---
## Perché esiste
In palestra la musica di sottofondo finisce nel microfono del telefono. Il relay YouTube fa `-c:a copy`, quindi YouTube sente quella musica e può reclamare il copyright.
Il mute **non ferma** lo stream: continua video + overlay + AAC, ma i sample audio sono silenzio.
---
## Contratto API / Cable (già in backend)
| Pezzo | Dettaglio |
|-------|-----------|
| Campo sessione | `audio_muted` (boolean, default `false`) in JSON sessione e `GET/POST /regia/:token/status.json` |
| REST app | `PATCH /api/v1/sessions/:id/audio_mute` body `{ "muted": true \| false }` |
| REST regia | `POST /regia/:token/audio_mute` body `{ "muted": true \| false }` (senza body: toggle) |
| Cable command | `{ "type": "command", "action": "mute_audio" \| "unmute_audio", "muted": true \| false }` |
| Cable event | `{ "type": "stream_event", "event": "audio_muted", "muted": true \| false }` |
Idempotente: se lo stato è già quello richiesto, il backend non reinvia eventi.
---
## File iOS già modificati
```
native/ios/MatchLiveTv/Streaming/BroadcastModels.swift # BroadcastMetrics.audioMuted
native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift # setAudioMuted + AudioMixerSettings.isMuted
native/ios/MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift # setAudioMuted
native/ios/MatchLiveTv/Domain/Models.swift # StreamSession.audioMuted + withAudioMuted
native/ios/MatchLiveTv/Data/API/ApiDtos.swift # audioMuted + AudioMuteRequest
native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift # setAudioMute
native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift
native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift # onAudioMute + AudioMuteCommandLogic
native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift
native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift
native/ios/MatchLiveTv/Core/AppLanguage.swift # IT/EN/FR/DE/ES
native/ios/MatchLiveTvTests/LiveBroadcastCoordinatorTests.swift
```
---
## Engine HaishinKit 2.2.5 (punto da verificare su Mac)
Implementazione attesa in `LiveBroadcastEngine`:
```swift
func setAudioMuted(_ muted: Bool) async {
audioMuted = muted
await applyAudioMute()
metrics.audioMuted = muted
emitMetrics()
}
private func applyAudioMute() async {
guard pipelineConfigured else { return }
var settings = await mixer.audioMixerSettings
settings.isMuted = audioMuted
var track = settings.tracks[0] ?? AudioMixerTrackSettings(downmix: true, channelMap: [0])
track.isMuted = audioMuted
settings.tracks[0] = track
try? await mixer.setAudioMixerSettings(settings)
}
```
In `configurePipeline` i settings iniziali passano `isMuted: audioMuted` sia sul mixer sia sulla track 0.
**Se il build fallisce:**
1. `AudioMixerSettings` potrebbe non avere `isMuted` nel memberwise usato oggi — in quel caso muta solo `settings.tracks[0].isMuted`.
2. `setAudioMixerSettings` è `async throws` nel codice attuale; se la signature è sync, togli `try await`.
3. Non staccare il microfono (`attachAudio(nil)`): YouTube/MediaMTX vogliono AAC continuo. Mute = silenzio, non assenza di traccia.
Dopo `prepareBroadcast` / `resumeBroadcast` la schermata richiama `setAudioMuted(session.audioMuted)` per riallineare lo stato persistito.
---
## UI overlay
Toolbar destra (come Android), sotto pausa:
| Stato | SF Symbol | Chiave L10n |
|-------|-----------|-------------|
| Audio on | `speaker.wave.2.fill` | `broadcast.mute.cd` |
| Audio off | `speaker.slash.fill` | `broadcast.unmute.cd` |
Bottone `highlighted` quando mutato (stesso verde della pausa). Pannello telemetria: riga `broadcast.audio.muted.label` se mutato.
---
## Chiavi L10n (già aggiunte, 5 lingue)
| Android | iOS |
|---------|-----|
| `broadcast_mute_cd` | `broadcast.mute.cd` |
| `broadcast_unmute_cd` | `broadcast.unmute.cd` |
| `broadcast_audio_muted_label` | `broadcast.audio.muted.label` |
| `broadcast_snackbar_muted` | `broadcast.snackbar.muted` |
| `broadcast_snackbar_unmuted` | `broadcast.snackbar.unmuted` |
| `broadcast_snackbar_muted_remote` | `broadcast.snackbar.muted.remote` |
| `broadcast_snackbar_unmuted_remote` | `broadcast.snackbar.unmuted.remote` |
| `broadcast_snackbar_mute_error` | `broadcast.snackbar.mute.error` |
---
## Test su Mac (acceptance)
1. `cd native/ios && python3 generate_xcodeproj.py`
2. Build simulatore / device.
3. Avvia una diretta (anche verso ambiente di staging).
4. Overlay: tap mute → icona speaker.slash, snackbar «Audio silenziato», telemetria «Audio off».
5. Apri la **regia** sullo stesso match → il pulsante deve dire «Riattiva audio».
6. Dalla regia riattiva → il telefono mostra speaker.wave e snackbar «dalla regia».
7. Mute, metti in **pausa**, riprendi: laudio resta mutato (stato persistito).
8. YouTube / player HLS: video continua, audio silenzioso (non assente).
9. Unit test: `LiveBroadcastCoordinatorTests.testAudioMuteCommandFromCable`.
```bash
cd native/ios
python3 generate_xcodeproj.py
xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath build/DerivedData \
ONLY_ACTIVE_ARCH=YES ARCHS=arm64 EXCLUDED_ARCHS=x86_64 \
build
```
---
## Criterio “fatto”
- [ ] Build iOS senza errori HaishinKit
- [ ] Mute/unmute locale in overlay
- [ ] Sync bidirezionale con la regia via Cable + REST
- [ ] Mute sopravvive a pausa/ripresa
- [ ] AAC silenzioso in onda (niente drop della traccia audio)
- [ ] Stringhe IT/EN/FR/DE/ES visibili in overlay
+14 -1
View File
@@ -1,6 +1,6 @@
# Diretta live — architettura MediaMTX # Diretta live — architettura MediaMTX
**Ultimo aggiornamento:** 2026-07-29 **Ultimo aggiornamento:** 2026-08-12
## Idea ## Idea
@@ -106,6 +106,19 @@ Lapp mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (o `
`GET /live/:id/status.json` resta per messaggi sotto il player e recovery HLS; `Cache-Control: no-store`. `GET /live/:id/status.json` resta per messaggi sotto il player e recovery HLS; `Cache-Control: no-store`.
### Mute audio (copyright palestra / YouTube)
Se in palestra c’è musica di sottofondo, YouTube può reclamare il copyright sul flusso inoltrato (`-c:a copy`). Loperatore può **silenziare il microfono** senza interrompere video, overlay, HLS o relay:
1. Lo stato `audio_muted` è persistito su `stream_sessions` (default `false`).
2. API `PATCH /api/v1/sessions/:id/audio_mute` `{ "muted": true|false }` e `POST /regia/:token/audio_mute`.
3. Action Cable: `{ type: "command", action: "mute_audio"|"unmute_audio" }` e `{ type: "stream_event", event: "audio_muted", muted: true|false }`.
4. Lapp **non toglie la traccia AAC**: azzera i sample PCM (silenzio). YouTube e MediaMTX continuano a ricevere audio 48 kHz mono.
Pulsante mute: overlay diretta (Android/iOS, accanto a pausa) e pagina regia.
Dettaglio implementazione iOS: [`IOS_STREAM_AUDIO_MUTE.md`](IOS_STREAM_AUDIO_MUTE.md).
## Rigenerare la slate ## Rigenerare la slate
```bash ```bash
-2
View File
@@ -59,8 +59,6 @@ services:
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
MEDIAMTX_API_URL: http://mediamtx:9997 MEDIAMTX_API_URL: http://mediamtx:9997
MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://127.0.0.1:1935} MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://127.0.0.1:1935}
MEDIAMTX_HLS_URL: ${MEDIAMTX_HLS_URL:-http://mediamtx:8888}
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:3000/hls}
MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret} MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}
RAILS_WEBHOOK_URL: http://rails:3000 RAILS_WEBHOOK_URL: http://rails:3000
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min} SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min}
@@ -1,71 +0,0 @@
package com.matchlivetv.match_live_tv
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.matchlivetv.match_live_tv.core.AppConfig
import com.matchlivetv.match_live_tv.data.AppContainer
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import retrofit2.HttpException
/**
* Contratto API qualità minima contro il backend locale (emulatore → 10.0.2.2).
*/
@RunWith(AndroidJUnit4::class)
class LocalAdaptiveBitrateApiTest {
private lateinit var container: AppContainer
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
container = AppContainer(context)
assertTrue(
"Questo test richiede API locale, trovato ${AppConfig.apiBaseUrl}",
AppConfig.apiBaseUrl.contains("10.0.2.2") || AppConfig.apiBaseUrl.contains("localhost"),
)
}
@Test
fun setMinQuality_persistsAndRejects1080On720Session() = runBlocking {
container.authRepository.login(
email = "coach@matchlivetv.test",
password = "Password123",
)
val teams = container.matchRepository.fetchTeams()
assertTrue(teams.isNotEmpty())
val match = container.matchRepository.createQuickMatch(teams.first().id)
val created = container.sessionRepository.createSession(match.id)
assertEquals("auto", created.minQualityPreset)
val updated = container.sessionRepository.setMinQuality(created.id, "720p_30_1.5mbps")
assertEquals("720p_30_1.5mbps", updated.minQualityPreset)
val fetched = container.sessionRepository.fetchSession(created.id)
assertEquals("720p_30_1.5mbps", fetched.minQualityPreset)
try {
container.sessionRepository.setMinQuality(created.id, "1080p_30_4.5mbps")
fail("1080p avrebbe dovuto essere rifiutato su sessione 720p")
} catch (e: HttpException) {
assertEquals(422, e.code())
}
val forced = container.sessionRepository.setMinQuality(created.id, "720p_30_4mbps")
assertEquals("720p_30_4mbps", forced.minQualityPreset)
val auto = container.sessionRepository.setMinQuality(created.id, "auto")
assertEquals("auto", auto.minQualityPreset)
val started = container.sessionRepository.startSession(created.id)
assertFalse(started.rtmpIngestUrl.isNullOrBlank())
container.sessionRepository.stopSession(created.id)
Unit
}
}
@@ -1,265 +0,0 @@
package com.matchlivetv.match_live_tv
import android.content.Intent
import android.view.KeyEvent
import android.os.SystemClock
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* E2E locale: login seed → wizard → schermata ripresa → qualità minima.
*/
@RunWith(AndroidJUnit4::class)
class LocalAdaptiveBitrateUiTest {
private lateinit var device: UiDevice
private val pkg = "com.matchlivetv.match_live_tv"
@Before
fun setUp() {
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
grantRuntimePermissions()
launchApp()
}
@Test
fun broadcastScreen_canForceMinimumQuality() {
waitForAny(45_000, "Email", "ACCEDI", "LOG IN", "NUOVA PARTITA", "NEW MATCH")
if (!hasAny("NUOVA PARTITA", "NEW MATCH")) {
fillLogin()
if (!waitQuietly(8_000, "NUOVA PARTITA", "NEW MATCH")) {
tapClickableLogin()
}
waitForAny(45_000, "NUOVA PARTITA", "NEW MATCH")
}
tapAny("NUOVA PARTITA", "NEW MATCH")
waitForAny(15_000, "Avvia subito", "Start now")
tapAny("Avvia subito", "Start now")
waitForAny(45_000, "01 · Partita", "01 · Match")
scrollDown()
tapAny("AVANTI >", "NEXT >")
waitForAny(45_000, "02 · Trasmissione", "02 · Broadcast")
waitForAny(30_000, "Piattaforma", "Platform")
scrollDown()
tapAny("AVANTI >", "NEXT >")
waitForAny(45_000, "03 · Test rete", "03 · Network test")
waitForAny(30_000, "AVVIA TEST RETE", "START NETWORK TEST")
tapAny("AVVIA TEST RETE", "START NETWORK TEST")
waitForAny(30_000, "INIZIA >", "START >")
waitUntilEnabledAny("INIZIA >", "START >", timeoutMs = 25_000)
scrollDown()
tapAny("INIZIA >", "START >")
waitForAnyDesc(
60_000,
"Qualità minima di trasmissione",
"Minimum streaming quality",
)
tapAnyDesc("Qualità minima di trasmissione", "Minimum streaming quality")
waitForAny(10_000, "Qualità minima", "Minimum quality")
waitForAny(5_000, "Automatica", "Automatic")
tapClickableText("720p · 1.5 Mbps")
assertNotNull(
waitForAny(
20_000,
"Qualità minima: 720p · 1.5 Mbps",
"Minimum quality: 720p · 1.5 Mbps",
"720p · 1.5 Mbps",
),
)
}
private fun grantRuntimePermissions() {
listOf(
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO",
"android.permission.POST_NOTIFICATIONS",
).forEach { permission ->
device.executeShellCommand("pm grant $pkg $permission")
}
}
private fun launchApp() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val intent = context.packageManager.getLaunchIntentForPackage(pkg)?.apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
} ?: error("Launch intent mancante per $pkg")
context.startActivity(intent)
device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 15_000)
}
private fun fillLogin() {
val email = waitForRes("login_email")
val password = waitForRes("login_password")
typeInto(email, "coach@matchlivetv.test")
typeInto(password, "Password123")
device.pressKeyCode(KeyEvent.KEYCODE_ENTER)
device.waitForIdle()
SystemClock.sleep(800)
}
private fun hasAny(vararg texts: String): Boolean =
texts.any { device.hasObject(By.text(it)) || device.hasObject(By.desc(it)) }
private fun waitQuietly(timeoutMs: Long, vararg texts: String): Boolean {
val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) {
if (hasAny(*texts)) return true
SystemClock.sleep(250)
}
return false
}
private fun waitForRes(tag: String): UiObject2 {
val obj = device.wait(Until.findObject(By.res(tag)), 10_000)
?: device.wait(Until.findObject(By.res("$pkg:id/$tag")), 2_000)
?: error("Nodo non trovato: $tag | visibili: ${visibleTexts()}")
return obj
}
private fun typeInto(field: UiObject2, value: String) {
field.click()
device.waitForIdle()
field.text = value
device.waitForIdle()
SystemClock.sleep(300)
}
private fun tapClickableLogin() {
waitForResOrNull("login_submit")?.let { btn ->
val bounds = btn.visibleBounds
device.click(bounds.centerX(), bounds.centerY())
device.waitForIdle()
return
}
val deadline = SystemClock.elapsedRealtime() + 10_000
while (SystemClock.elapsedRealtime() < deadline) {
val candidates = device.findObjects(By.text("ACCEDI")) +
device.findObjects(By.text("LOG IN"))
for (node in candidates) {
if (clickEnabled(node)) return
}
SystemClock.sleep(250)
}
error("Login non cliccabile | visibili: ${visibleTexts()}")
}
private fun waitForResOrNull(tag: String): UiObject2? {
return device.wait(Until.findObject(By.res(tag)), 3_000)
?: device.wait(Until.findObject(By.res("$pkg:id/$tag")), 1_000)
}
private fun clickEnabled(start: UiObject2): Boolean {
var node: UiObject2? = start
for (i in 0 until 6) {
val current = node ?: break
if (current.isClickable && current.isEnabled) {
current.click()
device.waitForIdle()
return true
}
node = current.parent
}
return false
}
private fun visibleTexts(): String {
val interesting = listOf("ACCEDI", "LOG IN", "Email", "Password", "NUOVA", "NEW MATCH", "error", "Error", "Server")
return device.findObjects(By.pkg(pkg)).mapNotNull { it.text?.takeIf { t -> t.isNotBlank() } }
.distinct()
.filter { text -> interesting.any { key -> text.contains(key, ignoreCase = true) } || text.length < 40 }
.take(30)
.joinToString(" | ")
}
private fun waitForAny(timeoutMs: Long, vararg texts: String): UiObject2 {
val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) {
for (text in texts) {
device.findObject(By.text(text))?.let { return it }
device.findObject(By.desc(text))?.let { return it }
}
SystemClock.sleep(250)
}
error("Nessuno dei testi trovato: ${texts.joinToString()} | visibili: ${visibleTexts()}")
}
private fun waitForAnyDesc(timeoutMs: Long, vararg descs: String): UiObject2 {
val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) {
for (desc in descs) {
device.findObject(By.desc(desc))?.let { return it }
}
SystemClock.sleep(250)
}
error("Nessuna contentDescription trovata: ${descs.joinToString()}")
}
private fun tapAny(vararg texts: String) {
val label = waitForAny(15_000, *texts)
tapNode(label)
}
private fun tapClickableText(text: String) {
tapAny(text)
}
private fun tapAnyDesc(vararg descs: String) {
tapNode(waitForAnyDesc(10_000, *descs))
}
private fun tapNode(label: UiObject2) {
if (label.isClickable) {
label.click()
device.waitForIdle()
return
}
var node: UiObject2? = label
for (i in 0 until 6) {
val current = node ?: break
if (current.isClickable) {
current.click()
device.waitForIdle()
return
}
node = current.parent
}
label.click()
device.waitForIdle()
}
private fun waitUntilEnabledAny(vararg texts: String, timeoutMs: Long) {
val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) {
for (text in texts) {
val label = device.findObject(By.text(text)) ?: continue
var node: UiObject2? = label
for (i in 0 until 6) {
val current = node ?: break
if (current.isClickable && current.isEnabled) return
node = current.parent
}
}
SystemClock.sleep(500)
}
error("Pulsante non abilitato entro timeout: ${texts.joinToString()}")
}
private fun scrollDown(steps: Int = 2) {
val centerX = device.displayWidth / 2
val startY = (device.displayHeight * 0.75).toInt()
val endY = (device.displayHeight * 0.25).toInt()
repeat(steps) {
device.swipe(centerX, startY, centerX, endY, 24)
device.waitForIdle()
SystemClock.sleep(300)
}
}
}
@@ -6,17 +6,11 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import com.matchlivetv.match_live_tv.core.AppLocale import com.matchlivetv.match_live_tv.core.AppLocale
import com.matchlivetv.match_live_tv.core.ProvideAppLocale import com.matchlivetv.match_live_tv.core.ProvideAppLocale
import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost
import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
@OptIn(ExperimentalComposeUiApi::class)
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val container by lazy { (application as MatchLiveTvApplication).container } private val container by lazy { (application as MatchLiveTvApplication).container }
@@ -39,9 +33,7 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
ProvideAppLocale { ProvideAppLocale {
MatchLiveTheme { MatchLiveTheme {
Box(Modifier.semantics { testTagsAsResourceId = true }) { AppNavHost(container)
AppNavHost(container)
}
} }
} }
} }
@@ -183,8 +183,8 @@ data class StreamSessionDto(
@Json(name = "target_bitrate") val targetBitrate: Int? = null, @Json(name = "target_bitrate") val targetBitrate: Int? = null,
@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 = "min_quality_preset") val minQualityPreset: 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(
@@ -202,9 +202,9 @@ data class StreamSessionDto(
targetBitrate = targetBitrate ?: 2_500_000, targetBitrate = targetBitrate ?: 2_500_000,
targetFps = targetFps ?: 30, targetFps = targetFps ?: 30,
qualityPreset = qualityPreset ?: "720p_30_2.5mbps", qualityPreset = qualityPreset ?: "720p_30_2.5mbps",
minQualityPreset = minQualityPreset ?: "auto",
startedAt = startedAt, startedAt = startedAt,
score = score?.toDomain(), score = score?.toDomain(),
audioMuted = audioMuted ?: false,
) )
} }
@@ -287,10 +287,6 @@ data class CreateSessionRequest(
@Json(name = "youtube_channel") val youtubeChannel: String? = null, @Json(name = "youtube_channel") val youtubeChannel: String? = null,
) )
data class MinQualityRequest(
@Json(name = "min_quality_preset") val minQualityPreset: String,
)
data class UpdateMatchBody( data class UpdateMatchBody(
@Json(name = "opponent_name") val opponentName: String? = null, @Json(name = "opponent_name") val opponentName: String? = null,
val location: String? = null, val location: String? = null,
@@ -373,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",
@@ -398,5 +398,4 @@ data class TelemetryRequest(
@Json(name = "current_bitrate") val currentBitrate: Int? = null, @Json(name = "current_bitrate") val currentBitrate: Int? = null,
@Json(name = "target_bitrate") val targetBitrate: Int? = null, @Json(name = "target_bitrate") val targetBitrate: Int? = null,
val fps: Int? = null, val fps: Int? = null,
@Json(name = "thermal_state") val thermalState: String? = null,
) )
@@ -117,10 +117,10 @@ 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}/min_quality") @PATCH("sessions/{sessionId}/audio_mute")
suspend fun setMinQuality( suspend fun setAudioMute(
@Path("sessionId") sessionId: String, @Path("sessionId") sessionId: String,
@Body body: MinQualityRequest, @Body body: AudioMuteRequest,
): StreamSessionDto ): StreamSessionDto
@POST("sessions/{sessionId}/network_test") @POST("sessions/{sessionId}/network_test")
@@ -0,0 +1,26 @@
package com.matchlivetv.match_live_tv.data.cable
/**
* Interpreta i payload ActionCable del mute audio.
* Usato da app e test: lo stream resta in onda, cambia solo il microfono.
*/
object AudioMuteCommands {
fun mutedFromPayload(
type: String?,
action: String? = null,
event: String? = null,
muted: Any? = null,
): Boolean? = when (type) {
"command" -> when (action) {
"mute_audio" -> true
"unmute_audio" -> false
else -> null
}
"stream_event" -> if (event == "audio_muted") {
muted == true || muted == "true"
} else {
null
}
else -> null
}
}
@@ -33,7 +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 onMinQuality: ((String) -> 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
@@ -106,16 +106,28 @@ 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" -> {
"pause_stream" -> onPauseStream?.invoke() AudioMuteCommands.mutedFromPayload(
"resume_stream" -> onResumeStream?.invoke() type = "command",
"stop_stream" -> onEndStream?.invoke() action = payload["action"] as? String,
"set_min_quality" -> (payload["min_quality_preset"] as? String)?.let { onMinQuality?.invoke(it) } )?.let { onAudioMute?.invoke(it) }
when (payload["action"] as? String) {
"pause_stream" -> onPauseStream?.invoke()
"resume_stream" -> onResumeStream?.invoke()
"stop_stream" -> onEndStream?.invoke()
}
} }
"stream_event" -> when (payload["event"] as? String) { "stream_event" -> {
"paused" -> onPauseStream?.invoke() AudioMuteCommands.mutedFromPayload(
"resumed" -> onResumeStream?.invoke() type = "stream_event",
"ended" -> onEndStream?.invoke() event = payload["event"] as? String,
muted = payload["muted"],
)?.let { onAudioMute?.invoke(it) }
when (payload["event"] as? String) {
"paused" -> onPauseStream?.invoke()
"resumed" -> onResumeStream?.invoke()
"ended" -> onEndStream?.invoke()
}
} }
} }
} }
@@ -2,7 +2,6 @@ package com.matchlivetv.match_live_tv.data.repository
import com.matchlivetv.match_live_tv.data.api.CreateSessionRequest import com.matchlivetv.match_live_tv.data.api.CreateSessionRequest
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.api.MinQualityRequest
import com.matchlivetv.match_live_tv.data.api.NetworkTestRequest import com.matchlivetv.match_live_tv.data.api.NetworkTestRequest
import com.matchlivetv.match_live_tv.data.api.NetworkTestResponse import com.matchlivetv.match_live_tv.data.api.NetworkTestResponse
import com.matchlivetv.match_live_tv.domain.StreamSession import com.matchlivetv.match_live_tv.domain.StreamSession
@@ -39,8 +38,8 @@ class SessionRepository(
suspend fun resumeSession(sessionId: String): StreamSession = suspend fun resumeSession(sessionId: String): StreamSession =
api.resumeSession(sessionId).toDomain() api.resumeSession(sessionId).toDomain()
suspend fun setMinQuality(sessionId: String, preset: String): StreamSession = suspend fun setAudioMute(sessionId: String, muted: Boolean): StreamSession =
api.setMinQuality(sessionId, MinQualityRequest(minQualityPreset = preset)).toDomain() api.setAudioMute(sessionId, com.matchlivetv.match_live_tv.data.api.AudioMuteRequest(muted)).toDomain()
suspend fun submitNetworkTest( suspend fun submitNetworkTest(
sessionId: String, sessionId: String,
@@ -65,7 +64,6 @@ class SessionRepository(
currentBitrate: Int? = null, currentBitrate: Int? = null,
targetBitrate: Int? = null, targetBitrate: Int? = null,
fps: Int? = null, fps: Int? = null,
thermalState: String? = null,
) { ) {
api.postTelemetry( api.postTelemetry(
sessionId, sessionId,
@@ -77,7 +75,6 @@ class SessionRepository(
currentBitrate = currentBitrate, currentBitrate = currentBitrate,
targetBitrate = targetBitrate, targetBitrate = targetBitrate,
fps = fps, fps = fps,
thermalState = thermalState,
), ),
) )
} }
@@ -126,16 +126,14 @@ data class StreamSession(
val targetBitrate: Int = 2_500_000, val targetBitrate: Int = 2_500_000,
val targetFps: Int = 30, val targetFps: Int = 30,
val qualityPreset: String = "720p_30_2.5mbps", val qualityPreset: String = "720p_30_2.5mbps",
val minQualityPreset: String = "auto",
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"
val isPaused: Boolean get() = status == "paused" val isPaused: Boolean get() = status == "paused"
val isEnded: Boolean get() = status == "ended" || status == "error"
fun watchShareUrl(): String? = when { fun watchShareUrl(): String? = when {
platform == "youtube" && !youtubeWatchUrl.isNullOrBlank() -> youtubeWatchUrl platform == "youtube" && !youtubeWatchUrl.isNullOrBlank() -> youtubeWatchUrl
!shareUrl.isNullOrBlank() -> shareUrl !shareUrl.isNullOrBlank() -> shareUrl
@@ -1,115 +0,0 @@
package com.matchlivetv.match_live_tv.streaming
import kotlin.math.abs
import kotlin.math.min
/**
* ABR conservativo per RTMP: scende in fretta in congestione, risale piano.
* Il pavimento è la qualità minima scelta dall'utente; il soffitto è il target
* di sessione (eventualmente ridotto dal termico).
*/
class AdaptiveBitrateController(
private val audioBitrate: Int = 128_000,
private val healthySamplesBeforeUp: Int = 8,
private val minDelta: Int = 50_000,
) {
@Volatile
var ceiling: Int = DEFAULT_CEILING
private set
@Volatile
var floor: Int = AUTO_FLOOR
private set
@Volatile
var currentBitrate: Int = DEFAULT_CEILING
private set
private var healthyCount = 0
@Synchronized
fun configure(ceiling: Int, floor: Int) {
this.ceiling = ceiling.coerceAtLeast(MIN_ABSOLUTE)
this.floor = floor.coerceIn(MIN_ABSOLUTE, this.ceiling)
currentBitrate = this.ceiling
healthyCount = 0
}
@Synchronized
fun setCeiling(value: Int): Int {
ceiling = value.coerceAtLeast(MIN_ABSOLUTE)
if (floor > ceiling) floor = ceiling
if (currentBitrate > ceiling) {
currentBitrate = ceiling
healthyCount = 0
}
return currentBitrate
}
@Synchronized
fun setFloor(value: Int): Int {
floor = value.coerceIn(MIN_ABSOLUTE, ceiling)
if (currentBitrate < floor) {
currentBitrate = floor
healthyCount = 0
}
return currentBitrate
}
@Synchronized
fun onCongestion(measuredBitsPerSecond: Int): Int? {
healthyCount = 0
val next = if (measuredBitsPerSecond <= 0) {
(currentBitrate * 0.8).toInt()
} else {
min(currentBitrate, measuredBitsPerSecond - audioBitrate)
}
return maybeApply(next.coerceIn(floor, ceiling))
}
@Synchronized
fun onHealthy(): Int? {
if (currentBitrate >= ceiling) {
healthyCount = 0
return null
}
healthyCount += 1
if (healthyCount < healthySamplesBeforeUp) return null
healthyCount = 0
val step = (ceiling / 10).coerceAtLeast(minDelta)
return maybeApply(min(currentBitrate + step, ceiling))
}
@Synchronized
fun onMeasured(measuredBitsPerSecond: Long, congested: Boolean): Int? {
if (congested) return onCongestion(measuredBitsPerSecond.toInt())
if (measuredBitsPerSecond <= 0L) return null
val dropThreshold = (currentBitrate * 0.80).toLong()
return if (measuredBitsPerSecond < dropThreshold) {
onCongestion(measuredBitsPerSecond.toInt())
} else {
onHealthy()
}
}
@Synchronized
fun resetToCeiling(): Int {
healthyCount = 0
currentBitrate = ceiling
return currentBitrate
}
private fun maybeApply(next: Int): Int? {
if (next == currentBitrate) return null
val atBound = next == floor || next == ceiling
if (!atBound && abs(next - currentBitrate) < minDelta) return null
currentBitrate = next
return next
}
companion object {
const val AUTO_FLOOR = 800_000
const val MIN_ABSOLUTE = 500_000
const val DEFAULT_CEILING = 2_500_000
}
}
@@ -9,7 +9,6 @@ data class BroadcastConfig(
val width: Int = 1280, val width: Int = 1280,
val height: Int = 720, val height: Int = 720,
val videoBitrate: Int = 2_500_000, val videoBitrate: Int = 2_500_000,
val minVideoBitrate: Int = 800_000,
val audioBitrate: Int = 128_000, val audioBitrate: Int = 128_000,
val fps: Int = 30, val fps: Int = 30,
val rotation: Int = 0, val rotation: Int = 0,
@@ -40,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,
) )
@@ -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) {
@@ -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
@@ -39,10 +41,10 @@ class LiveBroadcastEngine(
private var publishInProgress = false private var publishInProgress = false
private var currentBitrateKbps: Long = 0 private var currentBitrateKbps: Long = 0
private var currentFps: Int = 0 private var currentFps: Int = 0
private val adaptiveBitrate = AdaptiveBitrateController()
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()
@@ -134,7 +136,6 @@ class LiveBroadcastEngine(
fun startBroadcast(broadcastConfig: BroadcastConfig) { fun startBroadcast(broadcastConfig: BroadcastConfig) {
mainHandler.post { mainHandler.post {
config = broadcastConfig config = broadcastConfig
adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
reconnectAttempts.set(0) reconnectAttempts.set(0)
streamStartPending = true streamStartPending = true
publishInProgress = false publishInProgress = false
@@ -229,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)
} }
} }
@@ -252,7 +254,6 @@ class LiveBroadcastEngine(
fun resumeBroadcast(broadcastConfig: BroadcastConfig) { fun resumeBroadcast(broadcastConfig: BroadcastConfig) {
mainHandler.post { mainHandler.post {
config = broadcastConfig config = broadcastConfig
adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
reconnectAttempts.set(0) reconnectAttempts.set(0)
streamStartPending = true streamStartPending = true
publishInProgress = false publishInProgress = false
@@ -267,45 +268,32 @@ class LiveBroadcastEngine(
} }
} }
/** Adattamento termico: aggiorna il soffitto ABR e l'FPS senza fermare lo stream. */ /** Silenzia il microfono senza togliere la traccia AAC (YouTube/MediaMTX richiedono audio continuo). */
fun setAudioMuted(muted: Boolean) {
mainHandler.post {
desiredAudioMuted.set(muted)
applyAudioMuteInternal()
emitMetrics()
}
}
fun isAudioMuted(): Boolean = desiredAudioMuted.get()
/** Adattamento termico: riduce bitrate/FPS senza fermare lo stream. */
fun applyThermalProfile(videoBitrate: Int, fps: Int) { fun applyThermalProfile(videoBitrate: Int, fps: Int) {
mainHandler.post { mainHandler.post {
val current = config ?: return@post val current = config ?: return@post
adaptiveBitrate.setCeiling(videoBitrate) if (current.videoBitrate == videoBitrate && current.fps == fps) return@post
val applied = adaptiveBitrate.setFloor(current.minVideoBitrate)
if (current.videoBitrate == videoBitrate && current.fps == fps) {
applyAdaptiveBitrate(applied)
return@post
}
config = current.copy(videoBitrate = videoBitrate, fps = fps) config = current.copy(videoBitrate = videoBitrate, fps = fps)
genericStream?.let { stream -> genericStream?.let { stream ->
applyAdaptiveBitrate(applied) runCatching { stream.setVideoBitrateOnFly(videoBitrate) }
.onFailure { Log.w(TAG, "applyThermalProfile bitrate: ${it.message}") }
runCatching { stream.getGlInterface().setForceRender(true, fps) } runCatching { stream.getGlInterface().setForceRender(true, fps) }
.onFailure { Log.w(TAG, "applyThermalProfile fps: ${it.message}") } .onFailure { Log.w(TAG, "applyThermalProfile fps: ${it.message}") }
} }
} }
} }
fun setMinQualityFloor(floorBitrate: Int, ceilingBitrate: Int? = null) {
mainHandler.post {
val current = config
if (current != null) {
config = current.copy(
videoBitrate = ceilingBitrate ?: current.videoBitrate,
minVideoBitrate = floorBitrate,
)
}
applyAdaptiveBitrate(adaptiveBitrate.setFloor(floorBitrate))
}
}
private fun applyAdaptiveBitrate(bitrate: Int) {
genericStream?.let { stream ->
runCatching { stream.setVideoBitrateOnFly(bitrate) }
.onFailure { Log.w(TAG, "ABR bitrate: ${it.message}") }
}
}
private fun obtainStream(cfg: BroadcastConfig): GenericStream { private fun obtainStream(cfg: BroadcastConfig): GenericStream {
val existing = genericStream val existing = genericStream
if (existing != null) return existing if (existing != null) return existing
@@ -367,6 +355,7 @@ class LiveBroadcastEngine(
applyInitialOrientation() applyInitialOrientation()
attachOverlayFilter(cfg) attachOverlayFilter(cfg)
startSensor() startSensor()
applyAudioMuteInternal()
return true return true
} }
@@ -541,6 +530,7 @@ class LiveBroadcastEngine(
}, },
connected = phase == BroadcastPhase.LIVE, connected = phase == BroadcastPhase.LIVE,
lastError = error, lastError = error,
audioMuted = desiredAudioMuted.get(),
), ),
) )
} }
@@ -579,12 +569,6 @@ class LiveBroadcastEngine(
override fun onNewBitrate(bitrate: Long) { override fun onNewBitrate(bitrate: Long) {
mainHandler.post { mainHandler.post {
currentBitrateKbps = bitrate / 1000 currentBitrateKbps = bitrate / 1000
val congestion = runCatching {
genericStream?.getStreamClient()?.hasCongestion() == true
}.getOrDefault(false)
adaptiveBitrate.onMeasured(bitrate, congestion)?.let { next ->
applyAdaptiveBitrate(next)
}
emitMetrics() emitMetrics()
} }
} }
@@ -612,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"
@@ -1,72 +0,0 @@
package com.matchlivetv.match_live_tv.streaming
import java.util.Locale
/**
* Scala qualità allineata a `Sessions::SelectQuality`.
* In diretta si adatta solo il bitrate (niente cambio risoluzione a caldo).
*/
object StreamQualityLadder {
const val AUTO = "auto"
data class Preset(
val id: String,
val bitrate: Int,
val fps: Int = 30,
) {
val is1080: Boolean get() = id.startsWith("1080p")
val resolutionLabel: String get() = if (is1080) "1080p" else "720p"
}
val PRESETS = listOf(
Preset("1080p_30_4.5mbps", 4_500_000),
Preset("720p_30_4mbps", 4_000_000),
Preset("720p_30_2.5mbps", 2_500_000),
Preset("720p_30_1.5mbps", 1_500_000),
)
fun normalize(presetId: String?): String {
val id = presetId?.ifBlank { AUTO } ?: AUTO
if (id == AUTO) return AUTO
return if (PRESETS.any { it.id == id }) id else AUTO
}
fun floorBitrate(presetId: String?): Int {
val id = normalize(presetId)
if (id == AUTO) return AdaptiveBitrateController.AUTO_FLOOR
return PRESETS.find { it.id == id }?.bitrate ?: AdaptiveBitrateController.AUTO_FLOOR
}
fun ceilingContribution(presetId: String?): Int {
val id = normalize(presetId)
if (id == AUTO) return 0
return PRESETS.find { it.id == id }?.bitrate ?: 0
}
fun encoderCeiling(sessionTarget: Int, minPresetId: String?): Int =
maxOf(sessionTarget, ceilingContribution(minPresetId))
fun encoderFloor(minPresetId: String?, ceiling: Int): Int =
minOf(floorBitrate(minPresetId), ceiling)
fun availableIds(sessionQualityPreset: String?): List<String> {
val ids = mutableListOf(AUTO)
ids += PRESETS.filter { !it.is1080 }.asReversed().map { it.id }
if (sessionQualityPreset?.startsWith("1080p") == true) {
PRESETS.find { it.is1080 }?.let { ids += it.id }
}
return ids
}
fun displayName(id: String, autoLabel: String): String {
if (normalize(id) == AUTO) return autoLabel
val preset = PRESETS.find { it.id == id } ?: return id
val mbps = preset.bitrate / 1_000_000.0
val mbpsText = if (mbps == mbps.toInt().toDouble()) {
mbps.toInt().toString()
} else {
"%.1f".format(Locale.US, mbps)
}
return "${preset.resolutionLabel} · $mbpsText Mbps"
}
}
@@ -15,14 +15,11 @@ object StreamVideoPreset {
fun broadcastConfig(session: StreamSession, rtmpUrl: String): BroadcastConfig { fun broadcastConfig(session: StreamSession, rtmpUrl: String): BroadcastConfig {
val dims = dimensions(session.qualityPreset) val dims = dimensions(session.qualityPreset)
val ceiling = StreamQualityLadder.encoderCeiling(session.targetBitrate, session.minQualityPreset)
val floor = StreamQualityLadder.encoderFloor(session.minQualityPreset, ceiling)
return BroadcastConfig( return BroadcastConfig(
rtmpUrl = rtmpUrl, rtmpUrl = rtmpUrl,
width = dims.width, width = dims.width,
height = dims.height, height = dims.height,
videoBitrate = ceiling, videoBitrate = session.targetBitrate,
minVideoBitrate = floor,
fps = session.targetFps, fps = session.targetFps,
) )
} }
@@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -30,10 +29,11 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Tune
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
@@ -60,8 +60,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.matchlivetv.match_live_tv.R import com.matchlivetv.match_live_tv.R
@@ -69,7 +67,6 @@ import com.matchlivetv.match_live_tv.core.resolveMediaUrl
import com.matchlivetv.match_live_tv.core.DeviceHealthSnapshot import com.matchlivetv.match_live_tv.core.DeviceHealthSnapshot
import com.matchlivetv.match_live_tv.core.ThermalState import com.matchlivetv.match_live_tv.core.ThermalState
import com.matchlivetv.match_live_tv.domain.ScoreState import com.matchlivetv.match_live_tv.domain.ScoreState
import com.matchlivetv.match_live_tv.streaming.StreamQualityLadder
import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge
import com.matchlivetv.match_live_tv.ui.theme.MatchColors import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@@ -105,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,
@@ -114,27 +113,9 @@ fun BroadcastControlsOverlay(
bitrateKbps: Long, bitrateKbps: Long,
networkType: String, networkType: String,
deviceHealth: DeviceHealthSnapshot, deviceHealth: DeviceHealthSnapshot,
minQualityPreset: String = StreamQualityLadder.AUTO,
sessionQualityPreset: String = "720p_30_2.5mbps",
onSelectMinQuality: (String) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var showTerminateConfirm by remember { mutableStateOf(false) } var showTerminateConfirm by remember { mutableStateOf(false) }
var showMinQualityPicker by remember { mutableStateOf(false) }
val autoLabel = stringResource(R.string.broadcast_min_quality_auto)
if (showMinQualityPicker) {
MinQualityPickerDialog(
selectedId = minQualityPreset,
sessionQualityPreset = sessionQualityPreset,
autoLabel = autoLabel,
onSelect = { id ->
showMinQualityPicker = false
onSelectMinQuality(id)
},
onDismiss = { showMinQualityPicker = false },
)
}
if (showTerminateConfirm) { if (showTerminateConfirm) {
AlertDialog( AlertDialog(
@@ -197,7 +178,7 @@ fun BroadcastControlsOverlay(
bitrateKbps = bitrateKbps, bitrateKbps = bitrateKbps,
networkType = networkType, networkType = networkType,
deviceHealth = deviceHealth, deviceHealth = deviceHealth,
minQualityLabel = StreamQualityLadder.displayName(minQualityPreset, autoLabel), audioMuted = audioMuted,
) )
} }
@@ -219,11 +200,6 @@ fun BroadcastControlsOverlay(
contentDescription = stringResource(R.string.broadcast_share_regia_cd), contentDescription = stringResource(R.string.broadcast_share_regia_cd),
onClick = onShareRegia, onClick = onShareRegia,
) )
SideIconButton(
icon = Icons.Default.Tune,
contentDescription = stringResource(R.string.broadcast_min_quality_cd),
onClick = { showMinQualityPicker = true },
)
if (onCloseSet != null) { if (onCloseSet != null) {
SideIconButton( SideIconButton(
icon = Icons.Default.Check, icon = Icons.Default.Check,
@@ -256,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),
@@ -316,7 +302,7 @@ private fun BroadcastTelemetryPanel(
bitrateKbps: Long, bitrateKbps: Long,
networkType: String, networkType: String,
deviceHealth: DeviceHealthSnapshot, deviceHealth: DeviceHealthSnapshot,
minQualityLabel: String, audioMuted: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val labelStyle = MaterialTheme.typography.labelSmall val labelStyle = MaterialTheme.typography.labelSmall
@@ -351,16 +337,19 @@ private fun BroadcastTelemetryPanel(
color = MatchColors.TextSecondary, color = MatchColors.TextSecondary,
) )
} }
Text(
minQualityLabel,
style = labelStyle,
color = MatchColors.AccentYellow,
)
Text( Text(
"${networkType} · ${deviceHealth.batteryPercent}%", "${networkType} · ${deviceHealth.batteryPercent}%",
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)
} }
} }
@@ -639,113 +628,6 @@ private fun TeamLogoThumbnail(url: String) {
} }
} }
@Composable
private fun MinQualityPickerDialog(
selectedId: String,
sessionQualityPreset: String,
autoLabel: String,
onSelect: (String) -> Unit,
onDismiss: () -> Unit,
) {
val options = StreamQualityLadder.availableIds(sessionQualityPreset)
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Column(
modifier = Modifier
.padding(horizontal = 24.dp)
.widthIn(max = 720.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(MatchColors.Surface)
.border(1.dp, MatchColors.Outline, RoundedCornerShape(16.dp))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.broadcast_min_quality_title),
color = Color.White,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel), color = MatchColors.TextSecondary)
}
}
Text(
stringResource(R.string.broadcast_min_quality_hint),
color = MatchColors.TextSecondary,
style = MaterialTheme.typography.bodySmall,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
options.forEach { id ->
MinQualityOptionChip(
label = StreamQualityLadder.displayName(id, autoLabel),
selected = id == selectedId,
onClick = { onSelect(id) },
modifier = Modifier.weight(1f),
)
}
}
}
}
}
@Composable
private fun MinQualityOptionChip(
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(12.dp)
val border = if (selected) MatchColors.AccentYellow else MatchColors.Outline
val background = if (selected) {
MatchColors.AccentYellow.copy(alpha = 0.16f)
} else {
MatchColors.SurfaceElevated
}
Column(
modifier = modifier
.clip(shape)
.background(background)
.border(1.dp, border, shape)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (selected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MatchColors.AccentYellow,
modifier = Modifier.size(16.dp),
)
} else {
Spacer(Modifier.height(16.dp))
}
Text(
label,
color = if (selected) MatchColors.AccentYellow else Color.White,
style = MaterialTheme.typography.labelMedium,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun SideIconButton( private fun SideIconButton(
@@ -44,7 +44,6 @@ import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.MatchScoringRules import com.matchlivetv.match_live_tv.domain.MatchScoringRules
import com.matchlivetv.match_live_tv.domain.StreamSession import com.matchlivetv.match_live_tv.domain.StreamSession
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
import com.matchlivetv.match_live_tv.streaming.StreamQualityLadder
import com.matchlivetv.match_live_tv.streaming.StreamVideoPreset import com.matchlivetv.match_live_tv.streaming.StreamVideoPreset
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
import com.matchlivetv.match_live_tv.streaming.LivePreviewView import com.matchlivetv.match_live_tv.streaming.LivePreviewView
@@ -79,7 +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 minQualityInFlight 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)) }
@@ -147,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(
@@ -158,16 +158,10 @@ fun BroadcastScreen(
} }
suspend fun applyRemotePause() { suspend fun applyRemotePause() {
if (pauseInFlight) return container.broadcastCoordinator.engine.pauseBroadcast()
pauseInFlight = true session = runCatching { container.sessionRepository.fetchSession(sessionId) }
try { .getOrNull() ?: session?.copy(status = "paused")
container.broadcastCoordinator.engine.pauseBroadcast() snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_paused_remote))
session = runCatching { container.sessionRepository.fetchSession(sessionId) }
.getOrNull() ?: session?.copy(status = "paused")
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_paused_remote))
} finally {
pauseInFlight = false
}
} }
suspend fun applyRemoteResume() { suspend fun applyRemoteResume() {
@@ -182,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),
@@ -199,36 +194,38 @@ fun BroadcastScreen(
onFinished() onFinished()
} }
fun applyMinQualityLocally(preset: String, loaded: StreamSession) { suspend fun setAudioMuted(muted: Boolean, remote: Boolean) {
val updated = loaded.copy(minQualityPreset = preset) if (muteInFlight) return
session = updated if ((session?.audioMuted == true || metrics.audioMuted) == muted) {
val cfg = broadcastConfig(updated) container.broadcastCoordinator.setAudioMuted(muted)
container.broadcastCoordinator.engine.setMinQualityFloor(cfg.minVideoBitrate, cfg.videoBitrate) return
thermalManager.updateBaseline(cfg) }
} muteInFlight = true
try {
suspend fun persistMinQuality(preset: String) { if (!remote) {
val current = session ?: return val updated = container.sessionRepository.setAudioMute(sessionId, muted)
minQualityInFlight = true session = updated
runCatching { container.sessionRepository.setMinQuality(sessionId, preset) } } else {
.onSuccess { updated -> session = session?.copy(audioMuted = muted)
applyMinQualityLocally(updated.minQualityPreset, updated)
snackbar.showSnackbar(
context.getString(
R.string.broadcast_min_quality_applied,
StreamQualityLadder.displayName(
updated.minQualityPreset,
context.getString(R.string.broadcast_min_quality_auto),
),
),
)
} }
.onFailure { container.broadcastCoordinator.setAudioMuted(muted)
snackbar.showSnackbar( snackbar.showSnackbar(
it.message ?: context.getString(R.string.broadcast_min_quality_error), context.getString(
) when {
} remote && muted -> R.string.broadcast_snackbar_muted_remote
minQualityInFlight = false 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) {
@@ -255,14 +252,8 @@ fun BroadcastScreen(
container.sessionCable.onEndStream = { container.sessionCable.onEndStream = {
scope.launch { applyRemoteStop() } scope.launch { applyRemoteStop() }
} }
container.sessionCable.onMinQuality = { preset -> container.sessionCable.onAudioMute = { muted ->
scope.launch { if (!muteInFlight) scope.launch { setAudioMuted(muted, remote = true) }
val current = session
if (current != null && current.minQualityPreset != preset) {
applyMinQualityLocally(preset, current)
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_min_quality_remote))
}
}
} }
container.sessionCable.connect(sessionId, token, deviceRole = "camera") container.sessionCable.connect(sessionId, token, deviceRole = "camera")
} }
@@ -274,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)
@@ -378,28 +371,12 @@ fun BroadcastScreen(
LaunchedEffect(sessionId) { LaunchedEffect(sessionId) {
while (true) { while (true) {
delay(2_000) delay(4_000)
if (minQualityInFlight || pauseInFlight || resumeInFlight) continue runCatching {
runCatching { container.sessionRepository.fetchSession(sessionId) } container.sessionRepository.fetchSession(sessionId).score
.getOrNull() }.getOrNull()?.let { remote ->
?.let { fresh -> container.scoreController.applyRemote(remote)
fresh.score?.let { container.scoreController.applyRemote(it) } }
val current = session ?: return@let
if (fresh.minQualityPreset != current.minQualityPreset) {
applyMinQualityLocally(fresh.minQualityPreset, current)
}
val phase = container.broadcastCoordinator.metrics.value.phase
val publishing = phase == BroadcastPhase.LIVE ||
phase == BroadcastPhase.CONNECTING ||
phase == BroadcastPhase.RECONNECTING
when {
fresh.isEnded && phase != BroadcastPhase.IDLE -> applyRemoteStop()
fresh.isPaused && publishing -> applyRemotePause()
!fresh.isPaused && !fresh.isEnded && phase == BroadcastPhase.PAUSED ->
applyRemoteResume()
else -> session = fresh
}
}
} }
} }
@@ -407,6 +384,7 @@ fun BroadcastScreen(
while (true) { while (true) {
delay(10_000) delay(10_000)
val current = session ?: continue val current = session ?: continue
if (current.isPaused) continue
runCatching { runCatching {
container.sessionRepository.postTelemetry( container.sessionRepository.postTelemetry(
sessionId = sessionId, sessionId = sessionId,
@@ -416,7 +394,6 @@ fun BroadcastScreen(
currentBitrate = (metrics.bitrateKbps * 1000).toInt().takeIf { it > 0 }, currentBitrate = (metrics.bitrateKbps * 1000).toInt().takeIf { it > 0 },
targetBitrate = current.targetBitrate, targetBitrate = current.targetBitrate,
fps = metrics.fps.takeIf { it > 0 }, fps = metrics.fps.takeIf { it > 0 },
thermalState = thermalManager.state.value.name.lowercase(),
) )
} }
} }
@@ -429,7 +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.onMinQuality = 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()
@@ -437,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)
@@ -610,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
@@ -644,11 +626,6 @@ fun BroadcastScreen(
bitrateKbps = metrics.bitrateKbps, bitrateKbps = metrics.bitrateKbps,
networkType = DeviceTelemetry.networkType(context), networkType = DeviceTelemetry.networkType(context),
deviceHealth = DeviceTelemetry.snapshot(context, thermalState), deviceHealth = DeviceTelemetry.snapshot(context, thermalState),
minQualityPreset = currentSession?.minQualityPreset ?: StreamQualityLadder.AUTO,
sessionQualityPreset = currentSession?.qualityPreset ?: "720p_30_2.5mbps",
onSelectMinQuality = { preset ->
scope.launch { persistMinQuality(preset) }
},
) )
} }
} }
@@ -29,15 +29,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -54,7 +50,7 @@ import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
import com.matchlivetv.match_live_tv.ui.theme.MatchColors import com.matchlivetv.match_live_tv.ui.theme.MatchColors
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LoginScreen( fun LoginScreen(
container: AppContainer, container: AppContainer,
@@ -134,10 +130,7 @@ fun LoginScreen(
onValueChange = { email = it }, onValueChange = { email = it },
label = { Text(stringResource(R.string.login_email)) }, label = { Text(stringResource(R.string.login_email)) },
placeholder = { Text(stringResource(R.string.login_email_placeholder)) }, placeholder = { Text(stringResource(R.string.login_email_placeholder)) },
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth()
.testTag("login_email")
.semantics { testTagsAsResourceId = true },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email, keyboardType = KeyboardType.Email,
@@ -153,10 +146,7 @@ fun LoginScreen(
value = password, value = password,
onValueChange = { password = it }, onValueChange = { password = it },
label = { Text(stringResource(R.string.login_password)) }, label = { Text(stringResource(R.string.login_password)) },
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth()
.testTag("login_password")
.semantics { testTagsAsResourceId = true },
singleLine = true, singleLine = true,
visualTransformation = if (passwordVisible) { visualTransformation = if (passwordVisible) {
VisualTransformation.None VisualTransformation.None
@@ -208,9 +198,6 @@ fun LoginScreen(
loading = loading, loading = loading,
enabled = email.isNotBlank() && password.isNotBlank(), enabled = email.isNotBlank() && password.isNotBlank(),
onClick = { submitLogin() }, onClick = { submitLogin() },
modifier = Modifier
.testTag("login_submit")
.semantics { testTagsAsResourceId = true },
) )
} }
} }
@@ -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,14 +168,10 @@
<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_min_quality_title">Mindestqualität</string>
<string name="broadcast_min_quality_cd">Minimale Übertragungsqualität</string>
<string name="broadcast_min_quality_hint">Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.</string>
<string name="broadcast_min_quality_auto">Automatisch</string>
<string name="broadcast_min_quality_applied">Mindestqualität: %1$s</string>
<string name="broadcast_min_quality_error">Mindestqualität konnte nicht gesetzt werden</string>
<string name="broadcast_snackbar_min_quality_remote">Mindestqualität von der Regie aktualisiert</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>
<string name="broadcast_scoreboard_connected">Anzeigetafel OK</string> <string name="broadcast_scoreboard_connected">Anzeigetafel OK</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,14 +168,10 @@
<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_min_quality_title">Minimum quality</string>
<string name="broadcast_min_quality_cd">Minimum streaming quality</string>
<string name="broadcast_min_quality_hint">The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.</string>
<string name="broadcast_min_quality_auto">Automatic</string>
<string name="broadcast_min_quality_applied">Minimum quality: %1$s</string>
<string name="broadcast_min_quality_error">Unable to set minimum quality</string>
<string name="broadcast_snackbar_min_quality_remote">Minimum quality updated from control room</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>
<string name="broadcast_scoreboard_connected">Scoreboard OK</string> <string name="broadcast_scoreboard_connected">Scoreboard OK</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,14 +168,10 @@
<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_min_quality_title">Calidad mínima</string>
<string name="broadcast_min_quality_cd">Calidad mínima de transmisión</string>
<string name="broadcast_min_quality_hint">El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.</string>
<string name="broadcast_min_quality_auto">Automática</string>
<string name="broadcast_min_quality_applied">Calidad mínima: %1$s</string>
<string name="broadcast_min_quality_error">No se pudo fijar la calidad mínima</string>
<string name="broadcast_snackbar_min_quality_remote">Calidad mínima actualizada desde la regie</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>
<string name="broadcast_scoreboard_connected">Marcador OK</string> <string name="broadcast_scoreboard_connected">Marcador OK</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,14 +168,10 @@
<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_min_quality_title">Qualité minimale</string>
<string name="broadcast_min_quality_cd">Qualité minimale de transmission</string>
<string name="broadcast_min_quality_hint">L\'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.</string>
<string name="broadcast_min_quality_auto">Automatique</string>
<string name="broadcast_min_quality_applied">Qualité minimale : %1$s</string>
<string name="broadcast_min_quality_error">Impossible de définir la qualité minimale</string>
<string name="broadcast_snackbar_min_quality_remote">Qualité minimale mise à jour depuis la régie</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>
<string name="broadcast_scoreboard_connected">Tableau OK</string> <string name="broadcast_scoreboard_connected">Tableau OK</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,14 +168,10 @@
<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_min_quality_title">Qualità minima</string>
<string name="broadcast_min_quality_cd">Qualità minima di trasmissione</string>
<string name="broadcast_min_quality_hint">L\'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.</string>
<string name="broadcast_min_quality_auto">Automatica</string>
<string name="broadcast_min_quality_applied">Qualità minima: %1$s</string>
<string name="broadcast_min_quality_error">Impossibile impostare la qualità minima</string>
<string name="broadcast_snackbar_min_quality_remote">Qualità minima aggiornata dalla regia</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>
<string name="broadcast_scoreboard_connected">Tabellone OK</string> <string name="broadcast_scoreboard_connected">Tabellone OK</string>
@@ -0,0 +1,21 @@
package com.matchlivetv.match_live_tv.data.cable
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AudioMuteCommandsTest {
@Test
fun commandMuteAudio() {
assertEquals(true, AudioMuteCommands.mutedFromPayload(type = "command", action = "mute_audio"))
assertEquals(false, AudioMuteCommands.mutedFromPayload(type = "command", action = "unmute_audio"))
assertNull(AudioMuteCommands.mutedFromPayload(type = "command", action = "pause_stream"))
}
@Test
fun streamEventAudioMuted() {
assertEquals(true, AudioMuteCommands.mutedFromPayload(type = "stream_event", event = "audio_muted", muted = true))
assertEquals(false, AudioMuteCommands.mutedFromPayload(type = "stream_event", event = "audio_muted", muted = false))
assertNull(AudioMuteCommands.mutedFromPayload(type = "stream_event", event = "paused"))
}
}
@@ -1,106 +0,0 @@
package com.matchlivetv.match_live_tv.streaming
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class AdaptiveBitrateControllerTest {
private fun controller() = AdaptiveBitrateController().also {
it.configure(ceiling = 2_500_000, floor = AdaptiveBitrateController.AUTO_FLOOR)
}
@Test
fun congestionDropsTowardMeasuredAndRespectsFloor() {
val abr = controller()
val next = abr.onCongestion(1_200_000)
assertEquals(1_200_000 - 128_000, next)
assertEquals(1_072_000, abr.currentBitrate)
}
@Test
fun congestionDoesNotGoBelowFloor() {
val abr = controller()
abr.setFloor(1_500_000)
val next = abr.onCongestion(400_000)
assertEquals(1_500_000, next)
}
@Test
fun healthyRampsUpAfterEnoughSamples() {
val abr = controller()
abr.onCongestion(1_200_000)
repeat(7) { assertNull(abr.onHealthy()) }
val raised = abr.onHealthy()
assertEquals(1_072_000 + 250_000, raised)
}
@Test
fun thermalCeilingClampsCurrent() {
val abr = controller()
val applied = abr.setCeiling(1_250_000)
assertEquals(1_250_000, applied)
assertEquals(1_250_000, abr.ceiling)
assertTrue(abr.floor <= abr.ceiling)
}
@Test
fun minFloorBumpsCurrentUp() {
val abr = controller()
abr.onCongestion(1_200_000)
val applied = abr.setFloor(2_000_000)
assertEquals(2_000_000, applied)
}
@Test
fun autoOptionsExclude1080On720Session() {
val ids = StreamQualityLadder.availableIds("720p_30_2.5mbps")
assertEquals(
listOf(
StreamQualityLadder.AUTO,
"720p_30_1.5mbps",
"720p_30_2.5mbps",
"720p_30_4mbps",
),
ids,
)
}
@Test
fun measuredZeroWithoutCongestionDoesNotDrop() {
val abr = controller()
assertNull(abr.onMeasured(0L, congested = false))
assertEquals(2_500_000, abr.currentBitrate)
}
@Test
fun measuredLowThroughputDropsWithoutCongestionFlag() {
val abr = controller()
val next = abr.onMeasured(1_200_000L, congested = false)
assertEquals(1_200_000 - 128_000, next)
}
@Test
fun thermalDropThenRecoverRestoresUserFloor() {
val abr = controller()
abr.setFloor(1_500_000)
abr.setCeiling(1_000_000)
assertEquals(1_000_000, abr.floor)
abr.setCeiling(2_500_000)
assertEquals(1_000_000, abr.floor)
assertEquals(1_500_000, abr.setFloor(1_500_000))
}
@Test
fun encoderCeilingRisesWhenMinExceedsSessionTarget() {
val ceiling = StreamQualityLadder.encoderCeiling(
sessionTarget = 2_500_000,
minPresetId = "720p_30_4mbps",
)
assertEquals(4_000_000, ceiling)
assertEquals(
4_000_000,
StreamQualityLadder.encoderFloor("720p_30_4mbps", ceiling),
)
}
}
@@ -28,7 +28,6 @@
39335ED5DE5847E5904222AB /* MatchSessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813463F3E17D453EAA801739 /* MatchSessionLauncher.swift */; }; 39335ED5DE5847E5904222AB /* MatchSessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813463F3E17D453EAA801739 /* MatchSessionLauncher.swift */; };
39F7D49D44244F51907E803D /* TeamColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7001BB5D65B34280B306ECCF /* TeamColorPicker.swift */; }; 39F7D49D44244F51907E803D /* TeamColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7001BB5D65B34280B306ECCF /* TeamColorPicker.swift */; };
451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */; }; 451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */; };
5F9B2C3D4E5A4B72C8D9E0F1 /* AdaptiveBitrateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8A1B2C3D4F4A61B7C8D9E0 /* AdaptiveBitrateController.swift */; };
454725D14B224276B06BCC67 /* MatchHubFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */; }; 454725D14B224276B06BCC67 /* MatchHubFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */; };
45776A6A76D04EF5A0C0D187 /* TeamBrandingEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBBE0F1399840FFA0E7EF2E /* TeamBrandingEditor.swift */; }; 45776A6A76D04EF5A0C0D187 /* TeamBrandingEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBBE0F1399840FFA0E7EF2E /* TeamBrandingEditor.swift */; };
470957DE79A84D9889F0663C /* ScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC8ECFD24A14054BDD984C3 /* ScoreRepository.swift */; }; 470957DE79A84D9889F0663C /* ScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC8ECFD24A14054BDD984C3 /* ScoreRepository.swift */; };
@@ -69,7 +68,6 @@
AA18DECE8D08461B9197EB71 /* MatchScoringRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */; }; AA18DECE8D08461B9197EB71 /* MatchScoringRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */; };
AA3A68B2C1784294ADB89605 /* MatchLiveTv/Resources/it.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 857C036B5B5C43368506DD8B /* MatchLiveTv/Resources/it.lproj */; }; AA3A68B2C1784294ADB89605 /* MatchLiveTv/Resources/it.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 857C036B5B5C43368506DD8B /* MatchLiveTv/Resources/it.lproj */; };
AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */; }; AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */; };
7B1D4E5F6A7C4D94E0F1A2B3 /* AdaptiveBitrateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A0C3D4E5F6B4C83D9E0F1A2 /* AdaptiveBitrateTests.swift */; };
ABC5853FEC554FA4AFBDCD2E /* OverlayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DDC7305F3B549D0B7646B14 /* OverlayState.swift */; }; ABC5853FEC554FA4AFBDCD2E /* OverlayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DDC7305F3B549D0B7646B14 /* OverlayState.swift */; };
ACC54AA19C5747CFA118C70B /* ApiInstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FEB862C3A34BF8AC24052E /* ApiInstantTests.swift */; }; ACC54AA19C5747CFA118C70B /* ApiInstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FEB862C3A34BF8AC24052E /* ApiInstantTests.swift */; };
AECC36D80A0C411AB47D581A /* MatchRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537FC2C063D341DF97A391B9 /* MatchRepository.swift */; }; AECC36D80A0C411AB47D581A /* MatchRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537FC2C063D341DF97A391B9 /* MatchRepository.swift */; };
@@ -129,7 +127,6 @@
31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = "<group>"; }; 31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = "<group>"; };
34BB2251A67E401A9F1E789E /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; }; 34BB2251A67E401A9F1E789E /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; };
377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThermalAdaptationTests.swift; path = MatchLiveTvTests/ThermalAdaptationTests.swift; sourceTree = "<group>"; }; 377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThermalAdaptationTests.swift; path = MatchLiveTvTests/ThermalAdaptationTests.swift; sourceTree = "<group>"; };
6A0C3D4E5F6B4C83D9E0F1A2 /* AdaptiveBitrateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AdaptiveBitrateTests.swift; path = MatchLiveTvTests/AdaptiveBitrateTests.swift; sourceTree = "<group>"; };
39F3C00B63534E6AA6D8A90A /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; }; 39F3C00B63534E6AA6D8A90A /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; };
3A6F9AAB5A1249BBB4FC02E6 /* MatchLiveTv/Resources/en.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/en.lproj; sourceTree = "<group>"; }; 3A6F9AAB5A1249BBB4FC02E6 /* MatchLiveTv/Resources/en.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/en.lproj; sourceTree = "<group>"; };
3BFB2F18615747B1A1C9E9B2 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = "<group>"; }; 3BFB2F18615747B1A1C9E9B2 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = "<group>"; };
@@ -163,7 +160,6 @@
87C01CC031C34343B3EBFF08 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = "<group>"; }; 87C01CC031C34343B3EBFF08 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = "<group>"; };
8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = "<group>"; }; 8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = "<group>"; };
8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamVideoPreset.swift; path = MatchLiveTv/Streaming/StreamVideoPreset.swift; sourceTree = "<group>"; }; 8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamVideoPreset.swift; path = MatchLiveTv/Streaming/StreamVideoPreset.swift; sourceTree = "<group>"; };
4E8A1B2C3D4F4A61B7C8D9E0 /* AdaptiveBitrateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AdaptiveBitrateController.swift; path = MatchLiveTv/Streaming/AdaptiveBitrateController.swift; sourceTree = "<group>"; };
902831E3D87C46359F0D0A21 /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = "<group>"; }; 902831E3D87C46359F0D0A21 /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = "<group>"; };
951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = "<group>"; }; 951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = "<group>"; };
98526C26B2584F55AADF0FFF /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = "<group>"; }; 98526C26B2584F55AADF0FFF /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = "<group>"; };
@@ -241,7 +237,6 @@
10AD0F18190542818572CAEF /* ScoreActionDecodeTests.swift */, 10AD0F18190542818572CAEF /* ScoreActionDecodeTests.swift */,
6DF142EBF33B45739BA8E42C /* ScoreControllerTests.swift */, 6DF142EBF33B45739BA8E42C /* ScoreControllerTests.swift */,
377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */, 377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */,
6A0C3D4E5F6B4C83D9E0F1A2 /* AdaptiveBitrateTests.swift */,
); );
name = MatchLiveTvTests; name = MatchLiveTvTests;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -301,7 +296,6 @@
31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */, 31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */,
46CE696CAB994E2C8E399776 /* LivePreviewView.swift */, 46CE696CAB994E2C8E399776 /* LivePreviewView.swift */,
8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */, 8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */,
4E8A1B2C3D4F4A61B7C8D9E0 /* AdaptiveBitrateController.swift */,
4419E10415414556B7C58458 /* AccountScreen.swift */, 4419E10415414556B7C58458 /* AccountScreen.swift */,
E860A0E35362427DA84F8972 /* BroadcastControlsOverlay.swift */, E860A0E35362427DA84F8972 /* BroadcastControlsOverlay.swift */,
71816F0D99E44551839474CE /* BroadcastScreen.swift */, 71816F0D99E44551839474CE /* BroadcastScreen.swift */,
@@ -479,7 +473,6 @@
6AD0B3220B154EA2936C634A /* WatermarkElement.swift in Sources */, 6AD0B3220B154EA2936C634A /* WatermarkElement.swift in Sources */,
713E0129656649FC8630FDC6 /* LivePreviewView.swift in Sources */, 713E0129656649FC8630FDC6 /* LivePreviewView.swift in Sources */,
451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */, 451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */,
5F9B2C3D4E5A4B72C8D9E0F1 /* AdaptiveBitrateController.swift in Sources */,
E5E21D088782469D8EB23543 /* AccountScreen.swift in Sources */, E5E21D088782469D8EB23543 /* AccountScreen.swift in Sources */,
5B51805239FA4D4D90919D86 /* BroadcastControlsOverlay.swift in Sources */, 5B51805239FA4D4D90919D86 /* BroadcastControlsOverlay.swift in Sources */,
EE568EA02C404102B4DFD651 /* BroadcastScreen.swift in Sources */, EE568EA02C404102B4DFD651 /* BroadcastScreen.swift in Sources */,
@@ -523,7 +516,6 @@
5D91C53D2E8A4952937F816B /* ScoreActionDecodeTests.swift in Sources */, 5D91C53D2E8A4952937F816B /* ScoreActionDecodeTests.swift in Sources */,
FB66DD0422E14C54AC5B3EDB /* ScoreControllerTests.swift in Sources */, FB66DD0422E14C54AC5B3EDB /* ScoreControllerTests.swift in Sources */,
AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */, AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */,
7B1D4E5F6A7C4D94E0F1A2B3 /* AdaptiveBitrateTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
+50 -45
View File
@@ -118,13 +118,8 @@ enum L10n {
"broadcast.error.rtmp.session.create": "Impossibile creare sessione RTMP", "broadcast.error.rtmp.session.create": "Impossibile creare sessione RTMP",
"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.min.quality.applied": "Qualità minima: %1$@", "broadcast.mute.cd": "Silenzia audio",
"broadcast.min.quality.auto": "Automatica",
"broadcast.min.quality.cd": "Qualità minima di trasmissione",
"broadcast.min.quality.error": "Impossibile impostare la qualità minima",
"broadcast.min.quality.hint": "L'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.",
"broadcast.min.quality.title": "Qualità minima",
"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",
@@ -145,8 +140,10 @@ enum L10n {
"broadcast.show.controls.cd": "Mostra controlli", "broadcast.show.controls.cd": "Mostra controlli",
"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.min.quality.remote": "Qualità minima aggiornata 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",
@@ -155,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",
@@ -171,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",
@@ -415,13 +416,8 @@ enum L10n {
"broadcast.error.rtmp.session.create": "Unable to create RTMP session", "broadcast.error.rtmp.session.create": "Unable to create RTMP session",
"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.min.quality.applied": "Minimum quality: %1$@", "broadcast.mute.cd": "Mute audio",
"broadcast.min.quality.auto": "Automatic",
"broadcast.min.quality.cd": "Minimum streaming quality",
"broadcast.min.quality.error": "Unable to set minimum quality",
"broadcast.min.quality.hint": "The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.",
"broadcast.min.quality.title": "Minimum quality",
"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",
@@ -442,8 +438,10 @@ enum L10n {
"broadcast.show.controls.cd": "Show controls", "broadcast.show.controls.cd": "Show controls",
"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.min.quality.remote": "Minimum quality updated 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",
@@ -452,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",
@@ -468,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",
@@ -712,13 +714,8 @@ enum L10n {
"broadcast.error.rtmp.session.create": "Impossible de créer la session RTMP", "broadcast.error.rtmp.session.create": "Impossible de créer la session RTMP",
"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.min.quality.applied": "Qualité minimale : %1$@", "broadcast.mute.cd": "Couper l'audio",
"broadcast.min.quality.auto": "Automatique",
"broadcast.min.quality.cd": "Qualité minimale de transmission",
"broadcast.min.quality.error": "Impossible de définir la qualité minimale",
"broadcast.min.quality.hint": "L'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.",
"broadcast.min.quality.title": "Qualité minimale",
"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",
@@ -739,8 +736,10 @@ enum L10n {
"broadcast.show.controls.cd": "Afficher les commandes", "broadcast.show.controls.cd": "Afficher les commandes",
"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.min.quality.remote": "Qualité minimale mise à jour 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",
@@ -749,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",
@@ -765,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",
@@ -1009,13 +1012,8 @@ enum L10n {
"broadcast.error.rtmp.session.create": "RTMP-Sitzung konnte nicht erstellt werden", "broadcast.error.rtmp.session.create": "RTMP-Sitzung konnte nicht erstellt werden",
"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.min.quality.applied": "Mindestqualität: %1$@", "broadcast.mute.cd": "Audio stummschalten",
"broadcast.min.quality.auto": "Automatisch",
"broadcast.min.quality.cd": "Minimale Übertragungsqualität",
"broadcast.min.quality.error": "Mindestqualität konnte nicht gesetzt werden",
"broadcast.min.quality.hint": "Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.",
"broadcast.min.quality.title": "Mindestqualität",
"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",
@@ -1036,8 +1034,10 @@ enum L10n {
"broadcast.show.controls.cd": "Steuerung einblenden", "broadcast.show.controls.cd": "Steuerung einblenden",
"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.min.quality.remote": "Mindestqualität von der Regie aktualisiert", "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",
@@ -1046,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",
@@ -1062,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",
@@ -1306,13 +1310,8 @@ enum L10n {
"broadcast.error.rtmp.session.create": "No se pudo crear la sesión RTMP", "broadcast.error.rtmp.session.create": "No se pudo crear la sesión RTMP",
"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.min.quality.applied": "Calidad mínima: %1$@", "broadcast.mute.cd": "Silenciar audio",
"broadcast.min.quality.auto": "Automática",
"broadcast.min.quality.cd": "Calidad mínima de transmisión",
"broadcast.min.quality.error": "No se pudo fijar la calidad mínima",
"broadcast.min.quality.hint": "El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.",
"broadcast.min.quality.title": "Calidad mínima",
"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",
@@ -1333,8 +1332,10 @@ enum L10n {
"broadcast.show.controls.cd": "Mostrar controles", "broadcast.show.controls.cd": "Mostrar controles",
"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.min.quality.remote": "Calidad mínima actualizada desde la 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",
@@ -1343,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",
@@ -1359,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",
@@ -39,13 +39,4 @@ enum ThermalState: Int, Sendable, Equatable, Comparable {
case .critical: return "🔴" case .critical: return "🔴"
} }
} }
var apiValue: String {
switch self {
case .nominal: return "nominal"
case .fair: return "fair"
case .serious: return "serious"
case .critical: return "critical"
}
}
} }
@@ -202,8 +202,8 @@ struct StreamSessionDto: Decodable {
let targetBitrate: Int? let targetBitrate: Int?
let targetFps: Int? let targetFps: Int?
let qualityPreset: String? let qualityPreset: String?
let minQualityPreset: String?
let startedAt: String? let startedAt: String?
let audioMuted: Bool?
let score: ScoreStateDto? let score: ScoreStateDto?
func toDomain() -> StreamSession { func toDomain() -> StreamSession {
@@ -222,9 +222,9 @@ struct StreamSessionDto: Decodable {
targetBitrate: targetBitrate ?? 2_500_000, targetBitrate: targetBitrate ?? 2_500_000,
targetFps: targetFps ?? 30, targetFps: targetFps ?? 30,
qualityPreset: qualityPreset ?? "720p_30_2.5mbps", qualityPreset: qualityPreset ?? "720p_30_2.5mbps",
minQualityPreset: minQualityPreset ?? "auto",
startedAt: startedAt, startedAt: startedAt,
score: score?.toDomain() score: score?.toDomain(),
audioMuted: audioMuted ?? false
) )
} }
} }
@@ -416,8 +416,8 @@ struct CreateSessionRequest: Encodable {
let youtubeChannel: String? let youtubeChannel: String?
} }
struct MinQualityRequest: Encodable { struct AudioMuteRequest: Encodable {
let minQualityPreset: String let muted: Bool
} }
struct ScoringRulesBody: Codable { struct ScoringRulesBody: Codable {
@@ -505,7 +505,6 @@ struct TelemetryRequest: Encodable {
let currentBitrate: Int? let currentBitrate: Int?
let targetBitrate: Int? let targetBitrate: Int?
let fps: Int? let fps: Int?
let thermalState: String?
} }
extension ScoringRules { extension ScoringRules {
@@ -186,8 +186,8 @@ final class MatchLiveAPI: @unchecked Sendable {
try await patch("sessions/\(id)/resume", body: EmptyBody()) try await patch("sessions/\(id)/resume", body: EmptyBody())
} }
func setMinQuality(id: String, preset: String) async throws -> StreamSessionDto { func setAudioMute(id: String, muted: Bool) async throws -> StreamSessionDto {
try await patch("sessions/\(id)/min_quality", body: MinQualityRequest(minQualityPreset: preset)) 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 {
@@ -8,7 +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 onMinQuality: ((String) -> Void)? var onAudioMute: ((Bool) -> Void)?
private var client: ActionCableClient? private var client: ActionCableClient?
private var intentionalDisconnect = false private var intentionalDisconnect = false
@@ -90,18 +90,20 @@ 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?()
case "stop_stream": onStopStream?() case "stop_stream": onStopStream?()
case "set_min_quality":
if let preset = payload["min_quality_preset"] as? String {
onMinQuality?(preset)
}
default: break default: break
} }
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?()
@@ -114,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,8 +50,8 @@ final class SessionRepository {
try await api.resumeSession(id: id).toDomain() try await api.resumeSession(id: id).toDomain()
} }
func setMinQuality(id: String, preset: String) async throws -> StreamSession { func setAudioMute(id: String, muted: Bool) async throws -> StreamSession {
try await api.setMinQuality(id: id, preset: preset).toDomain() try await api.setAudioMute(id: id, muted: muted).toDomain()
} }
func submitNetworkTest( func submitNetworkTest(
@@ -92,8 +92,7 @@ final class SessionRepository {
signalStrength: nil, signalStrength: nil,
currentBitrate: currentBitrate, currentBitrate: currentBitrate,
targetBitrate: targetBitrate, targetBitrate: targetBitrate,
fps: fps, fps: fps
thermalState: health.thermalState.apiValue
) )
) )
} }
+4 -5
View File
@@ -119,15 +119,14 @@ struct StreamSession: Identifiable, Equatable, Sendable {
let targetBitrate: Int let targetBitrate: Int
let targetFps: Int let targetFps: Int
let qualityPreset: String let qualityPreset: String
let minQualityPreset: 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" }
var isEnded: Bool { status == "ended" || status == "error" }
func with(minQualityPreset: String) -> StreamSession { func withAudioMuted(_ muted: Bool) -> StreamSession {
StreamSession( StreamSession(
id: id, id: id,
matchId: matchId, matchId: matchId,
@@ -143,9 +142,9 @@ struct StreamSession: Identifiable, Equatable, Sendable {
targetBitrate: targetBitrate, targetBitrate: targetBitrate,
targetFps: targetFps, targetFps: targetFps,
qualityPreset: qualityPreset, qualityPreset: qualityPreset,
minQualityPreset: minQualityPreset,
startedAt: startedAt, startedAt: startedAt,
score: score score: score,
audioMuted: muted
) )
} }
@@ -1,193 +0,0 @@
import Foundation
enum StreamQualityLadder {
static let auto = "auto"
struct Preset: Equatable, Sendable {
let id: String
let bitrate: Int
let fps: Int
var is1080: Bool { id.hasPrefix("1080p") }
var resolutionLabel: String { is1080 ? "1080p" : "720p" }
}
static let presets: [Preset] = [
Preset(id: "1080p_30_4.5mbps", bitrate: 4_500_000, fps: 30),
Preset(id: "720p_30_4mbps", bitrate: 4_000_000, fps: 30),
Preset(id: "720p_30_2.5mbps", bitrate: 2_500_000, fps: 30),
Preset(id: "720p_30_1.5mbps", bitrate: 1_500_000, fps: 30)
]
static func normalize(_ presetId: String?) -> String {
let id = presetId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if id.isEmpty || id == auto { return auto }
return presets.contains(where: { $0.id == id }) ? id : auto
}
static func floorBitrate(_ presetId: String?) -> Int {
let id = normalize(presetId)
if id == auto { return AdaptiveBitrateController.autoFloor }
return presets.first(where: { $0.id == id })?.bitrate ?? AdaptiveBitrateController.autoFloor
}
static func ceilingContribution(_ presetId: String?) -> Int {
let id = normalize(presetId)
if id == auto { return 0 }
return presets.first(where: { $0.id == id })?.bitrate ?? 0
}
static func encoderCeiling(sessionTarget: Int, minPresetId: String?) -> Int {
max(sessionTarget, ceilingContribution(minPresetId))
}
static func encoderFloor(minPresetId: String?, ceiling: Int) -> Int {
min(floorBitrate(minPresetId), ceiling)
}
static func availableIds(sessionQualityPreset: String?) -> [String] {
var ids = [auto]
ids += presets.filter { !$0.is1080 }.reversed().map(\.id)
if sessionQualityPreset?.hasPrefix("1080p") == true,
let p1080 = presets.first(where: { $0.is1080 }) {
ids.append(p1080.id)
}
return ids
}
static func displayName(id: String, autoLabel: String) -> String {
if normalize(id) == auto { return autoLabel }
guard let preset = presets.first(where: { $0.id == id }) else { return id }
let mbps = Double(preset.bitrate) / 1_000_000.0
let mbpsText = mbps == Double(Int(mbps))
? String(Int(mbps))
: String(format: "%.1f", mbps)
return "\(preset.resolutionLabel) · \(mbpsText) Mbps"
}
}
/// ABR conservativo per RTMP: scende in fretta in congestione, risale piano.
final class AdaptiveBitrateController: @unchecked Sendable {
static let autoFloor = 800_000
static let minAbsolute = 500_000
static let defaultCeiling = 2_500_000
private let lock = NSLock()
private let audioBitrate: Int
private let healthySamplesBeforeUp: Int
private let minDelta: Int
private var healthyCount = 0
private(set) var ceiling: Int = AdaptiveBitrateController.defaultCeiling
private(set) var floor: Int = AdaptiveBitrateController.autoFloor
private(set) var currentBitrate: Int = AdaptiveBitrateController.defaultCeiling
init(
audioBitrate: Int = 128_000,
healthySamplesBeforeUp: Int = 8,
minDelta: Int = 50_000
) {
self.audioBitrate = audioBitrate
self.healthySamplesBeforeUp = healthySamplesBeforeUp
self.minDelta = minDelta
}
func configure(ceiling: Int, floor: Int) {
lock.lock()
defer { lock.unlock() }
self.ceiling = max(ceiling, Self.minAbsolute)
self.floor = min(max(floor, Self.minAbsolute), self.ceiling)
currentBitrate = self.ceiling
healthyCount = 0
}
@discardableResult
func setCeiling(_ value: Int) -> Int {
lock.lock()
defer { lock.unlock() }
ceiling = max(value, Self.minAbsolute)
if floor > ceiling { floor = ceiling }
if currentBitrate > ceiling {
currentBitrate = ceiling
healthyCount = 0
}
return currentBitrate
}
@discardableResult
func setFloor(_ value: Int) -> Int {
lock.lock()
defer { lock.unlock() }
floor = min(max(value, Self.minAbsolute), ceiling)
if currentBitrate < floor {
currentBitrate = floor
healthyCount = 0
}
return currentBitrate
}
func onCongestion(measuredBitsPerSecond: Int) -> Int? {
lock.lock()
defer { lock.unlock() }
return applyCongestionLocked(measuredBitsPerSecond)
}
func onHealthy() -> Int? {
lock.lock()
defer { lock.unlock() }
return applyHealthyLocked()
}
func onMeasured(measuredBitsPerSecond: Int, congested: Bool) -> Int? {
lock.lock()
defer { lock.unlock() }
if congested {
return applyCongestionLocked(measuredBitsPerSecond)
}
if measuredBitsPerSecond <= 0 { return nil }
let dropThreshold = Int(Double(currentBitrate) * 0.80)
if measuredBitsPerSecond < dropThreshold {
return applyCongestionLocked(measuredBitsPerSecond)
}
return applyHealthyLocked()
}
func resetToCeiling() -> Int {
lock.lock()
defer { lock.unlock() }
healthyCount = 0
currentBitrate = ceiling
return currentBitrate
}
private func applyCongestionLocked(_ measuredBitsPerSecond: Int) -> Int? {
healthyCount = 0
let next: Int
if measuredBitsPerSecond <= 0 {
next = Int(Double(currentBitrate) * 0.8)
} else {
next = min(currentBitrate, measuredBitsPerSecond - audioBitrate)
}
return maybeApplyLocked(min(max(next, floor), ceiling))
}
private func applyHealthyLocked() -> Int? {
if currentBitrate >= ceiling {
healthyCount = 0
return nil
}
healthyCount += 1
if healthyCount < healthySamplesBeforeUp { return nil }
healthyCount = 0
let step = max(ceiling / 10, minDelta)
return maybeApplyLocked(min(currentBitrate + step, ceiling))
}
private func maybeApplyLocked(_ next: Int) -> Int? {
if next == currentBitrate { return nil }
let atBound = next == floor || next == ceiling
if !atBound && abs(next - currentBitrate) < minDelta { return nil }
currentBitrate = next
return next
}
}
@@ -7,7 +7,6 @@ struct BroadcastConfig: Sendable {
var width: Int = 1280 var width: Int = 1280
var height: Int = 720 var height: Int = 720
var videoBitrate: Int = 2_500_000 var videoBitrate: Int = 2_500_000
var minVideoBitrate: Int = 800_000
var audioBitrate: Int = 128_000 var audioBitrate: Int = 128_000
var fps: Int = 30 var fps: Int = 30
var rotation: Int = 0 var rotation: Int = 0
@@ -29,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])
@@ -5,37 +5,18 @@ import RTMPHaishinKit
import UIKit import UIKit
import VideoToolbox import VideoToolbox
private final class AdaptiveBroadcastBitRateStrategy: StreamBitRateStrategy, @unchecked Sendable { private struct BroadcastBitrateMonitor: StreamBitRateStrategy {
let controller: AdaptiveBitrateController let mamimumVideoBitRate: Int = 0
let onUpdate: @Sendable (Int) -> Void
var mamimumVideoBitRate: Int { controller.ceiling }
let mamimumAudioBitRate: Int = 0 let mamimumAudioBitRate: Int = 0
let onUpdate: @Sendable (Int) -> Void
init(controller: AdaptiveBitrateController, onUpdate: @escaping @Sendable (Int) -> Void) {
self.controller = controller
self.onUpdate = onUpdate
}
func adjustBitrate(_ event: NetworkMonitorEvent, stream: some StreamConvertible) async { func adjustBitrate(_ event: NetworkMonitorEvent, stream: some StreamConvertible) async {
let next: Int?
switch event { switch event {
case .status(let report): case .status(let report), .publishInsufficientBWOccured(let report):
let measuredBits = report.currentBytesOutPerSecond * 8
onUpdate(max(0, report.currentBytesOutPerSecond / 1024)) onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
next = controller.onMeasured(measuredBitsPerSecond: measuredBits, congested: false)
case .publishInsufficientBWOccured(let report):
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
next = controller.onCongestion(measuredBitsPerSecond: report.currentBytesOutPerSecond * 8)
case .reset: case .reset:
next = controller.resetToCeiling() break
@unknown default:
next = nil
} }
guard let bitrate = next else { return }
var videoSettings = await stream.videoSettings
guard videoSettings.bitRate != bitrate else { return }
videoSettings.bitRate = bitrate
try? await stream.setVideoSettings(videoSettings)
} }
} }
@@ -66,7 +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 let adaptiveBitrate = AdaptiveBitrateController() private var audioMuted = false
private static var rtmpFactoryRegistered = false private static var rtmpFactoryRegistered = false
private static func ensureRTMPFactoryRegistered() async { private static func ensureRTMPFactoryRegistered() async {
@@ -121,7 +102,6 @@ final class LiveBroadcastEngine: ObservableObject {
func preparePreview(config: BroadcastConfig) async throws { func preparePreview(config: BroadcastConfig) async throws {
self.config = config self.config = config
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
publishPending = false publishPending = false
try await configurePipeline(config) try await configurePipeline(config)
setPhase(.preview) setPhase(.preview)
@@ -130,7 +110,6 @@ final class LiveBroadcastEngine: ObservableObject {
/// Configura camera/mixer e mette in coda il publish RTMP (eseguito quando l'anteprima è pronta). /// Configura camera/mixer e mette in coda il publish RTMP (eseguito quando l'anteprima è pronta).
func prepareForBroadcast(config: BroadcastConfig) async throws { func prepareForBroadcast(config: BroadcastConfig) async throws {
self.config = config self.config = config
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
publishPending = true publishPending = true
try await configurePipeline(config) try await configurePipeline(config)
setPhase(.connecting) setPhase(.connecting)
@@ -157,7 +136,6 @@ final class LiveBroadcastEngine: ObservableObject {
func resumeBroadcast(config: BroadcastConfig) async throws { func resumeBroadcast(config: BroadcastConfig) async throws {
self.config = config self.config = config
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
reconnectAttempts = 0 reconnectAttempts = 0
reconnectTask?.cancel() reconnectTask?.cancel()
reconnectTask = nil reconnectTask = nil
@@ -196,46 +174,33 @@ 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()
} }
/// Adattamento termico: aggiorna il soffitto ABR e l'FPS senza interrompere la sessione RTMP. /// Silenzia il microfono senza togliere la traccia AAC (YouTube/MediaMTX richiedono audio continuo).
func setAudioMuted(_ muted: Bool) async {
audioMuted = muted
await applyAudioMute()
metrics.audioMuted = muted
emitMetrics()
}
/// Adattamento termico: aggiorna profilo video senza interrompere la sessione RTMP attiva.
func applyThermalProfile(videoBitrate: Int, fps: Int) async { func applyThermalProfile(videoBitrate: Int, fps: Int) async {
guard var current = config else { return } guard var current = config else { return }
adaptiveBitrate.setCeiling(videoBitrate) guard current.videoBitrate != videoBitrate || current.fps != fps else { return }
let applied = adaptiveBitrate.setFloor(current.minVideoBitrate)
guard current.videoBitrate != videoBitrate || current.fps != fps else {
await applyEncoderBitrate(applied)
return
}
current.videoBitrate = videoBitrate current.videoBitrate = videoBitrate
current.fps = fps current.fps = fps
config = current config = current
if pipelineConfigured, let stream = await rtmpSession?.stream { if pipelineConfigured, let stream = await rtmpSession?.stream {
try? await applyCodecSettings(to: stream, config: current, videoBitrate: applied) try? await applyCodecSettings(to: stream, config: current)
} }
} }
func setMinQualityFloor(_ floorBitrate: Int, ceiling: Int? = nil) async {
if var current = config {
if let ceiling { current.videoBitrate = ceiling }
current.minVideoBitrate = floorBitrate
config = current
}
await applyEncoderBitrate(adaptiveBitrate.setFloor(floorBitrate))
}
private func applyEncoderBitrate(_ bitrate: Int) async {
guard pipelineConfigured, let stream = await rtmpSession?.stream else { return }
var videoSettings = await stream.videoSettings
guard videoSettings.bitRate != bitrate else { return }
videoSettings.bitRate = bitrate
try? await stream.setVideoSettings(videoSettings)
}
private func configurePipeline(_ config: BroadcastConfig) async throws { private func configurePipeline(_ config: BroadcastConfig) async throws {
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized, guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized,
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
@@ -264,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) {
@@ -361,7 +327,7 @@ final class LiveBroadcastEngine: ObservableObject {
let stream = await session.stream let stream = await session.stream
try await applyCodecSettings(to: stream, config: config) try await applyCodecSettings(to: stream, config: config)
await stream.setBitRateStrategy(AdaptiveBroadcastBitRateStrategy(controller: adaptiveBitrate) { [weak self] kbps in await stream.setBitRateStrategy(BroadcastBitrateMonitor { [weak self] kbps in
Task { @MainActor in Task { @MainActor in
guard let self else { return } guard let self else { return }
self.metrics = BroadcastMetrics( self.metrics = BroadcastMetrics(
@@ -369,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)
} }
@@ -394,10 +361,10 @@ final class LiveBroadcastEngine: ObservableObject {
setPhase(.live) setPhase(.live)
} }
private func applyCodecSettings(to stream: any StreamConvertible, config: BroadcastConfig, videoBitrate: Int? = nil) async throws { private func applyCodecSettings(to stream: any StreamConvertible, config: BroadcastConfig) async throws {
var videoSettings = await stream.videoSettings var videoSettings = await stream.videoSettings
videoSettings.videoSize = .init(width: config.width, height: config.height) videoSettings.videoSize = .init(width: config.width, height: config.height)
videoSettings.bitRate = videoBitrate ?? adaptiveBitrate.currentBitrate videoSettings.bitRate = config.videoBitrate
videoSettings.maxKeyFrameIntervalDuration = 2 videoSettings.maxKeyFrameIntervalDuration = 2
videoSettings.profileLevel = config.height > 720 videoSettings.profileLevel = config.height > 720
? (kVTProfileLevel_H264_Baseline_4_1 as String) ? (kVTProfileLevel_H264_Baseline_4_1 as String)
@@ -541,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(
@@ -548,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)
} }
@@ -15,17 +15,11 @@ enum StreamVideoPreset {
static func broadcastConfig(for session: StreamSession, rtmpUrl: String) -> BroadcastConfig { static func broadcastConfig(for session: StreamSession, rtmpUrl: String) -> BroadcastConfig {
let dims = dimensions(for: session.qualityPreset) let dims = dimensions(for: session.qualityPreset)
let ceiling = StreamQualityLadder.encoderCeiling(
sessionTarget: session.targetBitrate,
minPresetId: session.minQualityPreset
)
let floor = StreamQualityLadder.encoderFloor(minPresetId: session.minQualityPreset, ceiling: ceiling)
return BroadcastConfig( return BroadcastConfig(
rtmpUrl: rtmpUrl, rtmpUrl: rtmpUrl,
width: dims.width, width: dims.width,
height: dims.height, height: dims.height,
videoBitrate: ceiling, videoBitrate: session.targetBitrate,
minVideoBitrate: floor,
audioBitrate: 128_000, audioBitrate: 128_000,
fps: session.targetFps fps: session.targetFps
) )
@@ -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
@@ -40,12 +42,8 @@ struct BroadcastControlsOverlay: View {
let bitrateKbps: Int let bitrateKbps: Int
let networkType: String let networkType: String
let deviceHealth: DeviceHealth let deviceHealth: DeviceHealth
var minQualityPreset: String = StreamQualityLadder.auto
var sessionQualityPreset: String = "720p_30_2.5mbps"
var onSelectMinQuality: (String) -> Void = { _ in }
@State private var showTerminateConfirm = false @State private var showTerminateConfirm = false
@State private var showMinQualityPicker = false
var body: some View { var body: some View {
Color.clear Color.clear
@@ -74,10 +72,7 @@ struct BroadcastControlsOverlay: View {
bitrateKbps: bitrateKbps, bitrateKbps: bitrateKbps,
networkType: networkType, networkType: networkType,
deviceHealth: deviceHealth, deviceHealth: deviceHealth,
minQualityLabel: StreamQualityLadder.displayName( audioMuted: audioMuted
id: minQualityPreset,
autoLabel: L10n.t("broadcast.min.quality.auto")
)
) )
} }
.padding(.trailing, 8) .padding(.trailing, 8)
@@ -102,19 +97,6 @@ struct BroadcastControlsOverlay: View {
.padding(.bottom, 8) .padding(.bottom, 8)
} }
} }
.overlay {
if showMinQualityPicker {
MinQualityPickerOverlay(
selectedId: minQualityPreset,
sessionQualityPreset: sessionQualityPreset,
onSelect: { id in
showMinQualityPicker = false
onSelectMinQuality(id)
},
onDismiss: { showMinQualityPicker = false }
)
}
}
.alert(L10n.t("broadcast.terminate.title"), isPresented: $showTerminateConfirm) { .alert(L10n.t("broadcast.terminate.title"), isPresented: $showTerminateConfirm) {
Button(L10n.t("action.cancel"), role: .cancel) {} Button(L10n.t("action.cancel"), role: .cancel) {}
Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: onTerminate) Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: onTerminate)
@@ -136,11 +118,6 @@ struct BroadcastControlsOverlay: View {
accessibilityLabel: L10n.t("broadcast.share.regia.cd"), accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
action: onShareRegia action: onShareRegia
) )
SideIconButton(
systemName: "slider.horizontal.3",
accessibilityLabel: L10n.t("broadcast.min.quality.cd"),
action: { showMinQualityPicker = true }
)
if let onCloseSet { if let onCloseSet {
SideIconButton( SideIconButton(
systemName: "checkmark", systemName: "checkmark",
@@ -168,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"),
@@ -214,85 +197,6 @@ struct BroadcastControlsOverlay: View {
} }
} }
private struct MinQualityPickerOverlay: View {
let selectedId: String
let sessionQualityPreset: String
let onSelect: (String) -> Void
let onDismiss: () -> Void
private var options: [String] {
StreamQualityLadder.availableIds(sessionQualityPreset: sessionQualityPreset)
}
var body: some View {
ZStack {
Color.black.opacity(0.55)
.ignoresSafeArea()
.onTapGesture(perform: onDismiss)
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(L10n.t("broadcast.min.quality.title"))
.font(.headline)
.foregroundStyle(.white)
Spacer()
Button(L10n.t("action.cancel"), action: onDismiss)
.foregroundStyle(MatchColors.textSecondary)
}
Text(L10n.t("broadcast.min.quality.hint"))
.font(.footnote)
.foregroundStyle(MatchColors.textSecondary)
HStack(spacing: 8) {
ForEach(options, id: \.self) { id in
let selected = id == selectedId
Button {
onSelect(id)
} label: {
VStack(spacing: 4) {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(selected ? MatchColors.accentYellow : .clear)
Text(
StreamQualityLadder.displayName(
id: id,
autoLabel: L10n.t("broadcast.min.quality.auto")
)
)
.font(.system(size: 13, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? MatchColors.accentYellow : .white)
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 8)
.padding(.vertical, 12)
.background(
selected
? MatchColors.accentYellow.opacity(0.16)
: MatchColors.surfaceElevated,
in: RoundedRectangle(cornerRadius: 12)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(selected ? MatchColors.accentYellow : MatchColors.outline, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
}
.padding(16)
.frame(maxWidth: 720)
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(MatchColors.outline, lineWidth: 1)
)
.padding(.horizontal, 24)
}
}
}
private struct BroadcastTelemetryPanel: View { private struct BroadcastTelemetryPanel: View {
let cableConnected: Bool let cableConnected: Bool
let fps: Int let fps: Int
@@ -300,7 +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 minQualityLabel: String let audioMuted: Bool
var body: some View { var body: some View {
VStack(alignment: .trailing, spacing: 2) { VStack(alignment: .trailing, spacing: 2) {
@@ -317,12 +221,14 @@ private struct BroadcastTelemetryPanel: View {
.font(.system(size: 11, weight: .medium)) .font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
} }
Text(minQualityLabel)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.accentYellow)
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,7 +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 minQualityInFlight = 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?
@@ -84,15 +84,9 @@ struct BroadcastScreen: View {
.task(id: sessionId) { .task(id: sessionId) {
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 2_000_000_000) try? await Task.sleep(nanoseconds: 2_000_000_000)
guard !minQualityInFlight, !pauseInFlight, !resumeInFlight else { continue } if let remote = try? await container.sessionRepository.fetchSession(id: sessionId).score {
guard let remote = try? await container.sessionRepository.fetchSession(id: sessionId) else { continue } container.scoreController.applyRemote(remote, source: .polling)
if let score = remote.score {
container.scoreController.applyRemote(score, source: .polling)
} }
if session?.minQualityPreset != remote.minQualityPreset {
applyMinQualityLocally(preset: remote.minQualityPreset, fromRemote: true, notify: false)
}
await syncBroadcastToRemoteSession(remote)
} }
} }
.task(id: sessionId) { .task(id: sessionId) {
@@ -195,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
@@ -289,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) },
@@ -297,12 +296,7 @@ struct BroadcastScreen: View {
targetFps: session.targetFps, targetFps: session.targetFps,
bitrateKbps: metrics.bitrateKbps, bitrateKbps: metrics.bitrateKbps,
networkType: deviceHealth.networkType, networkType: deviceHealth.networkType,
deviceHealth: DeviceTelemetry.snapshot(thermalState: thermalManager.state), deviceHealth: DeviceTelemetry.snapshot(thermalState: thermalManager.state)
minQualityPreset: session.minQualityPreset,
sessionQualityPreset: session.qualityPreset,
onSelectMinQuality: { preset in
Task { await persistMinQuality(preset) }
}
) )
} }
@@ -396,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 }
@@ -430,10 +425,10 @@ 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.onMinQuality = { [weak container] preset in container.sessionCable.onAudioMute = { muted in
Task { @MainActor in Task { @MainActor in
guard let container else { return } guard !muteInFlight else { return }
applyMinQualityLocally(preset: preset, fromRemote: true) await setAudioMuted(muted, remote: true)
} }
} }
container.sessionCable.connect(sessionId: sessionId, accessToken: token) container.sessionCable.connect(sessionId: sessionId, accessToken: token)
@@ -516,39 +511,13 @@ 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"))
} }
} }
/// Allinea encoder locale allo stato sessione se Cable non ha consegnato il comando.
private func syncBroadcastToRemoteSession(_ remote: StreamSession) async {
let phase = container.broadcastCoordinator.metrics.phase
let publishing = phase == .live || phase == .connecting || phase == .reconnecting
if remote.isEnded {
if phase != .idle && phase != .preview {
await stopStream()
} else {
session = remote
}
return
}
if remote.isPaused {
if publishing {
await applyRemotePause(container: container)
} else {
session = remote
}
return
}
if phase == .paused {
await applyRemoteResume(container: container)
return
}
session = remote
}
/// Pausa dalla regia / echo cable: solo RTMP locale, senza PATCH pause. /// Pausa dalla regia / echo cable: solo RTMP locale, senza PATCH pause.
private func applyRemotePause(container: AppContainer) async { private func applyRemotePause(container: AppContainer) async {
guard !pauseInFlight else { return } guard !pauseInFlight else { return }
@@ -561,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 }
@@ -573,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"))
} }
@@ -586,43 +581,6 @@ struct BroadcastScreen: View {
) )
} }
private func applyMinQualityLocally(preset: String, fromRemote: Bool, notify: Bool = true) {
guard var current = session else { return }
if current.minQualityPreset == preset && fromRemote { return }
current = current.with(minQualityPreset: preset)
session = current
guard let url = current.rtmpIngestUrl, !url.isEmpty else { return }
let config = broadcastConfig(for: current, rtmpUrl: url)
Task {
await container.broadcastCoordinator.engine.setMinQualityFloor(
config.minVideoBitrate,
ceiling: config.videoBitrate
)
thermalManager.updateBaseline(config)
}
if fromRemote && notify {
snackbarMessage = L10n.t("broadcast.snackbar.min.quality.remote")
}
}
private func persistMinQuality(_ preset: String) async {
minQualityInFlight = true
defer { minQualityInFlight = false }
do {
let updated = try await container.sessionRepository.setMinQuality(id: sessionId, preset: preset)
applyMinQualityLocally(preset: updated.minQualityPreset, fromRemote: false)
snackbarMessage = L10n.t(
"broadcast.min.quality.applied",
StreamQualityLadder.displayName(
id: updated.minQualityPreset,
autoLabel: L10n.t("broadcast.min.quality.auto")
)
)
} catch {
snackbarMessage = UserFacingError.message(for: error) ?? L10n.t("broadcast.min.quality.error")
}
}
private func retryBroadcast() async { private func retryBroadcast() async {
error = nil error = nil
guard let session, let url = session.rtmpIngestUrl, !url.isEmpty else { return } guard let session, let url = session.rtmpIngestUrl, !url.isEmpty else { return }
@@ -649,7 +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.onMinQuality = nil container.sessionCable.onAudioMute = nil
container.sessionCable.disconnect() container.sessionCable.disconnect()
await container.broadcastCoordinator.stopBroadcast(sessionId: sessionId) await container.broadcastCoordinator.stopBroadcast(sessionId: sessionId)
} }
@@ -1,92 +0,0 @@
import XCTest
@testable import MatchLiveTv
final class AdaptiveBitrateTests: XCTestCase {
private func controller() -> AdaptiveBitrateController {
let abr = AdaptiveBitrateController()
abr.configure(ceiling: 2_500_000, floor: AdaptiveBitrateController.autoFloor)
return abr
}
func testCongestionDropsTowardMeasuredAndRespectsFloor() {
let abr = controller()
let next = abr.onCongestion(measuredBitsPerSecond: 1_200_000)
XCTAssertEqual(next, 1_200_000 - 128_000)
XCTAssertEqual(abr.currentBitrate, 1_072_000)
}
func testCongestionDoesNotGoBelowFloor() {
let abr = controller()
abr.setFloor(1_500_000)
let next = abr.onCongestion(measuredBitsPerSecond: 400_000)
XCTAssertEqual(next, 1_500_000)
}
func testHealthyRampsUpAfterEnoughSamples() {
let abr = controller()
abr.onCongestion(measuredBitsPerSecond: 1_200_000)
for _ in 0..<7 {
XCTAssertNil(abr.onHealthy())
}
XCTAssertEqual(abr.onHealthy(), 1_072_000 + 250_000)
}
func testThermalCeilingClampsCurrent() {
let abr = controller()
let applied = abr.setCeiling(1_250_000)
XCTAssertEqual(applied, 1_250_000)
XCTAssertEqual(abr.ceiling, 1_250_000)
XCTAssertLessThanOrEqual(abr.floor, abr.ceiling)
}
func testMinFloorBumpsCurrentUp() {
let abr = controller()
abr.onCongestion(measuredBitsPerSecond: 1_200_000)
XCTAssertEqual(abr.setFloor(2_000_000), 2_000_000)
}
func testAutoOptionsExclude1080On720Session() {
XCTAssertEqual(
StreamQualityLadder.availableIds(sessionQualityPreset: "720p_30_2.5mbps"),
[
StreamQualityLadder.auto,
"720p_30_1.5mbps",
"720p_30_2.5mbps",
"720p_30_4mbps"
]
)
}
func testMeasuredZeroWithoutCongestionDoesNotDrop() {
let abr = controller()
XCTAssertNil(abr.onMeasured(measuredBitsPerSecond: 0, congested: false))
XCTAssertEqual(abr.currentBitrate, 2_500_000)
}
func testMeasuredLowThroughputDropsWithoutCongestionFlag() {
let abr = controller()
XCTAssertEqual(
abr.onMeasured(measuredBitsPerSecond: 1_200_000, congested: false),
1_200_000 - 128_000
)
}
func testThermalDropThenRecoverRestoresUserFloor() {
let abr = controller()
abr.setFloor(1_500_000)
abr.setCeiling(1_000_000)
XCTAssertEqual(abr.floor, 1_000_000)
abr.setCeiling(2_500_000)
XCTAssertEqual(abr.floor, 1_000_000)
XCTAssertEqual(abr.setFloor(1_500_000), 1_500_000)
}
func testEncoderCeilingRisesWhenMinExceedsSessionTarget() {
let ceiling = StreamQualityLadder.encoderCeiling(
sessionTarget: 2_500_000,
minPresetId: "720p_30_4mbps"
)
XCTAssertEqual(ceiling, 4_000_000)
XCTAssertEqual(StreamQualityLadder.encoderFloor(minPresetId: "720p_30_4mbps", ceiling: ceiling), 4_000_000)
}
}
@@ -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)
}
} }