diff --git a/backend/app/channels/session_channel.rb b/backend/app/channels/session_channel.rb
index d50def1..eda1368 100644
--- a/backend/app/channels/session_channel.rb
+++ b/backend/app/channels/session_channel.rb
@@ -55,6 +55,8 @@ class SessionChannel < ApplicationCable::Channel
Sessions::Pause.new(session).call
when "resume_stream"
Sessions::Resume.new(session).call
+ when "set_min_quality"
+ Sessions::SetMinQuality.new(session, preset: data["min_quality_preset"]).call
end
end
diff --git a/backend/app/controllers/api/v1/stream_sessions_controller.rb b/backend/app/controllers/api/v1/stream_sessions_controller.rb
index 4ca1845..6d048ed 100644
--- a/backend/app/controllers/api/v1/stream_sessions_controller.rb
+++ b/backend/app/controllers/api/v1/stream_sessions_controller.rb
@@ -68,6 +68,7 @@ module Api
current_bitrate: params[:current_bitrate],
target_bitrate: params[:target_bitrate],
fps: params[:fps],
+ thermal_state: sanitized_thermal_state,
last_seen_at: Time.current
)
sync_publisher_when_streaming!(params[:fps].to_f)
@@ -141,6 +142,13 @@ module Api
}
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
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
Youtube::BroadcastService.new(@session.match.team)
@@ -160,6 +168,11 @@ module Api
.find(params[:id])
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
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
end
@@ -187,6 +200,7 @@ module Api
youtube_ready: session.youtube_ready?,
privacy_status: session.privacy_status,
quality_preset: session.quality_preset,
+ min_quality_preset: session.min_quality_preset,
target_bitrate: session.target_bitrate,
started_at: session.started_at,
disconnection_count: session.disconnection_count
diff --git a/backend/app/controllers/public/regia_controller.rb b/backend/app/controllers/public/regia_controller.rb
index 83c8404..c1386cd 100644
--- a/backend/app/controllers/public/regia_controller.rb
+++ b/backend/app/controllers/public/regia_controller.rb
@@ -7,7 +7,7 @@ module Public
layout "regia"
protect_from_forgery with: :null_session
- skip_before_action :verify_authenticity_token, only: %i[score pause resume stop]
+ skip_before_action :verify_authenticity_token, only: %i[score pause resume stop min_quality]
before_action :set_session_from_token
@@ -61,8 +61,20 @@ module Public
render json: status_payload
end
+ def min_quality
+ Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
+ render json: status_payload
+ rescue ArgumentError => e
+ render json: status_payload.merge(error: e.message), status: :unprocessable_entity
+ end
+
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
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
return if @session
@@ -86,7 +98,10 @@ module Public
home_name: @session.match.team.name,
away_name: @session.match.opponent_name,
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
diff --git a/backend/app/helpers/public/regia_helper.rb b/backend/app/helpers/public/regia_helper.rb
index 6a6a64d..3eda72b 100644
--- a/backend/app/helpers/public/regia_helper.rb
+++ b/backend/app/helpers/public/regia_helper.rb
@@ -54,15 +54,34 @@ module Public
copyPrompt: t("regia.js.copy_prompt"),
shareRegiaText: t("regia.js.share_regia_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"),
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"),
pauseLabel: t("regia.pause"),
endedBadge: t("regia.status.ended"),
pausedBadge: t("regia.status.paused"),
liveBadge: t("regia.status.live"),
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
end
diff --git a/backend/app/models/device_state.rb b/backend/app/models/device_state.rb
index 922ec9c..7f1badf 100644
--- a/backend/app/models/device_state.rb
+++ b/backend/app/models/device_state.rb
@@ -16,4 +16,17 @@ class DeviceState < ApplicationRecord
status: stream_session.status
}
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
diff --git a/backend/app/models/stream_session.rb b/backend/app/models/stream_session.rb
index e8ea493..baf2997 100644
--- a/backend/app/models/stream_session.rb
+++ b/backend/app/models/stream_session.rb
@@ -14,6 +14,7 @@ class StreamSession < ApplicationRecord
validates :platform, inclusion: { in: PLATFORMS }
validates :status, inclusion: { in: 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 :ensure_publish_token, on: :create
diff --git a/backend/app/services/sessions/pause.rb b/backend/app/services/sessions/pause.rb
index b21fe6d..b5b9895 100644
--- a/backend/app/services/sessions/pause.rb
+++ b/backend/app/services/sessions/pause.rb
@@ -9,11 +9,12 @@ module Sessions
@session.pause! if @session.may_pause?
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
begin
- Mediamtx::Client.new.set_path_recording(@session, enabled: false)
+ mtx = Mediamtx::Client.new
+ mtx.set_path_recording(@session, enabled: false)
+ mtx.set_always_available(@session, enabled: true)
rescue Mediamtx::Client::Error => e
- Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}")
+ Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}")
end
- Mediamtx::Client.new.set_always_available(@session, enabled: true)
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
log_event("paused")
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
diff --git a/backend/app/services/sessions/select_quality.rb b/backend/app/services/sessions/select_quality.rb
index ec9b9ae..4139125 100644
--- a/backend/app/services/sessions/select_quality.rb
+++ b/backend/app/services/sessions/select_quality.rb
@@ -3,6 +3,8 @@ module Sessions
class SelectQuality
UPLOAD_HEADROOM = 0.8
UPLOAD_HEADROOM_1080P = 1.0
+ AUTO_ID = "auto"
+ AUTO_FLOOR_BITRATE = 800_000
PRESETS = [
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
@@ -11,6 +13,32 @@ module Sessions
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
].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:)
new(upload_mbps: upload_mbps).call
end
diff --git a/backend/app/services/sessions/set_min_quality.rb b/backend/app/services/sessions/set_min_quality.rb
new file mode 100644
index 0000000..90d9dd9
--- /dev/null
+++ b/backend/app/services/sessions/set_min_quality.rb
@@ -0,0 +1,39 @@
+# 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
diff --git a/backend/app/views/layouts/regia.html.erb b/backend/app/views/layouts/regia.html.erb
index 0e36030..8dd1774 100644
--- a/backend/app/views/layouts/regia.html.erb
+++ b/backend/app/views/layouts/regia.html.erb
@@ -7,7 +7,7 @@
<%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %>
<%= render "shared/meta_tags" %>
-
+
<%= yield :head %>
diff --git a/backend/app/views/public/regia/_volley.html.erb b/backend/app/views/public/regia/_volley.html.erb
index 81b1510..1902e68 100644
--- a/backend/app/views/public/regia/_volley.html.erb
+++ b/backend/app/views/public/regia/_volley.html.erb
@@ -21,4 +21,4 @@
-
+
diff --git a/backend/app/views/public/regia/show.html.erb b/backend/app/views/public/regia/show.html.erb
index 19c6add..30d98c6 100644
--- a/backend/app/views/public/regia/show.html.erb
+++ b/backend/app/views/public/regia/show.html.erb
@@ -16,6 +16,8 @@
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
data-resume-url="<%= j public_regia_resume_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-hls-url="<%= j @session.hls_playback_url %>"
data-home-name="<%= j @team.name %>"
@@ -29,55 +31,118 @@
data-initial-paused="<%= @session.paused? %>">
- <% if @live_share_url.present? %>
-
-
<%= t("regia.live_share_label") %>
-
-
-
+
diff --git a/backend/config/environments/development.rb b/backend/config/environments/development.rb
index 3401d2b..7da8851 100644
--- a/backend/config/environments/development.rb
+++ b/backend/config/environments/development.rb
@@ -62,8 +62,8 @@ Rails.application.configure do
# Annotate rendered view with file names.
config.action_view.annotate_rendered_view_with_filenames = true
- # Uncomment if you wish to allow Action Cable access from any origin.
- # config.action_cable.disable_request_forgery_protection = true
+ # Permette Action Cable dall'emulatore Android (10.0.2.2) e da altri host locali.
+ config.action_cable.disable_request_forgery_protection = true
# Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true
diff --git a/backend/config/locales/app.de.yml b/backend/config/locales/app.de.yml
index 63e5db2..3b2cc1b 100644
--- a/backend/config/locales/app.de.yml
+++ b/backend/config/locales/app.de.yml
@@ -432,16 +432,51 @@ de:
paused: Pausiert
live: Live
waiting: Wartet
- live_share_label: "Live-Link (Zuschauer)"
+ live_share_label: Live
share_live: Livestream teilen
+ share_action: Teilen
copy_link: Link kopieren
- regia_share_label: "Regie-Link (Punktestand)"
+ copy_action: Kopieren
+ copy_live: Live kopieren
+ copy_regia: Regie kopieren
+ links_nav: Schnellaktionen
+ regia_share_label: Regie
+ stream_controls_label: Live
share_regia: Regie-Link teilen
preview_ended: Livestream beendet
preview_waiting: Vorschau wartet auf Signal…
+ preview_hide: Vorschau ausblenden
+ preview_show: Vorschau anzeigen
+ preview_off_hint: Vorschau aus · kein Video-Download
pause: Pausieren
resume: Livestream fortsetzen
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:
set_won_title: Satz gewonnen
confirm: Satz beenden
@@ -486,6 +521,8 @@ de:
copy_prompt: "Link kopieren:"
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
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:
archive:
back_prefix: "←"
diff --git a/backend/config/locales/app.en.yml b/backend/config/locales/app.en.yml
index 1150d77..c76b9b6 100644
--- a/backend/config/locales/app.en.yml
+++ b/backend/config/locales/app.en.yml
@@ -427,16 +427,51 @@ en:
paused: Paused
live: Live
waiting: Waiting
- live_share_label: "Live link (viewers)"
+ live_share_label: Live
share_live: Share live link
+ share_action: Share
copy_link: Copy link
- regia_share_label: "Control room link (score)"
+ copy_action: Copy
+ 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
preview_ended: Stream ended
preview_waiting: Waiting for signal…
+ preview_hide: Hide preview
+ preview_show: Show preview
+ preview_off_hint: Preview off · no video download
pause: Pause stream
resume: Resume 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:
set_won_title: Set won
confirm: Close set
@@ -481,6 +516,8 @@ en:
copy_prompt: "Copy the link:"
share_regia_text: "Open this link to manage the live score:"
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:
archive:
back_prefix: "←"
diff --git a/backend/config/locales/app.es.yml b/backend/config/locales/app.es.yml
index 08c1647..7803177 100644
--- a/backend/config/locales/app.es.yml
+++ b/backend/config/locales/app.es.yml
@@ -432,16 +432,51 @@ es:
paused: En pausa
live: En directo
waiting: En espera
- live_share_label: "Enlace del directo (espectadores)"
+ live_share_label: Directo
share_live: Compartir directo
+ share_action: Compartir
copy_link: Copiar enlace
- regia_share_label: "Enlace de control (marcador)"
+ copy_action: Copiar
+ 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
preview_ended: Directo finalizado
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
resume: Reanudar 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:
set_won_title: Set ganado
confirm: Cerrar set
@@ -486,6 +521,8 @@ es:
copy_prompt: "Copia el enlace:"
share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
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:
archive:
back_prefix: "←"
diff --git a/backend/config/locales/app.fr.yml b/backend/config/locales/app.fr.yml
index 60f7f8c..278616b 100644
--- a/backend/config/locales/app.fr.yml
+++ b/backend/config/locales/app.fr.yml
@@ -432,16 +432,51 @@ fr:
paused: En pause
live: En direct
waiting: En attente
- live_share_label: "Lien direct (spectateurs)"
+ live_share_label: Direct
share_live: Partager le direct
+ share_action: Partager
copy_link: Copier le lien
- regia_share_label: "Lien régie (score)"
+ copy_action: Copier
+ 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
preview_ended: Direct terminé
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
resume: Reprendre 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:
set_won_title: Set gagné
confirm: Terminer le set
@@ -486,6 +521,8 @@ fr:
copy_prompt: "Copiez le lien :"
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
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:
archive:
back_prefix: "←"
diff --git a/backend/config/locales/app.it.yml b/backend/config/locales/app.it.yml
index 7cbb6bf..0bc58f6 100644
--- a/backend/config/locales/app.it.yml
+++ b/backend/config/locales/app.it.yml
@@ -427,16 +427,51 @@ it:
paused: In pausa
live: In onda
waiting: In attesa
- live_share_label: "Link diretta (spettatori)"
+ live_share_label: Diretta
share_live: Condividi diretta
+ share_action: Condividi
copy_link: Copia link
- regia_share_label: "Link regia (punteggio)"
+ copy_action: Copia
+ 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
preview_ended: Diretta terminata
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
resume: Riprendi 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:
set_won_title: Set vinto
confirm: Chiudi set
@@ -481,6 +516,8 @@ it:
copy_prompt: "Copia il link:"
share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
share_live_text: "Guarda la diretta su Match Live TV:"
+ min_quality_updated: Qualità minima aggiornata
+ min_quality_error: Errore aggiornamento qualità minima
recordings:
archive:
back_prefix: "←"
diff --git a/backend/config/routes.rb b/backend/config/routes.rb
index 3107a01..bfc895b 100644
--- a/backend/config/routes.rb
+++ b/backend/config/routes.rb
@@ -55,6 +55,7 @@ Rails.application.routes.draw do
post :claim_pairing
post :regia_link
post :network_test
+ patch :min_quality
get :youtube_stats
end
end
@@ -143,6 +144,7 @@ Rails.application.routes.draw do
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/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"
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
diff --git a/backend/db/migrate/20260813170000_add_min_quality_preset_to_stream_sessions.rb b/backend/db/migrate/20260813170000_add_min_quality_preset_to_stream_sessions.rb
new file mode 100644
index 0000000..3eae00a
--- /dev/null
+++ b/backend/db/migrate/20260813170000_add_min_quality_preset_to_stream_sessions.rb
@@ -0,0 +1,7 @@
+# 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
diff --git a/backend/db/migrate/20260813184100_add_thermal_state_to_device_states.rb b/backend/db/migrate/20260813184100_add_thermal_state_to_device_states.rb
new file mode 100644
index 0000000..92c32fd
--- /dev/null
+++ b/backend/db/migrate/20260813184100_add_thermal_state_to_device_states.rb
@@ -0,0 +1,5 @@
+class AddThermalStateToDeviceStates < ActiveRecord::Migration[7.2]
+ def change
+ add_column :device_states, :thermal_state, :string
+ end
+end
diff --git a/backend/db/schema.rb b/backend/db/schema.rb
index 1e8e2e6..0a10593 100644
--- a/backend/db/schema.rb
+++ b/backend/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
+ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -138,6 +138,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
t.datetime "last_seen_at"
t.datetime "created_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"], name: "index_device_states_on_stream_session_id"
end
@@ -255,6 +256,29 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id"
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|
t.uuid "match_id", null: false
t.uuid "user_id", null: false
@@ -280,10 +304,13 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
t.datetime "updated_at", null: false
t.string "regia_token_digest"
t.datetime "regia_token_expires_at"
+ t.uuid "stream_node_id"
+ t.string "min_quality_preset", default: "auto", null: false
t.index ["match_id"], name: "index_stream_sessions_on_match_id"
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
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"
end
@@ -411,6 +438,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
add_foreign_key "score_states", "stream_sessions"
add_foreign_key "stream_events", "stream_sessions"
add_foreign_key "stream_sessions", "matches"
+ add_foreign_key "stream_sessions", "stream_nodes"
add_foreign_key "stream_sessions", "users"
add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
add_foreign_key "subscriptions", "clubs"
diff --git a/backend/public/regia.css b/backend/public/regia.css
index 1b4ff89..d1c3b3a 100644
--- a/backend/public/regia.css
+++ b/backend/public/regia.css
@@ -4,6 +4,8 @@
--regia-surface: #14141c;
--regia-red: #e53935;
--regia-yellow: #f9a825;
+ --regia-green: #43a047;
+ --regia-orange: #fb8c00;
--regia-text: #f5f5f5;
--regia-muted: #9ca3af;
--regia-safe-bottom: env(safe-area-inset-bottom, 0px);
@@ -22,37 +24,166 @@
.regia-page {
max-width: 480px;
margin: 0 auto;
- padding: 12px 16px calc(24px + var(--regia-safe-bottom));
+ padding: 8px 12px calc(12px + 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 {
margin: 0;
- font-size: 1.1rem;
+ font-size: 1rem;
font-weight: 700;
+ line-height: 1.25;
}
.regia-header p {
- margin: 4px 0 0;
- font-size: 0.85rem;
+ margin: 2px 0 0;
+ font-size: 0.78rem;
color: var(--regia-muted);
}
.regia-badge {
display: inline-block;
- margin-top: 8px;
- padding: 4px 10px;
+ margin-top: 0;
+ padding: 3px 8px;
border-radius: 999px;
- font-size: 0.7rem;
+ font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
+ flex-shrink: 0;
}
.regia-badge--live { background: #2e7d32; color: #fff; }
.regia-badge--wait { background: #444; 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 {
- margin: 14px 0;
+ margin: 0;
border-radius: 12px;
overflow: hidden;
background: #000;
@@ -90,29 +221,29 @@
.regia-scoreboard {
background: var(--regia-surface);
border-radius: 12px;
- padding: 16px;
- margin-bottom: 12px;
+ padding: 10px 12px;
+ margin-bottom: 8px;
}
.regia-sets {
text-align: center;
- font-size: 0.82rem;
+ font-size: 0.78rem;
color: var(--regia-muted);
- margin: 0 0 8px;
+ margin: 0 0 4px;
}
.regia-score-line {
text-align: center;
- font-size: 1.5rem;
+ font-size: 1.2rem;
font-weight: 800;
margin: 0;
}
.regia-partials {
text-align: center;
- font-size: 0.78rem;
+ font-size: 0.75rem;
color: var(--regia-muted);
- margin: 8px 0 0;
+ margin: 4px 0 0;
}
.regia-team-row {
@@ -120,7 +251,7 @@
grid-template-columns: 1fr auto 1fr;
gap: 8px;
align-items: center;
- margin-top: 16px;
+ margin-top: 8px;
}
.regia-team-name {
@@ -131,7 +262,7 @@
}
.regia-points {
- font-size: 2rem;
+ font-size: 1.55rem;
font-weight: 800;
text-align: center;
}
@@ -146,7 +277,7 @@
.regia-btn {
border: none;
border-radius: 10px;
- padding: 16px 12px;
+ padding: 10px 8px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
@@ -156,8 +287,8 @@
.regia-btn:active { transform: scale(0.98); }
.regia-btn--point { background: var(--regia-red); color: #fff; }
-.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: 10px; }
+.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 8px; }
+.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 8px; padding: 12px; }
.regia-btn--outline {
background: transparent;
border: 1px solid #444;
@@ -173,26 +304,6 @@
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 {
position: fixed;
bottom: calc(16px + var(--regia-safe-bottom));
@@ -240,7 +351,7 @@
text-align: center;
color: var(--regia-muted);
font-size: 0.72rem;
- padding-top: 24px;
+ padding-top: 8px;
}
.regia-clock-line {
@@ -255,3 +366,128 @@
gap: 8px;
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);
+}
diff --git a/backend/public/regia.js b/backend/public/regia.js
index 97f0c63..6440c49 100644
--- a/backend/public/regia.js
+++ b/backend/public/regia.js
@@ -37,13 +37,32 @@
shareLiveText: "Guarda la diretta su Match Live TV:",
previewWaiting: "Anteprima in attesa del segnale…",
streamEnded: "Diretta terminata",
+ previewHide: "Nascondi anteprima",
+ previewShow: "Mostra anteprima",
+ previewOffHint: "Anteprima spenta · nessun download video",
resumeLabel: "Riprendi diretta",
pauseLabel: "Metti in pausa",
endedBadge: "Terminata",
pausedBadge: "In pausa",
liveBadge: "In onda",
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() {
@@ -77,6 +96,7 @@
pauseUrl: root.dataset.pauseUrl,
resumeUrl: root.dataset.resumeUrl,
stopUrl: root.dataset.stopUrl,
+ minQualityUrl: root.dataset.minQualityUrl,
cableUrl: root.dataset.cableUrl,
hlsUrl: root.dataset.hlsUrl,
homeName: root.dataset.homeName,
@@ -102,15 +122,28 @@
modalCancel: document.getElementById("modal-cancel"),
preview: document.getElementById("regia-preview"),
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
+ previewWrap: document.getElementById("regia-preview-wrap"),
+ btnTogglePreview: document.getElementById("btn-toggle-preview"),
btnPause: document.getElementById("btn-pause"),
- subtitle: document.getElementById("regia-subtitle")
+ minQuality: document.getElementById("min-quality"),
+ 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 hls = null;
let previewStarted = false;
+ let previewWanted = true;
let wasPausedPreview = false;
let streamPaused = root.dataset.initialPaused === "true";
+ const PREVIEW_PREF_KEY = "mltv-regia-preview";
function hlsUrlFresh() {
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
@@ -139,8 +172,43 @@
}
}
+ 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() {
- if (!els.preview || !cfg.hlsUrl || cfg.streamClosed) return;
+ if (!els.preview || !cfg.hlsUrl || cfg.streamClosed || !previewWanted) return;
destroyPreview();
previewStarted = false;
startPreview();
@@ -243,7 +311,10 @@
if (!els.btnPause) return;
const paused = !!(data.paused || data.status === "paused");
streamPaused = paused;
- els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel;
+ const label = paused ? I18N.resumeLabel : I18N.pauseLabel;
+ els.btnPause.classList.toggle("is-paused", paused);
+ els.btnPause.setAttribute("aria-label", label);
+ els.btnPause.title = label;
}
function setBadge(data) {
@@ -364,6 +435,11 @@
}
const paused = data.paused || data.status === "paused";
+ if (!previewWanted) {
+ destroyPreview();
+ wasPausedPreview = paused;
+ return;
+ }
if (data.on_air) {
if (!previewStarted) {
startPreview();
@@ -382,6 +458,119 @@
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() {
try {
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
@@ -389,6 +578,8 @@
const data = await res.json();
applyScorePayload(data);
setBadge(data);
+ syncMinQuality(data);
+ syncTelemetry(data);
syncPreviewFromStatus(data);
} catch (_) {}
}
@@ -401,7 +592,7 @@
}
function startPreview() {
- if (cfg.streamClosed || !els.preview || !cfg.hlsUrl || previewStarted) return;
+ if (cfg.streamClosed || !previewWanted || !els.preview || !cfg.hlsUrl || previewStarted) return;
previewStarted = true;
const url = hlsUrlFresh();
@@ -454,6 +645,8 @@
const wasPaused = streamPaused;
applyScorePayload(data);
setBadge(data);
+ syncMinQuality(data);
+ syncTelemetry(data);
syncPreviewFromStatus(data);
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
}
@@ -473,6 +666,70 @@
els.btnPause?.addEventListener("click", () => togglePauseStream().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) {
if (!url) {
toast(I18N.linkUnavailable);
diff --git a/backend/spec/requests/public/regia_spec.rb b/backend/spec/requests/public/regia_spec.rb
index 5f85e17..fe481c2 100644
--- a/backend/spec/requests/public/regia_spec.rb
+++ b/backend/spec/requests/public/regia_spec.rb
@@ -1,7 +1,7 @@
require "rails_helper"
RSpec.describe "Public regia", type: :request do
- let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "password123", role: "coach") }
+ let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "Password123", role: "coach") }
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
@@ -12,7 +12,18 @@ RSpec.describe "Public regia", type: :request do
get public_regia_path(token)
expect(response).to have_http_status(:ok)
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("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).not_to include("Aruba")
end
@@ -156,4 +167,39 @@ RSpec.describe "Public regia", type: :request do
get public_regia_path(token)
expect(response).to have_http_status(:ok)
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
diff --git a/backend/spec/services/sessions/select_quality_spec.rb b/backend/spec/services/sessions/select_quality_spec.rb
index 2f0e537..b5d6a2b 100644
--- a/backend/spec/services/sessions/select_quality_spec.rb
+++ b/backend/spec/services/sessions/select_quality_spec.rb
@@ -11,4 +11,11 @@ RSpec.describe Sessions::SelectQuality do
expect(preset[:id]).to eq("1080p_30_4.5mbps")
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
diff --git a/backend/spec/services/sessions/set_min_quality_spec.rb b/backend/spec/services/sessions/set_min_quality_spec.rb
new file mode 100644
index 0000000..7f28dfe
--- /dev/null
+++ b/backend/spec/services/sessions/set_min_quality_spec.rb
@@ -0,0 +1,41 @@
+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
diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml
index 96b5da6..bd9c34d 100644
--- a/infra/docker-compose.yml
+++ b/infra/docker-compose.yml
@@ -59,6 +59,8 @@ services:
REDIS_URL: redis://redis:6379/0
MEDIAMTX_API_URL: http://mediamtx:9997
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}
RAILS_WEBHOOK_URL: http://rails:3000
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min}
diff --git a/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/LocalAdaptiveBitrateApiTest.kt b/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/LocalAdaptiveBitrateApiTest.kt
new file mode 100644
index 0000000..455cdf0
--- /dev/null
+++ b/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/LocalAdaptiveBitrateApiTest.kt
@@ -0,0 +1,71 @@
+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()
+ 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
+ }
+}
diff --git a/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/LocalAdaptiveBitrateUiTest.kt b/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/LocalAdaptiveBitrateUiTest.kt
new file mode 100644
index 0000000..81e0c82
--- /dev/null
+++ b/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/LocalAdaptiveBitrateUiTest.kt
@@ -0,0 +1,265 @@
+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)
+ }
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MainActivity.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MainActivity.kt
index 94ce481..5b70304 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MainActivity.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MainActivity.kt
@@ -6,11 +6,17 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
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.ProvideAppLocale
import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost
import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
+@OptIn(ExperimentalComposeUiApi::class)
class MainActivity : ComponentActivity() {
private val container by lazy { (application as MatchLiveTvApplication).container }
@@ -33,7 +39,9 @@ class MainActivity : ComponentActivity() {
setContent {
ProvideAppLocale {
MatchLiveTheme {
- AppNavHost(container)
+ Box(Modifier.semantics { testTagsAsResourceId = true }) {
+ AppNavHost(container)
+ }
}
}
}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt
index aaa2863..0abba75 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt
@@ -183,6 +183,7 @@ data class StreamSessionDto(
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
@Json(name = "target_fps") val targetFps: Int? = 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,
val score: ScoreStateDto? = null,
) {
@@ -201,6 +202,7 @@ data class StreamSessionDto(
targetBitrate = targetBitrate ?: 2_500_000,
targetFps = targetFps ?: 30,
qualityPreset = qualityPreset ?: "720p_30_2.5mbps",
+ minQualityPreset = minQualityPreset ?: "auto",
startedAt = startedAt,
score = score?.toDomain(),
)
@@ -285,6 +287,10 @@ data class CreateSessionRequest(
@Json(name = "youtube_channel") val youtubeChannel: String? = null,
)
+data class MinQualityRequest(
+ @Json(name = "min_quality_preset") val minQualityPreset: String,
+)
+
data class UpdateMatchBody(
@Json(name = "opponent_name") val opponentName: String? = null,
val location: String? = null,
@@ -392,4 +398,5 @@ data class TelemetryRequest(
@Json(name = "current_bitrate") val currentBitrate: Int? = null,
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
val fps: Int? = null,
+ @Json(name = "thermal_state") val thermalState: String? = null,
)
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt
index b620567..4cf4ac9 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt
@@ -117,6 +117,12 @@ interface MatchLiveApi {
@PATCH("sessions/{sessionId}/resume")
suspend fun resumeSession(@Path("sessionId") sessionId: String): StreamSessionDto
+ @PATCH("sessions/{sessionId}/min_quality")
+ suspend fun setMinQuality(
+ @Path("sessionId") sessionId: String,
+ @Body body: MinQualityRequest,
+ ): StreamSessionDto
+
@POST("sessions/{sessionId}/network_test")
suspend fun networkTest(
@Path("sessionId") sessionId: String,
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt
index 8d5bc14..8da26e4 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt
@@ -33,6 +33,7 @@ class SessionCableService(
var onPauseStream: (() -> Unit)? = null
var onResumeStream: (() -> Unit)? = null
var onEndStream: (() -> Unit)? = null
+ var onMinQuality: ((String) -> Unit)? = null
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
intentionalDisconnect = false
@@ -109,6 +110,7 @@ class SessionCableService(
"pause_stream" -> onPauseStream?.invoke()
"resume_stream" -> onResumeStream?.invoke()
"stop_stream" -> onEndStream?.invoke()
+ "set_min_quality" -> (payload["min_quality_preset"] as? String)?.let { onMinQuality?.invoke(it) }
}
"stream_event" -> when (payload["event"] as? String) {
"paused" -> onPauseStream?.invoke()
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt
index 659350a..3ebd70d 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt
@@ -2,6 +2,7 @@ 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.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.NetworkTestResponse
import com.matchlivetv.match_live_tv.domain.StreamSession
@@ -38,6 +39,9 @@ class SessionRepository(
suspend fun resumeSession(sessionId: String): StreamSession =
api.resumeSession(sessionId).toDomain()
+ suspend fun setMinQuality(sessionId: String, preset: String): StreamSession =
+ api.setMinQuality(sessionId, MinQualityRequest(minQualityPreset = preset)).toDomain()
+
suspend fun submitNetworkTest(
sessionId: String,
downloadMbps: Double,
@@ -61,6 +65,7 @@ class SessionRepository(
currentBitrate: Int? = null,
targetBitrate: Int? = null,
fps: Int? = null,
+ thermalState: String? = null,
) {
api.postTelemetry(
sessionId,
@@ -72,6 +77,7 @@ class SessionRepository(
currentBitrate = currentBitrate,
targetBitrate = targetBitrate,
fps = fps,
+ thermalState = thermalState,
),
)
}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt
index bea0ffe..dc22ec5 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt
@@ -126,6 +126,7 @@ data class StreamSession(
val targetBitrate: Int = 2_500_000,
val targetFps: Int = 30,
val qualityPreset: String = "720p_30_2.5mbps",
+ val minQualityPreset: String = "auto",
val startedAt: String? = null,
val score: ScoreState? = null,
) {
@@ -133,6 +134,8 @@ data class StreamSession(
val isPaused: Boolean get() = status == "paused"
+ val isEnded: Boolean get() = status == "ended" || status == "error"
+
fun watchShareUrl(): String? = when {
platform == "youtube" && !youtubeWatchUrl.isNullOrBlank() -> youtubeWatchUrl
!shareUrl.isNullOrBlank() -> shareUrl
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/AdaptiveBitrateController.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/AdaptiveBitrateController.kt
new file mode 100644
index 0000000..8b8787c
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/AdaptiveBitrateController.kt
@@ -0,0 +1,115 @@
+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
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt
index 8455ffc..5e7d4b8 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt
@@ -9,6 +9,7 @@ data class BroadcastConfig(
val width: Int = 1280,
val height: Int = 720,
val videoBitrate: Int = 2_500_000,
+ val minVideoBitrate: Int = 800_000,
val audioBitrate: Int = 128_000,
val fps: Int = 30,
val rotation: Int = 0,
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt
index 52f8629..ab1fe71 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt
@@ -39,6 +39,7 @@ class LiveBroadcastEngine(
private var publishInProgress = false
private var currentBitrateKbps: Long = 0
private var currentFps: Int = 0
+ private val adaptiveBitrate = AdaptiveBitrateController()
private val pausingIntentionally = AtomicBoolean(false)
private val reconnectAttempts = AtomicInteger(0)
private val overlayRenderer = OverlayRenderer(appContext)
@@ -133,6 +134,7 @@ class LiveBroadcastEngine(
fun startBroadcast(broadcastConfig: BroadcastConfig) {
mainHandler.post {
config = broadcastConfig
+ adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
reconnectAttempts.set(0)
streamStartPending = true
publishInProgress = false
@@ -250,6 +252,7 @@ class LiveBroadcastEngine(
fun resumeBroadcast(broadcastConfig: BroadcastConfig) {
mainHandler.post {
config = broadcastConfig
+ adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
reconnectAttempts.set(0)
streamStartPending = true
publishInProgress = false
@@ -264,21 +267,45 @@ class LiveBroadcastEngine(
}
}
- /** Adattamento termico: riduce bitrate/FPS senza fermare lo stream. */
+ /** Adattamento termico: aggiorna il soffitto ABR e l'FPS senza fermare lo stream. */
fun applyThermalProfile(videoBitrate: Int, fps: Int) {
mainHandler.post {
val current = config ?: return@post
- if (current.videoBitrate == videoBitrate && current.fps == fps) return@post
+ adaptiveBitrate.setCeiling(videoBitrate)
+ val applied = adaptiveBitrate.setFloor(current.minVideoBitrate)
+ if (current.videoBitrate == videoBitrate && current.fps == fps) {
+ applyAdaptiveBitrate(applied)
+ return@post
+ }
config = current.copy(videoBitrate = videoBitrate, fps = fps)
genericStream?.let { stream ->
- runCatching { stream.setVideoBitrateOnFly(videoBitrate) }
- .onFailure { Log.w(TAG, "applyThermalProfile bitrate: ${it.message}") }
+ applyAdaptiveBitrate(applied)
runCatching { stream.getGlInterface().setForceRender(true, fps) }
.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 {
val existing = genericStream
if (existing != null) return existing
@@ -552,6 +579,12 @@ class LiveBroadcastEngine(
override fun onNewBitrate(bitrate: Long) {
mainHandler.post {
currentBitrateKbps = bitrate / 1000
+ val congestion = runCatching {
+ genericStream?.getStreamClient()?.hasCongestion() == true
+ }.getOrDefault(false)
+ adaptiveBitrate.onMeasured(bitrate, congestion)?.let { next ->
+ applyAdaptiveBitrate(next)
+ }
emitMetrics()
}
}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/StreamQualityLadder.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/StreamQualityLadder.kt
new file mode 100644
index 0000000..f34023c
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/StreamQualityLadder.kt
@@ -0,0 +1,72 @@
+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 {
+ 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"
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/StreamVideoPreset.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/StreamVideoPreset.kt
index e57a2fc..d0b09c4 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/StreamVideoPreset.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/StreamVideoPreset.kt
@@ -15,11 +15,14 @@ object StreamVideoPreset {
fun broadcastConfig(session: StreamSession, rtmpUrl: String): BroadcastConfig {
val dims = dimensions(session.qualityPreset)
+ val ceiling = StreamQualityLadder.encoderCeiling(session.targetBitrate, session.minQualityPreset)
+ val floor = StreamQualityLadder.encoderFloor(session.minQualityPreset, ceiling)
return BroadcastConfig(
rtmpUrl = rtmpUrl,
width = dims.width,
height = dims.height,
- videoBitrate = session.targetBitrate,
+ videoBitrate = ceiling,
+ minVideoBitrate = floor,
fps = session.targetFps,
)
}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt
index 59bed6f..0d84428 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -29,6 +30,7 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.Share
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.VisibilityOff
import androidx.compose.material.icons.filled.Videocam
@@ -58,6 +60,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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 coil.compose.AsyncImage
import com.matchlivetv.match_live_tv.R
@@ -65,6 +69,7 @@ import com.matchlivetv.match_live_tv.core.resolveMediaUrl
import com.matchlivetv.match_live_tv.core.DeviceHealthSnapshot
import com.matchlivetv.match_live_tv.core.ThermalState
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.theme.MatchColors
@@ -109,9 +114,27 @@ fun BroadcastControlsOverlay(
bitrateKbps: Long,
networkType: String,
deviceHealth: DeviceHealthSnapshot,
+ minQualityPreset: String = StreamQualityLadder.AUTO,
+ sessionQualityPreset: String = "720p_30_2.5mbps",
+ onSelectMinQuality: (String) -> Unit = {},
modifier: Modifier = Modifier,
) {
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) {
AlertDialog(
@@ -174,6 +197,7 @@ fun BroadcastControlsOverlay(
bitrateKbps = bitrateKbps,
networkType = networkType,
deviceHealth = deviceHealth,
+ minQualityLabel = StreamQualityLadder.displayName(minQualityPreset, autoLabel),
)
}
@@ -195,6 +219,11 @@ fun BroadcastControlsOverlay(
contentDescription = stringResource(R.string.broadcast_share_regia_cd),
onClick = onShareRegia,
)
+ SideIconButton(
+ icon = Icons.Default.Tune,
+ contentDescription = stringResource(R.string.broadcast_min_quality_cd),
+ onClick = { showMinQualityPicker = true },
+ )
if (onCloseSet != null) {
SideIconButton(
icon = Icons.Default.Check,
@@ -287,6 +316,7 @@ private fun BroadcastTelemetryPanel(
bitrateKbps: Long,
networkType: String,
deviceHealth: DeviceHealthSnapshot,
+ minQualityLabel: String,
modifier: Modifier = Modifier,
) {
val labelStyle = MaterialTheme.typography.labelSmall
@@ -321,6 +351,11 @@ private fun BroadcastTelemetryPanel(
color = MatchColors.TextSecondary,
)
}
+ Text(
+ minQualityLabel,
+ style = labelStyle,
+ color = MatchColors.AccentYellow,
+ )
Text(
"${networkType} · ${deviceHealth.batteryPercent}%",
style = labelStyle,
@@ -604,6 +639,113 @@ 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)
@Composable
private fun SideIconButton(
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt
index a49fe63..c5cabe1 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt
@@ -44,6 +44,7 @@ import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.MatchScoringRules
import com.matchlivetv.match_live_tv.domain.StreamSession
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.BroadcastPhase
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
@@ -78,6 +79,7 @@ fun BroadcastScreen(
var loading by remember { mutableStateOf(true) }
var pauseInFlight by remember { mutableStateOf(false) }
var resumeInFlight by remember { mutableStateOf(false) }
+ var minQualityInFlight by remember { mutableStateOf(false) }
var controlsVisible by remember { mutableStateOf(true) }
var logoReady by remember { mutableStateOf(0) }
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) }
@@ -156,10 +158,16 @@ fun BroadcastScreen(
}
suspend fun applyRemotePause() {
- container.broadcastCoordinator.engine.pauseBroadcast()
- session = runCatching { container.sessionRepository.fetchSession(sessionId) }
- .getOrNull() ?: session?.copy(status = "paused")
- snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_paused_remote))
+ if (pauseInFlight) return
+ pauseInFlight = true
+ try {
+ container.broadcastCoordinator.engine.pauseBroadcast()
+ 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() {
@@ -191,6 +199,38 @@ fun BroadcastScreen(
onFinished()
}
+ fun applyMinQualityLocally(preset: String, loaded: StreamSession) {
+ val updated = loaded.copy(minQualityPreset = preset)
+ session = updated
+ val cfg = broadcastConfig(updated)
+ container.broadcastCoordinator.engine.setMinQualityFloor(cfg.minVideoBitrate, cfg.videoBitrate)
+ thermalManager.updateBaseline(cfg)
+ }
+
+ suspend fun persistMinQuality(preset: String) {
+ val current = session ?: return
+ minQualityInFlight = true
+ runCatching { container.sessionRepository.setMinQuality(sessionId, preset) }
+ .onSuccess { updated ->
+ 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 {
+ snackbar.showSnackbar(
+ it.message ?: context.getString(R.string.broadcast_min_quality_error),
+ )
+ }
+ minQualityInFlight = false
+ }
+
LaunchedEffect(sessionId, permissions.granted) {
if (!permissions.granted) return@LaunchedEffect
loading = true
@@ -215,6 +255,15 @@ fun BroadcastScreen(
container.sessionCable.onEndStream = {
scope.launch { applyRemoteStop() }
}
+ container.sessionCable.onMinQuality = { preset ->
+ scope.launch {
+ 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.broadcastCoordinator.startService()
@@ -329,12 +378,28 @@ fun BroadcastScreen(
LaunchedEffect(sessionId) {
while (true) {
- delay(4_000)
- runCatching {
- container.sessionRepository.fetchSession(sessionId).score
- }.getOrNull()?.let { remote ->
- container.scoreController.applyRemote(remote)
- }
+ delay(2_000)
+ if (minQualityInFlight || pauseInFlight || resumeInFlight) continue
+ runCatching { container.sessionRepository.fetchSession(sessionId) }
+ .getOrNull()
+ ?.let { fresh ->
+ 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
+ }
+ }
}
}
@@ -342,7 +407,6 @@ fun BroadcastScreen(
while (true) {
delay(10_000)
val current = session ?: continue
- if (current.isPaused) continue
runCatching {
container.sessionRepository.postTelemetry(
sessionId = sessionId,
@@ -352,6 +416,7 @@ fun BroadcastScreen(
currentBitrate = (metrics.bitrateKbps * 1000).toInt().takeIf { it > 0 },
targetBitrate = current.targetBitrate,
fps = metrics.fps.takeIf { it > 0 },
+ thermalState = thermalManager.state.value.name.lowercase(),
)
}
}
@@ -364,6 +429,7 @@ fun BroadcastScreen(
container.sessionCable.onPauseStream = null
container.sessionCable.onResumeStream = null
container.sessionCable.onEndStream = null
+ container.sessionCable.onMinQuality = null
container.sessionCable.disconnect()
container.broadcastCoordinator.stopService()
container.broadcastCoordinator.engine.stopBroadcast()
@@ -578,6 +644,11 @@ fun BroadcastScreen(
bitrateKbps = metrics.bitrateKbps,
networkType = DeviceTelemetry.networkType(context),
deviceHealth = DeviceTelemetry.snapshot(context, thermalState),
+ minQualityPreset = currentSession?.minQualityPreset ?: StreamQualityLadder.AUTO,
+ sessionQualityPreset = currentSession?.qualityPreset ?: "720p_30_2.5mbps",
+ onSelectMinQuality = { preset ->
+ scope.launch { persistMinQuality(preset) }
+ },
)
}
}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt
index ccab3bd..808b223 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt
@@ -29,11 +29,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.testTag
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.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -50,7 +54,7 @@ import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
import kotlinx.coroutines.launch
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun LoginScreen(
container: AppContainer,
@@ -130,7 +134,10 @@ fun LoginScreen(
onValueChange = { email = it },
label = { Text(stringResource(R.string.login_email)) },
placeholder = { Text(stringResource(R.string.login_email_placeholder)) },
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("login_email")
+ .semantics { testTagsAsResourceId = true },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
@@ -146,7 +153,10 @@ fun LoginScreen(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.login_password)) },
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("login_password")
+ .semantics { testTagsAsResourceId = true },
singleLine = true,
visualTransformation = if (passwordVisible) {
VisualTransformation.None
@@ -198,6 +208,9 @@ fun LoginScreen(
loading = loading,
enabled = email.isNotBlank() && password.isNotBlank(),
onClick = { submitLogin() },
+ modifier = Modifier
+ .testTag("login_submit")
+ .semantics { testTagsAsResourceId = true },
)
}
}
diff --git a/native/android/app/src/main/res/values-de/strings.xml b/native/android/app/src/main/res/values-de/strings.xml
index 250597d..d347f30 100644
--- a/native/android/app/src/main/res/values-de/strings.xml
+++ b/native/android/app/src/main/res/values-de/strings.xml
@@ -164,6 +164,13 @@
Livestream fortsetzen
Livestream pausieren
Livestream beenden
+ Mindestqualität
+ Minimale Übertragungsqualität
+ Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.
+ Automatisch
+ Mindestqualität: %1$s
+ Mindestqualität konnte nicht gesetzt werden
+ Mindestqualität von der Regie aktualisiert
HEIM
GAST
Anzeigetafel OK
diff --git a/native/android/app/src/main/res/values-en/strings.xml b/native/android/app/src/main/res/values-en/strings.xml
index 4e2faa4..0f1fa49 100644
--- a/native/android/app/src/main/res/values-en/strings.xml
+++ b/native/android/app/src/main/res/values-en/strings.xml
@@ -164,6 +164,13 @@
Resume live
Pause live
End live
+ Minimum quality
+ Minimum streaming quality
+ The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.
+ Automatic
+ Minimum quality: %1$s
+ Unable to set minimum quality
+ Minimum quality updated from control room
HOME
AWAY
Scoreboard OK
diff --git a/native/android/app/src/main/res/values-es/strings.xml b/native/android/app/src/main/res/values-es/strings.xml
index 5371de8..a026fce 100644
--- a/native/android/app/src/main/res/values-es/strings.xml
+++ b/native/android/app/src/main/res/values-es/strings.xml
@@ -164,6 +164,13 @@
Reanudar directo
Pausar directo
Terminar directo
+ Calidad mínima
+ Calidad mínima de transmisión
+ El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.
+ Automática
+ Calidad mínima: %1$s
+ No se pudo fijar la calidad mínima
+ Calidad mínima actualizada desde la regie
LOCAL
VISITANTE
Marcador OK
diff --git a/native/android/app/src/main/res/values-fr/strings.xml b/native/android/app/src/main/res/values-fr/strings.xml
index fad9546..ae02b1b 100644
--- a/native/android/app/src/main/res/values-fr/strings.xml
+++ b/native/android/app/src/main/res/values-fr/strings.xml
@@ -164,6 +164,13 @@
Reprendre le direct
Mettre le direct en pause
Terminer le direct
+ Qualité minimale
+ Qualité minimale de transmission
+ L\'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.
+ Automatique
+ Qualité minimale : %1$s
+ Impossible de définir la qualité minimale
+ Qualité minimale mise à jour depuis la régie
DOMICILE
EXTÉRIEUR
Tableau OK
diff --git a/native/android/app/src/main/res/values/strings.xml b/native/android/app/src/main/res/values/strings.xml
index d726872..a7a78fa 100644
--- a/native/android/app/src/main/res/values/strings.xml
+++ b/native/android/app/src/main/res/values/strings.xml
@@ -164,6 +164,13 @@
Riprendi diretta
Pausa diretta
Termina diretta
+ Qualità minima
+ Qualità minima di trasmissione
+ L\'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.
+ Automatica
+ Qualità minima: %1$s
+ Impossibile impostare la qualità minima
+ Qualità minima aggiornata dalla regia
CASA
OSPITE
Tabellone OK
diff --git a/native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/streaming/AdaptiveBitrateControllerTest.kt b/native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/streaming/AdaptiveBitrateControllerTest.kt
new file mode 100644
index 0000000..0ead77c
--- /dev/null
+++ b/native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/streaming/AdaptiveBitrateControllerTest.kt
@@ -0,0 +1,106 @@
+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),
+ )
+ }
+}
diff --git a/native/ios/MatchLiveTv.xcodeproj/project.pbxproj b/native/ios/MatchLiveTv.xcodeproj/project.pbxproj
index ecebd6f..113872e 100644
--- a/native/ios/MatchLiveTv.xcodeproj/project.pbxproj
+++ b/native/ios/MatchLiveTv.xcodeproj/project.pbxproj
@@ -28,6 +28,7 @@
39335ED5DE5847E5904222AB /* MatchSessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813463F3E17D453EAA801739 /* MatchSessionLauncher.swift */; };
39F7D49D44244F51907E803D /* TeamColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7001BB5D65B34280B306ECCF /* TeamColorPicker.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 */; };
45776A6A76D04EF5A0C0D187 /* TeamBrandingEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBBE0F1399840FFA0E7EF2E /* TeamBrandingEditor.swift */; };
470957DE79A84D9889F0663C /* ScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC8ECFD24A14054BDD984C3 /* ScoreRepository.swift */; };
@@ -68,6 +69,7 @@
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 */; };
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 */; };
ACC54AA19C5747CFA118C70B /* ApiInstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FEB862C3A34BF8AC24052E /* ApiInstantTests.swift */; };
AECC36D80A0C411AB47D581A /* MatchRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537FC2C063D341DF97A391B9 /* MatchRepository.swift */; };
@@ -127,6 +129,7 @@
31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = ""; };
34BB2251A67E401A9F1E789E /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = ""; };
377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThermalAdaptationTests.swift; path = MatchLiveTvTests/ThermalAdaptationTests.swift; sourceTree = ""; };
+ 6A0C3D4E5F6B4C83D9E0F1A2 /* AdaptiveBitrateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AdaptiveBitrateTests.swift; path = MatchLiveTvTests/AdaptiveBitrateTests.swift; sourceTree = ""; };
39F3C00B63534E6AA6D8A90A /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = ""; };
3A6F9AAB5A1249BBB4FC02E6 /* MatchLiveTv/Resources/en.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/en.lproj; sourceTree = ""; };
3BFB2F18615747B1A1C9E9B2 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = ""; };
@@ -160,6 +163,7 @@
87C01CC031C34343B3EBFF08 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = ""; };
8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = ""; };
8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamVideoPreset.swift; path = MatchLiveTv/Streaming/StreamVideoPreset.swift; sourceTree = ""; };
+ 4E8A1B2C3D4F4A61B7C8D9E0 /* AdaptiveBitrateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AdaptiveBitrateController.swift; path = MatchLiveTv/Streaming/AdaptiveBitrateController.swift; sourceTree = ""; };
902831E3D87C46359F0D0A21 /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = ""; };
951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = ""; };
98526C26B2584F55AADF0FFF /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = ""; };
@@ -237,6 +241,7 @@
10AD0F18190542818572CAEF /* ScoreActionDecodeTests.swift */,
6DF142EBF33B45739BA8E42C /* ScoreControllerTests.swift */,
377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */,
+ 6A0C3D4E5F6B4C83D9E0F1A2 /* AdaptiveBitrateTests.swift */,
);
name = MatchLiveTvTests;
sourceTree = "";
@@ -296,6 +301,7 @@
31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */,
46CE696CAB994E2C8E399776 /* LivePreviewView.swift */,
8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */,
+ 4E8A1B2C3D4F4A61B7C8D9E0 /* AdaptiveBitrateController.swift */,
4419E10415414556B7C58458 /* AccountScreen.swift */,
E860A0E35362427DA84F8972 /* BroadcastControlsOverlay.swift */,
71816F0D99E44551839474CE /* BroadcastScreen.swift */,
@@ -473,6 +479,7 @@
6AD0B3220B154EA2936C634A /* WatermarkElement.swift in Sources */,
713E0129656649FC8630FDC6 /* LivePreviewView.swift in Sources */,
451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */,
+ 5F9B2C3D4E5A4B72C8D9E0F1 /* AdaptiveBitrateController.swift in Sources */,
E5E21D088782469D8EB23543 /* AccountScreen.swift in Sources */,
5B51805239FA4D4D90919D86 /* BroadcastControlsOverlay.swift in Sources */,
EE568EA02C404102B4DFD651 /* BroadcastScreen.swift in Sources */,
@@ -516,6 +523,7 @@
5D91C53D2E8A4952937F816B /* ScoreActionDecodeTests.swift in Sources */,
FB66DD0422E14C54AC5B3EDB /* ScoreControllerTests.swift in Sources */,
AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */,
+ 7B1D4E5F6A7C4D94E0F1A2B3 /* AdaptiveBitrateTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/native/ios/MatchLiveTv/Core/AppLanguage.swift b/native/ios/MatchLiveTv/Core/AppLanguage.swift
index ad8317d..f973aec 100644
--- a/native/ios/MatchLiveTv/Core/AppLanguage.swift
+++ b/native/ios/MatchLiveTv/Core/AppLanguage.swift
@@ -118,7 +118,13 @@ enum L10n {
"broadcast.error.rtmp.session.create": "Impossibile creare sessione RTMP",
"broadcast.error.rtmp.url.invalid": "URL RTMP non valido",
"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.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.pause.cd": "Pausa diretta",
"broadcast.period.label.timed": "%1$d° tempo",
@@ -139,7 +145,8 @@ enum L10n {
"broadcast.show.controls.cd": "Mostra controlli",
"broadcast.side.away": "ospite",
"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.pause.error": "Pausa: %1$@",
"broadcast.snackbar.paused": "Diretta in pausa",
"broadcast.snackbar.paused.remote": "Pausa dalla regia",
@@ -408,7 +415,13 @@ enum L10n {
"broadcast.error.rtmp.session.create": "Unable to create RTMP session",
"broadcast.error.rtmp.url.invalid": "Invalid RTMP URL",
"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.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.pause.cd": "Pause live",
"broadcast.period.label.timed": "Period %1$d",
@@ -429,7 +442,8 @@ enum L10n {
"broadcast.show.controls.cd": "Show controls",
"broadcast.side.away": "away",
"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.pause.error": "Pause: %1$@",
"broadcast.snackbar.paused": "Live paused",
"broadcast.snackbar.paused.remote": "Paused from control room",
@@ -698,7 +712,13 @@ enum L10n {
"broadcast.error.rtmp.session.create": "Impossible de créer la session RTMP",
"broadcast.error.rtmp.url.invalid": "URL RTMP non valide",
"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.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.pause.cd": "Mettre le direct en pause",
"broadcast.period.label.timed": "Période %1$d",
@@ -719,7 +739,8 @@ enum L10n {
"broadcast.show.controls.cd": "Afficher les commandes",
"broadcast.side.away": "extérieur",
"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.pause.error": "Pause : %1$@",
"broadcast.snackbar.paused": "Direct en pause",
"broadcast.snackbar.paused.remote": "Mis en pause depuis la régie",
@@ -988,7 +1009,13 @@ enum L10n {
"broadcast.error.rtmp.session.create": "RTMP-Sitzung konnte nicht erstellt werden",
"broadcast.error.rtmp.url.invalid": "Ungültige RTMP-URL",
"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.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.pause.cd": "Livestream pausieren",
"broadcast.period.label.timed": "Halbzeit %1$d",
@@ -1009,7 +1036,8 @@ enum L10n {
"broadcast.show.controls.cd": "Steuerung einblenden",
"broadcast.side.away": "Gast",
"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.pause.error": "Pause: %1$@",
"broadcast.snackbar.paused": "Livestream pausiert",
"broadcast.snackbar.paused.remote": "Von der Regie pausiert",
@@ -1278,7 +1306,13 @@ enum L10n {
"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.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.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.pause.cd": "Pausar directo",
"broadcast.period.label.timed": "Tiempo %1$d",
@@ -1299,7 +1333,8 @@ enum L10n {
"broadcast.show.controls.cd": "Mostrar controles",
"broadcast.side.away": "visitante",
"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.pause.error": "Pausa: %1$@",
"broadcast.snackbar.paused": "Directo en pausa",
"broadcast.snackbar.paused.remote": "Pausado desde regie",
diff --git a/native/ios/MatchLiveTv/Core/Thermal/ThermalState.swift b/native/ios/MatchLiveTv/Core/Thermal/ThermalState.swift
index 5b9d2b8..40aaca6 100644
--- a/native/ios/MatchLiveTv/Core/Thermal/ThermalState.swift
+++ b/native/ios/MatchLiveTv/Core/Thermal/ThermalState.swift
@@ -39,4 +39,13 @@ enum ThermalState: Int, Sendable, Equatable, Comparable {
case .critical: return "🔴"
}
}
+
+ var apiValue: String {
+ switch self {
+ case .nominal: return "nominal"
+ case .fair: return "fair"
+ case .serious: return "serious"
+ case .critical: return "critical"
+ }
+ }
}
diff --git a/native/ios/MatchLiveTv/Data/API/ApiDtos.swift b/native/ios/MatchLiveTv/Data/API/ApiDtos.swift
index 26d5c77..1d8392e 100644
--- a/native/ios/MatchLiveTv/Data/API/ApiDtos.swift
+++ b/native/ios/MatchLiveTv/Data/API/ApiDtos.swift
@@ -202,6 +202,7 @@ struct StreamSessionDto: Decodable {
let targetBitrate: Int?
let targetFps: Int?
let qualityPreset: String?
+ let minQualityPreset: String?
let startedAt: String?
let score: ScoreStateDto?
@@ -221,6 +222,7 @@ struct StreamSessionDto: Decodable {
targetBitrate: targetBitrate ?? 2_500_000,
targetFps: targetFps ?? 30,
qualityPreset: qualityPreset ?? "720p_30_2.5mbps",
+ minQualityPreset: minQualityPreset ?? "auto",
startedAt: startedAt,
score: score?.toDomain()
)
@@ -414,6 +416,10 @@ struct CreateSessionRequest: Encodable {
let youtubeChannel: String?
}
+struct MinQualityRequest: Encodable {
+ let minQualityPreset: String
+}
+
struct ScoringRulesBody: Codable {
let setsToWin: Int?
let pointsPerSet: Int?
@@ -499,6 +505,7 @@ struct TelemetryRequest: Encodable {
let currentBitrate: Int?
let targetBitrate: Int?
let fps: Int?
+ let thermalState: String?
}
extension ScoringRules {
diff --git a/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift b/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift
index fa2f3d7..260c302 100644
--- a/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift
+++ b/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift
@@ -186,6 +186,10 @@ final class MatchLiveAPI: @unchecked Sendable {
try await patch("sessions/\(id)/resume", body: EmptyBody())
}
+ func setMinQuality(id: String, preset: String) async throws -> StreamSessionDto {
+ try await patch("sessions/\(id)/min_quality", body: MinQualityRequest(minQualityPreset: preset))
+ }
+
func networkTest(sessionId: String, body: NetworkTestRequest) async throws -> NetworkTestResponse {
try await post("sessions/\(sessionId)/network_test", body: body)
}
diff --git a/native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift b/native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift
index a79de5b..d7e2114 100644
--- a/native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift
+++ b/native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift
@@ -8,6 +8,7 @@ final class SessionCableService: ObservableObject {
var onPauseStream: (() -> Void)?
var onResumeStream: (() -> Void)?
var onStopStream: (() -> Void)?
+ var onMinQuality: ((String) -> Void)?
private var client: ActionCableClient?
private var intentionalDisconnect = false
@@ -93,6 +94,10 @@ extension SessionCableService: ActionCableClient.Listener {
case "pause_stream": onPauseStream?()
case "resume_stream": onResumeStream?()
case "stop_stream": onStopStream?()
+ case "set_min_quality":
+ if let preset = payload["min_quality_preset"] as? String {
+ onMinQuality?(preset)
+ }
default: break
}
case "stream_event":
diff --git a/native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift b/native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift
index 3419585..95f06b7 100644
--- a/native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift
+++ b/native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift
@@ -50,6 +50,10 @@ final class SessionRepository {
try await api.resumeSession(id: id).toDomain()
}
+ func setMinQuality(id: String, preset: String) async throws -> StreamSession {
+ try await api.setMinQuality(id: id, preset: preset).toDomain()
+ }
+
func submitNetworkTest(
sessionId: String,
downloadMbps: Double,
@@ -88,7 +92,8 @@ final class SessionRepository {
signalStrength: nil,
currentBitrate: currentBitrate,
targetBitrate: targetBitrate,
- fps: fps
+ fps: fps,
+ thermalState: health.thermalState.apiValue
)
)
}
diff --git a/native/ios/MatchLiveTv/Domain/Models.swift b/native/ios/MatchLiveTv/Domain/Models.swift
index 1997a6b..08313ab 100644
--- a/native/ios/MatchLiveTv/Domain/Models.swift
+++ b/native/ios/MatchLiveTv/Domain/Models.swift
@@ -119,11 +119,35 @@ struct StreamSession: Identifiable, Equatable, Sendable {
let targetBitrate: Int
let targetFps: Int
let qualityPreset: String
+ let minQualityPreset: String
let startedAt: String?
let score: ScoreState?
var isLive: Bool { status == "live" || status == "reconnecting" }
var isPaused: Bool { status == "paused" }
+ var isEnded: Bool { status == "ended" || status == "error" }
+
+ func with(minQualityPreset: String) -> StreamSession {
+ StreamSession(
+ id: id,
+ matchId: matchId,
+ status: status,
+ platform: platform,
+ rtmpIngestUrl: rtmpIngestUrl,
+ hlsPlaybackUrl: hlsPlaybackUrl,
+ watchPageUrl: watchPageUrl,
+ shareUrl: shareUrl,
+ youtubeWatchUrl: youtubeWatchUrl,
+ youtubeReady: youtubeReady,
+ privacyStatus: privacyStatus,
+ targetBitrate: targetBitrate,
+ targetFps: targetFps,
+ qualityPreset: qualityPreset,
+ minQualityPreset: minQualityPreset,
+ startedAt: startedAt,
+ score: score
+ )
+ }
func watchShareUrl() -> String? {
if platform == "youtube", let url = youtubeWatchUrl, !url.isEmpty { return url }
diff --git a/native/ios/MatchLiveTv/Streaming/AdaptiveBitrateController.swift b/native/ios/MatchLiveTv/Streaming/AdaptiveBitrateController.swift
new file mode 100644
index 0000000..02951c0
--- /dev/null
+++ b/native/ios/MatchLiveTv/Streaming/AdaptiveBitrateController.swift
@@ -0,0 +1,193 @@
+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
+ }
+}
diff --git a/native/ios/MatchLiveTv/Streaming/BroadcastModels.swift b/native/ios/MatchLiveTv/Streaming/BroadcastModels.swift
index 488c4cd..a8e0dac 100644
--- a/native/ios/MatchLiveTv/Streaming/BroadcastModels.swift
+++ b/native/ios/MatchLiveTv/Streaming/BroadcastModels.swift
@@ -7,6 +7,7 @@ struct BroadcastConfig: Sendable {
var width: Int = 1280
var height: Int = 720
var videoBitrate: Int = 2_500_000
+ var minVideoBitrate: Int = 800_000
var audioBitrate: Int = 128_000
var fps: Int = 30
var rotation: Int = 0
diff --git a/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift b/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift
index 3e6f18d..99e3066 100644
--- a/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift
+++ b/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift
@@ -5,18 +5,37 @@ import RTMPHaishinKit
import UIKit
import VideoToolbox
-private struct BroadcastBitrateMonitor: StreamBitRateStrategy {
- let mamimumVideoBitRate: Int = 0
- let mamimumAudioBitRate: Int = 0
+private final class AdaptiveBroadcastBitRateStrategy: StreamBitRateStrategy, @unchecked Sendable {
+ let controller: AdaptiveBitrateController
let onUpdate: @Sendable (Int) -> Void
+ var mamimumVideoBitRate: Int { controller.ceiling }
+ let mamimumAudioBitRate: Int = 0
+
+ init(controller: AdaptiveBitrateController, onUpdate: @escaping @Sendable (Int) -> Void) {
+ self.controller = controller
+ self.onUpdate = onUpdate
+ }
func adjustBitrate(_ event: NetworkMonitorEvent, stream: some StreamConvertible) async {
+ let next: Int?
switch event {
- case .status(let report), .publishInsufficientBWOccured(let report):
+ case .status(let report):
+ let measuredBits = report.currentBytesOutPerSecond * 8
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:
- break
+ next = controller.resetToCeiling()
+ @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)
}
}
@@ -47,6 +66,7 @@ final class LiveBroadcastEngine: ObservableObject {
private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
private var orientationDebounceTask: Task?
private var rtmpSession: (any Session)?
+ private let adaptiveBitrate = AdaptiveBitrateController()
private static var rtmpFactoryRegistered = false
private static func ensureRTMPFactoryRegistered() async {
@@ -101,6 +121,7 @@ final class LiveBroadcastEngine: ObservableObject {
func preparePreview(config: BroadcastConfig) async throws {
self.config = config
+ adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
publishPending = false
try await configurePipeline(config)
setPhase(.preview)
@@ -109,6 +130,7 @@ final class LiveBroadcastEngine: ObservableObject {
/// Configura camera/mixer e mette in coda il publish RTMP (eseguito quando l'anteprima è pronta).
func prepareForBroadcast(config: BroadcastConfig) async throws {
self.config = config
+ adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
publishPending = true
try await configurePipeline(config)
setPhase(.connecting)
@@ -135,6 +157,7 @@ final class LiveBroadcastEngine: ObservableObject {
func resumeBroadcast(config: BroadcastConfig) async throws {
self.config = config
+ adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
reconnectAttempts = 0
reconnectTask?.cancel()
reconnectTask = nil
@@ -179,18 +202,40 @@ final class LiveBroadcastEngine: ObservableObject {
await stopBroadcast()
}
- /// Adattamento termico: aggiorna profilo video senza interrompere la sessione RTMP attiva.
+ /// Adattamento termico: aggiorna il soffitto ABR e l'FPS senza interrompere la sessione RTMP.
func applyThermalProfile(videoBitrate: Int, fps: Int) async {
guard var current = config else { return }
- guard current.videoBitrate != videoBitrate || current.fps != fps else { return }
+ adaptiveBitrate.setCeiling(videoBitrate)
+ let applied = adaptiveBitrate.setFloor(current.minVideoBitrate)
+ guard current.videoBitrate != videoBitrate || current.fps != fps else {
+ await applyEncoderBitrate(applied)
+ return
+ }
current.videoBitrate = videoBitrate
current.fps = fps
config = current
if pipelineConfigured, let stream = await rtmpSession?.stream {
- try? await applyCodecSettings(to: stream, config: current)
+ try? await applyCodecSettings(to: stream, config: current, videoBitrate: applied)
}
}
+ 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 {
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized,
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
@@ -316,7 +361,7 @@ final class LiveBroadcastEngine: ObservableObject {
let stream = await session.stream
try await applyCodecSettings(to: stream, config: config)
- await stream.setBitRateStrategy(BroadcastBitrateMonitor { [weak self] kbps in
+ await stream.setBitRateStrategy(AdaptiveBroadcastBitRateStrategy(controller: adaptiveBitrate) { [weak self] kbps in
Task { @MainActor in
guard let self else { return }
self.metrics = BroadcastMetrics(
@@ -349,10 +394,10 @@ final class LiveBroadcastEngine: ObservableObject {
setPhase(.live)
}
- private func applyCodecSettings(to stream: any StreamConvertible, config: BroadcastConfig) async throws {
+ private func applyCodecSettings(to stream: any StreamConvertible, config: BroadcastConfig, videoBitrate: Int? = nil) async throws {
var videoSettings = await stream.videoSettings
videoSettings.videoSize = .init(width: config.width, height: config.height)
- videoSettings.bitRate = config.videoBitrate
+ videoSettings.bitRate = videoBitrate ?? adaptiveBitrate.currentBitrate
videoSettings.maxKeyFrameIntervalDuration = 2
videoSettings.profileLevel = config.height > 720
? (kVTProfileLevel_H264_Baseline_4_1 as String)
diff --git a/native/ios/MatchLiveTv/Streaming/StreamVideoPreset.swift b/native/ios/MatchLiveTv/Streaming/StreamVideoPreset.swift
index e3f23c8..e61d444 100644
--- a/native/ios/MatchLiveTv/Streaming/StreamVideoPreset.swift
+++ b/native/ios/MatchLiveTv/Streaming/StreamVideoPreset.swift
@@ -15,11 +15,17 @@ enum StreamVideoPreset {
static func broadcastConfig(for session: StreamSession, rtmpUrl: String) -> BroadcastConfig {
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(
rtmpUrl: rtmpUrl,
width: dims.width,
height: dims.height,
- videoBitrate: session.targetBitrate,
+ videoBitrate: ceiling,
+ minVideoBitrate: floor,
audioBitrate: 128_000,
fps: session.targetFps
)
diff --git a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift
index 084ac75..133906e 100644
--- a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift
+++ b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift
@@ -40,8 +40,12 @@ struct BroadcastControlsOverlay: View {
let bitrateKbps: Int
let networkType: String
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 showMinQualityPicker = false
var body: some View {
Color.clear
@@ -69,7 +73,11 @@ struct BroadcastControlsOverlay: View {
targetFps: targetFps,
bitrateKbps: bitrateKbps,
networkType: networkType,
- deviceHealth: deviceHealth
+ deviceHealth: deviceHealth,
+ minQualityLabel: StreamQualityLadder.displayName(
+ id: minQualityPreset,
+ autoLabel: L10n.t("broadcast.min.quality.auto")
+ )
)
}
.padding(.trailing, 8)
@@ -94,6 +102,19 @@ struct BroadcastControlsOverlay: View {
.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) {
Button(L10n.t("action.cancel"), role: .cancel) {}
Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: onTerminate)
@@ -115,6 +136,11 @@ struct BroadcastControlsOverlay: View {
accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
action: onShareRegia
)
+ SideIconButton(
+ systemName: "slider.horizontal.3",
+ accessibilityLabel: L10n.t("broadcast.min.quality.cd"),
+ action: { showMinQualityPicker = true }
+ )
if let onCloseSet {
SideIconButton(
systemName: "checkmark",
@@ -188,6 +214,85 @@ 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 {
let cableConnected: Bool
let fps: Int
@@ -195,6 +300,7 @@ private struct BroadcastTelemetryPanel: View {
let bitrateKbps: Int
let networkType: String
let deviceHealth: DeviceHealth
+ let minQualityLabel: String
var body: some View {
VStack(alignment: .trailing, spacing: 2) {
@@ -211,6 +317,9 @@ private struct BroadcastTelemetryPanel: View {
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
}
+ Text(minQualityLabel)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(MatchColors.accentYellow)
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
diff --git a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift
index f15ad7a..7e9059c 100644
--- a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift
+++ b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift
@@ -27,6 +27,7 @@ struct BroadcastScreen: View {
@State private var logoReady = 0
@State private var pauseInFlight = false
@State private var resumeInFlight = false
+ @State private var minQualityInFlight = false
@State private var deviceHealth = DeviceTelemetry.snapshot()
@State private var snackbarMessage: String?
@State private var shareItem: ShareItem?
@@ -83,9 +84,15 @@ struct BroadcastScreen: View {
.task(id: sessionId) {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 2_000_000_000)
- if let remote = try? await container.sessionRepository.fetchSession(id: sessionId).score {
- container.scoreController.applyRemote(remote, source: .polling)
+ guard !minQualityInFlight, !pauseInFlight, !resumeInFlight else { continue }
+ guard let remote = try? await container.sessionRepository.fetchSession(id: sessionId) else { continue }
+ 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) {
@@ -290,7 +297,12 @@ struct BroadcastScreen: View {
targetFps: session.targetFps,
bitrateKbps: metrics.bitrateKbps,
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) }
+ }
)
}
@@ -418,6 +430,12 @@ struct BroadcastScreen: View {
container.sessionCable.onStopStream = { [weak container] in
Task { @MainActor in await stopStream() }
}
+ container.sessionCable.onMinQuality = { [weak container] preset in
+ Task { @MainActor in
+ guard let container else { return }
+ applyMinQualityLocally(preset: preset, fromRemote: true)
+ }
+ }
container.sessionCable.connect(sessionId: sessionId, accessToken: token)
}
@@ -504,6 +522,33 @@ struct BroadcastScreen: View {
}
}
+ /// 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.
private func applyRemotePause(container: AppContainer) async {
guard !pauseInFlight else { return }
@@ -541,6 +586,43 @@ 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 {
error = nil
guard let session, let url = session.rtmpIngestUrl, !url.isEmpty else { return }
@@ -567,6 +649,7 @@ struct BroadcastScreen: View {
container.sessionCable.onPauseStream = nil
container.sessionCable.onResumeStream = nil
container.sessionCable.onStopStream = nil
+ container.sessionCable.onMinQuality = nil
container.sessionCable.disconnect()
await container.broadcastCoordinator.stopBroadcast(sessionId: sessionId)
}
diff --git a/native/ios/MatchLiveTvTests/AdaptiveBitrateTests.swift b/native/ios/MatchLiveTvTests/AdaptiveBitrateTests.swift
new file mode 100644
index 0000000..c79da96
--- /dev/null
+++ b/native/ios/MatchLiveTvTests/AdaptiveBitrateTests.swift
@@ -0,0 +1,92 @@
+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)
+ }
+}