Aggiunge bitrate adattivo con qualità minima da regia e telemetria del telefono.
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -55,6 +55,8 @@ class SessionChannel < ApplicationCable::Channel
|
|||||||
Sessions::Pause.new(session).call
|
Sessions::Pause.new(session).call
|
||||||
when "resume_stream"
|
when "resume_stream"
|
||||||
Sessions::Resume.new(session).call
|
Sessions::Resume.new(session).call
|
||||||
|
when "set_min_quality"
|
||||||
|
Sessions::SetMinQuality.new(session, preset: data["min_quality_preset"]).call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ module Api
|
|||||||
current_bitrate: params[:current_bitrate],
|
current_bitrate: params[:current_bitrate],
|
||||||
target_bitrate: params[:target_bitrate],
|
target_bitrate: params[:target_bitrate],
|
||||||
fps: params[:fps],
|
fps: params[:fps],
|
||||||
|
thermal_state: sanitized_thermal_state,
|
||||||
last_seen_at: Time.current
|
last_seen_at: Time.current
|
||||||
)
|
)
|
||||||
sync_publisher_when_streaming!(params[:fps].to_f)
|
sync_publisher_when_streaming!(params[:fps].to_f)
|
||||||
@@ -141,6 +142,13 @@ module Api
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def min_quality
|
||||||
|
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
|
||||||
|
render json: session_json(@session)
|
||||||
|
rescue ArgumentError => e
|
||||||
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
def youtube_stats
|
def youtube_stats
|
||||||
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
|
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
|
||||||
Youtube::BroadcastService.new(@session.match.team)
|
Youtube::BroadcastService.new(@session.match.team)
|
||||||
@@ -160,6 +168,11 @@ module Api
|
|||||||
.find(params[:id])
|
.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sanitized_thermal_state
|
||||||
|
value = params[:thermal_state].to_s.downcase.presence
|
||||||
|
value if value.in?(%w[nominal fair serious critical])
|
||||||
|
end
|
||||||
|
|
||||||
def session_params
|
def session_params
|
||||||
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
|
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
|
||||||
end
|
end
|
||||||
@@ -187,6 +200,7 @@ module Api
|
|||||||
youtube_ready: session.youtube_ready?,
|
youtube_ready: session.youtube_ready?,
|
||||||
privacy_status: session.privacy_status,
|
privacy_status: session.privacy_status,
|
||||||
quality_preset: session.quality_preset,
|
quality_preset: session.quality_preset,
|
||||||
|
min_quality_preset: session.min_quality_preset,
|
||||||
target_bitrate: session.target_bitrate,
|
target_bitrate: session.target_bitrate,
|
||||||
started_at: session.started_at,
|
started_at: session.started_at,
|
||||||
disconnection_count: session.disconnection_count
|
disconnection_count: session.disconnection_count
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ module Public
|
|||||||
layout "regia"
|
layout "regia"
|
||||||
|
|
||||||
protect_from_forgery with: :null_session
|
protect_from_forgery with: :null_session
|
||||||
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop]
|
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop min_quality]
|
||||||
|
|
||||||
before_action :set_session_from_token
|
before_action :set_session_from_token
|
||||||
|
|
||||||
@@ -61,8 +61,20 @@ module Public
|
|||||||
render json: status_payload
|
render json: status_payload
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
|
def camera_telemetry_payload(publisher_online)
|
||||||
|
cam = @session.device_states.find_by(device_role: "camera")
|
||||||
|
(cam&.as_regia_payload || {}).merge(publisher_online: publisher_online)
|
||||||
|
end
|
||||||
|
|
||||||
def set_session_from_token
|
def set_session_from_token
|
||||||
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
|
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
|
||||||
return if @session
|
return if @session
|
||||||
@@ -86,7 +98,10 @@ module Public
|
|||||||
home_name: @session.match.team.name,
|
home_name: @session.match.team.name,
|
||||||
away_name: @session.match.opponent_name,
|
away_name: @session.match.opponent_name,
|
||||||
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
|
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
|
||||||
score: score&.as_cable_payload
|
score: score&.as_cable_payload,
|
||||||
|
min_quality_preset: @session.min_quality_preset,
|
||||||
|
min_quality_options: Sessions::SelectQuality.allowed_min_ids(@session.quality_preset),
|
||||||
|
telemetry: camera_telemetry_payload(publisher_online)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -54,15 +54,34 @@ module Public
|
|||||||
copyPrompt: t("regia.js.copy_prompt"),
|
copyPrompt: t("regia.js.copy_prompt"),
|
||||||
shareRegiaText: t("regia.js.share_regia_text"),
|
shareRegiaText: t("regia.js.share_regia_text"),
|
||||||
shareLiveText: t("regia.js.share_live_text"),
|
shareLiveText: t("regia.js.share_live_text"),
|
||||||
|
minQualityUpdated: t("regia.js.min_quality_updated"),
|
||||||
|
minQualityError: t("regia.js.min_quality_error"),
|
||||||
|
minQualityAuto: t("regia.min_quality.auto"),
|
||||||
previewWaiting: t("regia.preview_waiting"),
|
previewWaiting: t("regia.preview_waiting"),
|
||||||
streamEnded: t("regia.preview_ended"),
|
streamEnded: t("regia.preview_ended"),
|
||||||
|
previewHide: t("regia.preview_hide"),
|
||||||
|
previewShow: t("regia.preview_show"),
|
||||||
|
previewOffHint: t("regia.preview_off_hint"),
|
||||||
resumeLabel: t("regia.resume"),
|
resumeLabel: t("regia.resume"),
|
||||||
pauseLabel: t("regia.pause"),
|
pauseLabel: t("regia.pause"),
|
||||||
endedBadge: t("regia.status.ended"),
|
endedBadge: t("regia.status.ended"),
|
||||||
pausedBadge: t("regia.status.paused"),
|
pausedBadge: t("regia.status.paused"),
|
||||||
liveBadge: t("regia.status.live"),
|
liveBadge: t("regia.status.live"),
|
||||||
waitingBadge: t("regia.status.waiting"),
|
waitingBadge: t("regia.status.waiting"),
|
||||||
subtitlePrefix: t("regia.subtitle_prefix")
|
subtitlePrefix: t("regia.subtitle_prefix"),
|
||||||
|
telemetryWaiting: t("regia.telemetry.waiting"),
|
||||||
|
telemetryStale: t("regia.telemetry.stale"),
|
||||||
|
telemetryJustNow: t("regia.telemetry.just_now"),
|
||||||
|
telemetrySecondsAgo: t("regia.telemetry.seconds_ago"),
|
||||||
|
telemetryPublisherOn: t("regia.telemetry.publisher_on"),
|
||||||
|
telemetryPublisherOff: t("regia.telemetry.publisher_off"),
|
||||||
|
telemetryThermalNominal: t("regia.telemetry.thermal_nominal"),
|
||||||
|
telemetryThermalFair: t("regia.telemetry.thermal_fair"),
|
||||||
|
telemetryThermalSerious: t("regia.telemetry.thermal_serious"),
|
||||||
|
telemetryThermalCritical: t("regia.telemetry.thermal_critical"),
|
||||||
|
telemetryBitrateMax: t("regia.telemetry.bitrate_max"),
|
||||||
|
telemetryBitrateTitle: t("regia.telemetry.bitrate_title"),
|
||||||
|
telemetryBitrateTap: t("regia.telemetry.bitrate_tap")
|
||||||
}.to_json
|
}.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -16,4 +16,17 @@ class DeviceState < ApplicationRecord
|
|||||||
status: stream_session.status
|
status: stream_session.status
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def as_regia_payload
|
||||||
|
{
|
||||||
|
battery: battery_level,
|
||||||
|
network: network_type,
|
||||||
|
signal: signal_strength,
|
||||||
|
bitrate: current_bitrate,
|
||||||
|
target_bitrate: target_bitrate,
|
||||||
|
fps: fps,
|
||||||
|
thermal_state: thermal_state,
|
||||||
|
last_seen_at: last_seen_at&.iso8601
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class StreamSession < ApplicationRecord
|
|||||||
validates :platform, inclusion: { in: PLATFORMS }
|
validates :platform, inclusion: { in: PLATFORMS }
|
||||||
validates :status, inclusion: { in: STATUSES }
|
validates :status, inclusion: { in: STATUSES }
|
||||||
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
|
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
|
||||||
|
validates :min_quality_preset, inclusion: { in: Sessions::SelectQuality::MIN_QUALITY_IDS }
|
||||||
|
|
||||||
before_validation :normalize_privacy_status
|
before_validation :normalize_privacy_status
|
||||||
before_validation :ensure_publish_token, on: :create
|
before_validation :ensure_publish_token, on: :create
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ module Sessions
|
|||||||
@session.pause! if @session.may_pause?
|
@session.pause! if @session.may_pause?
|
||||||
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
||||||
begin
|
begin
|
||||||
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
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}")
|
Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}")
|
||||||
end
|
end
|
||||||
Mediamtx::Client.new.set_always_available(@session, enabled: true)
|
|
||||||
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
|
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
|
||||||
log_event("paused")
|
log_event("paused")
|
||||||
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ module Sessions
|
|||||||
class SelectQuality
|
class SelectQuality
|
||||||
UPLOAD_HEADROOM = 0.8
|
UPLOAD_HEADROOM = 0.8
|
||||||
UPLOAD_HEADROOM_1080P = 1.0
|
UPLOAD_HEADROOM_1080P = 1.0
|
||||||
|
AUTO_ID = "auto"
|
||||||
|
AUTO_FLOOR_BITRATE = 800_000
|
||||||
|
|
||||||
PRESETS = [
|
PRESETS = [
|
||||||
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
|
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
|
||||||
@@ -11,6 +13,32 @@ module Sessions
|
|||||||
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
|
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
|
MIN_QUALITY_IDS = ([AUTO_ID] + PRESETS.map { |preset| preset[:id] }).freeze
|
||||||
|
|
||||||
|
def self.floor_bitrate(preset_id)
|
||||||
|
return AUTO_FLOOR_BITRATE if preset_id.blank? || preset_id == AUTO_ID
|
||||||
|
|
||||||
|
PRESETS.find { |preset| preset[:id] == preset_id }&.fetch(:target_bitrate) || AUTO_FLOOR_BITRATE
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.allowed_min_ids(session_preset)
|
||||||
|
ids = [AUTO_ID] + PRESETS.reject { |preset| preset[:id].start_with?("1080p") }.reverse.map { |preset| preset[:id] }
|
||||||
|
ids << "1080p_30_4.5mbps" if session_preset.to_s.start_with?("1080p")
|
||||||
|
ids
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.display_name(preset_id)
|
||||||
|
return AUTO_ID if preset_id.blank? || preset_id == AUTO_ID
|
||||||
|
|
||||||
|
preset = PRESETS.find { |item| item[:id] == preset_id }
|
||||||
|
return preset_id unless preset
|
||||||
|
|
||||||
|
resolution = preset[:id].start_with?("1080p") ? "1080p" : "720p"
|
||||||
|
mbps = preset[:target_bitrate] / 1_000_000.0
|
||||||
|
mbps_text = (mbps == mbps.to_i) ? mbps.to_i.to_s : format("%.1f", mbps)
|
||||||
|
"#{resolution} · #{mbps_text} Mbps"
|
||||||
|
end
|
||||||
|
|
||||||
def self.call(upload_mbps:)
|
def self.call(upload_mbps:)
|
||||||
new(upload_mbps: upload_mbps).call
|
new(upload_mbps: upload_mbps).call
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<meta name="theme-color" content="#0a0a0e">
|
<meta name="theme-color" content="#0a0a0e">
|
||||||
<title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title>
|
<title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title>
|
||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<link rel="stylesheet" href="/regia.css?v=1">
|
<link rel="stylesheet" href="/regia.css?v=11">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
<body class="regia-body">
|
<body class="regia-body">
|
||||||
|
|||||||
@@ -21,4 +21,4 @@
|
|||||||
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set" style="margin-top:12px"><%= t("regia.board.close_set") %></button>
|
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set"><%= t("regia.board.close_set") %></button>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
||||||
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
|
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
|
||||||
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
|
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
|
||||||
|
data-min-quality-url="<%= j public_regia_min_quality_path(params[:token]) %>"
|
||||||
|
data-min-quality="<%= j @session.min_quality_preset %>"
|
||||||
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
||||||
data-hls-url="<%= j @session.hls_playback_url %>"
|
data-hls-url="<%= j @session.hls_playback_url %>"
|
||||||
data-home-name="<%= j @team.name %>"
|
data-home-name="<%= j @team.name %>"
|
||||||
@@ -29,55 +31,118 @@
|
|||||||
data-initial-paused="<%= @session.paused? %>">
|
data-initial-paused="<%= @session.paused? %>">
|
||||||
|
|
||||||
<header class="regia-header">
|
<header class="regia-header">
|
||||||
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
<div class="regia-header__text">
|
||||||
<p id="regia-subtitle"><%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %></p>
|
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
||||||
|
<p id="regia-subtitle"><%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %></p>
|
||||||
|
</div>
|
||||||
<span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>">
|
<span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>">
|
||||||
<%= @stream_closed ? t("regia.status.ended") : (@session.paused? ? t("regia.status.paused") : (@on_air ? t("regia.status.live") : t("regia.status.waiting"))) %>
|
<%= @stream_closed ? t("regia.status.ended") : (@session.paused? ? t("regia.status.paused") : (@on_air ? t("regia.status.live") : t("regia.status.waiting"))) %>
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<% if @live_share_url.present? %>
|
<nav class="regia-toolbar" aria-label="<%= t("regia.links_nav") %>">
|
||||||
<div class="regia-share-group">
|
<% if @live_share_url.present? %>
|
||||||
<p class="regia-share-label"><%= t("regia.live_share_label") %></p>
|
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.live_share_label") %>">
|
||||||
<div class="regia-share">
|
<span class="regia-tool-group__label"><%= t("regia.live_share_label") %></span>
|
||||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-live" style="margin:0">
|
<div class="regia-tool-group__btns">
|
||||||
<%= t("regia.share_live") %>
|
<button type="button" class="regia-icon-btn" id="btn-share-live" aria-label="<%= t("regia.share_live") %>" title="<%= t("regia.share_live") %>">
|
||||||
|
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="regia-icon-btn" id="btn-copy-live" aria-label="<%= t("regia.copy_live") %>" title="<%= t("regia.copy_live") %>">
|
||||||
|
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.regia_share_label") %>">
|
||||||
|
<span class="regia-tool-group__label"><%= t("regia.regia_share_label") %></span>
|
||||||
|
<div class="regia-tool-group__btns">
|
||||||
|
<button type="button" class="regia-icon-btn" id="btn-share-regia" aria-label="<%= t("regia.share_regia") %>" title="<%= t("regia.share_regia") %>">
|
||||||
|
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-live" style="margin:0">
|
<button type="button" class="regia-icon-btn" id="btn-copy-regia" aria-label="<%= t("regia.copy_regia") %>" title="<%= t("regia.copy_regia") %>">
|
||||||
<%= t("regia.copy_link") %>
|
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="regia-share-group">
|
|
||||||
<p class="regia-share-label"><%= t("regia.regia_share_label") %></p>
|
|
||||||
<div class="regia-share">
|
|
||||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-regia" style="margin:0">
|
|
||||||
<%= t("regia.share_regia") %>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-regia" style="margin:0">
|
|
||||||
<%= t("regia.copy_link") %>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="regia-preview">
|
|
||||||
<% unless @stream_closed %>
|
<% unless @stream_closed %>
|
||||||
<video id="regia-preview" playsinline muted autoplay></video>
|
<div class="regia-tool-group regia-tool-group--stream" role="group" aria-label="<%= t("regia.stream_controls_label") %>">
|
||||||
|
<span class="regia-tool-group__label" aria-hidden="true"> </span>
|
||||||
|
<div class="regia-tool-group__btns">
|
||||||
|
<button type="button" class="regia-icon-btn" id="btn-toggle-preview" aria-label="<%= t("regia.preview_hide") %>" title="<%= t("regia.preview_hide") %>" aria-pressed="true">
|
||||||
|
<svg class="icon-fill icon-eye" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
|
||||||
|
<svg class="icon-fill icon-eye-off" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78 3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="regia-icon-btn<%= " is-paused" if @session.paused? %>" id="btn-pause" aria-label="<%= @session.paused? ? t("regia.resume") : t("regia.pause") %>" title="<%= @session.paused? ? t("regia.resume") : t("regia.pause") %>">
|
||||||
|
<svg class="icon-fill icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||||
|
<svg class="icon-fill icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="regia-icon-btn regia-icon-btn--danger" id="btn-stop" aria-label="<%= t("regia.stop") %>" title="<%= t("regia.stop") %>">
|
||||||
|
<svg class="icon-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6h12v12H6z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div id="regia-preview-placeholder" class="regia-preview__placeholder">
|
</nav>
|
||||||
<%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %>
|
|
||||||
|
<section class="regia-preview-wrap" id="regia-preview-wrap">
|
||||||
|
<div class="regia-preview" id="regia-preview-box">
|
||||||
|
<% unless @stream_closed %>
|
||||||
|
<video id="regia-preview" playsinline muted autoplay></video>
|
||||||
|
<% end %>
|
||||||
|
<div id="regia-preview-placeholder" class="regia-preview__placeholder">
|
||||||
|
<%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<section class="regia-scoreboard">
|
<section class="regia-scoreboard">
|
||||||
<%= render regia_board_partial(@match) %>
|
<%= render regia_board_partial(@match) %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<% unless @stream_closed %>
|
<% unless @stream_closed %>
|
||||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause"><%= @session.paused? ? t("regia.resume") : t("regia.pause") %></button>
|
<section class="regia-telemetry" id="regia-telemetry" aria-label="<%= t("regia.telemetry.label") %>">
|
||||||
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop"><%= t("regia.stop") %></button>
|
<div class="regia-telemetry__row">
|
||||||
|
<span class="regia-chip" id="tel-battery" title="<%= t("regia.telemetry.battery") %>">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 4h-1V3h-6v1H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H8V6h8v14z"/></svg>
|
||||||
|
<span class="regia-chip__label"><%= t("regia.telemetry.battery") %></span>
|
||||||
|
<span class="regia-chip__value">—</span>
|
||||||
|
</span>
|
||||||
|
<span class="regia-chip" id="tel-network" title="<%= t("regia.telemetry.network") %>">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/></svg>
|
||||||
|
<span class="regia-chip__label"><%= t("regia.telemetry.network") %></span>
|
||||||
|
<span class="regia-chip__value">—</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" class="regia-chip regia-chip--action" id="tel-bitrate" aria-expanded="false" aria-controls="regia-quality" title="<%= t("regia.telemetry.bitrate_tap") %>">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 13h2v-2H3v2zm4 4h2v-6H7v6zm4 4h2V7h-2v14zm4-8h2V9h-2v4zm4-4h2V5h-2v4z"/></svg>
|
||||||
|
<span class="regia-chip__label"><%= t("regia.telemetry.bitrate") %></span>
|
||||||
|
<span class="regia-chip__value">—</span>
|
||||||
|
<span class="regia-chip__hint" hidden></span>
|
||||||
|
</button>
|
||||||
|
<span class="regia-chip" id="tel-fps" title="<%= t("regia.telemetry.fps") %>">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
|
||||||
|
<span class="regia-chip__label"><%= t("regia.telemetry.fps") %></span>
|
||||||
|
<span class="regia-chip__value">—</span>
|
||||||
|
</span>
|
||||||
|
<span class="regia-chip" id="tel-thermal" title="<%= t("regia.telemetry.thermal") %>">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 13V5c0-1.66-1.34-3-3-3S9 3.34 9 5v8c-1.21.91-2 2.37-2 4 0 2.76 2.24 5 5 5s5-2.24 5-5c0-1.63-.79-3.09-2-4zm-3 8c-1.66 0-3-1.34-3-3 0-1.19.7-2.23 1.71-2.72L11 14.73V5c0-.55.45-1 1-1s1 .45 1 1v9.73l.29.18C14.3 15.77 15 16.81 15 18c0 1.66-1.34 3-3 3z"/></svg>
|
||||||
|
<span class="regia-chip__label"><%= t("regia.telemetry.thermal") %></span>
|
||||||
|
<span class="regia-chip__value">—</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="regia-quality" id="regia-quality" hidden>
|
||||||
|
<p class="regia-quality__hint"><%= t("regia.min_quality.recommend") %></p>
|
||||||
|
<label for="min-quality"><%= t("regia.min_quality.label") %></label>
|
||||||
|
<select id="min-quality" title="<%= t("regia.min_quality.hint") %>">
|
||||||
|
<% Sessions::SelectQuality.allowed_min_ids(@session.quality_preset).each do |id| %>
|
||||||
|
<option value="<%= id %>" <%= "selected" if @session.min_quality_preset == id %>>
|
||||||
|
<%= id == Sessions::SelectQuality::AUTO_ID ? t("regia.min_quality.auto") : Sessions::SelectQuality.display_name(id) %>
|
||||||
|
</option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="regia-telemetry__meta" id="tel-meta"><%= t("regia.telemetry.waiting") %></p>
|
||||||
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -94,4 +159,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/regia.js?v=8"></script>
|
<script src="/regia.js?v=18"></script>
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ Rails.application.configure do
|
|||||||
# Annotate rendered view with file names.
|
# Annotate rendered view with file names.
|
||||||
config.action_view.annotate_rendered_view_with_filenames = true
|
config.action_view.annotate_rendered_view_with_filenames = true
|
||||||
|
|
||||||
# Uncomment if you wish to allow Action Cable access from any origin.
|
# Permette Action Cable dall'emulatore Android (10.0.2.2) e da altri host locali.
|
||||||
# config.action_cable.disable_request_forgery_protection = true
|
config.action_cable.disable_request_forgery_protection = true
|
||||||
|
|
||||||
# Raise error when a before_action's only/except options reference missing actions.
|
# Raise error when a before_action's only/except options reference missing actions.
|
||||||
config.action_controller.raise_on_missing_callback_actions = true
|
config.action_controller.raise_on_missing_callback_actions = true
|
||||||
|
|||||||
@@ -432,16 +432,51 @@ de:
|
|||||||
paused: Pausiert
|
paused: Pausiert
|
||||||
live: Live
|
live: Live
|
||||||
waiting: Wartet
|
waiting: Wartet
|
||||||
live_share_label: "Live-Link (Zuschauer)"
|
live_share_label: Live
|
||||||
share_live: Livestream teilen
|
share_live: Livestream teilen
|
||||||
|
share_action: Teilen
|
||||||
copy_link: Link kopieren
|
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
|
share_regia: Regie-Link teilen
|
||||||
preview_ended: Livestream beendet
|
preview_ended: Livestream beendet
|
||||||
preview_waiting: Vorschau wartet auf Signal…
|
preview_waiting: Vorschau wartet auf Signal…
|
||||||
|
preview_hide: Vorschau ausblenden
|
||||||
|
preview_show: Vorschau anzeigen
|
||||||
|
preview_off_hint: Vorschau aus · kein Video-Download
|
||||||
pause: Pausieren
|
pause: Pausieren
|
||||||
resume: Livestream fortsetzen
|
resume: Livestream fortsetzen
|
||||||
stop: Livestream beenden
|
stop: Livestream beenden
|
||||||
|
min_quality:
|
||||||
|
label: Minimale Übertragungsqualität
|
||||||
|
summary: Qualität
|
||||||
|
hint: Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.
|
||||||
|
recommend: "Automatisch wird empfohlen: passt sich an Netz und Telefon an, ohne das Maximum zu erzwingen."
|
||||||
|
auto: Automatisch
|
||||||
|
telemetry:
|
||||||
|
label: Telefon
|
||||||
|
waiting: Warte auf das Telefon…
|
||||||
|
stale: Telefon antwortet nicht
|
||||||
|
just_now: jetzt
|
||||||
|
seconds_ago: "vor %{n}s"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP fehlt
|
||||||
|
battery: Akku
|
||||||
|
network: Netz
|
||||||
|
bitrate: Bitrate
|
||||||
|
bitrate_max: "max %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps aktuell · max. %{target} Mbps"
|
||||||
|
bitrate_tap: Tippen, um die Mindestqualität festzulegen
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Warm
|
||||||
|
thermal_serious: Heiß
|
||||||
|
thermal_critical: Kritisch
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Satz gewonnen
|
set_won_title: Satz gewonnen
|
||||||
confirm: Satz beenden
|
confirm: Satz beenden
|
||||||
@@ -486,6 +521,8 @@ de:
|
|||||||
copy_prompt: "Link kopieren:"
|
copy_prompt: "Link kopieren:"
|
||||||
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
|
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
|
||||||
share_live_text: "Sieh dir die Übertragung auf Match Live TV an:"
|
share_live_text: "Sieh dir die Übertragung auf Match Live TV an:"
|
||||||
|
min_quality_updated: Mindestqualität aktualisiert
|
||||||
|
min_quality_error: Fehler beim Aktualisieren der Mindestqualität
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -427,16 +427,51 @@ en:
|
|||||||
paused: Paused
|
paused: Paused
|
||||||
live: Live
|
live: Live
|
||||||
waiting: Waiting
|
waiting: Waiting
|
||||||
live_share_label: "Live link (viewers)"
|
live_share_label: Live
|
||||||
share_live: Share live link
|
share_live: Share live link
|
||||||
|
share_action: Share
|
||||||
copy_link: Copy link
|
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
|
share_regia: Share control room link
|
||||||
preview_ended: Stream ended
|
preview_ended: Stream ended
|
||||||
preview_waiting: Waiting for signal…
|
preview_waiting: Waiting for signal…
|
||||||
|
preview_hide: Hide preview
|
||||||
|
preview_show: Show preview
|
||||||
|
preview_off_hint: Preview off · no video download
|
||||||
pause: Pause stream
|
pause: Pause stream
|
||||||
resume: Resume stream
|
resume: Resume stream
|
||||||
stop: End stream
|
stop: End stream
|
||||||
|
min_quality:
|
||||||
|
label: Minimum streaming quality
|
||||||
|
summary: Quality
|
||||||
|
hint: The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.
|
||||||
|
recommend: "Automatic is recommended: it adapts to the network and the phone, without forcing maximum quality."
|
||||||
|
auto: Automatic
|
||||||
|
telemetry:
|
||||||
|
label: Phone
|
||||||
|
waiting: Waiting for the phone…
|
||||||
|
stale: Phone not responding
|
||||||
|
just_now: now
|
||||||
|
seconds_ago: "%{n}s ago"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP down
|
||||||
|
battery: Battery
|
||||||
|
network: Network
|
||||||
|
bitrate: Bitrate
|
||||||
|
bitrate_max: "max %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps current · max %{target} Mbps"
|
||||||
|
bitrate_tap: Tap to set the minimum quality
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Warm
|
||||||
|
thermal_serious: Hot
|
||||||
|
thermal_critical: Critical
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set won
|
set_won_title: Set won
|
||||||
confirm: Close set
|
confirm: Close set
|
||||||
@@ -481,6 +516,8 @@ en:
|
|||||||
copy_prompt: "Copy the link:"
|
copy_prompt: "Copy the link:"
|
||||||
share_regia_text: "Open this link to manage the live score:"
|
share_regia_text: "Open this link to manage the live score:"
|
||||||
share_live_text: "Watch the live stream on Match Live TV:"
|
share_live_text: "Watch the live stream on Match Live TV:"
|
||||||
|
min_quality_updated: Minimum quality updated
|
||||||
|
min_quality_error: Error updating minimum quality
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -432,16 +432,51 @@ es:
|
|||||||
paused: En pausa
|
paused: En pausa
|
||||||
live: En directo
|
live: En directo
|
||||||
waiting: En espera
|
waiting: En espera
|
||||||
live_share_label: "Enlace del directo (espectadores)"
|
live_share_label: Directo
|
||||||
share_live: Compartir directo
|
share_live: Compartir directo
|
||||||
|
share_action: Compartir
|
||||||
copy_link: Copiar enlace
|
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
|
share_regia: Compartir enlace de control
|
||||||
preview_ended: Directo finalizado
|
preview_ended: Directo finalizado
|
||||||
preview_waiting: Vista previa a la espera de señal…
|
preview_waiting: Vista previa a la espera de señal…
|
||||||
|
preview_hide: Ocultar vista previa
|
||||||
|
preview_show: Mostrar vista previa
|
||||||
|
preview_off_hint: Vista previa apagada · sin descarga de vídeo
|
||||||
pause: Pausar directo
|
pause: Pausar directo
|
||||||
resume: Reanudar directo
|
resume: Reanudar directo
|
||||||
stop: Finalizar directo
|
stop: Finalizar directo
|
||||||
|
min_quality:
|
||||||
|
label: Calidad mínima de transmisión
|
||||||
|
summary: Calidad
|
||||||
|
hint: El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.
|
||||||
|
recommend: "Se recomienda Automática: se adapta a la red y al teléfono, sin forzar el máximo."
|
||||||
|
auto: Automática
|
||||||
|
telemetry:
|
||||||
|
label: Teléfono
|
||||||
|
waiting: Esperando al teléfono…
|
||||||
|
stale: El teléfono no responde
|
||||||
|
just_now: ahora
|
||||||
|
seconds_ago: "hace %{n}s"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP ausente
|
||||||
|
battery: Batería
|
||||||
|
network: Red
|
||||||
|
bitrate: Bitrate
|
||||||
|
bitrate_max: "máx %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps actuales · máximo %{target} Mbps"
|
||||||
|
bitrate_tap: Toca para definir la calidad mínima
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Caliente
|
||||||
|
thermal_serious: Muy caliente
|
||||||
|
thermal_critical: Crítica
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set ganado
|
set_won_title: Set ganado
|
||||||
confirm: Cerrar set
|
confirm: Cerrar set
|
||||||
@@ -486,6 +521,8 @@ es:
|
|||||||
copy_prompt: "Copia el enlace:"
|
copy_prompt: "Copia el enlace:"
|
||||||
share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
|
share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
|
||||||
share_live_text: "Mira la retransmisión en Match Live TV:"
|
share_live_text: "Mira la retransmisión en Match Live TV:"
|
||||||
|
min_quality_updated: Calidad mínima actualizada
|
||||||
|
min_quality_error: Error al actualizar la calidad mínima
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -432,16 +432,51 @@ fr:
|
|||||||
paused: En pause
|
paused: En pause
|
||||||
live: En direct
|
live: En direct
|
||||||
waiting: En attente
|
waiting: En attente
|
||||||
live_share_label: "Lien direct (spectateurs)"
|
live_share_label: Direct
|
||||||
share_live: Partager le direct
|
share_live: Partager le direct
|
||||||
|
share_action: Partager
|
||||||
copy_link: Copier le lien
|
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
|
share_regia: Partager le lien régie
|
||||||
preview_ended: Direct terminé
|
preview_ended: Direct terminé
|
||||||
preview_waiting: Aperçu en attente du signal…
|
preview_waiting: Aperçu en attente du signal…
|
||||||
|
preview_hide: Masquer l'aperçu
|
||||||
|
preview_show: Afficher l'aperçu
|
||||||
|
preview_off_hint: Aperçu désactivé · aucun téléchargement vidéo
|
||||||
pause: Mettre en pause
|
pause: Mettre en pause
|
||||||
resume: Reprendre le direct
|
resume: Reprendre le direct
|
||||||
stop: Terminer le direct
|
stop: Terminer le direct
|
||||||
|
min_quality:
|
||||||
|
label: Qualité minimale de transmission
|
||||||
|
summary: Qualité
|
||||||
|
hint: L'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.
|
||||||
|
recommend: "Automatique est conseillé : s'adapte au réseau et au téléphone, sans forcer le maximum."
|
||||||
|
auto: Automatique
|
||||||
|
telemetry:
|
||||||
|
label: Téléphone
|
||||||
|
waiting: En attente du téléphone…
|
||||||
|
stale: Téléphone sans réponse
|
||||||
|
just_now: maintenant
|
||||||
|
seconds_ago: "il y a %{n}s"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP absent
|
||||||
|
battery: Batterie
|
||||||
|
network: Réseau
|
||||||
|
bitrate: Débit
|
||||||
|
bitrate_max: "max %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps actuels · max %{target} Mbps"
|
||||||
|
bitrate_tap: Touchez pour définir la qualité minimale
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Chaude
|
||||||
|
thermal_serious: Très chaude
|
||||||
|
thermal_critical: Critique
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set gagné
|
set_won_title: Set gagné
|
||||||
confirm: Terminer le set
|
confirm: Terminer le set
|
||||||
@@ -486,6 +521,8 @@ fr:
|
|||||||
copy_prompt: "Copiez le lien :"
|
copy_prompt: "Copiez le lien :"
|
||||||
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
|
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
|
||||||
share_live_text: "Regardez le direct sur Match Live TV :"
|
share_live_text: "Regardez le direct sur Match Live TV :"
|
||||||
|
min_quality_updated: Qualité minimale mise à jour
|
||||||
|
min_quality_error: Erreur de mise à jour de la qualité minimale
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -427,16 +427,51 @@ it:
|
|||||||
paused: In pausa
|
paused: In pausa
|
||||||
live: In onda
|
live: In onda
|
||||||
waiting: In attesa
|
waiting: In attesa
|
||||||
live_share_label: "Link diretta (spettatori)"
|
live_share_label: Diretta
|
||||||
share_live: Condividi diretta
|
share_live: Condividi diretta
|
||||||
|
share_action: Condividi
|
||||||
copy_link: Copia link
|
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
|
share_regia: Condividi link regia
|
||||||
preview_ended: Diretta terminata
|
preview_ended: Diretta terminata
|
||||||
preview_waiting: Anteprima in attesa del segnale…
|
preview_waiting: Anteprima in attesa del segnale…
|
||||||
|
preview_hide: Nascondi anteprima
|
||||||
|
preview_show: Mostra anteprima
|
||||||
|
preview_off_hint: Anteprima spenta · nessun download video
|
||||||
pause: Metti in pausa
|
pause: Metti in pausa
|
||||||
resume: Riprendi diretta
|
resume: Riprendi diretta
|
||||||
stop: Chiudi diretta
|
stop: Chiudi diretta
|
||||||
|
min_quality:
|
||||||
|
label: Qualità minima di trasmissione
|
||||||
|
summary: Qualità
|
||||||
|
hint: L'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.
|
||||||
|
recommend: "Consigliata Automatica: si adatta alla rete e al telefono, senza forzare il massimo."
|
||||||
|
auto: Automatica
|
||||||
|
telemetry:
|
||||||
|
label: Telefono
|
||||||
|
waiting: In attesa del telefono…
|
||||||
|
stale: Telefono non risponde
|
||||||
|
just_now: ora
|
||||||
|
seconds_ago: "%{n}s fa"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP assente
|
||||||
|
battery: Batteria
|
||||||
|
network: Rete
|
||||||
|
bitrate: Bitrate
|
||||||
|
bitrate_max: "max %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps attuali · massimo %{target} Mbps"
|
||||||
|
bitrate_tap: Tocca per impostare la qualità minima
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Calda
|
||||||
|
thermal_serious: Molto calda
|
||||||
|
thermal_critical: Critica
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set vinto
|
set_won_title: Set vinto
|
||||||
confirm: Chiudi set
|
confirm: Chiudi set
|
||||||
@@ -481,6 +516,8 @@ it:
|
|||||||
copy_prompt: "Copia il link:"
|
copy_prompt: "Copia il link:"
|
||||||
share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
|
share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
|
||||||
share_live_text: "Guarda la diretta su Match Live TV:"
|
share_live_text: "Guarda la diretta su Match Live TV:"
|
||||||
|
min_quality_updated: Qualità minima aggiornata
|
||||||
|
min_quality_error: Errore aggiornamento qualità minima
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ Rails.application.routes.draw do
|
|||||||
post :claim_pairing
|
post :claim_pairing
|
||||||
post :regia_link
|
post :regia_link
|
||||||
post :network_test
|
post :network_test
|
||||||
|
patch :min_quality
|
||||||
get :youtube_stats
|
get :youtube_stats
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -143,6 +144,7 @@ Rails.application.routes.draw do
|
|||||||
post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause
|
post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause
|
||||||
post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume
|
post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume
|
||||||
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
|
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
|
||||||
|
patch "regia/:token/min_quality", to: "public/regia#min_quality", as: :public_regia_min_quality
|
||||||
|
|
||||||
root to: "public/pages#home"
|
root to: "public/pages#home"
|
||||||
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
|
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddThermalStateToDeviceStates < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :device_states, :thermal_state, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
Generated
+29
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
@@ -138,6 +138,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
t.datetime "last_seen_at"
|
t.datetime "last_seen_at"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "thermal_state"
|
||||||
t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true
|
t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true
|
||||||
t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id"
|
t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id"
|
||||||
end
|
end
|
||||||
@@ -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"
|
t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "stream_nodes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.string "slug", null: false
|
||||||
|
t.string "hostname", null: false
|
||||||
|
t.string "role", default: "home", null: false
|
||||||
|
t.string "status", default: "ready", null: false
|
||||||
|
t.string "rtmp_base_url", null: false
|
||||||
|
t.string "hls_base_url", null: false
|
||||||
|
t.string "api_base_url", null: false
|
||||||
|
t.string "internal_rtmp_url"
|
||||||
|
t.string "internal_hls_url"
|
||||||
|
t.integer "max_publishers", default: 6, null: false
|
||||||
|
t.integer "max_relays", default: 6, null: false
|
||||||
|
t.string "provider", default: "local", null: false
|
||||||
|
t.string "provider_instance_id"
|
||||||
|
t.datetime "last_health_at"
|
||||||
|
t.jsonb "metadata", default: {}, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["role"], name: "index_stream_nodes_on_role"
|
||||||
|
t.index ["slug"], name: "index_stream_nodes_on_slug", unique: true
|
||||||
|
t.index ["status"], name: "index_stream_nodes_on_status"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "match_id", null: false
|
t.uuid "match_id", null: false
|
||||||
t.uuid "user_id", null: false
|
t.uuid "user_id", null: false
|
||||||
@@ -280,10 +304,13 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.string "regia_token_digest"
|
t.string "regia_token_digest"
|
||||||
t.datetime "regia_token_expires_at"
|
t.datetime "regia_token_expires_at"
|
||||||
|
t.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 ["match_id"], name: "index_stream_sessions_on_match_id"
|
||||||
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
||||||
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
||||||
t.index ["status"], name: "index_stream_sessions_on_status"
|
t.index ["status"], name: "index_stream_sessions_on_status"
|
||||||
|
t.index ["stream_node_id"], name: "index_stream_sessions_on_stream_node_id"
|
||||||
t.index ["user_id"], name: "index_stream_sessions_on_user_id"
|
t.index ["user_id"], name: "index_stream_sessions_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -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 "score_states", "stream_sessions"
|
||||||
add_foreign_key "stream_events", "stream_sessions"
|
add_foreign_key "stream_events", "stream_sessions"
|
||||||
add_foreign_key "stream_sessions", "matches"
|
add_foreign_key "stream_sessions", "matches"
|
||||||
|
add_foreign_key "stream_sessions", "stream_nodes"
|
||||||
add_foreign_key "stream_sessions", "users"
|
add_foreign_key "stream_sessions", "users"
|
||||||
add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
|
add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
|
||||||
add_foreign_key "subscriptions", "clubs"
|
add_foreign_key "subscriptions", "clubs"
|
||||||
|
|||||||
+277
-41
@@ -4,6 +4,8 @@
|
|||||||
--regia-surface: #14141c;
|
--regia-surface: #14141c;
|
||||||
--regia-red: #e53935;
|
--regia-red: #e53935;
|
||||||
--regia-yellow: #f9a825;
|
--regia-yellow: #f9a825;
|
||||||
|
--regia-green: #43a047;
|
||||||
|
--regia-orange: #fb8c00;
|
||||||
--regia-text: #f5f5f5;
|
--regia-text: #f5f5f5;
|
||||||
--regia-muted: #9ca3af;
|
--regia-muted: #9ca3af;
|
||||||
--regia-safe-bottom: env(safe-area-inset-bottom, 0px);
|
--regia-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
@@ -22,37 +24,166 @@
|
|||||||
.regia-page {
|
.regia-page {
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
margin: 0 auto;
|
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 {
|
.regia-header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-header p {
|
.regia-header p {
|
||||||
margin: 4px 0 0;
|
margin: 2px 0 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.78rem;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-badge {
|
.regia-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 8px;
|
margin-top: 0;
|
||||||
padding: 4px 10px;
|
padding: 3px 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 0.7rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-badge--live { background: #2e7d32; color: #fff; }
|
.regia-badge--live { background: #2e7d32; color: #fff; }
|
||||||
.regia-badge--wait { background: #444; color: #ddd; }
|
.regia-badge--wait { background: #444; color: #ddd; }
|
||||||
.regia-badge--ended { background: #374151; color: #ddd; }
|
.regia-badge--ended { background: #374151; color: #ddd; }
|
||||||
|
|
||||||
|
.regia-toolbar {
|
||||||
|
margin: 8px 0 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-tool-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-tool-group__label {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-tool-group__btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-tool-group--stream {
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #16161f;
|
||||||
|
color: var(--regia-text);
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn svg.icon-fill {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn svg.icon-outline {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn .icon-eye-off,
|
||||||
|
.regia-icon-btn .icon-play {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-off .icon-eye {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-off .icon-eye-off {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-paused .icon-pause {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-paused .icon-play {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn:active { transform: scale(0.96); }
|
||||||
|
|
||||||
|
.regia-icon-btn.is-off {
|
||||||
|
color: var(--regia-yellow);
|
||||||
|
border-color: #5a4a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn--danger {
|
||||||
|
color: var(--regia-red);
|
||||||
|
border-color: #7a2a28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-preview-wrap {
|
||||||
|
margin: 6px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-preview-wrap.is-off {
|
||||||
|
margin: 0;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.regia-preview {
|
.regia-preview {
|
||||||
margin: 14px 0;
|
margin: 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
@@ -90,29 +221,29 @@
|
|||||||
.regia-scoreboard {
|
.regia-scoreboard {
|
||||||
background: var(--regia-surface);
|
background: var(--regia-surface);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 16px;
|
padding: 10px 12px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-sets {
|
.regia-sets {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.82rem;
|
font-size: 0.78rem;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
margin: 0 0 8px;
|
margin: 0 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-score-line {
|
.regia-score-line {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1.5rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-partials {
|
.regia-partials {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.78rem;
|
font-size: 0.75rem;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
margin: 8px 0 0;
|
margin: 4px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-team-row {
|
.regia-team-row {
|
||||||
@@ -120,7 +251,7 @@
|
|||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 16px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-team-name {
|
.regia-team-name {
|
||||||
@@ -131,7 +262,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.regia-points {
|
.regia-points {
|
||||||
font-size: 2rem;
|
font-size: 1.55rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -146,7 +277,7 @@
|
|||||||
.regia-btn {
|
.regia-btn {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 16px 12px;
|
padding: 10px 8px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -156,8 +287,8 @@
|
|||||||
|
|
||||||
.regia-btn:active { transform: scale(0.98); }
|
.regia-btn:active { transform: scale(0.98); }
|
||||||
.regia-btn--point { background: var(--regia-red); color: #fff; }
|
.regia-btn--point { background: var(--regia-red); color: #fff; }
|
||||||
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 12px; }
|
.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: 10px; }
|
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 8px; padding: 12px; }
|
||||||
.regia-btn--outline {
|
.regia-btn--outline {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid #444;
|
border: 1px solid #444;
|
||||||
@@ -173,26 +304,6 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-share-group {
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-share-label {
|
|
||||||
margin: 0 0 6px;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--regia-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-share {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-share .regia-btn { flex: 1; font-size: 0.9rem; padding: 12px; }
|
|
||||||
|
|
||||||
.regia-toast {
|
.regia-toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: calc(16px + var(--regia-safe-bottom));
|
bottom: calc(16px + var(--regia-safe-bottom));
|
||||||
@@ -240,7 +351,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
padding-top: 24px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-clock-line {
|
.regia-clock-line {
|
||||||
@@ -255,3 +366,128 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.regia-quality {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding: 8px 10px 10px;
|
||||||
|
background: var(--regia-surface);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-quality[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
.regia-quality__hint {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-quality label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-quality select {
|
||||||
|
width: 100%;
|
||||||
|
background: #0a0a0e;
|
||||||
|
color: var(--regia-text);
|
||||||
|
border: 1px solid #2a2a35;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-telemetry {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-telemetry__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 2px 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--regia-surface);
|
||||||
|
color: var(--regia-text);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip--action {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip--action:focus-visible {
|
||||||
|
outline: 2px solid var(--regia-yellow);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip--action.is-open {
|
||||||
|
border-color: #3a3a48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip__label {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip__value {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.15;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip__hint {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip__hint[hidden] { display: none; }
|
||||||
|
|
||||||
|
.regia-chip.is-ok { color: var(--regia-green); }
|
||||||
|
.regia-chip.is-warn { color: var(--regia-orange); }
|
||||||
|
.regia-chip.is-bad { color: var(--regia-red); }
|
||||||
|
|
||||||
|
.regia-telemetry__meta {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-telemetry.is-stale .regia-telemetry__meta {
|
||||||
|
color: var(--regia-yellow);
|
||||||
|
}
|
||||||
|
|||||||
+262
-5
@@ -37,13 +37,32 @@
|
|||||||
shareLiveText: "Guarda la diretta su Match Live TV:",
|
shareLiveText: "Guarda la diretta su Match Live TV:",
|
||||||
previewWaiting: "Anteprima in attesa del segnale…",
|
previewWaiting: "Anteprima in attesa del segnale…",
|
||||||
streamEnded: "Diretta terminata",
|
streamEnded: "Diretta terminata",
|
||||||
|
previewHide: "Nascondi anteprima",
|
||||||
|
previewShow: "Mostra anteprima",
|
||||||
|
previewOffHint: "Anteprima spenta · nessun download video",
|
||||||
resumeLabel: "Riprendi diretta",
|
resumeLabel: "Riprendi diretta",
|
||||||
pauseLabel: "Metti in pausa",
|
pauseLabel: "Metti in pausa",
|
||||||
endedBadge: "Terminata",
|
endedBadge: "Terminata",
|
||||||
pausedBadge: "In pausa",
|
pausedBadge: "In pausa",
|
||||||
liveBadge: "In onda",
|
liveBadge: "In onda",
|
||||||
waitingBadge: "In attesa",
|
waitingBadge: "In attesa",
|
||||||
subtitlePrefix: "Regia ·"
|
subtitlePrefix: "Regia ·",
|
||||||
|
minQualityUpdated: "Qualità minima aggiornata",
|
||||||
|
minQualityError: "Errore aggiornamento qualità minima",
|
||||||
|
minQualityAuto: "Automatica",
|
||||||
|
telemetryWaiting: "In attesa del telefono…",
|
||||||
|
telemetryStale: "Telefono non risponde",
|
||||||
|
telemetryJustNow: "ora",
|
||||||
|
telemetrySecondsAgo: "%{n}s fa",
|
||||||
|
telemetryPublisherOn: "RTMP ok",
|
||||||
|
telemetryPublisherOff: "RTMP assente",
|
||||||
|
telemetryThermalNominal: "OK",
|
||||||
|
telemetryThermalFair: "Calda",
|
||||||
|
telemetryThermalSerious: "Calda",
|
||||||
|
telemetryThermalCritical: "Critica",
|
||||||
|
telemetryBitrateMax: "max %{n}",
|
||||||
|
telemetryBitrateTitle: "%{current} Mbps attuali · massimo %{target} Mbps",
|
||||||
|
telemetryBitrateTap: "Tocca per impostare la qualità minima"
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadI18n() {
|
function loadI18n() {
|
||||||
@@ -77,6 +96,7 @@
|
|||||||
pauseUrl: root.dataset.pauseUrl,
|
pauseUrl: root.dataset.pauseUrl,
|
||||||
resumeUrl: root.dataset.resumeUrl,
|
resumeUrl: root.dataset.resumeUrl,
|
||||||
stopUrl: root.dataset.stopUrl,
|
stopUrl: root.dataset.stopUrl,
|
||||||
|
minQualityUrl: root.dataset.minQualityUrl,
|
||||||
cableUrl: root.dataset.cableUrl,
|
cableUrl: root.dataset.cableUrl,
|
||||||
hlsUrl: root.dataset.hlsUrl,
|
hlsUrl: root.dataset.hlsUrl,
|
||||||
homeName: root.dataset.homeName,
|
homeName: root.dataset.homeName,
|
||||||
@@ -102,15 +122,28 @@
|
|||||||
modalCancel: document.getElementById("modal-cancel"),
|
modalCancel: document.getElementById("modal-cancel"),
|
||||||
preview: document.getElementById("regia-preview"),
|
preview: document.getElementById("regia-preview"),
|
||||||
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
||||||
|
previewWrap: document.getElementById("regia-preview-wrap"),
|
||||||
|
btnTogglePreview: document.getElementById("btn-toggle-preview"),
|
||||||
btnPause: document.getElementById("btn-pause"),
|
btnPause: document.getElementById("btn-pause"),
|
||||||
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 pendingAction = null;
|
||||||
let hls = null;
|
let hls = null;
|
||||||
let previewStarted = false;
|
let previewStarted = false;
|
||||||
|
let previewWanted = true;
|
||||||
let wasPausedPreview = false;
|
let wasPausedPreview = false;
|
||||||
let streamPaused = root.dataset.initialPaused === "true";
|
let streamPaused = root.dataset.initialPaused === "true";
|
||||||
|
const PREVIEW_PREF_KEY = "mltv-regia-preview";
|
||||||
|
|
||||||
function hlsUrlFresh() {
|
function hlsUrlFresh() {
|
||||||
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
|
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() {
|
function refreshPreviewForCover() {
|
||||||
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed) return;
|
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed || !previewWanted) return;
|
||||||
destroyPreview();
|
destroyPreview();
|
||||||
previewStarted = false;
|
previewStarted = false;
|
||||||
startPreview();
|
startPreview();
|
||||||
@@ -243,7 +311,10 @@
|
|||||||
if (!els.btnPause) return;
|
if (!els.btnPause) return;
|
||||||
const paused = !!(data.paused || data.status === "paused");
|
const paused = !!(data.paused || data.status === "paused");
|
||||||
streamPaused = paused;
|
streamPaused = paused;
|
||||||
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) {
|
function setBadge(data) {
|
||||||
@@ -364,6 +435,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const paused = data.paused || data.status === "paused";
|
const paused = data.paused || data.status === "paused";
|
||||||
|
if (!previewWanted) {
|
||||||
|
destroyPreview();
|
||||||
|
wasPausedPreview = paused;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.on_air) {
|
if (data.on_air) {
|
||||||
if (!previewStarted) {
|
if (!previewStarted) {
|
||||||
startPreview();
|
startPreview();
|
||||||
@@ -382,6 +458,119 @@
|
|||||||
wasPausedPreview = paused;
|
wasPausedPreview = paused;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mbpsNumber(bps) {
|
||||||
|
const n = Number(bps);
|
||||||
|
if (!n || n <= 0) return null;
|
||||||
|
const mbps = n >= 10_000 ? n / 1e6 : n / 1e3;
|
||||||
|
return mbps >= 10 ? mbps.toFixed(0) : mbps.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAge(iso) {
|
||||||
|
if (!iso) return null;
|
||||||
|
const then = Date.parse(iso);
|
||||||
|
if (!then) return null;
|
||||||
|
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
|
||||||
|
if (secs < 8) return I18N.telemetryJustNow;
|
||||||
|
return fillTemplate(I18N.telemetrySecondsAgo, { n: secs });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setChip(el, text, level, hint) {
|
||||||
|
if (!el) return;
|
||||||
|
const valueEl = el.querySelector(".regia-chip__value") || el;
|
||||||
|
valueEl.textContent = text;
|
||||||
|
el.classList.toggle("is-ok", level === "ok");
|
||||||
|
el.classList.toggle("is-warn", level === "warn");
|
||||||
|
el.classList.toggle("is-bad", level === "bad");
|
||||||
|
const hintEl = el.querySelector(".regia-chip__hint");
|
||||||
|
if (hintEl) {
|
||||||
|
hintEl.textContent = hint || "";
|
||||||
|
hintEl.hidden = !hint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatThermal(state) {
|
||||||
|
const key = String(state || "").toLowerCase();
|
||||||
|
if (key === "nominal") return { text: I18N.telemetryThermalNominal, level: "ok" };
|
||||||
|
if (key === "fair") return { text: I18N.telemetryThermalFair, level: "warn" };
|
||||||
|
if (key === "serious") return { text: I18N.telemetryThermalSerious, level: "bad" };
|
||||||
|
if (key === "critical") return { text: I18N.telemetryThermalCritical, level: "bad" };
|
||||||
|
return { text: "—", level: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bitrateLevel(current, target, paused) {
|
||||||
|
if (paused || !current || current <= 0) return null;
|
||||||
|
if (target && target > 0) {
|
||||||
|
const ratio = current / target;
|
||||||
|
if (ratio >= 0.7) return "ok";
|
||||||
|
if (ratio >= 0.4) return "warn";
|
||||||
|
return "bad";
|
||||||
|
}
|
||||||
|
const mbps = current >= 10_000 ? current / 1e6 : current / 1e3;
|
||||||
|
if (mbps >= 2.5) return "ok";
|
||||||
|
if (mbps >= 1.5) return "warn";
|
||||||
|
return "bad";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bitrateTitle(currentMbps, targetMbps) {
|
||||||
|
const tap = I18N.telemetryBitrateTap;
|
||||||
|
if (currentMbps && targetMbps) {
|
||||||
|
return `${fillTemplate(I18N.telemetryBitrateTitle, { current: currentMbps, target: targetMbps })} · ${tap}`;
|
||||||
|
}
|
||||||
|
if (currentMbps) return `${currentMbps} Mbps · ${tap}`;
|
||||||
|
return tap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTelemetry(data) {
|
||||||
|
if (!els.telemetry) return;
|
||||||
|
const tel = data.telemetry || {};
|
||||||
|
const battery = tel.battery;
|
||||||
|
const fps = tel.fps;
|
||||||
|
const bitrate = tel.bitrate;
|
||||||
|
const target = tel.target_bitrate || tel.targetBitrate;
|
||||||
|
const lastSeen = tel.last_seen_at || tel.lastSeenAt;
|
||||||
|
const ageSecs = lastSeen ? Math.max(0, Math.round((Date.now() - Date.parse(lastSeen)) / 1000)) : null;
|
||||||
|
const stale = ageSecs == null || ageSecs > 25;
|
||||||
|
const publishing = !!tel.publisher_online || !!tel.publisherOnline;
|
||||||
|
|
||||||
|
let battLevel = null;
|
||||||
|
let battText = "—";
|
||||||
|
if (battery != null && battery !== "") {
|
||||||
|
battText = `${battery}%`;
|
||||||
|
if (battery <= 15) battLevel = "bad";
|
||||||
|
else if (battery <= 25) battLevel = "warn";
|
||||||
|
}
|
||||||
|
setChip(els.telBattery, battText, battLevel);
|
||||||
|
setChip(els.telNetwork, tel.network || "—");
|
||||||
|
|
||||||
|
const currentMbps = mbpsNumber(bitrate);
|
||||||
|
const targetMbps = mbpsNumber(target);
|
||||||
|
const rateText = currentMbps || "—";
|
||||||
|
const rateHint = currentMbps && targetMbps
|
||||||
|
? fillTemplate(I18N.telemetryBitrateMax, { n: targetMbps })
|
||||||
|
: null;
|
||||||
|
if (els.telBitrate) els.telBitrate.title = bitrateTitle(currentMbps, targetMbps);
|
||||||
|
setChip(els.telBitrate, rateText, bitrateLevel(Number(bitrate), Number(target), data.paused), rateHint);
|
||||||
|
|
||||||
|
let fpsLevel = null;
|
||||||
|
if (fps != null && fps !== "" && Number(fps) > 0 && Number(fps) < 20) fpsLevel = "warn";
|
||||||
|
setChip(els.telFps, fps && Number(fps) > 0 ? String(fps) : "—", fpsLevel);
|
||||||
|
|
||||||
|
const thermal = formatThermal(tel.thermal_state || tel.thermalState);
|
||||||
|
setChip(els.telThermal, thermal.text, thermal.level);
|
||||||
|
|
||||||
|
els.telemetry.classList.toggle("is-stale", stale && !data.paused && !data.stream_closed);
|
||||||
|
if (!els.telMeta) return;
|
||||||
|
if (stale && !lastSeen) {
|
||||||
|
els.telMeta.textContent = I18N.telemetryWaiting;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parts = [publishing ? I18N.telemetryPublisherOn : I18N.telemetryPublisherOff];
|
||||||
|
const age = formatAge(lastSeen);
|
||||||
|
if (age) parts.push(age);
|
||||||
|
if (stale && !data.paused && !data.stream_closed) parts.push(I18N.telemetryStale);
|
||||||
|
els.telMeta.textContent = parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
async function pollStatus() {
|
async function pollStatus() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
|
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
|
||||||
@@ -389,6 +578,8 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
applyScorePayload(data);
|
applyScorePayload(data);
|
||||||
setBadge(data);
|
setBadge(data);
|
||||||
|
syncMinQuality(data);
|
||||||
|
syncTelemetry(data);
|
||||||
syncPreviewFromStatus(data);
|
syncPreviewFromStatus(data);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -401,7 +592,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startPreview() {
|
function startPreview() {
|
||||||
if (cfg.streamClosed || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
if (cfg.streamClosed || !previewWanted || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
||||||
|
|
||||||
previewStarted = true;
|
previewStarted = true;
|
||||||
const url = hlsUrlFresh();
|
const url = hlsUrlFresh();
|
||||||
@@ -454,6 +645,8 @@
|
|||||||
const wasPaused = streamPaused;
|
const wasPaused = streamPaused;
|
||||||
applyScorePayload(data);
|
applyScorePayload(data);
|
||||||
setBadge(data);
|
setBadge(data);
|
||||||
|
syncMinQuality(data);
|
||||||
|
syncTelemetry(data);
|
||||||
syncPreviewFromStatus(data);
|
syncPreviewFromStatus(data);
|
||||||
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
|
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
|
||||||
}
|
}
|
||||||
@@ -473,6 +666,70 @@
|
|||||||
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
|
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
|
||||||
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
|
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
|
||||||
|
|
||||||
|
let minQualityBusy = false;
|
||||||
|
|
||||||
|
function setQualityPanelOpen(open) {
|
||||||
|
if (!els.qualityPanel || !els.telBitrate) return;
|
||||||
|
els.qualityPanel.hidden = !open;
|
||||||
|
els.telBitrate.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
els.telBitrate.classList.toggle("is-open", open);
|
||||||
|
if (open && els.minQuality) {
|
||||||
|
window.requestAnimationFrame(() => els.minQuality.focus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMinQuality(data) {
|
||||||
|
if (!els.minQuality || minQualityBusy) return;
|
||||||
|
const value = data.min_quality_preset || "auto";
|
||||||
|
if (els.minQuality.value !== value) els.minQuality.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMinQuality(preset) {
|
||||||
|
if (!cfg.minQualityUrl) return;
|
||||||
|
minQualityBusy = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(cfg.minQualityUrl, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ min_quality_preset: preset })
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || I18N.minQualityError);
|
||||||
|
minQualityBusy = false;
|
||||||
|
syncMinQuality(data);
|
||||||
|
toast(I18N.minQualityUpdated);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || I18N.minQualityError);
|
||||||
|
} finally {
|
||||||
|
minQualityBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
els.minQuality?.addEventListener("change", () => {
|
||||||
|
setMinQuality(els.minQuality.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.telBitrate?.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const opening = !!(els.qualityPanel && els.qualityPanel.hidden);
|
||||||
|
setQualityPanelOpen(opening);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
if (!els.qualityPanel || els.qualityPanel.hidden) return;
|
||||||
|
if (els.qualityPanel.contains(e.target) || els.telBitrate?.contains(e.target)) return;
|
||||||
|
setQualityPanelOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") setQualityPanelOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.btnTogglePreview?.addEventListener("click", () => setPreviewWanted(!previewWanted));
|
||||||
|
previewWanted = readPreviewPref();
|
||||||
|
syncPreviewToggleUi();
|
||||||
|
if (!previewWanted) destroyPreview();
|
||||||
|
|
||||||
async function shareLink(url, title, text) {
|
async function shareLink(url, title, text) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
toast(I18N.linkUnavailable);
|
toast(I18N.linkUnavailable);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
require "rails_helper"
|
require "rails_helper"
|
||||||
|
|
||||||
RSpec.describe "Public regia", type: :request do
|
RSpec.describe "Public regia", type: :request do
|
||||||
let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "password123", role: "coach") }
|
let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "Password123", role: "coach") }
|
||||||
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||||
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
||||||
@@ -12,7 +12,18 @@ RSpec.describe "Public regia", type: :request do
|
|||||||
get public_regia_path(token)
|
get public_regia_path(token)
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.body).to include("Condividi diretta")
|
expect(response.body).to include("Condividi diretta")
|
||||||
|
expect(response.body).to include("Copia live")
|
||||||
expect(response.body).to include("Condividi link regia")
|
expect(response.body).to include("Condividi link regia")
|
||||||
|
expect(response.body).to include("Copia regia")
|
||||||
|
expect(response.body).to include(">Diretta<")
|
||||||
|
expect(response.body).to include(">Regia<")
|
||||||
|
expect(response.body).to include("regia-tool-group")
|
||||||
|
expect(response.body).to include("Nascondi anteprima")
|
||||||
|
expect(response.body).to include('id="btn-pause"')
|
||||||
|
expect(response.body).to include('id="btn-stop"')
|
||||||
|
expect(response.body).to include("Qualità minima di trasmissione")
|
||||||
|
expect(response.body).to include('id="tel-bitrate"')
|
||||||
|
expect(response.body).to include('aria-controls="regia-quality"')
|
||||||
expect(response.body).to include("/live/#{session.id}")
|
expect(response.body).to include("/live/#{session.id}")
|
||||||
expect(response.body).not_to include("Aruba")
|
expect(response.body).not_to include("Aruba")
|
||||||
end
|
end
|
||||||
@@ -156,4 +167,39 @@ RSpec.describe "Public regia", type: :request do
|
|||||||
get public_regia_path(token)
|
get public_regia_path(token)
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "aggiorna la qualità minima di trasmissione" do
|
||||||
|
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||||
|
patch public_regia_min_quality_path(token), params: { min_quality_preset: "720p_30_1.5mbps" }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(session.reload.min_quality_preset).to eq("720p_30_1.5mbps")
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["min_quality_preset"]).to eq("720p_30_1.5mbps")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "espone la telemetria della camera nello status" do
|
||||||
|
session.device_states.create!(
|
||||||
|
device_role: "camera",
|
||||||
|
battery_level: 81,
|
||||||
|
network_type: "WiFi",
|
||||||
|
current_bitrate: 2_400_000,
|
||||||
|
target_bitrate: 4_500_000,
|
||||||
|
fps: 30,
|
||||||
|
thermal_state: "nominal",
|
||||||
|
last_seen_at: Time.current
|
||||||
|
)
|
||||||
|
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||||
|
get public_regia_path(token)
|
||||||
|
expect(response.body).to include('id="regia-telemetry"')
|
||||||
|
|
||||||
|
get public_regia_status_path(token)
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
tel = response.parsed_body["telemetry"]
|
||||||
|
expect(tel["battery"]).to eq(81)
|
||||||
|
expect(tel["network"]).to eq("WiFi")
|
||||||
|
expect(tel["fps"]).to eq(30)
|
||||||
|
expect(tel["bitrate"]).to eq(2_400_000)
|
||||||
|
expect(tel["thermal_state"]).to eq("nominal")
|
||||||
|
expect(tel).to have_key("publisher_online")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,4 +11,11 @@ RSpec.describe Sessions::SelectQuality do
|
|||||||
|
|
||||||
expect(preset[:id]).to eq("1080p_30_4.5mbps")
|
expect(preset[:id]).to eq("1080p_30_4.5mbps")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "offre 1080p come qualità minima solo se la sessione è 1080p" do
|
||||||
|
expect(described_class.allowed_min_ids("720p_30_2.5mbps")).to eq(
|
||||||
|
%w[auto 720p_30_1.5mbps 720p_30_2.5mbps 720p_30_4mbps]
|
||||||
|
)
|
||||||
|
expect(described_class.allowed_min_ids("1080p_30_4.5mbps")).to include("1080p_30_4.5mbps")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
@@ -59,6 +59,8 @@ services:
|
|||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
MEDIAMTX_API_URL: http://mediamtx:9997
|
MEDIAMTX_API_URL: http://mediamtx:9997
|
||||||
MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://127.0.0.1:1935}
|
MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://127.0.0.1:1935}
|
||||||
|
MEDIAMTX_HLS_URL: ${MEDIAMTX_HLS_URL:-http://mediamtx:8888}
|
||||||
|
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:3000/hls}
|
||||||
MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}
|
MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}
|
||||||
RAILS_WEBHOOK_URL: http://rails:3000
|
RAILS_WEBHOOK_URL: http://rails:3000
|
||||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min}
|
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min}
|
||||||
|
|||||||
+71
@@ -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<Context>()
|
||||||
|
container = AppContainer(context)
|
||||||
|
assertTrue(
|
||||||
|
"Questo test richiede API locale, trovato ${AppConfig.apiBaseUrl}",
|
||||||
|
AppConfig.apiBaseUrl.contains("10.0.2.2") || AppConfig.apiBaseUrl.contains("localhost"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun setMinQuality_persistsAndRejects1080On720Session() = runBlocking {
|
||||||
|
container.authRepository.login(
|
||||||
|
email = "coach@matchlivetv.test",
|
||||||
|
password = "Password123",
|
||||||
|
)
|
||||||
|
val teams = container.matchRepository.fetchTeams()
|
||||||
|
assertTrue(teams.isNotEmpty())
|
||||||
|
val match = container.matchRepository.createQuickMatch(teams.first().id)
|
||||||
|
val created = container.sessionRepository.createSession(match.id)
|
||||||
|
assertEquals("auto", created.minQualityPreset)
|
||||||
|
|
||||||
|
val updated = container.sessionRepository.setMinQuality(created.id, "720p_30_1.5mbps")
|
||||||
|
assertEquals("720p_30_1.5mbps", updated.minQualityPreset)
|
||||||
|
|
||||||
|
val fetched = container.sessionRepository.fetchSession(created.id)
|
||||||
|
assertEquals("720p_30_1.5mbps", fetched.minQualityPreset)
|
||||||
|
|
||||||
|
try {
|
||||||
|
container.sessionRepository.setMinQuality(created.id, "1080p_30_4.5mbps")
|
||||||
|
fail("1080p avrebbe dovuto essere rifiutato su sessione 720p")
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
assertEquals(422, e.code())
|
||||||
|
}
|
||||||
|
|
||||||
|
val forced = container.sessionRepository.setMinQuality(created.id, "720p_30_4mbps")
|
||||||
|
assertEquals("720p_30_4mbps", forced.minQualityPreset)
|
||||||
|
|
||||||
|
val auto = container.sessionRepository.setMinQuality(created.id, "auto")
|
||||||
|
assertEquals("auto", auto.minQualityPreset)
|
||||||
|
|
||||||
|
val started = container.sessionRepository.startSession(created.id)
|
||||||
|
assertFalse(started.rtmpIngestUrl.isNullOrBlank())
|
||||||
|
container.sessionRepository.stopSession(created.id)
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
+265
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,17 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
import com.matchlivetv.match_live_tv.core.AppLocale
|
import com.matchlivetv.match_live_tv.core.AppLocale
|
||||||
import com.matchlivetv.match_live_tv.core.ProvideAppLocale
|
import com.matchlivetv.match_live_tv.core.ProvideAppLocale
|
||||||
import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost
|
import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost
|
||||||
import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
|
import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
private val container by lazy { (application as MatchLiveTvApplication).container }
|
private val container by lazy { (application as MatchLiveTvApplication).container }
|
||||||
@@ -33,7 +39,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
setContent {
|
setContent {
|
||||||
ProvideAppLocale {
|
ProvideAppLocale {
|
||||||
MatchLiveTheme {
|
MatchLiveTheme {
|
||||||
AppNavHost(container)
|
Box(Modifier.semantics { testTagsAsResourceId = true }) {
|
||||||
|
AppNavHost(container)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ data class StreamSessionDto(
|
|||||||
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
|
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
|
||||||
@Json(name = "target_fps") val targetFps: Int? = null,
|
@Json(name = "target_fps") val targetFps: Int? = null,
|
||||||
@Json(name = "quality_preset") val qualityPreset: String? = null,
|
@Json(name = "quality_preset") val qualityPreset: String? = null,
|
||||||
|
@Json(name = "min_quality_preset") val minQualityPreset: String? = null,
|
||||||
@Json(name = "started_at") val startedAt: String? = null,
|
@Json(name = "started_at") val startedAt: String? = null,
|
||||||
val score: ScoreStateDto? = null,
|
val score: ScoreStateDto? = null,
|
||||||
) {
|
) {
|
||||||
@@ -201,6 +202,7 @@ data class StreamSessionDto(
|
|||||||
targetBitrate = targetBitrate ?: 2_500_000,
|
targetBitrate = targetBitrate ?: 2_500_000,
|
||||||
targetFps = targetFps ?: 30,
|
targetFps = targetFps ?: 30,
|
||||||
qualityPreset = qualityPreset ?: "720p_30_2.5mbps",
|
qualityPreset = qualityPreset ?: "720p_30_2.5mbps",
|
||||||
|
minQualityPreset = minQualityPreset ?: "auto",
|
||||||
startedAt = startedAt,
|
startedAt = startedAt,
|
||||||
score = score?.toDomain(),
|
score = score?.toDomain(),
|
||||||
)
|
)
|
||||||
@@ -285,6 +287,10 @@ data class CreateSessionRequest(
|
|||||||
@Json(name = "youtube_channel") val youtubeChannel: String? = null,
|
@Json(name = "youtube_channel") val youtubeChannel: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class MinQualityRequest(
|
||||||
|
@Json(name = "min_quality_preset") val minQualityPreset: String,
|
||||||
|
)
|
||||||
|
|
||||||
data class UpdateMatchBody(
|
data class UpdateMatchBody(
|
||||||
@Json(name = "opponent_name") val opponentName: String? = null,
|
@Json(name = "opponent_name") val opponentName: String? = null,
|
||||||
val location: String? = null,
|
val location: String? = null,
|
||||||
@@ -392,4 +398,5 @@ data class TelemetryRequest(
|
|||||||
@Json(name = "current_bitrate") val currentBitrate: Int? = null,
|
@Json(name = "current_bitrate") val currentBitrate: Int? = null,
|
||||||
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
|
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
|
||||||
val fps: Int? = null,
|
val fps: Int? = null,
|
||||||
|
@Json(name = "thermal_state") val thermalState: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
+6
@@ -117,6 +117,12 @@ interface MatchLiveApi {
|
|||||||
@PATCH("sessions/{sessionId}/resume")
|
@PATCH("sessions/{sessionId}/resume")
|
||||||
suspend fun resumeSession(@Path("sessionId") sessionId: String): StreamSessionDto
|
suspend fun resumeSession(@Path("sessionId") sessionId: String): StreamSessionDto
|
||||||
|
|
||||||
|
@PATCH("sessions/{sessionId}/min_quality")
|
||||||
|
suspend fun setMinQuality(
|
||||||
|
@Path("sessionId") sessionId: String,
|
||||||
|
@Body body: MinQualityRequest,
|
||||||
|
): StreamSessionDto
|
||||||
|
|
||||||
@POST("sessions/{sessionId}/network_test")
|
@POST("sessions/{sessionId}/network_test")
|
||||||
suspend fun networkTest(
|
suspend fun networkTest(
|
||||||
@Path("sessionId") sessionId: String,
|
@Path("sessionId") sessionId: String,
|
||||||
|
|||||||
+2
@@ -33,6 +33,7 @@ class SessionCableService(
|
|||||||
var onPauseStream: (() -> Unit)? = null
|
var onPauseStream: (() -> Unit)? = null
|
||||||
var onResumeStream: (() -> Unit)? = null
|
var onResumeStream: (() -> Unit)? = null
|
||||||
var onEndStream: (() -> Unit)? = null
|
var onEndStream: (() -> Unit)? = null
|
||||||
|
var onMinQuality: ((String) -> Unit)? = null
|
||||||
|
|
||||||
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
|
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
|
||||||
intentionalDisconnect = false
|
intentionalDisconnect = false
|
||||||
@@ -109,6 +110,7 @@ class SessionCableService(
|
|||||||
"pause_stream" -> onPauseStream?.invoke()
|
"pause_stream" -> onPauseStream?.invoke()
|
||||||
"resume_stream" -> onResumeStream?.invoke()
|
"resume_stream" -> onResumeStream?.invoke()
|
||||||
"stop_stream" -> onEndStream?.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) {
|
"stream_event" -> when (payload["event"] as? String) {
|
||||||
"paused" -> onPauseStream?.invoke()
|
"paused" -> onPauseStream?.invoke()
|
||||||
|
|||||||
+6
@@ -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.CreateSessionRequest
|
||||||
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
||||||
|
import com.matchlivetv.match_live_tv.data.api.MinQualityRequest
|
||||||
import com.matchlivetv.match_live_tv.data.api.NetworkTestRequest
|
import com.matchlivetv.match_live_tv.data.api.NetworkTestRequest
|
||||||
import com.matchlivetv.match_live_tv.data.api.NetworkTestResponse
|
import com.matchlivetv.match_live_tv.data.api.NetworkTestResponse
|
||||||
import com.matchlivetv.match_live_tv.domain.StreamSession
|
import com.matchlivetv.match_live_tv.domain.StreamSession
|
||||||
@@ -38,6 +39,9 @@ class SessionRepository(
|
|||||||
suspend fun resumeSession(sessionId: String): StreamSession =
|
suspend fun resumeSession(sessionId: String): StreamSession =
|
||||||
api.resumeSession(sessionId).toDomain()
|
api.resumeSession(sessionId).toDomain()
|
||||||
|
|
||||||
|
suspend fun setMinQuality(sessionId: String, preset: String): StreamSession =
|
||||||
|
api.setMinQuality(sessionId, MinQualityRequest(minQualityPreset = preset)).toDomain()
|
||||||
|
|
||||||
suspend fun submitNetworkTest(
|
suspend fun submitNetworkTest(
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
downloadMbps: Double,
|
downloadMbps: Double,
|
||||||
@@ -61,6 +65,7 @@ class SessionRepository(
|
|||||||
currentBitrate: Int? = null,
|
currentBitrate: Int? = null,
|
||||||
targetBitrate: Int? = null,
|
targetBitrate: Int? = null,
|
||||||
fps: Int? = null,
|
fps: Int? = null,
|
||||||
|
thermalState: String? = null,
|
||||||
) {
|
) {
|
||||||
api.postTelemetry(
|
api.postTelemetry(
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -72,6 +77,7 @@ class SessionRepository(
|
|||||||
currentBitrate = currentBitrate,
|
currentBitrate = currentBitrate,
|
||||||
targetBitrate = targetBitrate,
|
targetBitrate = targetBitrate,
|
||||||
fps = fps,
|
fps = fps,
|
||||||
|
thermalState = thermalState,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ data class StreamSession(
|
|||||||
val targetBitrate: Int = 2_500_000,
|
val targetBitrate: Int = 2_500_000,
|
||||||
val targetFps: Int = 30,
|
val targetFps: Int = 30,
|
||||||
val qualityPreset: String = "720p_30_2.5mbps",
|
val qualityPreset: String = "720p_30_2.5mbps",
|
||||||
|
val minQualityPreset: String = "auto",
|
||||||
val startedAt: String? = null,
|
val startedAt: String? = null,
|
||||||
val score: ScoreState? = null,
|
val score: ScoreState? = null,
|
||||||
) {
|
) {
|
||||||
@@ -133,6 +134,8 @@ data class StreamSession(
|
|||||||
|
|
||||||
val isPaused: Boolean get() = status == "paused"
|
val isPaused: Boolean get() = status == "paused"
|
||||||
|
|
||||||
|
val isEnded: Boolean get() = status == "ended" || status == "error"
|
||||||
|
|
||||||
fun watchShareUrl(): String? = when {
|
fun watchShareUrl(): String? = when {
|
||||||
platform == "youtube" && !youtubeWatchUrl.isNullOrBlank() -> youtubeWatchUrl
|
platform == "youtube" && !youtubeWatchUrl.isNullOrBlank() -> youtubeWatchUrl
|
||||||
!shareUrl.isNullOrBlank() -> shareUrl
|
!shareUrl.isNullOrBlank() -> shareUrl
|
||||||
|
|||||||
+115
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -9,6 +9,7 @@ data class BroadcastConfig(
|
|||||||
val width: Int = 1280,
|
val width: Int = 1280,
|
||||||
val height: Int = 720,
|
val height: Int = 720,
|
||||||
val videoBitrate: Int = 2_500_000,
|
val videoBitrate: Int = 2_500_000,
|
||||||
|
val minVideoBitrate: Int = 800_000,
|
||||||
val audioBitrate: Int = 128_000,
|
val audioBitrate: Int = 128_000,
|
||||||
val fps: Int = 30,
|
val fps: Int = 30,
|
||||||
val rotation: Int = 0,
|
val rotation: Int = 0,
|
||||||
|
|||||||
+37
-4
@@ -39,6 +39,7 @@ class LiveBroadcastEngine(
|
|||||||
private var publishInProgress = false
|
private var publishInProgress = false
|
||||||
private var currentBitrateKbps: Long = 0
|
private var currentBitrateKbps: Long = 0
|
||||||
private var currentFps: Int = 0
|
private var currentFps: Int = 0
|
||||||
|
private val adaptiveBitrate = AdaptiveBitrateController()
|
||||||
private val pausingIntentionally = AtomicBoolean(false)
|
private val pausingIntentionally = AtomicBoolean(false)
|
||||||
private val reconnectAttempts = AtomicInteger(0)
|
private val reconnectAttempts = AtomicInteger(0)
|
||||||
private val overlayRenderer = OverlayRenderer(appContext)
|
private val overlayRenderer = OverlayRenderer(appContext)
|
||||||
@@ -133,6 +134,7 @@ class LiveBroadcastEngine(
|
|||||||
fun startBroadcast(broadcastConfig: BroadcastConfig) {
|
fun startBroadcast(broadcastConfig: BroadcastConfig) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
config = broadcastConfig
|
config = broadcastConfig
|
||||||
|
adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
|
||||||
reconnectAttempts.set(0)
|
reconnectAttempts.set(0)
|
||||||
streamStartPending = true
|
streamStartPending = true
|
||||||
publishInProgress = false
|
publishInProgress = false
|
||||||
@@ -250,6 +252,7 @@ class LiveBroadcastEngine(
|
|||||||
fun resumeBroadcast(broadcastConfig: BroadcastConfig) {
|
fun resumeBroadcast(broadcastConfig: BroadcastConfig) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
config = broadcastConfig
|
config = broadcastConfig
|
||||||
|
adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
|
||||||
reconnectAttempts.set(0)
|
reconnectAttempts.set(0)
|
||||||
streamStartPending = true
|
streamStartPending = true
|
||||||
publishInProgress = false
|
publishInProgress = false
|
||||||
@@ -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) {
|
fun applyThermalProfile(videoBitrate: Int, fps: Int) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
val current = config ?: return@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)
|
config = current.copy(videoBitrate = videoBitrate, fps = fps)
|
||||||
genericStream?.let { stream ->
|
genericStream?.let { stream ->
|
||||||
runCatching { stream.setVideoBitrateOnFly(videoBitrate) }
|
applyAdaptiveBitrate(applied)
|
||||||
.onFailure { Log.w(TAG, "applyThermalProfile bitrate: ${it.message}") }
|
|
||||||
runCatching { stream.getGlInterface().setForceRender(true, fps) }
|
runCatching { stream.getGlInterface().setForceRender(true, fps) }
|
||||||
.onFailure { Log.w(TAG, "applyThermalProfile fps: ${it.message}") }
|
.onFailure { Log.w(TAG, "applyThermalProfile fps: ${it.message}") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setMinQualityFloor(floorBitrate: Int, ceilingBitrate: Int? = null) {
|
||||||
|
mainHandler.post {
|
||||||
|
val current = config
|
||||||
|
if (current != null) {
|
||||||
|
config = current.copy(
|
||||||
|
videoBitrate = ceilingBitrate ?: current.videoBitrate,
|
||||||
|
minVideoBitrate = floorBitrate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
applyAdaptiveBitrate(adaptiveBitrate.setFloor(floorBitrate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyAdaptiveBitrate(bitrate: Int) {
|
||||||
|
genericStream?.let { stream ->
|
||||||
|
runCatching { stream.setVideoBitrateOnFly(bitrate) }
|
||||||
|
.onFailure { Log.w(TAG, "ABR bitrate: ${it.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun obtainStream(cfg: BroadcastConfig): GenericStream {
|
private fun obtainStream(cfg: BroadcastConfig): GenericStream {
|
||||||
val existing = genericStream
|
val existing = genericStream
|
||||||
if (existing != null) return existing
|
if (existing != null) return existing
|
||||||
@@ -552,6 +579,12 @@ class LiveBroadcastEngine(
|
|||||||
override fun onNewBitrate(bitrate: Long) {
|
override fun onNewBitrate(bitrate: Long) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
currentBitrateKbps = bitrate / 1000
|
currentBitrateKbps = bitrate / 1000
|
||||||
|
val congestion = runCatching {
|
||||||
|
genericStream?.getStreamClient()?.hasCongestion() == true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
adaptiveBitrate.onMeasured(bitrate, congestion)?.let { next ->
|
||||||
|
applyAdaptiveBitrate(next)
|
||||||
|
}
|
||||||
emitMetrics()
|
emitMetrics()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+72
@@ -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<String> {
|
||||||
|
val ids = mutableListOf(AUTO)
|
||||||
|
ids += PRESETS.filter { !it.is1080 }.asReversed().map { it.id }
|
||||||
|
if (sessionQualityPreset?.startsWith("1080p") == true) {
|
||||||
|
PRESETS.find { it.is1080 }?.let { ids += it.id }
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
fun displayName(id: String, autoLabel: String): String {
|
||||||
|
if (normalize(id) == AUTO) return autoLabel
|
||||||
|
val preset = PRESETS.find { it.id == id } ?: return id
|
||||||
|
val mbps = preset.bitrate / 1_000_000.0
|
||||||
|
val mbpsText = if (mbps == mbps.toInt().toDouble()) {
|
||||||
|
mbps.toInt().toString()
|
||||||
|
} else {
|
||||||
|
"%.1f".format(Locale.US, mbps)
|
||||||
|
}
|
||||||
|
return "${preset.resolutionLabel} · $mbpsText Mbps"
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -15,11 +15,14 @@ object StreamVideoPreset {
|
|||||||
|
|
||||||
fun broadcastConfig(session: StreamSession, rtmpUrl: String): BroadcastConfig {
|
fun broadcastConfig(session: StreamSession, rtmpUrl: String): BroadcastConfig {
|
||||||
val dims = dimensions(session.qualityPreset)
|
val dims = dimensions(session.qualityPreset)
|
||||||
|
val ceiling = StreamQualityLadder.encoderCeiling(session.targetBitrate, session.minQualityPreset)
|
||||||
|
val floor = StreamQualityLadder.encoderFloor(session.minQualityPreset, ceiling)
|
||||||
return BroadcastConfig(
|
return BroadcastConfig(
|
||||||
rtmpUrl = rtmpUrl,
|
rtmpUrl = rtmpUrl,
|
||||||
width = dims.width,
|
width = dims.width,
|
||||||
height = dims.height,
|
height = dims.height,
|
||||||
videoBitrate = session.targetBitrate,
|
videoBitrate = ceiling,
|
||||||
|
minVideoBitrate = floor,
|
||||||
fps = session.targetFps,
|
fps = session.targetFps,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+142
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -29,6 +30,7 @@ import androidx.compose.material.icons.filled.PlayArrow
|
|||||||
import androidx.compose.material.icons.filled.SkipNext
|
import androidx.compose.material.icons.filled.SkipNext
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material.icons.filled.Videocam
|
import androidx.compose.material.icons.filled.Videocam
|
||||||
@@ -58,6 +60,8 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.matchlivetv.match_live_tv.R
|
import com.matchlivetv.match_live_tv.R
|
||||||
@@ -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.DeviceHealthSnapshot
|
||||||
import com.matchlivetv.match_live_tv.core.ThermalState
|
import com.matchlivetv.match_live_tv.core.ThermalState
|
||||||
import com.matchlivetv.match_live_tv.domain.ScoreState
|
import com.matchlivetv.match_live_tv.domain.ScoreState
|
||||||
|
import com.matchlivetv.match_live_tv.streaming.StreamQualityLadder
|
||||||
import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge
|
import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge
|
||||||
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||||
|
|
||||||
@@ -109,9 +114,27 @@ fun BroadcastControlsOverlay(
|
|||||||
bitrateKbps: Long,
|
bitrateKbps: Long,
|
||||||
networkType: String,
|
networkType: String,
|
||||||
deviceHealth: DeviceHealthSnapshot,
|
deviceHealth: DeviceHealthSnapshot,
|
||||||
|
minQualityPreset: String = StreamQualityLadder.AUTO,
|
||||||
|
sessionQualityPreset: String = "720p_30_2.5mbps",
|
||||||
|
onSelectMinQuality: (String) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var showTerminateConfirm by remember { mutableStateOf(false) }
|
var showTerminateConfirm by remember { mutableStateOf(false) }
|
||||||
|
var showMinQualityPicker by remember { mutableStateOf(false) }
|
||||||
|
val autoLabel = stringResource(R.string.broadcast_min_quality_auto)
|
||||||
|
|
||||||
|
if (showMinQualityPicker) {
|
||||||
|
MinQualityPickerDialog(
|
||||||
|
selectedId = minQualityPreset,
|
||||||
|
sessionQualityPreset = sessionQualityPreset,
|
||||||
|
autoLabel = autoLabel,
|
||||||
|
onSelect = { id ->
|
||||||
|
showMinQualityPicker = false
|
||||||
|
onSelectMinQuality(id)
|
||||||
|
},
|
||||||
|
onDismiss = { showMinQualityPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showTerminateConfirm) {
|
if (showTerminateConfirm) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
@@ -174,6 +197,7 @@ fun BroadcastControlsOverlay(
|
|||||||
bitrateKbps = bitrateKbps,
|
bitrateKbps = bitrateKbps,
|
||||||
networkType = networkType,
|
networkType = networkType,
|
||||||
deviceHealth = deviceHealth,
|
deviceHealth = deviceHealth,
|
||||||
|
minQualityLabel = StreamQualityLadder.displayName(minQualityPreset, autoLabel),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +219,11 @@ fun BroadcastControlsOverlay(
|
|||||||
contentDescription = stringResource(R.string.broadcast_share_regia_cd),
|
contentDescription = stringResource(R.string.broadcast_share_regia_cd),
|
||||||
onClick = onShareRegia,
|
onClick = onShareRegia,
|
||||||
)
|
)
|
||||||
|
SideIconButton(
|
||||||
|
icon = Icons.Default.Tune,
|
||||||
|
contentDescription = stringResource(R.string.broadcast_min_quality_cd),
|
||||||
|
onClick = { showMinQualityPicker = true },
|
||||||
|
)
|
||||||
if (onCloseSet != null) {
|
if (onCloseSet != null) {
|
||||||
SideIconButton(
|
SideIconButton(
|
||||||
icon = Icons.Default.Check,
|
icon = Icons.Default.Check,
|
||||||
@@ -287,6 +316,7 @@ private fun BroadcastTelemetryPanel(
|
|||||||
bitrateKbps: Long,
|
bitrateKbps: Long,
|
||||||
networkType: String,
|
networkType: String,
|
||||||
deviceHealth: DeviceHealthSnapshot,
|
deviceHealth: DeviceHealthSnapshot,
|
||||||
|
minQualityLabel: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val labelStyle = MaterialTheme.typography.labelSmall
|
val labelStyle = MaterialTheme.typography.labelSmall
|
||||||
@@ -321,6 +351,11 @@ private fun BroadcastTelemetryPanel(
|
|||||||
color = MatchColors.TextSecondary,
|
color = MatchColors.TextSecondary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Text(
|
||||||
|
minQualityLabel,
|
||||||
|
style = labelStyle,
|
||||||
|
color = MatchColors.AccentYellow,
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
"${networkType} · ${deviceHealth.batteryPercent}%",
|
"${networkType} · ${deviceHealth.batteryPercent}%",
|
||||||
style = labelStyle,
|
style = labelStyle,
|
||||||
@@ -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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SideIconButton(
|
private fun SideIconButton(
|
||||||
|
|||||||
+82
-11
@@ -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.MatchScoringRules
|
||||||
import com.matchlivetv.match_live_tv.domain.StreamSession
|
import com.matchlivetv.match_live_tv.domain.StreamSession
|
||||||
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
|
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
|
||||||
|
import com.matchlivetv.match_live_tv.streaming.StreamQualityLadder
|
||||||
import com.matchlivetv.match_live_tv.streaming.StreamVideoPreset
|
import com.matchlivetv.match_live_tv.streaming.StreamVideoPreset
|
||||||
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
|
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
|
||||||
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
|
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
|
||||||
@@ -78,6 +79,7 @@ fun BroadcastScreen(
|
|||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var pauseInFlight by remember { mutableStateOf(false) }
|
var pauseInFlight by remember { mutableStateOf(false) }
|
||||||
var resumeInFlight by remember { mutableStateOf(false) }
|
var resumeInFlight by remember { mutableStateOf(false) }
|
||||||
|
var minQualityInFlight by remember { mutableStateOf(false) }
|
||||||
var controlsVisible by remember { mutableStateOf(true) }
|
var controlsVisible by remember { mutableStateOf(true) }
|
||||||
var logoReady by remember { mutableStateOf(0) }
|
var logoReady by remember { mutableStateOf(0) }
|
||||||
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) }
|
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) }
|
||||||
@@ -156,10 +158,16 @@ fun BroadcastScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun applyRemotePause() {
|
suspend fun applyRemotePause() {
|
||||||
container.broadcastCoordinator.engine.pauseBroadcast()
|
if (pauseInFlight) return
|
||||||
session = runCatching { container.sessionRepository.fetchSession(sessionId) }
|
pauseInFlight = true
|
||||||
.getOrNull() ?: session?.copy(status = "paused")
|
try {
|
||||||
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_paused_remote))
|
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() {
|
suspend fun applyRemoteResume() {
|
||||||
@@ -191,6 +199,38 @@ fun BroadcastScreen(
|
|||||||
onFinished()
|
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) {
|
LaunchedEffect(sessionId, permissions.granted) {
|
||||||
if (!permissions.granted) return@LaunchedEffect
|
if (!permissions.granted) return@LaunchedEffect
|
||||||
loading = true
|
loading = true
|
||||||
@@ -215,6 +255,15 @@ fun BroadcastScreen(
|
|||||||
container.sessionCable.onEndStream = {
|
container.sessionCable.onEndStream = {
|
||||||
scope.launch { applyRemoteStop() }
|
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.sessionCable.connect(sessionId, token, deviceRole = "camera")
|
||||||
}
|
}
|
||||||
container.broadcastCoordinator.startService()
|
container.broadcastCoordinator.startService()
|
||||||
@@ -329,12 +378,28 @@ fun BroadcastScreen(
|
|||||||
|
|
||||||
LaunchedEffect(sessionId) {
|
LaunchedEffect(sessionId) {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(4_000)
|
delay(2_000)
|
||||||
runCatching {
|
if (minQualityInFlight || pauseInFlight || resumeInFlight) continue
|
||||||
container.sessionRepository.fetchSession(sessionId).score
|
runCatching { container.sessionRepository.fetchSession(sessionId) }
|
||||||
}.getOrNull()?.let { remote ->
|
.getOrNull()
|
||||||
container.scoreController.applyRemote(remote)
|
?.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) {
|
while (true) {
|
||||||
delay(10_000)
|
delay(10_000)
|
||||||
val current = session ?: continue
|
val current = session ?: continue
|
||||||
if (current.isPaused) continue
|
|
||||||
runCatching {
|
runCatching {
|
||||||
container.sessionRepository.postTelemetry(
|
container.sessionRepository.postTelemetry(
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
@@ -352,6 +416,7 @@ fun BroadcastScreen(
|
|||||||
currentBitrate = (metrics.bitrateKbps * 1000).toInt().takeIf { it > 0 },
|
currentBitrate = (metrics.bitrateKbps * 1000).toInt().takeIf { it > 0 },
|
||||||
targetBitrate = current.targetBitrate,
|
targetBitrate = current.targetBitrate,
|
||||||
fps = metrics.fps.takeIf { it > 0 },
|
fps = metrics.fps.takeIf { it > 0 },
|
||||||
|
thermalState = thermalManager.state.value.name.lowercase(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,6 +429,7 @@ fun BroadcastScreen(
|
|||||||
container.sessionCable.onPauseStream = null
|
container.sessionCable.onPauseStream = null
|
||||||
container.sessionCable.onResumeStream = null
|
container.sessionCable.onResumeStream = null
|
||||||
container.sessionCable.onEndStream = null
|
container.sessionCable.onEndStream = null
|
||||||
|
container.sessionCable.onMinQuality = null
|
||||||
container.sessionCable.disconnect()
|
container.sessionCable.disconnect()
|
||||||
container.broadcastCoordinator.stopService()
|
container.broadcastCoordinator.stopService()
|
||||||
container.broadcastCoordinator.engine.stopBroadcast()
|
container.broadcastCoordinator.engine.stopBroadcast()
|
||||||
@@ -578,6 +644,11 @@ fun BroadcastScreen(
|
|||||||
bitrateKbps = metrics.bitrateKbps,
|
bitrateKbps = metrics.bitrateKbps,
|
||||||
networkType = DeviceTelemetry.networkType(context),
|
networkType = DeviceTelemetry.networkType(context),
|
||||||
deviceHealth = DeviceTelemetry.snapshot(context, thermalState),
|
deviceHealth = DeviceTelemetry.snapshot(context, thermalState),
|
||||||
|
minQualityPreset = currentSession?.minQualityPreset ?: StreamQualityLadder.AUTO,
|
||||||
|
sessionQualityPreset = currentSession?.qualityPreset ?: "720p_30_2.5mbps",
|
||||||
|
onSelectMinQuality = { preset ->
|
||||||
|
scope.launch { persistMinQuality(preset) }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-3
@@ -29,11 +29,15 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
@@ -50,7 +54,7 @@ import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
|
|||||||
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
container: AppContainer,
|
container: AppContainer,
|
||||||
@@ -130,7 +134,10 @@ fun LoginScreen(
|
|||||||
onValueChange = { email = it },
|
onValueChange = { email = it },
|
||||||
label = { Text(stringResource(R.string.login_email)) },
|
label = { Text(stringResource(R.string.login_email)) },
|
||||||
placeholder = { Text(stringResource(R.string.login_email_placeholder)) },
|
placeholder = { Text(stringResource(R.string.login_email_placeholder)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("login_email")
|
||||||
|
.semantics { testTagsAsResourceId = true },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = KeyboardType.Email,
|
keyboardType = KeyboardType.Email,
|
||||||
@@ -146,7 +153,10 @@ fun LoginScreen(
|
|||||||
value = password,
|
value = password,
|
||||||
onValueChange = { password = it },
|
onValueChange = { password = it },
|
||||||
label = { Text(stringResource(R.string.login_password)) },
|
label = { Text(stringResource(R.string.login_password)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("login_password")
|
||||||
|
.semantics { testTagsAsResourceId = true },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation = if (passwordVisible) {
|
visualTransformation = if (passwordVisible) {
|
||||||
VisualTransformation.None
|
VisualTransformation.None
|
||||||
@@ -198,6 +208,9 @@ fun LoginScreen(
|
|||||||
loading = loading,
|
loading = loading,
|
||||||
enabled = email.isNotBlank() && password.isNotBlank(),
|
enabled = email.isNotBlank() && password.isNotBlank(),
|
||||||
onClick = { submitLogin() },
|
onClick = { submitLogin() },
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag("login_submit")
|
||||||
|
.semantics { testTagsAsResourceId = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,13 @@
|
|||||||
<string name="broadcast_resume_cd">Livestream fortsetzen</string>
|
<string name="broadcast_resume_cd">Livestream fortsetzen</string>
|
||||||
<string name="broadcast_pause_cd">Livestream pausieren</string>
|
<string name="broadcast_pause_cd">Livestream pausieren</string>
|
||||||
<string name="broadcast_terminate_cd">Livestream beenden</string>
|
<string name="broadcast_terminate_cd">Livestream beenden</string>
|
||||||
|
<string name="broadcast_min_quality_title">Mindestqualität</string>
|
||||||
|
<string name="broadcast_min_quality_cd">Minimale Übertragungsqualität</string>
|
||||||
|
<string name="broadcast_min_quality_hint">Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.</string>
|
||||||
|
<string name="broadcast_min_quality_auto">Automatisch</string>
|
||||||
|
<string name="broadcast_min_quality_applied">Mindestqualität: %1$s</string>
|
||||||
|
<string name="broadcast_min_quality_error">Mindestqualität konnte nicht gesetzt werden</string>
|
||||||
|
<string name="broadcast_snackbar_min_quality_remote">Mindestqualität von der Regie aktualisiert</string>
|
||||||
<string name="broadcast_team_home_label">HEIM</string>
|
<string name="broadcast_team_home_label">HEIM</string>
|
||||||
<string name="broadcast_team_away_label">GAST</string>
|
<string name="broadcast_team_away_label">GAST</string>
|
||||||
<string name="broadcast_scoreboard_connected">Anzeigetafel OK</string>
|
<string name="broadcast_scoreboard_connected">Anzeigetafel OK</string>
|
||||||
|
|||||||
@@ -164,6 +164,13 @@
|
|||||||
<string name="broadcast_resume_cd">Resume live</string>
|
<string name="broadcast_resume_cd">Resume live</string>
|
||||||
<string name="broadcast_pause_cd">Pause live</string>
|
<string name="broadcast_pause_cd">Pause live</string>
|
||||||
<string name="broadcast_terminate_cd">End live</string>
|
<string name="broadcast_terminate_cd">End live</string>
|
||||||
|
<string name="broadcast_min_quality_title">Minimum quality</string>
|
||||||
|
<string name="broadcast_min_quality_cd">Minimum streaming quality</string>
|
||||||
|
<string name="broadcast_min_quality_hint">The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.</string>
|
||||||
|
<string name="broadcast_min_quality_auto">Automatic</string>
|
||||||
|
<string name="broadcast_min_quality_applied">Minimum quality: %1$s</string>
|
||||||
|
<string name="broadcast_min_quality_error">Unable to set minimum quality</string>
|
||||||
|
<string name="broadcast_snackbar_min_quality_remote">Minimum quality updated from control room</string>
|
||||||
<string name="broadcast_team_home_label">HOME</string>
|
<string name="broadcast_team_home_label">HOME</string>
|
||||||
<string name="broadcast_team_away_label">AWAY</string>
|
<string name="broadcast_team_away_label">AWAY</string>
|
||||||
<string name="broadcast_scoreboard_connected">Scoreboard OK</string>
|
<string name="broadcast_scoreboard_connected">Scoreboard OK</string>
|
||||||
|
|||||||
@@ -164,6 +164,13 @@
|
|||||||
<string name="broadcast_resume_cd">Reanudar directo</string>
|
<string name="broadcast_resume_cd">Reanudar directo</string>
|
||||||
<string name="broadcast_pause_cd">Pausar directo</string>
|
<string name="broadcast_pause_cd">Pausar directo</string>
|
||||||
<string name="broadcast_terminate_cd">Terminar directo</string>
|
<string name="broadcast_terminate_cd">Terminar directo</string>
|
||||||
|
<string name="broadcast_min_quality_title">Calidad mínima</string>
|
||||||
|
<string name="broadcast_min_quality_cd">Calidad mínima de transmisión</string>
|
||||||
|
<string name="broadcast_min_quality_hint">El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.</string>
|
||||||
|
<string name="broadcast_min_quality_auto">Automática</string>
|
||||||
|
<string name="broadcast_min_quality_applied">Calidad mínima: %1$s</string>
|
||||||
|
<string name="broadcast_min_quality_error">No se pudo fijar la calidad mínima</string>
|
||||||
|
<string name="broadcast_snackbar_min_quality_remote">Calidad mínima actualizada desde la regie</string>
|
||||||
<string name="broadcast_team_home_label">LOCAL</string>
|
<string name="broadcast_team_home_label">LOCAL</string>
|
||||||
<string name="broadcast_team_away_label">VISITANTE</string>
|
<string name="broadcast_team_away_label">VISITANTE</string>
|
||||||
<string name="broadcast_scoreboard_connected">Marcador OK</string>
|
<string name="broadcast_scoreboard_connected">Marcador OK</string>
|
||||||
|
|||||||
@@ -164,6 +164,13 @@
|
|||||||
<string name="broadcast_resume_cd">Reprendre le direct</string>
|
<string name="broadcast_resume_cd">Reprendre le direct</string>
|
||||||
<string name="broadcast_pause_cd">Mettre le direct en pause</string>
|
<string name="broadcast_pause_cd">Mettre le direct en pause</string>
|
||||||
<string name="broadcast_terminate_cd">Terminer le direct</string>
|
<string name="broadcast_terminate_cd">Terminer le direct</string>
|
||||||
|
<string name="broadcast_min_quality_title">Qualité minimale</string>
|
||||||
|
<string name="broadcast_min_quality_cd">Qualité minimale de transmission</string>
|
||||||
|
<string name="broadcast_min_quality_hint">L\'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.</string>
|
||||||
|
<string name="broadcast_min_quality_auto">Automatique</string>
|
||||||
|
<string name="broadcast_min_quality_applied">Qualité minimale : %1$s</string>
|
||||||
|
<string name="broadcast_min_quality_error">Impossible de définir la qualité minimale</string>
|
||||||
|
<string name="broadcast_snackbar_min_quality_remote">Qualité minimale mise à jour depuis la régie</string>
|
||||||
<string name="broadcast_team_home_label">DOMICILE</string>
|
<string name="broadcast_team_home_label">DOMICILE</string>
|
||||||
<string name="broadcast_team_away_label">EXTÉRIEUR</string>
|
<string name="broadcast_team_away_label">EXTÉRIEUR</string>
|
||||||
<string name="broadcast_scoreboard_connected">Tableau OK</string>
|
<string name="broadcast_scoreboard_connected">Tableau OK</string>
|
||||||
|
|||||||
@@ -164,6 +164,13 @@
|
|||||||
<string name="broadcast_resume_cd">Riprendi diretta</string>
|
<string name="broadcast_resume_cd">Riprendi diretta</string>
|
||||||
<string name="broadcast_pause_cd">Pausa diretta</string>
|
<string name="broadcast_pause_cd">Pausa diretta</string>
|
||||||
<string name="broadcast_terminate_cd">Termina diretta</string>
|
<string name="broadcast_terminate_cd">Termina diretta</string>
|
||||||
|
<string name="broadcast_min_quality_title">Qualità minima</string>
|
||||||
|
<string name="broadcast_min_quality_cd">Qualità minima di trasmissione</string>
|
||||||
|
<string name="broadcast_min_quality_hint">L\'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.</string>
|
||||||
|
<string name="broadcast_min_quality_auto">Automatica</string>
|
||||||
|
<string name="broadcast_min_quality_applied">Qualità minima: %1$s</string>
|
||||||
|
<string name="broadcast_min_quality_error">Impossibile impostare la qualità minima</string>
|
||||||
|
<string name="broadcast_snackbar_min_quality_remote">Qualità minima aggiornata dalla regia</string>
|
||||||
<string name="broadcast_team_home_label">CASA</string>
|
<string name="broadcast_team_home_label">CASA</string>
|
||||||
<string name="broadcast_team_away_label">OSPITE</string>
|
<string name="broadcast_team_away_label">OSPITE</string>
|
||||||
<string name="broadcast_scoreboard_connected">Tabellone OK</string>
|
<string name="broadcast_scoreboard_connected">Tabellone OK</string>
|
||||||
|
|||||||
+106
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
39335ED5DE5847E5904222AB /* MatchSessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813463F3E17D453EAA801739 /* MatchSessionLauncher.swift */; };
|
39335ED5DE5847E5904222AB /* MatchSessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813463F3E17D453EAA801739 /* MatchSessionLauncher.swift */; };
|
||||||
39F7D49D44244F51907E803D /* TeamColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7001BB5D65B34280B306ECCF /* TeamColorPicker.swift */; };
|
39F7D49D44244F51907E803D /* TeamColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7001BB5D65B34280B306ECCF /* TeamColorPicker.swift */; };
|
||||||
451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */; };
|
451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */; };
|
||||||
|
5F9B2C3D4E5A4B72C8D9E0F1 /* AdaptiveBitrateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8A1B2C3D4F4A61B7C8D9E0 /* AdaptiveBitrateController.swift */; };
|
||||||
454725D14B224276B06BCC67 /* MatchHubFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */; };
|
454725D14B224276B06BCC67 /* MatchHubFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */; };
|
||||||
45776A6A76D04EF5A0C0D187 /* TeamBrandingEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBBE0F1399840FFA0E7EF2E /* TeamBrandingEditor.swift */; };
|
45776A6A76D04EF5A0C0D187 /* TeamBrandingEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBBE0F1399840FFA0E7EF2E /* TeamBrandingEditor.swift */; };
|
||||||
470957DE79A84D9889F0663C /* ScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC8ECFD24A14054BDD984C3 /* ScoreRepository.swift */; };
|
470957DE79A84D9889F0663C /* ScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC8ECFD24A14054BDD984C3 /* ScoreRepository.swift */; };
|
||||||
@@ -68,6 +69,7 @@
|
|||||||
AA18DECE8D08461B9197EB71 /* MatchScoringRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */; };
|
AA18DECE8D08461B9197EB71 /* MatchScoringRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */; };
|
||||||
AA3A68B2C1784294ADB89605 /* MatchLiveTv/Resources/it.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 857C036B5B5C43368506DD8B /* MatchLiveTv/Resources/it.lproj */; };
|
AA3A68B2C1784294ADB89605 /* MatchLiveTv/Resources/it.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 857C036B5B5C43368506DD8B /* MatchLiveTv/Resources/it.lproj */; };
|
||||||
AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */; };
|
AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */; };
|
||||||
|
7B1D4E5F6A7C4D94E0F1A2B3 /* AdaptiveBitrateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A0C3D4E5F6B4C83D9E0F1A2 /* AdaptiveBitrateTests.swift */; };
|
||||||
ABC5853FEC554FA4AFBDCD2E /* OverlayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DDC7305F3B549D0B7646B14 /* OverlayState.swift */; };
|
ABC5853FEC554FA4AFBDCD2E /* OverlayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DDC7305F3B549D0B7646B14 /* OverlayState.swift */; };
|
||||||
ACC54AA19C5747CFA118C70B /* ApiInstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FEB862C3A34BF8AC24052E /* ApiInstantTests.swift */; };
|
ACC54AA19C5747CFA118C70B /* ApiInstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FEB862C3A34BF8AC24052E /* ApiInstantTests.swift */; };
|
||||||
AECC36D80A0C411AB47D581A /* MatchRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537FC2C063D341DF97A391B9 /* MatchRepository.swift */; };
|
AECC36D80A0C411AB47D581A /* MatchRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537FC2C063D341DF97A391B9 /* MatchRepository.swift */; };
|
||||||
@@ -127,6 +129,7 @@
|
|||||||
31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = "<group>"; };
|
31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = "<group>"; };
|
||||||
34BB2251A67E401A9F1E789E /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; };
|
34BB2251A67E401A9F1E789E /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; };
|
||||||
377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThermalAdaptationTests.swift; path = MatchLiveTvTests/ThermalAdaptationTests.swift; sourceTree = "<group>"; };
|
377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThermalAdaptationTests.swift; path = MatchLiveTvTests/ThermalAdaptationTests.swift; sourceTree = "<group>"; };
|
||||||
|
6A0C3D4E5F6B4C83D9E0F1A2 /* AdaptiveBitrateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AdaptiveBitrateTests.swift; path = MatchLiveTvTests/AdaptiveBitrateTests.swift; sourceTree = "<group>"; };
|
||||||
39F3C00B63534E6AA6D8A90A /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; };
|
39F3C00B63534E6AA6D8A90A /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; };
|
||||||
3A6F9AAB5A1249BBB4FC02E6 /* MatchLiveTv/Resources/en.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/en.lproj; sourceTree = "<group>"; };
|
3A6F9AAB5A1249BBB4FC02E6 /* MatchLiveTv/Resources/en.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/en.lproj; sourceTree = "<group>"; };
|
||||||
3BFB2F18615747B1A1C9E9B2 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = "<group>"; };
|
3BFB2F18615747B1A1C9E9B2 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = "<group>"; };
|
||||||
@@ -160,6 +163,7 @@
|
|||||||
87C01CC031C34343B3EBFF08 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = "<group>"; };
|
87C01CC031C34343B3EBFF08 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = "<group>"; };
|
||||||
8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = "<group>"; };
|
8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = "<group>"; };
|
||||||
8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamVideoPreset.swift; path = MatchLiveTv/Streaming/StreamVideoPreset.swift; sourceTree = "<group>"; };
|
8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamVideoPreset.swift; path = MatchLiveTv/Streaming/StreamVideoPreset.swift; sourceTree = "<group>"; };
|
||||||
|
4E8A1B2C3D4F4A61B7C8D9E0 /* AdaptiveBitrateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AdaptiveBitrateController.swift; path = MatchLiveTv/Streaming/AdaptiveBitrateController.swift; sourceTree = "<group>"; };
|
||||||
902831E3D87C46359F0D0A21 /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = "<group>"; };
|
902831E3D87C46359F0D0A21 /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = "<group>"; };
|
||||||
951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = "<group>"; };
|
951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = "<group>"; };
|
||||||
98526C26B2584F55AADF0FFF /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = "<group>"; };
|
98526C26B2584F55AADF0FFF /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = "<group>"; };
|
||||||
@@ -237,6 +241,7 @@
|
|||||||
10AD0F18190542818572CAEF /* ScoreActionDecodeTests.swift */,
|
10AD0F18190542818572CAEF /* ScoreActionDecodeTests.swift */,
|
||||||
6DF142EBF33B45739BA8E42C /* ScoreControllerTests.swift */,
|
6DF142EBF33B45739BA8E42C /* ScoreControllerTests.swift */,
|
||||||
377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */,
|
377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */,
|
||||||
|
6A0C3D4E5F6B4C83D9E0F1A2 /* AdaptiveBitrateTests.swift */,
|
||||||
);
|
);
|
||||||
name = MatchLiveTvTests;
|
name = MatchLiveTvTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -296,6 +301,7 @@
|
|||||||
31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */,
|
31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */,
|
||||||
46CE696CAB994E2C8E399776 /* LivePreviewView.swift */,
|
46CE696CAB994E2C8E399776 /* LivePreviewView.swift */,
|
||||||
8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */,
|
8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */,
|
||||||
|
4E8A1B2C3D4F4A61B7C8D9E0 /* AdaptiveBitrateController.swift */,
|
||||||
4419E10415414556B7C58458 /* AccountScreen.swift */,
|
4419E10415414556B7C58458 /* AccountScreen.swift */,
|
||||||
E860A0E35362427DA84F8972 /* BroadcastControlsOverlay.swift */,
|
E860A0E35362427DA84F8972 /* BroadcastControlsOverlay.swift */,
|
||||||
71816F0D99E44551839474CE /* BroadcastScreen.swift */,
|
71816F0D99E44551839474CE /* BroadcastScreen.swift */,
|
||||||
@@ -473,6 +479,7 @@
|
|||||||
6AD0B3220B154EA2936C634A /* WatermarkElement.swift in Sources */,
|
6AD0B3220B154EA2936C634A /* WatermarkElement.swift in Sources */,
|
||||||
713E0129656649FC8630FDC6 /* LivePreviewView.swift in Sources */,
|
713E0129656649FC8630FDC6 /* LivePreviewView.swift in Sources */,
|
||||||
451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */,
|
451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */,
|
||||||
|
5F9B2C3D4E5A4B72C8D9E0F1 /* AdaptiveBitrateController.swift in Sources */,
|
||||||
E5E21D088782469D8EB23543 /* AccountScreen.swift in Sources */,
|
E5E21D088782469D8EB23543 /* AccountScreen.swift in Sources */,
|
||||||
5B51805239FA4D4D90919D86 /* BroadcastControlsOverlay.swift in Sources */,
|
5B51805239FA4D4D90919D86 /* BroadcastControlsOverlay.swift in Sources */,
|
||||||
EE568EA02C404102B4DFD651 /* BroadcastScreen.swift in Sources */,
|
EE568EA02C404102B4DFD651 /* BroadcastScreen.swift in Sources */,
|
||||||
@@ -516,6 +523,7 @@
|
|||||||
5D91C53D2E8A4952937F816B /* ScoreActionDecodeTests.swift in Sources */,
|
5D91C53D2E8A4952937F816B /* ScoreActionDecodeTests.swift in Sources */,
|
||||||
FB66DD0422E14C54AC5B3EDB /* ScoreControllerTests.swift in Sources */,
|
FB66DD0422E14C54AC5B3EDB /* ScoreControllerTests.swift in Sources */,
|
||||||
AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */,
|
AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */,
|
||||||
|
7B1D4E5F6A7C4D94E0F1A2B3 /* AdaptiveBitrateTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -118,7 +118,13 @@ enum L10n {
|
|||||||
"broadcast.error.rtmp.session.create": "Impossibile creare sessione RTMP",
|
"broadcast.error.rtmp.session.create": "Impossibile creare sessione RTMP",
|
||||||
"broadcast.error.rtmp.url.invalid": "URL RTMP non valido",
|
"broadcast.error.rtmp.url.invalid": "URL RTMP non valido",
|
||||||
"broadcast.error.session.unavailable": "Sessione non disponibile",
|
"broadcast.error.session.unavailable": "Sessione non disponibile",
|
||||||
"broadcast.hide.controls.cd": "Nascondi controlli",
|
"broadcast.hide.controls.cd": "Nascondi controlli",
|
||||||
|
"broadcast.min.quality.applied": "Qualità minima: %1$@",
|
||||||
|
"broadcast.min.quality.auto": "Automatica",
|
||||||
|
"broadcast.min.quality.cd": "Qualità minima di trasmissione",
|
||||||
|
"broadcast.min.quality.error": "Impossibile impostare la qualità minima",
|
||||||
|
"broadcast.min.quality.hint": "L'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.",
|
||||||
|
"broadcast.min.quality.title": "Qualità minima",
|
||||||
"broadcast.next.period.cd": "Periodo successivo",
|
"broadcast.next.period.cd": "Periodo successivo",
|
||||||
"broadcast.pause.cd": "Pausa diretta",
|
"broadcast.pause.cd": "Pausa diretta",
|
||||||
"broadcast.period.label.timed": "%1$d° tempo",
|
"broadcast.period.label.timed": "%1$d° tempo",
|
||||||
@@ -139,7 +145,8 @@ enum L10n {
|
|||||||
"broadcast.show.controls.cd": "Mostra controlli",
|
"broadcast.show.controls.cd": "Mostra controlli",
|
||||||
"broadcast.side.away": "ospite",
|
"broadcast.side.away": "ospite",
|
||||||
"broadcast.side.home": "casa",
|
"broadcast.side.home": "casa",
|
||||||
"broadcast.snackbar.closed.remote": "Diretta chiusa dalla regia",
|
"broadcast.snackbar.closed.remote": "Diretta chiusa dalla regia",
|
||||||
|
"broadcast.snackbar.min.quality.remote": "Qualità minima aggiornata dalla regia",
|
||||||
"broadcast.snackbar.pause.error": "Pausa: %1$@",
|
"broadcast.snackbar.pause.error": "Pausa: %1$@",
|
||||||
"broadcast.snackbar.paused": "Diretta in pausa",
|
"broadcast.snackbar.paused": "Diretta in pausa",
|
||||||
"broadcast.snackbar.paused.remote": "Pausa dalla regia",
|
"broadcast.snackbar.paused.remote": "Pausa dalla regia",
|
||||||
@@ -408,7 +415,13 @@ enum L10n {
|
|||||||
"broadcast.error.rtmp.session.create": "Unable to create RTMP session",
|
"broadcast.error.rtmp.session.create": "Unable to create RTMP session",
|
||||||
"broadcast.error.rtmp.url.invalid": "Invalid RTMP URL",
|
"broadcast.error.rtmp.url.invalid": "Invalid RTMP URL",
|
||||||
"broadcast.error.session.unavailable": "Session not available",
|
"broadcast.error.session.unavailable": "Session not available",
|
||||||
"broadcast.hide.controls.cd": "Hide controls",
|
"broadcast.hide.controls.cd": "Hide controls",
|
||||||
|
"broadcast.min.quality.applied": "Minimum quality: %1$@",
|
||||||
|
"broadcast.min.quality.auto": "Automatic",
|
||||||
|
"broadcast.min.quality.cd": "Minimum streaming quality",
|
||||||
|
"broadcast.min.quality.error": "Unable to set minimum quality",
|
||||||
|
"broadcast.min.quality.hint": "The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.",
|
||||||
|
"broadcast.min.quality.title": "Minimum quality",
|
||||||
"broadcast.next.period.cd": "Next period",
|
"broadcast.next.period.cd": "Next period",
|
||||||
"broadcast.pause.cd": "Pause live",
|
"broadcast.pause.cd": "Pause live",
|
||||||
"broadcast.period.label.timed": "Period %1$d",
|
"broadcast.period.label.timed": "Period %1$d",
|
||||||
@@ -429,7 +442,8 @@ enum L10n {
|
|||||||
"broadcast.show.controls.cd": "Show controls",
|
"broadcast.show.controls.cd": "Show controls",
|
||||||
"broadcast.side.away": "away",
|
"broadcast.side.away": "away",
|
||||||
"broadcast.side.home": "home",
|
"broadcast.side.home": "home",
|
||||||
"broadcast.snackbar.closed.remote": "Live closed from control room",
|
"broadcast.snackbar.closed.remote": "Live closed from control room",
|
||||||
|
"broadcast.snackbar.min.quality.remote": "Minimum quality updated from control room",
|
||||||
"broadcast.snackbar.pause.error": "Pause: %1$@",
|
"broadcast.snackbar.pause.error": "Pause: %1$@",
|
||||||
"broadcast.snackbar.paused": "Live paused",
|
"broadcast.snackbar.paused": "Live paused",
|
||||||
"broadcast.snackbar.paused.remote": "Paused from control room",
|
"broadcast.snackbar.paused.remote": "Paused from control room",
|
||||||
@@ -698,7 +712,13 @@ enum L10n {
|
|||||||
"broadcast.error.rtmp.session.create": "Impossible de créer la session RTMP",
|
"broadcast.error.rtmp.session.create": "Impossible de créer la session RTMP",
|
||||||
"broadcast.error.rtmp.url.invalid": "URL RTMP non valide",
|
"broadcast.error.rtmp.url.invalid": "URL RTMP non valide",
|
||||||
"broadcast.error.session.unavailable": "Session non disponible",
|
"broadcast.error.session.unavailable": "Session non disponible",
|
||||||
"broadcast.hide.controls.cd": "Masquer les commandes",
|
"broadcast.hide.controls.cd": "Masquer les commandes",
|
||||||
|
"broadcast.min.quality.applied": "Qualité minimale : %1$@",
|
||||||
|
"broadcast.min.quality.auto": "Automatique",
|
||||||
|
"broadcast.min.quality.cd": "Qualité minimale de transmission",
|
||||||
|
"broadcast.min.quality.error": "Impossible de définir la qualité minimale",
|
||||||
|
"broadcast.min.quality.hint": "L'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.",
|
||||||
|
"broadcast.min.quality.title": "Qualité minimale",
|
||||||
"broadcast.next.period.cd": "Période suivante",
|
"broadcast.next.period.cd": "Période suivante",
|
||||||
"broadcast.pause.cd": "Mettre le direct en pause",
|
"broadcast.pause.cd": "Mettre le direct en pause",
|
||||||
"broadcast.period.label.timed": "Période %1$d",
|
"broadcast.period.label.timed": "Période %1$d",
|
||||||
@@ -719,7 +739,8 @@ enum L10n {
|
|||||||
"broadcast.show.controls.cd": "Afficher les commandes",
|
"broadcast.show.controls.cd": "Afficher les commandes",
|
||||||
"broadcast.side.away": "extérieur",
|
"broadcast.side.away": "extérieur",
|
||||||
"broadcast.side.home": "domicile",
|
"broadcast.side.home": "domicile",
|
||||||
"broadcast.snackbar.closed.remote": "Direct fermé depuis la régie",
|
"broadcast.snackbar.closed.remote": "Direct fermé depuis la régie",
|
||||||
|
"broadcast.snackbar.min.quality.remote": "Qualité minimale mise à jour depuis la régie",
|
||||||
"broadcast.snackbar.pause.error": "Pause : %1$@",
|
"broadcast.snackbar.pause.error": "Pause : %1$@",
|
||||||
"broadcast.snackbar.paused": "Direct en pause",
|
"broadcast.snackbar.paused": "Direct en pause",
|
||||||
"broadcast.snackbar.paused.remote": "Mis en pause depuis la régie",
|
"broadcast.snackbar.paused.remote": "Mis en pause depuis la régie",
|
||||||
@@ -988,7 +1009,13 @@ enum L10n {
|
|||||||
"broadcast.error.rtmp.session.create": "RTMP-Sitzung konnte nicht erstellt werden",
|
"broadcast.error.rtmp.session.create": "RTMP-Sitzung konnte nicht erstellt werden",
|
||||||
"broadcast.error.rtmp.url.invalid": "Ungültige RTMP-URL",
|
"broadcast.error.rtmp.url.invalid": "Ungültige RTMP-URL",
|
||||||
"broadcast.error.session.unavailable": "Sitzung nicht verfügbar",
|
"broadcast.error.session.unavailable": "Sitzung nicht verfügbar",
|
||||||
"broadcast.hide.controls.cd": "Steuerung ausblenden",
|
"broadcast.hide.controls.cd": "Steuerung ausblenden",
|
||||||
|
"broadcast.min.quality.applied": "Mindestqualität: %1$@",
|
||||||
|
"broadcast.min.quality.auto": "Automatisch",
|
||||||
|
"broadcast.min.quality.cd": "Minimale Übertragungsqualität",
|
||||||
|
"broadcast.min.quality.error": "Mindestqualität konnte nicht gesetzt werden",
|
||||||
|
"broadcast.min.quality.hint": "Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.",
|
||||||
|
"broadcast.min.quality.title": "Mindestqualität",
|
||||||
"broadcast.next.period.cd": "Nächste Periode",
|
"broadcast.next.period.cd": "Nächste Periode",
|
||||||
"broadcast.pause.cd": "Livestream pausieren",
|
"broadcast.pause.cd": "Livestream pausieren",
|
||||||
"broadcast.period.label.timed": "Halbzeit %1$d",
|
"broadcast.period.label.timed": "Halbzeit %1$d",
|
||||||
@@ -1009,7 +1036,8 @@ enum L10n {
|
|||||||
"broadcast.show.controls.cd": "Steuerung einblenden",
|
"broadcast.show.controls.cd": "Steuerung einblenden",
|
||||||
"broadcast.side.away": "Gast",
|
"broadcast.side.away": "Gast",
|
||||||
"broadcast.side.home": "Heim",
|
"broadcast.side.home": "Heim",
|
||||||
"broadcast.snackbar.closed.remote": "Livestream von der Regie beendet",
|
"broadcast.snackbar.closed.remote": "Livestream von der Regie beendet",
|
||||||
|
"broadcast.snackbar.min.quality.remote": "Mindestqualität von der Regie aktualisiert",
|
||||||
"broadcast.snackbar.pause.error": "Pause: %1$@",
|
"broadcast.snackbar.pause.error": "Pause: %1$@",
|
||||||
"broadcast.snackbar.paused": "Livestream pausiert",
|
"broadcast.snackbar.paused": "Livestream pausiert",
|
||||||
"broadcast.snackbar.paused.remote": "Von der Regie pausiert",
|
"broadcast.snackbar.paused.remote": "Von der Regie pausiert",
|
||||||
@@ -1278,7 +1306,13 @@ enum L10n {
|
|||||||
"broadcast.error.rtmp.session.create": "No se pudo crear la sesión RTMP",
|
"broadcast.error.rtmp.session.create": "No se pudo crear la sesión RTMP",
|
||||||
"broadcast.error.rtmp.url.invalid": "URL RTMP no válida",
|
"broadcast.error.rtmp.url.invalid": "URL RTMP no válida",
|
||||||
"broadcast.error.session.unavailable": "Sesión no disponible",
|
"broadcast.error.session.unavailable": "Sesión no disponible",
|
||||||
"broadcast.hide.controls.cd": "Ocultar controles",
|
"broadcast.hide.controls.cd": "Ocultar controles",
|
||||||
|
"broadcast.min.quality.applied": "Calidad mínima: %1$@",
|
||||||
|
"broadcast.min.quality.auto": "Automática",
|
||||||
|
"broadcast.min.quality.cd": "Calidad mínima de transmisión",
|
||||||
|
"broadcast.min.quality.error": "No se pudo fijar la calidad mínima",
|
||||||
|
"broadcast.min.quality.hint": "El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.",
|
||||||
|
"broadcast.min.quality.title": "Calidad mínima",
|
||||||
"broadcast.next.period.cd": "Periodo siguiente",
|
"broadcast.next.period.cd": "Periodo siguiente",
|
||||||
"broadcast.pause.cd": "Pausar directo",
|
"broadcast.pause.cd": "Pausar directo",
|
||||||
"broadcast.period.label.timed": "Tiempo %1$d",
|
"broadcast.period.label.timed": "Tiempo %1$d",
|
||||||
@@ -1299,7 +1333,8 @@ enum L10n {
|
|||||||
"broadcast.show.controls.cd": "Mostrar controles",
|
"broadcast.show.controls.cd": "Mostrar controles",
|
||||||
"broadcast.side.away": "visitante",
|
"broadcast.side.away": "visitante",
|
||||||
"broadcast.side.home": "local",
|
"broadcast.side.home": "local",
|
||||||
"broadcast.snackbar.closed.remote": "Directo cerrado desde regie",
|
"broadcast.snackbar.closed.remote": "Directo cerrado desde regie",
|
||||||
|
"broadcast.snackbar.min.quality.remote": "Calidad mínima actualizada desde la regie",
|
||||||
"broadcast.snackbar.pause.error": "Pausa: %1$@",
|
"broadcast.snackbar.pause.error": "Pausa: %1$@",
|
||||||
"broadcast.snackbar.paused": "Directo en pausa",
|
"broadcast.snackbar.paused": "Directo en pausa",
|
||||||
"broadcast.snackbar.paused.remote": "Pausado desde regie",
|
"broadcast.snackbar.paused.remote": "Pausado desde regie",
|
||||||
|
|||||||
@@ -39,4 +39,13 @@ enum ThermalState: Int, Sendable, Equatable, Comparable {
|
|||||||
case .critical: return "🔴"
|
case .critical: return "🔴"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var apiValue: String {
|
||||||
|
switch self {
|
||||||
|
case .nominal: return "nominal"
|
||||||
|
case .fair: return "fair"
|
||||||
|
case .serious: return "serious"
|
||||||
|
case .critical: return "critical"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ struct StreamSessionDto: Decodable {
|
|||||||
let targetBitrate: Int?
|
let targetBitrate: Int?
|
||||||
let targetFps: Int?
|
let targetFps: Int?
|
||||||
let qualityPreset: String?
|
let qualityPreset: String?
|
||||||
|
let minQualityPreset: String?
|
||||||
let startedAt: String?
|
let startedAt: String?
|
||||||
let score: ScoreStateDto?
|
let score: ScoreStateDto?
|
||||||
|
|
||||||
@@ -221,6 +222,7 @@ struct StreamSessionDto: Decodable {
|
|||||||
targetBitrate: targetBitrate ?? 2_500_000,
|
targetBitrate: targetBitrate ?? 2_500_000,
|
||||||
targetFps: targetFps ?? 30,
|
targetFps: targetFps ?? 30,
|
||||||
qualityPreset: qualityPreset ?? "720p_30_2.5mbps",
|
qualityPreset: qualityPreset ?? "720p_30_2.5mbps",
|
||||||
|
minQualityPreset: minQualityPreset ?? "auto",
|
||||||
startedAt: startedAt,
|
startedAt: startedAt,
|
||||||
score: score?.toDomain()
|
score: score?.toDomain()
|
||||||
)
|
)
|
||||||
@@ -414,6 +416,10 @@ struct CreateSessionRequest: Encodable {
|
|||||||
let youtubeChannel: String?
|
let youtubeChannel: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct MinQualityRequest: Encodable {
|
||||||
|
let minQualityPreset: String
|
||||||
|
}
|
||||||
|
|
||||||
struct ScoringRulesBody: Codable {
|
struct ScoringRulesBody: Codable {
|
||||||
let setsToWin: Int?
|
let setsToWin: Int?
|
||||||
let pointsPerSet: Int?
|
let pointsPerSet: Int?
|
||||||
@@ -499,6 +505,7 @@ struct TelemetryRequest: Encodable {
|
|||||||
let currentBitrate: Int?
|
let currentBitrate: Int?
|
||||||
let targetBitrate: Int?
|
let targetBitrate: Int?
|
||||||
let fps: Int?
|
let fps: Int?
|
||||||
|
let thermalState: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ScoringRules {
|
extension ScoringRules {
|
||||||
|
|||||||
@@ -186,6 +186,10 @@ final class MatchLiveAPI: @unchecked Sendable {
|
|||||||
try await patch("sessions/\(id)/resume", body: EmptyBody())
|
try await patch("sessions/\(id)/resume", body: EmptyBody())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func 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 {
|
func networkTest(sessionId: String, body: NetworkTestRequest) async throws -> NetworkTestResponse {
|
||||||
try await post("sessions/\(sessionId)/network_test", body: body)
|
try await post("sessions/\(sessionId)/network_test", body: body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ final class SessionCableService: ObservableObject {
|
|||||||
var onPauseStream: (() -> Void)?
|
var onPauseStream: (() -> Void)?
|
||||||
var onResumeStream: (() -> Void)?
|
var onResumeStream: (() -> Void)?
|
||||||
var onStopStream: (() -> Void)?
|
var onStopStream: (() -> Void)?
|
||||||
|
var onMinQuality: ((String) -> Void)?
|
||||||
|
|
||||||
private var client: ActionCableClient?
|
private var client: ActionCableClient?
|
||||||
private var intentionalDisconnect = false
|
private var intentionalDisconnect = false
|
||||||
@@ -93,6 +94,10 @@ extension SessionCableService: ActionCableClient.Listener {
|
|||||||
case "pause_stream": onPauseStream?()
|
case "pause_stream": onPauseStream?()
|
||||||
case "resume_stream": onResumeStream?()
|
case "resume_stream": onResumeStream?()
|
||||||
case "stop_stream": onStopStream?()
|
case "stop_stream": onStopStream?()
|
||||||
|
case "set_min_quality":
|
||||||
|
if let preset = payload["min_quality_preset"] as? String {
|
||||||
|
onMinQuality?(preset)
|
||||||
|
}
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
case "stream_event":
|
case "stream_event":
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ final class SessionRepository {
|
|||||||
try await api.resumeSession(id: id).toDomain()
|
try await api.resumeSession(id: id).toDomain()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setMinQuality(id: String, preset: String) async throws -> StreamSession {
|
||||||
|
try await api.setMinQuality(id: id, preset: preset).toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
func submitNetworkTest(
|
func submitNetworkTest(
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
downloadMbps: Double,
|
downloadMbps: Double,
|
||||||
@@ -88,7 +92,8 @@ final class SessionRepository {
|
|||||||
signalStrength: nil,
|
signalStrength: nil,
|
||||||
currentBitrate: currentBitrate,
|
currentBitrate: currentBitrate,
|
||||||
targetBitrate: targetBitrate,
|
targetBitrate: targetBitrate,
|
||||||
fps: fps
|
fps: fps,
|
||||||
|
thermalState: health.thermalState.apiValue
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,11 +119,35 @@ struct StreamSession: Identifiable, Equatable, Sendable {
|
|||||||
let targetBitrate: Int
|
let targetBitrate: Int
|
||||||
let targetFps: Int
|
let targetFps: Int
|
||||||
let qualityPreset: String
|
let qualityPreset: String
|
||||||
|
let minQualityPreset: String
|
||||||
let startedAt: String?
|
let startedAt: String?
|
||||||
let score: ScoreState?
|
let score: ScoreState?
|
||||||
|
|
||||||
var isLive: Bool { status == "live" || status == "reconnecting" }
|
var isLive: Bool { status == "live" || status == "reconnecting" }
|
||||||
var isPaused: Bool { status == "paused" }
|
var isPaused: Bool { status == "paused" }
|
||||||
|
var isEnded: Bool { status == "ended" || status == "error" }
|
||||||
|
|
||||||
|
func with(minQualityPreset: String) -> StreamSession {
|
||||||
|
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? {
|
func watchShareUrl() -> String? {
|
||||||
if platform == "youtube", let url = youtubeWatchUrl, !url.isEmpty { return url }
|
if platform == "youtube", let url = youtubeWatchUrl, !url.isEmpty { return url }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ struct BroadcastConfig: Sendable {
|
|||||||
var width: Int = 1280
|
var width: Int = 1280
|
||||||
var height: Int = 720
|
var height: Int = 720
|
||||||
var videoBitrate: Int = 2_500_000
|
var videoBitrate: Int = 2_500_000
|
||||||
|
var minVideoBitrate: Int = 800_000
|
||||||
var audioBitrate: Int = 128_000
|
var audioBitrate: Int = 128_000
|
||||||
var fps: Int = 30
|
var fps: Int = 30
|
||||||
var rotation: Int = 0
|
var rotation: Int = 0
|
||||||
|
|||||||
@@ -5,18 +5,37 @@ import RTMPHaishinKit
|
|||||||
import UIKit
|
import UIKit
|
||||||
import VideoToolbox
|
import VideoToolbox
|
||||||
|
|
||||||
private struct BroadcastBitrateMonitor: StreamBitRateStrategy {
|
private final class AdaptiveBroadcastBitRateStrategy: StreamBitRateStrategy, @unchecked Sendable {
|
||||||
let mamimumVideoBitRate: Int = 0
|
let controller: AdaptiveBitrateController
|
||||||
let mamimumAudioBitRate: Int = 0
|
|
||||||
let onUpdate: @Sendable (Int) -> Void
|
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 {
|
func adjustBitrate(_ event: NetworkMonitorEvent, stream: some StreamConvertible) async {
|
||||||
|
let next: Int?
|
||||||
switch event {
|
switch event {
|
||||||
case .status(let report), .publishInsufficientBWOccured(let report):
|
case .status(let report):
|
||||||
|
let measuredBits = report.currentBytesOutPerSecond * 8
|
||||||
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
|
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
|
||||||
|
next = controller.onMeasured(measuredBitsPerSecond: measuredBits, congested: false)
|
||||||
|
case .publishInsufficientBWOccured(let report):
|
||||||
|
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
|
||||||
|
next = controller.onCongestion(measuredBitsPerSecond: report.currentBytesOutPerSecond * 8)
|
||||||
case .reset:
|
case .reset:
|
||||||
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 lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
|
||||||
private var orientationDebounceTask: Task<Void, Never>?
|
private var orientationDebounceTask: Task<Void, Never>?
|
||||||
private var rtmpSession: (any Session)?
|
private var rtmpSession: (any Session)?
|
||||||
|
private let adaptiveBitrate = AdaptiveBitrateController()
|
||||||
private static var rtmpFactoryRegistered = false
|
private static var rtmpFactoryRegistered = false
|
||||||
|
|
||||||
private static func ensureRTMPFactoryRegistered() async {
|
private static func ensureRTMPFactoryRegistered() async {
|
||||||
@@ -101,6 +121,7 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
|
|
||||||
func preparePreview(config: BroadcastConfig) async throws {
|
func preparePreview(config: BroadcastConfig) async throws {
|
||||||
self.config = config
|
self.config = config
|
||||||
|
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
|
||||||
publishPending = false
|
publishPending = false
|
||||||
try await configurePipeline(config)
|
try await configurePipeline(config)
|
||||||
setPhase(.preview)
|
setPhase(.preview)
|
||||||
@@ -109,6 +130,7 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
/// Configura camera/mixer e mette in coda il publish RTMP (eseguito quando l'anteprima è pronta).
|
/// Configura camera/mixer e mette in coda il publish RTMP (eseguito quando l'anteprima è pronta).
|
||||||
func prepareForBroadcast(config: BroadcastConfig) async throws {
|
func prepareForBroadcast(config: BroadcastConfig) async throws {
|
||||||
self.config = config
|
self.config = config
|
||||||
|
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
|
||||||
publishPending = true
|
publishPending = true
|
||||||
try await configurePipeline(config)
|
try await configurePipeline(config)
|
||||||
setPhase(.connecting)
|
setPhase(.connecting)
|
||||||
@@ -135,6 +157,7 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
|
|
||||||
func resumeBroadcast(config: BroadcastConfig) async throws {
|
func resumeBroadcast(config: BroadcastConfig) async throws {
|
||||||
self.config = config
|
self.config = config
|
||||||
|
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
|
||||||
reconnectAttempts = 0
|
reconnectAttempts = 0
|
||||||
reconnectTask?.cancel()
|
reconnectTask?.cancel()
|
||||||
reconnectTask = nil
|
reconnectTask = nil
|
||||||
@@ -179,18 +202,40 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
await stopBroadcast()
|
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 {
|
func applyThermalProfile(videoBitrate: Int, fps: Int) async {
|
||||||
guard var current = config else { return }
|
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.videoBitrate = videoBitrate
|
||||||
current.fps = fps
|
current.fps = fps
|
||||||
config = current
|
config = current
|
||||||
if pipelineConfigured, let stream = await rtmpSession?.stream {
|
if pipelineConfigured, let stream = await rtmpSession?.stream {
|
||||||
try? await applyCodecSettings(to: stream, config: current)
|
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 {
|
private func configurePipeline(_ config: BroadcastConfig) async throws {
|
||||||
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized,
|
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized,
|
||||||
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||||
@@ -316,7 +361,7 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
|
|
||||||
let stream = await session.stream
|
let stream = await session.stream
|
||||||
try await applyCodecSettings(to: stream, config: config)
|
try await applyCodecSettings(to: stream, config: config)
|
||||||
await stream.setBitRateStrategy(BroadcastBitrateMonitor { [weak self] kbps in
|
await stream.setBitRateStrategy(AdaptiveBroadcastBitRateStrategy(controller: adaptiveBitrate) { [weak self] kbps in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.metrics = BroadcastMetrics(
|
self.metrics = BroadcastMetrics(
|
||||||
@@ -349,10 +394,10 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
setPhase(.live)
|
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
|
var videoSettings = await stream.videoSettings
|
||||||
videoSettings.videoSize = .init(width: config.width, height: config.height)
|
videoSettings.videoSize = .init(width: config.width, height: config.height)
|
||||||
videoSettings.bitRate = config.videoBitrate
|
videoSettings.bitRate = videoBitrate ?? adaptiveBitrate.currentBitrate
|
||||||
videoSettings.maxKeyFrameIntervalDuration = 2
|
videoSettings.maxKeyFrameIntervalDuration = 2
|
||||||
videoSettings.profileLevel = config.height > 720
|
videoSettings.profileLevel = config.height > 720
|
||||||
? (kVTProfileLevel_H264_Baseline_4_1 as String)
|
? (kVTProfileLevel_H264_Baseline_4_1 as String)
|
||||||
|
|||||||
@@ -15,11 +15,17 @@ enum StreamVideoPreset {
|
|||||||
|
|
||||||
static func broadcastConfig(for session: StreamSession, rtmpUrl: String) -> BroadcastConfig {
|
static func broadcastConfig(for session: StreamSession, rtmpUrl: String) -> BroadcastConfig {
|
||||||
let dims = dimensions(for: session.qualityPreset)
|
let dims = dimensions(for: session.qualityPreset)
|
||||||
|
let ceiling = StreamQualityLadder.encoderCeiling(
|
||||||
|
sessionTarget: session.targetBitrate,
|
||||||
|
minPresetId: session.minQualityPreset
|
||||||
|
)
|
||||||
|
let floor = StreamQualityLadder.encoderFloor(minPresetId: session.minQualityPreset, ceiling: ceiling)
|
||||||
return BroadcastConfig(
|
return BroadcastConfig(
|
||||||
rtmpUrl: rtmpUrl,
|
rtmpUrl: rtmpUrl,
|
||||||
width: dims.width,
|
width: dims.width,
|
||||||
height: dims.height,
|
height: dims.height,
|
||||||
videoBitrate: session.targetBitrate,
|
videoBitrate: ceiling,
|
||||||
|
minVideoBitrate: floor,
|
||||||
audioBitrate: 128_000,
|
audioBitrate: 128_000,
|
||||||
fps: session.targetFps
|
fps: session.targetFps
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,8 +40,12 @@ struct BroadcastControlsOverlay: View {
|
|||||||
let bitrateKbps: Int
|
let bitrateKbps: Int
|
||||||
let networkType: String
|
let networkType: String
|
||||||
let deviceHealth: DeviceHealth
|
let deviceHealth: DeviceHealth
|
||||||
|
var minQualityPreset: String = StreamQualityLadder.auto
|
||||||
|
var sessionQualityPreset: String = "720p_30_2.5mbps"
|
||||||
|
var onSelectMinQuality: (String) -> Void = { _ in }
|
||||||
|
|
||||||
@State private var showTerminateConfirm = false
|
@State private var showTerminateConfirm = false
|
||||||
|
@State private var showMinQualityPicker = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Color.clear
|
Color.clear
|
||||||
@@ -69,7 +73,11 @@ struct BroadcastControlsOverlay: View {
|
|||||||
targetFps: targetFps,
|
targetFps: targetFps,
|
||||||
bitrateKbps: bitrateKbps,
|
bitrateKbps: bitrateKbps,
|
||||||
networkType: networkType,
|
networkType: networkType,
|
||||||
deviceHealth: deviceHealth
|
deviceHealth: deviceHealth,
|
||||||
|
minQualityLabel: StreamQualityLadder.displayName(
|
||||||
|
id: minQualityPreset,
|
||||||
|
autoLabel: L10n.t("broadcast.min.quality.auto")
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(.trailing, 8)
|
.padding(.trailing, 8)
|
||||||
@@ -94,6 +102,19 @@ struct BroadcastControlsOverlay: View {
|
|||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.overlay {
|
||||||
|
if showMinQualityPicker {
|
||||||
|
MinQualityPickerOverlay(
|
||||||
|
selectedId: minQualityPreset,
|
||||||
|
sessionQualityPreset: sessionQualityPreset,
|
||||||
|
onSelect: { id in
|
||||||
|
showMinQualityPicker = false
|
||||||
|
onSelectMinQuality(id)
|
||||||
|
},
|
||||||
|
onDismiss: { showMinQualityPicker = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
.alert(L10n.t("broadcast.terminate.title"), isPresented: $showTerminateConfirm) {
|
.alert(L10n.t("broadcast.terminate.title"), isPresented: $showTerminateConfirm) {
|
||||||
Button(L10n.t("action.cancel"), role: .cancel) {}
|
Button(L10n.t("action.cancel"), role: .cancel) {}
|
||||||
Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: onTerminate)
|
Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: onTerminate)
|
||||||
@@ -115,6 +136,11 @@ struct BroadcastControlsOverlay: View {
|
|||||||
accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
|
accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
|
||||||
action: onShareRegia
|
action: onShareRegia
|
||||||
)
|
)
|
||||||
|
SideIconButton(
|
||||||
|
systemName: "slider.horizontal.3",
|
||||||
|
accessibilityLabel: L10n.t("broadcast.min.quality.cd"),
|
||||||
|
action: { showMinQualityPicker = true }
|
||||||
|
)
|
||||||
if let onCloseSet {
|
if let onCloseSet {
|
||||||
SideIconButton(
|
SideIconButton(
|
||||||
systemName: "checkmark",
|
systemName: "checkmark",
|
||||||
@@ -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 {
|
private struct BroadcastTelemetryPanel: View {
|
||||||
let cableConnected: Bool
|
let cableConnected: Bool
|
||||||
let fps: Int
|
let fps: Int
|
||||||
@@ -195,6 +300,7 @@ private struct BroadcastTelemetryPanel: View {
|
|||||||
let bitrateKbps: Int
|
let bitrateKbps: Int
|
||||||
let networkType: String
|
let networkType: String
|
||||||
let deviceHealth: DeviceHealth
|
let deviceHealth: DeviceHealth
|
||||||
|
let minQualityLabel: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
@@ -211,6 +317,9 @@ private struct BroadcastTelemetryPanel: View {
|
|||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 11, weight: .medium))
|
||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
}
|
}
|
||||||
|
Text(minQualityLabel)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(MatchColors.accentYellow)
|
||||||
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
|
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 11, weight: .medium))
|
||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ struct BroadcastScreen: View {
|
|||||||
@State private var logoReady = 0
|
@State private var logoReady = 0
|
||||||
@State private var pauseInFlight = false
|
@State private var pauseInFlight = false
|
||||||
@State private var resumeInFlight = false
|
@State private var resumeInFlight = false
|
||||||
|
@State private var minQualityInFlight = false
|
||||||
@State private var deviceHealth = DeviceTelemetry.snapshot()
|
@State private var deviceHealth = DeviceTelemetry.snapshot()
|
||||||
@State private var snackbarMessage: String?
|
@State private var snackbarMessage: String?
|
||||||
@State private var shareItem: ShareItem?
|
@State private var shareItem: ShareItem?
|
||||||
@@ -83,9 +84,15 @@ struct BroadcastScreen: View {
|
|||||||
.task(id: sessionId) {
|
.task(id: sessionId) {
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
if let remote = try? await container.sessionRepository.fetchSession(id: sessionId).score {
|
guard !minQualityInFlight, !pauseInFlight, !resumeInFlight else { continue }
|
||||||
container.scoreController.applyRemote(remote, source: .polling)
|
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) {
|
.task(id: sessionId) {
|
||||||
@@ -290,7 +297,12 @@ struct BroadcastScreen: View {
|
|||||||
targetFps: session.targetFps,
|
targetFps: session.targetFps,
|
||||||
bitrateKbps: metrics.bitrateKbps,
|
bitrateKbps: metrics.bitrateKbps,
|
||||||
networkType: deviceHealth.networkType,
|
networkType: deviceHealth.networkType,
|
||||||
deviceHealth: DeviceTelemetry.snapshot(thermalState: thermalManager.state)
|
deviceHealth: DeviceTelemetry.snapshot(thermalState: thermalManager.state),
|
||||||
|
minQualityPreset: session.minQualityPreset,
|
||||||
|
sessionQualityPreset: session.qualityPreset,
|
||||||
|
onSelectMinQuality: { preset in
|
||||||
|
Task { await persistMinQuality(preset) }
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,6 +430,12 @@ struct BroadcastScreen: View {
|
|||||||
container.sessionCable.onStopStream = { [weak container] in
|
container.sessionCable.onStopStream = { [weak container] in
|
||||||
Task { @MainActor in await stopStream() }
|
Task { @MainActor in await stopStream() }
|
||||||
}
|
}
|
||||||
|
container.sessionCable.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)
|
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.
|
/// Pausa dalla regia / echo cable: solo RTMP locale, senza PATCH pause.
|
||||||
private func applyRemotePause(container: AppContainer) async {
|
private func applyRemotePause(container: AppContainer) async {
|
||||||
guard !pauseInFlight else { return }
|
guard !pauseInFlight else { return }
|
||||||
@@ -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 {
|
private func retryBroadcast() async {
|
||||||
error = nil
|
error = nil
|
||||||
guard let session, let url = session.rtmpIngestUrl, !url.isEmpty else { return }
|
guard let session, let url = session.rtmpIngestUrl, !url.isEmpty else { return }
|
||||||
@@ -567,6 +649,7 @@ struct BroadcastScreen: View {
|
|||||||
container.sessionCable.onPauseStream = nil
|
container.sessionCable.onPauseStream = nil
|
||||||
container.sessionCable.onResumeStream = nil
|
container.sessionCable.onResumeStream = nil
|
||||||
container.sessionCable.onStopStream = nil
|
container.sessionCable.onStopStream = nil
|
||||||
|
container.sessionCable.onMinQuality = nil
|
||||||
container.sessionCable.disconnect()
|
container.sessionCable.disconnect()
|
||||||
await container.broadcastCoordinator.stopBroadcast(sessionId: sessionId)
|
await container.broadcastCoordinator.stopBroadcast(sessionId: sessionId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user