From f92ab3a813fb8b111b412dd60cf02ed4411e9a7e Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Thu, 13 Aug 2026 20:05:16 +0200 Subject: [PATCH] =?UTF-8?q?Aggiunge=20bitrate=20adattivo=20con=20qualit?= =?UTF-8?q?=C3=A0=20minima=20da=20regia=20e=20telemetria=20del=20telefono.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'encoder scende in congestione e risale piano; la regia imposta il pavimento, mostra lo stato del telefono e apre il selettore dal bitrate colorato. Co-authored-by: Cursor --- backend/app/channels/session_channel.rb | 2 + .../api/v1/stream_sessions_controller.rb | 14 + .../controllers/public/regia_controller.rb | 19 +- backend/app/helpers/public/regia_helper.rb | 21 +- backend/app/models/device_state.rb | 13 + backend/app/models/stream_session.rb | 1 + backend/app/services/sessions/pause.rb | 7 +- .../app/services/sessions/select_quality.rb | 28 ++ .../app/services/sessions/set_min_quality.rb | 39 +++ backend/app/views/layouts/regia.html.erb | 2 +- .../app/views/public/regia/_volley.html.erb | 2 +- backend/app/views/public/regia/show.html.erb | 129 +++++-- backend/config/environments/development.rb | 4 +- backend/config/locales/app.de.yml | 41 ++- backend/config/locales/app.en.yml | 41 ++- backend/config/locales/app.es.yml | 41 ++- backend/config/locales/app.fr.yml | 41 ++- backend/config/locales/app.it.yml | 41 ++- backend/config/routes.rb | 2 + ...d_min_quality_preset_to_stream_sessions.rb | 7 + ...4100_add_thermal_state_to_device_states.rb | 5 + backend/db/schema.rb | 30 +- backend/public/regia.css | 318 +++++++++++++++--- backend/public/regia.js | 267 ++++++++++++++- backend/spec/requests/public/regia_spec.rb | 48 ++- .../services/sessions/select_quality_spec.rb | 7 + .../services/sessions/set_min_quality_spec.rb | 41 +++ infra/docker-compose.yml | 2 + .../LocalAdaptiveBitrateApiTest.kt | 71 ++++ .../LocalAdaptiveBitrateUiTest.kt | 265 +++++++++++++++ .../matchlivetv/match_live_tv/MainActivity.kt | 10 +- .../match_live_tv/data/api/ApiDtos.kt | 7 + .../match_live_tv/data/api/MatchLiveApi.kt | 6 + .../data/cable/SessionCableService.kt | 2 + .../data/repository/SessionRepository.kt | 6 + .../match_live_tv/domain/Models.kt | 3 + .../streaming/AdaptiveBitrateController.kt | 115 +++++++ .../streaming/BroadcastModels.kt | 1 + .../streaming/LiveBroadcastEngine.kt | 41 ++- .../streaming/StreamQualityLadder.kt | 72 ++++ .../streaming/StreamVideoPreset.kt | 5 +- .../ui/broadcast/BroadcastControlsOverlay.kt | 142 ++++++++ .../ui/broadcast/BroadcastScreen.kt | 93 ++++- .../match_live_tv/ui/login/LoginScreen.kt | 19 +- .../app/src/main/res/values-de/strings.xml | 7 + .../app/src/main/res/values-en/strings.xml | 7 + .../app/src/main/res/values-es/strings.xml | 7 + .../app/src/main/res/values-fr/strings.xml | 7 + .../app/src/main/res/values/strings.xml | 7 + .../AdaptiveBitrateControllerTest.kt | 106 ++++++ .../ios/MatchLiveTv.xcodeproj/project.pbxproj | 8 + native/ios/MatchLiveTv/Core/AppLanguage.swift | 55 ++- .../Core/Thermal/ThermalState.swift | 9 + native/ios/MatchLiveTv/Data/API/ApiDtos.swift | 7 + .../MatchLiveTv/Data/API/MatchLiveAPI.swift | 4 + .../Data/Cable/SessionCableService.swift | 5 + .../Data/Repository/SessionRepository.swift | 7 +- native/ios/MatchLiveTv/Domain/Models.swift | 24 ++ .../Streaming/AdaptiveBitrateController.swift | 193 +++++++++++ .../Streaming/BroadcastModels.swift | 1 + .../Streaming/LiveBroadcastEngine.swift | 67 +++- .../Streaming/StreamVideoPreset.swift | 8 +- .../Broadcast/BroadcastControlsOverlay.swift | 111 +++++- .../UI/Broadcast/BroadcastScreen.swift | 89 ++++- .../AdaptiveBitrateTests.swift | 92 +++++ 65 files changed, 2745 insertions(+), 147 deletions(-) create mode 100644 backend/app/services/sessions/set_min_quality.rb create mode 100644 backend/db/migrate/20260813170000_add_min_quality_preset_to_stream_sessions.rb create mode 100644 backend/db/migrate/20260813184100_add_thermal_state_to_device_states.rb create mode 100644 backend/spec/services/sessions/set_min_quality_spec.rb create mode 100644 native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/LocalAdaptiveBitrateApiTest.kt create mode 100644 native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/LocalAdaptiveBitrateUiTest.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/AdaptiveBitrateController.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/StreamQualityLadder.kt create mode 100644 native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/streaming/AdaptiveBitrateControllerTest.kt create mode 100644 native/ios/MatchLiveTv/Streaming/AdaptiveBitrateController.swift create mode 100644 native/ios/MatchLiveTvTests/AdaptiveBitrateTests.swift 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? %>">
-

<%= @team.name %> vs <%= @match.opponent_name %>

-

<%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %>

+
+

<%= @team.name %> vs <%= @match.opponent_name %>

+

<%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %>

+
<%= @stream_closed ? t("regia.status.ended") : (@session.paused? ? t("regia.status.paused") : (@on_air ? t("regia.status.live") : t("regia.status.waiting"))) %>
- <% if @live_share_url.present? %> -
-

<%= t("regia.live_share_label") %>

-
- + +
+
+ <% end %> +
"> + <%= t("regia.regia_share_label") %> +
+ -
- <% end %> - -
-

<%= t("regia.regia_share_label") %>

-
- - -
-
- -
<% unless @stream_closed %> - +
"> + +
+ + + +
+
<% end %> -
- <%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %> + + +
+
+ <% unless @stream_closed %> + + <% end %> +
+ <%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %> +
-
+
<%= render regia_board_partial(@match) %>
<% unless @stream_closed %> - - +
"> +
+ "> + + <%= t("regia.telemetry.battery") %> + + + "> + + <%= t("regia.telemetry.network") %> + + + + "> + + <%= t("regia.telemetry.fps") %> + + + "> + + <%= t("regia.telemetry.thermal") %> + + +
+ +

<%= t("regia.telemetry.waiting") %>

+
<% end %>
@@ -94,4 +159,4 @@ - + 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) + } +}