Merge branch 'feature/adaptive-bitrate' into collaudo
Integra bitrate adattivo e telemetria, con il mute audio come icona nella toolbar della regia. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -59,6 +59,8 @@ class SessionChannel < ApplicationCable::Channel
|
||||
Sessions::SetAudioMute.new(session, muted: true).call
|
||||
when "unmute_audio"
|
||||
Sessions::SetAudioMute.new(session, muted: false).call
|
||||
when "set_min_quality"
|
||||
Sessions::SetMinQuality.new(session, preset: data["min_quality_preset"]).call
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ module Api
|
||||
current_bitrate: params[:current_bitrate],
|
||||
target_bitrate: params[:target_bitrate],
|
||||
fps: params[:fps],
|
||||
thermal_state: sanitized_thermal_state,
|
||||
last_seen_at: Time.current
|
||||
)
|
||||
sync_publisher_when_streaming!(params[:fps].to_f)
|
||||
@@ -147,6 +148,13 @@ module Api
|
||||
}
|
||||
end
|
||||
|
||||
def min_quality
|
||||
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
|
||||
render json: session_json(@session)
|
||||
rescue ArgumentError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def youtube_stats
|
||||
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
|
||||
Youtube::BroadcastService.new(@session.match.team)
|
||||
@@ -166,6 +174,11 @@ module Api
|
||||
.find(params[:id])
|
||||
end
|
||||
|
||||
def sanitized_thermal_state
|
||||
value = params[:thermal_state].to_s.downcase.presence
|
||||
value if value.in?(%w[nominal fair serious critical])
|
||||
end
|
||||
|
||||
def session_params
|
||||
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
|
||||
end
|
||||
@@ -194,6 +207,7 @@ module Api
|
||||
youtube_ready: session.youtube_ready?,
|
||||
privacy_status: session.privacy_status,
|
||||
quality_preset: session.quality_preset,
|
||||
min_quality_preset: session.min_quality_preset,
|
||||
target_bitrate: session.target_bitrate,
|
||||
started_at: session.started_at,
|
||||
disconnection_count: session.disconnection_count,
|
||||
|
||||
@@ -7,7 +7,7 @@ module Public
|
||||
layout "regia"
|
||||
|
||||
protect_from_forgery with: :null_session
|
||||
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop audio_mute]
|
||||
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop audio_mute min_quality]
|
||||
|
||||
before_action :set_session_from_token
|
||||
|
||||
@@ -67,8 +67,20 @@ module Public
|
||||
render json: status_payload
|
||||
end
|
||||
|
||||
def min_quality
|
||||
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
|
||||
render json: status_payload
|
||||
rescue ArgumentError => e
|
||||
render json: status_payload.merge(error: e.message), status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def camera_telemetry_payload(publisher_online)
|
||||
cam = @session.device_states.find_by(device_role: "camera")
|
||||
(cam&.as_regia_payload || {}).merge(publisher_online: publisher_online)
|
||||
end
|
||||
|
||||
def set_session_from_token
|
||||
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
|
||||
return if @session
|
||||
@@ -93,7 +105,10 @@ module Public
|
||||
home_name: @session.match.team.name,
|
||||
away_name: @session.match.opponent_name,
|
||||
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
|
||||
score: score&.as_cable_payload
|
||||
score: score&.as_cable_payload,
|
||||
min_quality_preset: @session.min_quality_preset,
|
||||
min_quality_options: Sessions::SelectQuality.allowed_min_ids(@session.quality_preset),
|
||||
telemetry: camera_telemetry_payload(publisher_online)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -57,8 +57,14 @@ module Public
|
||||
copyPrompt: t("regia.js.copy_prompt"),
|
||||
shareRegiaText: t("regia.js.share_regia_text"),
|
||||
shareLiveText: t("regia.js.share_live_text"),
|
||||
minQualityUpdated: t("regia.js.min_quality_updated"),
|
||||
minQualityError: t("regia.js.min_quality_error"),
|
||||
minQualityAuto: t("regia.min_quality.auto"),
|
||||
previewWaiting: t("regia.preview_waiting"),
|
||||
streamEnded: t("regia.preview_ended"),
|
||||
previewHide: t("regia.preview_hide"),
|
||||
previewShow: t("regia.preview_show"),
|
||||
previewOffHint: t("regia.preview_off_hint"),
|
||||
resumeLabel: t("regia.resume"),
|
||||
pauseLabel: t("regia.pause"),
|
||||
muteLabel: t("regia.mute"),
|
||||
@@ -67,7 +73,20 @@ module Public
|
||||
pausedBadge: t("regia.status.paused"),
|
||||
liveBadge: t("regia.status.live"),
|
||||
waitingBadge: t("regia.status.waiting"),
|
||||
subtitlePrefix: t("regia.subtitle_prefix")
|
||||
subtitlePrefix: t("regia.subtitle_prefix"),
|
||||
telemetryWaiting: t("regia.telemetry.waiting"),
|
||||
telemetryStale: t("regia.telemetry.stale"),
|
||||
telemetryJustNow: t("regia.telemetry.just_now"),
|
||||
telemetrySecondsAgo: t("regia.telemetry.seconds_ago"),
|
||||
telemetryPublisherOn: t("regia.telemetry.publisher_on"),
|
||||
telemetryPublisherOff: t("regia.telemetry.publisher_off"),
|
||||
telemetryThermalNominal: t("regia.telemetry.thermal_nominal"),
|
||||
telemetryThermalFair: t("regia.telemetry.thermal_fair"),
|
||||
telemetryThermalSerious: t("regia.telemetry.thermal_serious"),
|
||||
telemetryThermalCritical: t("regia.telemetry.thermal_critical"),
|
||||
telemetryBitrateMax: t("regia.telemetry.bitrate_max"),
|
||||
telemetryBitrateTitle: t("regia.telemetry.bitrate_title"),
|
||||
telemetryBitrateTap: t("regia.telemetry.bitrate_tap")
|
||||
}.to_json
|
||||
end
|
||||
|
||||
|
||||
@@ -16,4 +16,17 @@ class DeviceState < ApplicationRecord
|
||||
status: stream_session.status
|
||||
}
|
||||
end
|
||||
|
||||
def as_regia_payload
|
||||
{
|
||||
battery: battery_level,
|
||||
network: network_type,
|
||||
signal: signal_strength,
|
||||
bitrate: current_bitrate,
|
||||
target_bitrate: target_bitrate,
|
||||
fps: fps,
|
||||
thermal_state: thermal_state,
|
||||
last_seen_at: last_seen_at&.iso8601
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,6 +15,7 @@ class StreamSession < ApplicationRecord
|
||||
validates :platform, inclusion: { in: PLATFORMS }
|
||||
validates :status, inclusion: { in: STATUSES }
|
||||
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
|
||||
validates :min_quality_preset, inclusion: { in: Sessions::SelectQuality::MIN_QUALITY_IDS }
|
||||
|
||||
before_validation :normalize_privacy_status
|
||||
before_validation :ensure_publish_token, on: :create
|
||||
|
||||
@@ -9,11 +9,12 @@ module Sessions
|
||||
@session.pause! if @session.may_pause?
|
||||
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
||||
begin
|
||||
Mediamtx::Client.for_session(@session).set_path_recording(@session, enabled: false)
|
||||
mtx = Mediamtx::Client.for_session(@session)
|
||||
mtx.set_path_recording(@session, enabled: false)
|
||||
mtx.set_always_available(@session, enabled: true)
|
||||
rescue Mediamtx::Client::Error => e
|
||||
Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}")
|
||||
Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}")
|
||||
end
|
||||
Mediamtx::Client.for_session(@session).set_always_available(@session, enabled: true)
|
||||
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
|
||||
log_event("paused")
|
||||
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
||||
|
||||
@@ -3,6 +3,8 @@ module Sessions
|
||||
class SelectQuality
|
||||
UPLOAD_HEADROOM = 0.8
|
||||
UPLOAD_HEADROOM_1080P = 1.0
|
||||
AUTO_ID = "auto"
|
||||
AUTO_FLOOR_BITRATE = 800_000
|
||||
|
||||
PRESETS = [
|
||||
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
|
||||
@@ -11,6 +13,32 @@ module Sessions
|
||||
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
|
||||
].freeze
|
||||
|
||||
MIN_QUALITY_IDS = ([AUTO_ID] + PRESETS.map { |preset| preset[:id] }).freeze
|
||||
|
||||
def self.floor_bitrate(preset_id)
|
||||
return AUTO_FLOOR_BITRATE if preset_id.blank? || preset_id == AUTO_ID
|
||||
|
||||
PRESETS.find { |preset| preset[:id] == preset_id }&.fetch(:target_bitrate) || AUTO_FLOOR_BITRATE
|
||||
end
|
||||
|
||||
def self.allowed_min_ids(session_preset)
|
||||
ids = [AUTO_ID] + PRESETS.reject { |preset| preset[:id].start_with?("1080p") }.reverse.map { |preset| preset[:id] }
|
||||
ids << "1080p_30_4.5mbps" if session_preset.to_s.start_with?("1080p")
|
||||
ids
|
||||
end
|
||||
|
||||
def self.display_name(preset_id)
|
||||
return AUTO_ID if preset_id.blank? || preset_id == AUTO_ID
|
||||
|
||||
preset = PRESETS.find { |item| item[:id] == preset_id }
|
||||
return preset_id unless preset
|
||||
|
||||
resolution = preset[:id].start_with?("1080p") ? "1080p" : "720p"
|
||||
mbps = preset[:target_bitrate] / 1_000_000.0
|
||||
mbps_text = (mbps == mbps.to_i) ? mbps.to_i.to_s : format("%.1f", mbps)
|
||||
"#{resolution} · #{mbps_text} Mbps"
|
||||
end
|
||||
|
||||
def self.call(upload_mbps:)
|
||||
new(upload_mbps: upload_mbps).call
|
||||
end
|
||||
|
||||
@@ -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">
|
||||
<title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="/regia.css?v=1">
|
||||
<link rel="stylesheet" href="/regia.css?v=11">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
|
||||
data-audio-mute-url="<%= j public_regia_audio_mute_path(params[:token]) %>"
|
||||
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
|
||||
data-min-quality-url="<%= j public_regia_min_quality_path(params[:token]) %>"
|
||||
data-min-quality="<%= j @session.min_quality_preset %>"
|
||||
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
||||
data-hls-url="<%= j @session.hls_playback_url %>"
|
||||
data-home-name="<%= j @team.name %>"
|
||||
@@ -31,56 +33,122 @@
|
||||
data-initial-audio-muted="<%= @session.audio_muted %>">
|
||||
|
||||
<header class="regia-header">
|
||||
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
||||
<p id="regia-subtitle"><%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %></p>
|
||||
<div class="regia-header__text">
|
||||
<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')) %>">
|
||||
<%= @stream_closed ? t("regia.status.ended") : (@session.paused? ? t("regia.status.paused") : (@on_air ? t("regia.status.live") : t("regia.status.waiting"))) %>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<% if @live_share_url.present? %>
|
||||
<div class="regia-share-group">
|
||||
<p class="regia-share-label"><%= t("regia.live_share_label") %></p>
|
||||
<div class="regia-share">
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-live" style="margin:0">
|
||||
<%= t("regia.share_live") %>
|
||||
<nav class="regia-toolbar" aria-label="<%= t("regia.links_nav") %>">
|
||||
<% if @live_share_url.present? %>
|
||||
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.live_share_label") %>">
|
||||
<span class="regia-tool-group__label"><%= t("regia.live_share_label") %></span>
|
||||
<div class="regia-tool-group__btns">
|
||||
<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 type="button" class="regia-btn regia-btn--outline" id="btn-copy-live" style="margin:0">
|
||||
<%= t("regia.copy_link") %>
|
||||
<button type="button" class="regia-icon-btn" id="btn-copy-regia" aria-label="<%= t("regia.copy_regia") %>" title="<%= t("regia.copy_regia") %>">
|
||||
<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-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 %>
|
||||
<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<%= " is-muted" if @session.audio_muted %>" id="btn-audio-mute" aria-label="<%= @session.audio_muted ? t("regia.unmute") : t("regia.mute") %>" title="<%= @session.audio_muted ? t("regia.unmute") : t("regia.mute") %>">
|
||||
<svg class="icon-fill icon-mic" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5-3c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>
|
||||
<svg class="icon-fill icon-mic-off" viewBox="0 0 24 24" aria-hidden="true"><path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z"/></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 %>
|
||||
<div id="regia-preview-placeholder" class="regia-preview__placeholder">
|
||||
<%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %>
|
||||
</nav>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="regia-scoreboard">
|
||||
<%= render regia_board_partial(@match) %>
|
||||
</section>
|
||||
|
||||
<% unless @stream_closed %>
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause"><%= @session.paused? ? t("regia.resume") : t("regia.pause") %></button>
|
||||
<button type="button" class="regia-btn regia-btn--outline<%= @session.audio_muted ? ' is-muted' : '' %>" id="btn-audio-mute"><%= @session.audio_muted ? t("regia.unmute") : t("regia.mute") %></button>
|
||||
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop"><%= t("regia.stop") %></button>
|
||||
<section class="regia-telemetry" id="regia-telemetry" aria-label="<%= t("regia.telemetry.label") %>">
|
||||
<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 %>
|
||||
</div>
|
||||
|
||||
@@ -97,4 +165,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/regia.js?v=9"></script>
|
||||
<script src="/regia.js?v=19"></script>
|
||||
|
||||
@@ -62,8 +62,8 @@ Rails.application.configure do
|
||||
# Annotate rendered view with file names.
|
||||
config.action_view.annotate_rendered_view_with_filenames = true
|
||||
|
||||
# Uncomment if you wish to allow Action Cable access from any origin.
|
||||
# config.action_cable.disable_request_forgery_protection = true
|
||||
# Permette Action Cable dall'emulatore Android (10.0.2.2) e da altri host locali.
|
||||
config.action_cable.disable_request_forgery_protection = true
|
||||
|
||||
# Raise error when a before_action's only/except options reference missing actions.
|
||||
config.action_controller.raise_on_missing_callback_actions = true
|
||||
|
||||
@@ -432,18 +432,53 @@ de:
|
||||
paused: Pausiert
|
||||
live: Live
|
||||
waiting: Wartet
|
||||
live_share_label: "Live-Link (Zuschauer)"
|
||||
live_share_label: Live
|
||||
share_live: Livestream teilen
|
||||
share_action: Teilen
|
||||
copy_link: Link kopieren
|
||||
regia_share_label: "Regie-Link (Punktestand)"
|
||||
copy_action: Kopieren
|
||||
copy_live: Live kopieren
|
||||
copy_regia: Regie kopieren
|
||||
links_nav: Schnellaktionen
|
||||
regia_share_label: Regie
|
||||
stream_controls_label: Live
|
||||
share_regia: Regie-Link teilen
|
||||
preview_ended: Livestream beendet
|
||||
preview_waiting: Vorschau wartet auf Signal…
|
||||
preview_hide: Vorschau ausblenden
|
||||
preview_show: Vorschau anzeigen
|
||||
preview_off_hint: Vorschau aus · kein Video-Download
|
||||
pause: Pausieren
|
||||
resume: Livestream fortsetzen
|
||||
mute: Audio stummschalten
|
||||
unmute: Audio einschalten
|
||||
stop: Livestream beenden
|
||||
min_quality:
|
||||
label: Minimale Übertragungsqualität
|
||||
summary: Qualität
|
||||
hint: Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.
|
||||
recommend: "Automatisch wird empfohlen: passt sich an Netz und Telefon an, ohne das Maximum zu erzwingen."
|
||||
auto: Automatisch
|
||||
telemetry:
|
||||
label: Telefon
|
||||
waiting: Warte auf das Telefon…
|
||||
stale: Telefon antwortet nicht
|
||||
just_now: jetzt
|
||||
seconds_ago: "vor %{n}s"
|
||||
publisher_on: RTMP ok
|
||||
publisher_off: RTMP fehlt
|
||||
battery: Akku
|
||||
network: Netz
|
||||
bitrate: Bitrate
|
||||
bitrate_max: "max %{n}"
|
||||
bitrate_title: "%{current} Mbps aktuell · max. %{target} Mbps"
|
||||
bitrate_tap: Tippen, um die Mindestqualität festzulegen
|
||||
fps: FPS
|
||||
thermal: Temp.
|
||||
thermal_nominal: OK
|
||||
thermal_fair: Warm
|
||||
thermal_serious: Heiß
|
||||
thermal_critical: Kritisch
|
||||
modal:
|
||||
set_won_title: Satz gewonnen
|
||||
confirm: Satz beenden
|
||||
@@ -491,6 +526,8 @@ de:
|
||||
copy_prompt: "Link kopieren:"
|
||||
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
|
||||
share_live_text: "Sieh dir die Übertragung auf Match Live TV an:"
|
||||
min_quality_updated: Mindestqualität aktualisiert
|
||||
min_quality_error: Fehler beim Aktualisieren der Mindestqualität
|
||||
recordings:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
|
||||
@@ -427,18 +427,53 @@ en:
|
||||
paused: Paused
|
||||
live: Live
|
||||
waiting: Waiting
|
||||
live_share_label: "Live link (viewers)"
|
||||
live_share_label: Live
|
||||
share_live: Share live link
|
||||
share_action: Share
|
||||
copy_link: Copy link
|
||||
regia_share_label: "Control room link (score)"
|
||||
copy_action: Copy
|
||||
copy_live: Copy live
|
||||
copy_regia: Copy room
|
||||
links_nav: Quick actions
|
||||
regia_share_label: Control
|
||||
stream_controls_label: Live
|
||||
share_regia: Share control room link
|
||||
preview_ended: Stream ended
|
||||
preview_waiting: Waiting for signal…
|
||||
preview_hide: Hide preview
|
||||
preview_show: Show preview
|
||||
preview_off_hint: Preview off · no video download
|
||||
pause: Pause stream
|
||||
resume: Resume stream
|
||||
mute: Mute audio
|
||||
unmute: Unmute audio
|
||||
stop: End stream
|
||||
min_quality:
|
||||
label: Minimum streaming quality
|
||||
summary: Quality
|
||||
hint: The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.
|
||||
recommend: "Automatic is recommended: it adapts to the network and the phone, without forcing maximum quality."
|
||||
auto: Automatic
|
||||
telemetry:
|
||||
label: Phone
|
||||
waiting: Waiting for the phone…
|
||||
stale: Phone not responding
|
||||
just_now: now
|
||||
seconds_ago: "%{n}s ago"
|
||||
publisher_on: RTMP ok
|
||||
publisher_off: RTMP down
|
||||
battery: Battery
|
||||
network: Network
|
||||
bitrate: Bitrate
|
||||
bitrate_max: "max %{n}"
|
||||
bitrate_title: "%{current} Mbps current · max %{target} Mbps"
|
||||
bitrate_tap: Tap to set the minimum quality
|
||||
fps: FPS
|
||||
thermal: Temp.
|
||||
thermal_nominal: OK
|
||||
thermal_fair: Warm
|
||||
thermal_serious: Hot
|
||||
thermal_critical: Critical
|
||||
modal:
|
||||
set_won_title: Set won
|
||||
confirm: Close set
|
||||
@@ -486,6 +521,8 @@ en:
|
||||
copy_prompt: "Copy the link:"
|
||||
share_regia_text: "Open this link to manage the live score:"
|
||||
share_live_text: "Watch the live stream on Match Live TV:"
|
||||
min_quality_updated: Minimum quality updated
|
||||
min_quality_error: Error updating minimum quality
|
||||
recordings:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
|
||||
@@ -432,18 +432,53 @@ es:
|
||||
paused: En pausa
|
||||
live: En directo
|
||||
waiting: En espera
|
||||
live_share_label: "Enlace del directo (espectadores)"
|
||||
live_share_label: Directo
|
||||
share_live: Compartir directo
|
||||
share_action: Compartir
|
||||
copy_link: Copiar enlace
|
||||
regia_share_label: "Enlace de control (marcador)"
|
||||
copy_action: Copiar
|
||||
copy_live: Copiar directo
|
||||
copy_regia: Copiar control
|
||||
links_nav: Acciones rápidas
|
||||
regia_share_label: Control
|
||||
stream_controls_label: Live
|
||||
share_regia: Compartir enlace de control
|
||||
preview_ended: Directo finalizado
|
||||
preview_waiting: Vista previa a la espera de señal…
|
||||
preview_hide: Ocultar vista previa
|
||||
preview_show: Mostrar vista previa
|
||||
preview_off_hint: Vista previa apagada · sin descarga de vídeo
|
||||
pause: Pausar directo
|
||||
resume: Reanudar directo
|
||||
mute: Silenciar audio
|
||||
unmute: Reactivar audio
|
||||
stop: Finalizar directo
|
||||
min_quality:
|
||||
label: Calidad mínima de transmisión
|
||||
summary: Calidad
|
||||
hint: El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.
|
||||
recommend: "Se recomienda Automática: se adapta a la red y al teléfono, sin forzar el máximo."
|
||||
auto: Automática
|
||||
telemetry:
|
||||
label: Teléfono
|
||||
waiting: Esperando al teléfono…
|
||||
stale: El teléfono no responde
|
||||
just_now: ahora
|
||||
seconds_ago: "hace %{n}s"
|
||||
publisher_on: RTMP ok
|
||||
publisher_off: RTMP ausente
|
||||
battery: Batería
|
||||
network: Red
|
||||
bitrate: Bitrate
|
||||
bitrate_max: "máx %{n}"
|
||||
bitrate_title: "%{current} Mbps actuales · máximo %{target} Mbps"
|
||||
bitrate_tap: Toca para definir la calidad mínima
|
||||
fps: FPS
|
||||
thermal: Temp.
|
||||
thermal_nominal: OK
|
||||
thermal_fair: Caliente
|
||||
thermal_serious: Muy caliente
|
||||
thermal_critical: Crítica
|
||||
modal:
|
||||
set_won_title: Set ganado
|
||||
confirm: Cerrar set
|
||||
@@ -491,6 +526,8 @@ es:
|
||||
copy_prompt: "Copia el enlace:"
|
||||
share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
|
||||
share_live_text: "Mira la retransmisión en Match Live TV:"
|
||||
min_quality_updated: Calidad mínima actualizada
|
||||
min_quality_error: Error al actualizar la calidad mínima
|
||||
recordings:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
|
||||
@@ -432,18 +432,53 @@ fr:
|
||||
paused: En pause
|
||||
live: En direct
|
||||
waiting: En attente
|
||||
live_share_label: "Lien direct (spectateurs)"
|
||||
live_share_label: Direct
|
||||
share_live: Partager le direct
|
||||
share_action: Partager
|
||||
copy_link: Copier le lien
|
||||
regia_share_label: "Lien régie (score)"
|
||||
copy_action: Copier
|
||||
copy_live: Copier live
|
||||
copy_regia: Copier régie
|
||||
links_nav: Actions rapides
|
||||
regia_share_label: Régie
|
||||
stream_controls_label: Live
|
||||
share_regia: Partager le lien régie
|
||||
preview_ended: Direct terminé
|
||||
preview_waiting: Aperçu en attente du signal…
|
||||
preview_hide: Masquer l'aperçu
|
||||
preview_show: Afficher l'aperçu
|
||||
preview_off_hint: Aperçu désactivé · aucun téléchargement vidéo
|
||||
pause: Mettre en pause
|
||||
resume: Reprendre le direct
|
||||
mute: Couper l'audio
|
||||
unmute: Réactiver l'audio
|
||||
stop: Terminer le direct
|
||||
min_quality:
|
||||
label: Qualité minimale de transmission
|
||||
summary: Qualité
|
||||
hint: L'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.
|
||||
recommend: "Automatique est conseillé : s'adapte au réseau et au téléphone, sans forcer le maximum."
|
||||
auto: Automatique
|
||||
telemetry:
|
||||
label: Téléphone
|
||||
waiting: En attente du téléphone…
|
||||
stale: Téléphone sans réponse
|
||||
just_now: maintenant
|
||||
seconds_ago: "il y a %{n}s"
|
||||
publisher_on: RTMP ok
|
||||
publisher_off: RTMP absent
|
||||
battery: Batterie
|
||||
network: Réseau
|
||||
bitrate: Débit
|
||||
bitrate_max: "max %{n}"
|
||||
bitrate_title: "%{current} Mbps actuels · max %{target} Mbps"
|
||||
bitrate_tap: Touchez pour définir la qualité minimale
|
||||
fps: FPS
|
||||
thermal: Temp.
|
||||
thermal_nominal: OK
|
||||
thermal_fair: Chaude
|
||||
thermal_serious: Très chaude
|
||||
thermal_critical: Critique
|
||||
modal:
|
||||
set_won_title: Set gagné
|
||||
confirm: Terminer le set
|
||||
@@ -491,6 +526,8 @@ fr:
|
||||
copy_prompt: "Copiez le lien :"
|
||||
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
|
||||
share_live_text: "Regardez le direct sur Match Live TV :"
|
||||
min_quality_updated: Qualité minimale mise à jour
|
||||
min_quality_error: Erreur de mise à jour de la qualité minimale
|
||||
recordings:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
|
||||
@@ -427,18 +427,53 @@ it:
|
||||
paused: In pausa
|
||||
live: In onda
|
||||
waiting: In attesa
|
||||
live_share_label: "Link diretta (spettatori)"
|
||||
live_share_label: Diretta
|
||||
share_live: Condividi diretta
|
||||
share_action: Condividi
|
||||
copy_link: Copia link
|
||||
regia_share_label: "Link regia (punteggio)"
|
||||
copy_action: Copia
|
||||
copy_live: Copia live
|
||||
copy_regia: Copia regia
|
||||
links_nav: Azioni rapide
|
||||
regia_share_label: Regia
|
||||
stream_controls_label: Live
|
||||
share_regia: Condividi link regia
|
||||
preview_ended: Diretta terminata
|
||||
preview_waiting: Anteprima in attesa del segnale…
|
||||
preview_hide: Nascondi anteprima
|
||||
preview_show: Mostra anteprima
|
||||
preview_off_hint: Anteprima spenta · nessun download video
|
||||
pause: Metti in pausa
|
||||
resume: Riprendi diretta
|
||||
mute: Silenzia audio
|
||||
unmute: Riattiva audio
|
||||
stop: Chiudi diretta
|
||||
min_quality:
|
||||
label: Qualità minima di trasmissione
|
||||
summary: Qualità
|
||||
hint: L'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.
|
||||
recommend: "Consigliata Automatica: si adatta alla rete e al telefono, senza forzare il massimo."
|
||||
auto: Automatica
|
||||
telemetry:
|
||||
label: Telefono
|
||||
waiting: In attesa del telefono…
|
||||
stale: Telefono non risponde
|
||||
just_now: ora
|
||||
seconds_ago: "%{n}s fa"
|
||||
publisher_on: RTMP ok
|
||||
publisher_off: RTMP assente
|
||||
battery: Batteria
|
||||
network: Rete
|
||||
bitrate: Bitrate
|
||||
bitrate_max: "max %{n}"
|
||||
bitrate_title: "%{current} Mbps attuali · massimo %{target} Mbps"
|
||||
bitrate_tap: Tocca per impostare la qualità minima
|
||||
fps: FPS
|
||||
thermal: Temp.
|
||||
thermal_nominal: OK
|
||||
thermal_fair: Calda
|
||||
thermal_serious: Molto calda
|
||||
thermal_critical: Critica
|
||||
modal:
|
||||
set_won_title: Set vinto
|
||||
confirm: Chiudi set
|
||||
@@ -486,6 +521,8 @@ it:
|
||||
copy_prompt: "Copia il link:"
|
||||
share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
|
||||
share_live_text: "Guarda la diretta su Match Live TV:"
|
||||
min_quality_updated: Qualità minima aggiornata
|
||||
min_quality_error: Errore aggiornamento qualità minima
|
||||
recordings:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
|
||||
@@ -56,6 +56,7 @@ Rails.application.routes.draw do
|
||||
post :claim_pairing
|
||||
post :regia_link
|
||||
post :network_test
|
||||
patch :min_quality
|
||||
get :youtube_stats
|
||||
end
|
||||
end
|
||||
@@ -154,6 +155,7 @@ Rails.application.routes.draw do
|
||||
post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume
|
||||
post "regia/:token/audio_mute", to: "public/regia#audio_mute", as: :public_regia_audio_mute
|
||||
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
|
||||
patch "regia/:token/min_quality", to: "public/regia#min_quality", as: :public_regia_min_quality
|
||||
|
||||
root to: "public/pages#home"
|
||||
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
|
||||
|
||||
@@ -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
+3
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_08_12_220000) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -138,6 +138,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_12_220000) do
|
||||
t.datetime "last_seen_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "thermal_state"
|
||||
t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true
|
||||
t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id"
|
||||
end
|
||||
@@ -305,6 +306,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_12_220000) do
|
||||
t.datetime "regia_token_expires_at"
|
||||
t.uuid "stream_node_id"
|
||||
t.boolean "audio_muted", default: false, null: false
|
||||
t.string "min_quality_preset", default: "auto", null: false
|
||||
t.index ["match_id"], name: "index_stream_sessions_on_match_id"
|
||||
t.index ["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
|
||||
|
||||
+287
-41
@@ -4,6 +4,8 @@
|
||||
--regia-surface: #14141c;
|
||||
--regia-red: #e53935;
|
||||
--regia-yellow: #f9a825;
|
||||
--regia-green: #43a047;
|
||||
--regia-orange: #fb8c00;
|
||||
--regia-text: #f5f5f5;
|
||||
--regia-muted: #9ca3af;
|
||||
--regia-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
@@ -22,37 +24,176 @@
|
||||
.regia-page {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 16px calc(24px + var(--regia-safe-bottom));
|
||||
padding: 8px 12px calc(12px + var(--regia-safe-bottom));
|
||||
}
|
||||
|
||||
.regia-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.regia-header__text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.regia-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.regia-header p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.85rem;
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--regia-muted);
|
||||
}
|
||||
|
||||
.regia-badge {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
padding: 4px 10px;
|
||||
margin-top: 0;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.regia-badge--live { background: #2e7d32; color: #fff; }
|
||||
.regia-badge--wait { background: #444; color: #ddd; }
|
||||
.regia-badge--ended { background: #374151; color: #ddd; }
|
||||
|
||||
.regia-toolbar {
|
||||
margin: 8px 0 6px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.regia-tool-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.regia-tool-group__label {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--regia-muted);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.regia-tool-group__btns {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.regia-tool-group--stream {
|
||||
margin-left: auto;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid #333;
|
||||
}
|
||||
|
||||
.regia-icon-btn {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid #333;
|
||||
border-radius: 10px;
|
||||
background: #16161f;
|
||||
color: var(--regia-text);
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.regia-icon-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.regia-icon-btn svg.icon-fill {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.regia-icon-btn svg.icon-outline {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.regia-icon-btn .icon-eye-off,
|
||||
.regia-icon-btn .icon-play,
|
||||
.regia-icon-btn .icon-mic-off {
|
||||
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.is-muted .icon-mic {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.regia-icon-btn.is-muted .icon-mic-off {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.regia-icon-btn:active { transform: scale(0.96); }
|
||||
|
||||
.regia-icon-btn.is-off,
|
||||
.regia-icon-btn.is-muted {
|
||||
color: var(--regia-yellow);
|
||||
border-color: #5a4a1a;
|
||||
}
|
||||
|
||||
.regia-icon-btn--danger {
|
||||
color: var(--regia-red);
|
||||
border-color: #7a2a28;
|
||||
}
|
||||
|
||||
.regia-preview-wrap {
|
||||
margin: 6px 0 10px;
|
||||
}
|
||||
|
||||
.regia-preview-wrap.is-off {
|
||||
margin: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.regia-preview {
|
||||
margin: 14px 0;
|
||||
margin: 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
@@ -90,29 +231,29 @@
|
||||
.regia-scoreboard {
|
||||
background: var(--regia-surface);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.regia-sets {
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--regia-muted);
|
||||
margin: 0 0 8px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.regia-score-line {
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.regia-partials {
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--regia-muted);
|
||||
margin: 8px 0 0;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.regia-team-row {
|
||||
@@ -120,7 +261,7 @@
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.regia-team-name {
|
||||
@@ -131,7 +272,7 @@
|
||||
}
|
||||
|
||||
.regia-points {
|
||||
font-size: 2rem;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -146,7 +287,7 @@
|
||||
.regia-btn {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 16px 12px;
|
||||
padding: 10px 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
@@ -156,8 +297,8 @@
|
||||
|
||||
.regia-btn:active { transform: scale(0.98); }
|
||||
.regia-btn--point { background: var(--regia-red); color: #fff; }
|
||||
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 12px; }
|
||||
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 10px; }
|
||||
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 8px; }
|
||||
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 8px; padding: 12px; }
|
||||
.regia-btn--outline {
|
||||
background: transparent;
|
||||
border: 1px solid #444;
|
||||
@@ -177,26 +318,6 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.regia-share-group {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.regia-share-label {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--regia-muted);
|
||||
}
|
||||
|
||||
.regia-share {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.regia-share .regia-btn { flex: 1; font-size: 0.9rem; padding: 12px; }
|
||||
|
||||
.regia-toast {
|
||||
position: fixed;
|
||||
bottom: calc(16px + var(--regia-safe-bottom));
|
||||
@@ -244,7 +365,7 @@
|
||||
text-align: center;
|
||||
color: var(--regia-muted);
|
||||
font-size: 0.72rem;
|
||||
padding-top: 24px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.regia-clock-line {
|
||||
@@ -259,3 +380,128 @@
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.regia-quality {
|
||||
margin: 6px 0 0;
|
||||
padding: 8px 10px 10px;
|
||||
background: var(--regia-surface);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.regia-quality[hidden] { display: none !important; }
|
||||
|
||||
.regia-quality__hint {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.35;
|
||||
color: var(--regia-muted);
|
||||
}
|
||||
|
||||
.regia-quality label {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
color: var(--regia-muted);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.regia-quality select {
|
||||
width: 100%;
|
||||
background: #0a0a0e;
|
||||
color: var(--regia-text);
|
||||
border: 1px solid #2a2a35;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.regia-telemetry {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.regia-telemetry__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.regia-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
padding: 6px 2px 5px;
|
||||
border-radius: 8px;
|
||||
background: var(--regia-surface);
|
||||
color: var(--regia-text);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.regia-chip--action {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.regia-chip--action:focus-visible {
|
||||
outline: 2px solid var(--regia-yellow);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.regia-chip--action.is-open {
|
||||
border-color: #3a3a48;
|
||||
}
|
||||
|
||||
.regia-chip svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.regia-chip__label {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--regia-muted);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.regia-chip__value {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.regia-chip__hint {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
color: var(--regia-muted);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.regia-chip__hint[hidden] { display: none; }
|
||||
|
||||
.regia-chip.is-ok { color: var(--regia-green); }
|
||||
.regia-chip.is-warn { color: var(--regia-orange); }
|
||||
.regia-chip.is-bad { color: var(--regia-red); }
|
||||
|
||||
.regia-telemetry__meta {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.65rem;
|
||||
color: var(--regia-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.regia-telemetry.is-stale .regia-telemetry__meta {
|
||||
color: var(--regia-yellow);
|
||||
}
|
||||
|
||||
+265
-6
@@ -40,6 +40,9 @@
|
||||
shareLiveText: "Guarda la diretta su Match Live TV:",
|
||||
previewWaiting: "Anteprima in attesa del segnale…",
|
||||
streamEnded: "Diretta terminata",
|
||||
previewHide: "Nascondi anteprima",
|
||||
previewShow: "Mostra anteprima",
|
||||
previewOffHint: "Anteprima spenta · nessun download video",
|
||||
resumeLabel: "Riprendi diretta",
|
||||
pauseLabel: "Metti in pausa",
|
||||
muteLabel: "Silenzia audio",
|
||||
@@ -48,7 +51,23 @@
|
||||
pausedBadge: "In pausa",
|
||||
liveBadge: "In onda",
|
||||
waitingBadge: "In attesa",
|
||||
subtitlePrefix: "Regia ·"
|
||||
subtitlePrefix: "Regia ·",
|
||||
minQualityUpdated: "Qualità minima aggiornata",
|
||||
minQualityError: "Errore aggiornamento qualità minima",
|
||||
minQualityAuto: "Automatica",
|
||||
telemetryWaiting: "In attesa del telefono…",
|
||||
telemetryStale: "Telefono non risponde",
|
||||
telemetryJustNow: "ora",
|
||||
telemetrySecondsAgo: "%{n}s fa",
|
||||
telemetryPublisherOn: "RTMP ok",
|
||||
telemetryPublisherOff: "RTMP assente",
|
||||
telemetryThermalNominal: "OK",
|
||||
telemetryThermalFair: "Calda",
|
||||
telemetryThermalSerious: "Calda",
|
||||
telemetryThermalCritical: "Critica",
|
||||
telemetryBitrateMax: "max %{n}",
|
||||
telemetryBitrateTitle: "%{current} Mbps attuali · massimo %{target} Mbps",
|
||||
telemetryBitrateTap: "Tocca per impostare la qualità minima"
|
||||
};
|
||||
|
||||
function loadI18n() {
|
||||
@@ -83,6 +102,7 @@
|
||||
resumeUrl: root.dataset.resumeUrl,
|
||||
audioMuteUrl: root.dataset.audioMuteUrl,
|
||||
stopUrl: root.dataset.stopUrl,
|
||||
minQualityUrl: root.dataset.minQualityUrl,
|
||||
cableUrl: root.dataset.cableUrl,
|
||||
hlsUrl: root.dataset.hlsUrl,
|
||||
homeName: root.dataset.homeName,
|
||||
@@ -108,17 +128,30 @@
|
||||
modalCancel: document.getElementById("modal-cancel"),
|
||||
preview: document.getElementById("regia-preview"),
|
||||
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
||||
previewWrap: document.getElementById("regia-preview-wrap"),
|
||||
btnTogglePreview: document.getElementById("btn-toggle-preview"),
|
||||
btnPause: document.getElementById("btn-pause"),
|
||||
btnAudioMute: document.getElementById("btn-audio-mute"),
|
||||
subtitle: document.getElementById("regia-subtitle")
|
||||
minQuality: document.getElementById("min-quality"),
|
||||
subtitle: document.getElementById("regia-subtitle"),
|
||||
telemetry: document.getElementById("regia-telemetry"),
|
||||
telBattery: document.getElementById("tel-battery"),
|
||||
telNetwork: document.getElementById("tel-network"),
|
||||
telBitrate: document.getElementById("tel-bitrate"),
|
||||
telFps: document.getElementById("tel-fps"),
|
||||
telThermal: document.getElementById("tel-thermal"),
|
||||
telMeta: document.getElementById("tel-meta"),
|
||||
qualityPanel: document.getElementById("regia-quality")
|
||||
};
|
||||
|
||||
let pendingAction = null;
|
||||
let hls = null;
|
||||
let previewStarted = false;
|
||||
let previewWanted = true;
|
||||
let wasPausedPreview = false;
|
||||
let streamPaused = root.dataset.initialPaused === "true";
|
||||
let audioMuted = root.dataset.initialAudioMuted === "true";
|
||||
const PREVIEW_PREF_KEY = "mltv-regia-preview";
|
||||
|
||||
function hlsUrlFresh() {
|
||||
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
|
||||
@@ -147,8 +180,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
function readPreviewPref() {
|
||||
try {
|
||||
return localStorage.getItem(PREVIEW_PREF_KEY) !== "0";
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function writePreviewPref(on) {
|
||||
try {
|
||||
localStorage.setItem(PREVIEW_PREF_KEY, on ? "1" : "0");
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function syncPreviewToggleUi() {
|
||||
if (!els.previewWrap || !els.btnTogglePreview) return;
|
||||
const label = previewWanted ? I18N.previewHide : I18N.previewShow;
|
||||
els.previewWrap.classList.toggle("is-off", !previewWanted);
|
||||
els.btnTogglePreview.classList.toggle("is-off", !previewWanted);
|
||||
els.btnTogglePreview.setAttribute("aria-pressed", previewWanted ? "true" : "false");
|
||||
els.btnTogglePreview.setAttribute("aria-label", label);
|
||||
els.btnTogglePreview.title = previewWanted ? I18N.previewHide : I18N.previewOffHint;
|
||||
}
|
||||
|
||||
function setPreviewWanted(on) {
|
||||
previewWanted = !!on;
|
||||
writePreviewPref(previewWanted);
|
||||
syncPreviewToggleUi();
|
||||
if (previewWanted) {
|
||||
startPreview();
|
||||
} else {
|
||||
destroyPreview();
|
||||
}
|
||||
}
|
||||
|
||||
function refreshPreviewForCover() {
|
||||
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed) return;
|
||||
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed || !previewWanted) return;
|
||||
destroyPreview();
|
||||
previewStarted = false;
|
||||
startPreview();
|
||||
@@ -251,7 +319,10 @@
|
||||
if (!els.btnPause) return;
|
||||
const paused = !!(data.paused || data.status === "paused");
|
||||
streamPaused = paused;
|
||||
els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel;
|
||||
const label = paused ? I18N.resumeLabel : I18N.pauseLabel;
|
||||
els.btnPause.classList.toggle("is-paused", paused);
|
||||
els.btnPause.setAttribute("aria-label", label);
|
||||
els.btnPause.title = label;
|
||||
}
|
||||
|
||||
function syncMuteButton(data) {
|
||||
@@ -259,8 +330,10 @@
|
||||
if (typeof data.audio_muted === "boolean") {
|
||||
audioMuted = data.audio_muted;
|
||||
}
|
||||
els.btnAudioMute.textContent = audioMuted ? I18N.unmuteLabel : I18N.muteLabel;
|
||||
const label = audioMuted ? I18N.unmuteLabel : I18N.muteLabel;
|
||||
els.btnAudioMute.classList.toggle("is-muted", audioMuted);
|
||||
els.btnAudioMute.setAttribute("aria-label", label);
|
||||
els.btnAudioMute.title = label;
|
||||
}
|
||||
|
||||
function setBadge(data) {
|
||||
@@ -382,6 +455,11 @@
|
||||
}
|
||||
|
||||
const paused = data.paused || data.status === "paused";
|
||||
if (!previewWanted) {
|
||||
destroyPreview();
|
||||
wasPausedPreview = paused;
|
||||
return;
|
||||
}
|
||||
if (data.on_air) {
|
||||
if (!previewStarted) {
|
||||
startPreview();
|
||||
@@ -400,6 +478,119 @@
|
||||
wasPausedPreview = paused;
|
||||
}
|
||||
|
||||
function mbpsNumber(bps) {
|
||||
const n = Number(bps);
|
||||
if (!n || n <= 0) return null;
|
||||
const mbps = n >= 10_000 ? n / 1e6 : n / 1e3;
|
||||
return mbps >= 10 ? mbps.toFixed(0) : mbps.toFixed(1);
|
||||
}
|
||||
|
||||
function formatAge(iso) {
|
||||
if (!iso) return null;
|
||||
const then = Date.parse(iso);
|
||||
if (!then) return null;
|
||||
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
|
||||
if (secs < 8) return I18N.telemetryJustNow;
|
||||
return fillTemplate(I18N.telemetrySecondsAgo, { n: secs });
|
||||
}
|
||||
|
||||
function setChip(el, text, level, hint) {
|
||||
if (!el) return;
|
||||
const valueEl = el.querySelector(".regia-chip__value") || el;
|
||||
valueEl.textContent = text;
|
||||
el.classList.toggle("is-ok", level === "ok");
|
||||
el.classList.toggle("is-warn", level === "warn");
|
||||
el.classList.toggle("is-bad", level === "bad");
|
||||
const hintEl = el.querySelector(".regia-chip__hint");
|
||||
if (hintEl) {
|
||||
hintEl.textContent = hint || "";
|
||||
hintEl.hidden = !hint;
|
||||
}
|
||||
}
|
||||
|
||||
function formatThermal(state) {
|
||||
const key = String(state || "").toLowerCase();
|
||||
if (key === "nominal") return { text: I18N.telemetryThermalNominal, level: "ok" };
|
||||
if (key === "fair") return { text: I18N.telemetryThermalFair, level: "warn" };
|
||||
if (key === "serious") return { text: I18N.telemetryThermalSerious, level: "bad" };
|
||||
if (key === "critical") return { text: I18N.telemetryThermalCritical, level: "bad" };
|
||||
return { text: "—", level: null };
|
||||
}
|
||||
|
||||
function bitrateLevel(current, target, paused) {
|
||||
if (paused || !current || current <= 0) return null;
|
||||
if (target && target > 0) {
|
||||
const ratio = current / target;
|
||||
if (ratio >= 0.7) return "ok";
|
||||
if (ratio >= 0.4) return "warn";
|
||||
return "bad";
|
||||
}
|
||||
const mbps = current >= 10_000 ? current / 1e6 : current / 1e3;
|
||||
if (mbps >= 2.5) return "ok";
|
||||
if (mbps >= 1.5) return "warn";
|
||||
return "bad";
|
||||
}
|
||||
|
||||
function bitrateTitle(currentMbps, targetMbps) {
|
||||
const tap = I18N.telemetryBitrateTap;
|
||||
if (currentMbps && targetMbps) {
|
||||
return `${fillTemplate(I18N.telemetryBitrateTitle, { current: currentMbps, target: targetMbps })} · ${tap}`;
|
||||
}
|
||||
if (currentMbps) return `${currentMbps} Mbps · ${tap}`;
|
||||
return tap;
|
||||
}
|
||||
|
||||
function syncTelemetry(data) {
|
||||
if (!els.telemetry) return;
|
||||
const tel = data.telemetry || {};
|
||||
const battery = tel.battery;
|
||||
const fps = tel.fps;
|
||||
const bitrate = tel.bitrate;
|
||||
const target = tel.target_bitrate || tel.targetBitrate;
|
||||
const lastSeen = tel.last_seen_at || tel.lastSeenAt;
|
||||
const ageSecs = lastSeen ? Math.max(0, Math.round((Date.now() - Date.parse(lastSeen)) / 1000)) : null;
|
||||
const stale = ageSecs == null || ageSecs > 25;
|
||||
const publishing = !!tel.publisher_online || !!tel.publisherOnline;
|
||||
|
||||
let battLevel = null;
|
||||
let battText = "—";
|
||||
if (battery != null && battery !== "") {
|
||||
battText = `${battery}%`;
|
||||
if (battery <= 15) battLevel = "bad";
|
||||
else if (battery <= 25) battLevel = "warn";
|
||||
}
|
||||
setChip(els.telBattery, battText, battLevel);
|
||||
setChip(els.telNetwork, tel.network || "—");
|
||||
|
||||
const currentMbps = mbpsNumber(bitrate);
|
||||
const targetMbps = mbpsNumber(target);
|
||||
const rateText = currentMbps || "—";
|
||||
const rateHint = currentMbps && targetMbps
|
||||
? fillTemplate(I18N.telemetryBitrateMax, { n: targetMbps })
|
||||
: null;
|
||||
if (els.telBitrate) els.telBitrate.title = bitrateTitle(currentMbps, targetMbps);
|
||||
setChip(els.telBitrate, rateText, bitrateLevel(Number(bitrate), Number(target), data.paused), rateHint);
|
||||
|
||||
let fpsLevel = null;
|
||||
if (fps != null && fps !== "" && Number(fps) > 0 && Number(fps) < 20) fpsLevel = "warn";
|
||||
setChip(els.telFps, fps && Number(fps) > 0 ? String(fps) : "—", fpsLevel);
|
||||
|
||||
const thermal = formatThermal(tel.thermal_state || tel.thermalState);
|
||||
setChip(els.telThermal, thermal.text, thermal.level);
|
||||
|
||||
els.telemetry.classList.toggle("is-stale", stale && !data.paused && !data.stream_closed);
|
||||
if (!els.telMeta) return;
|
||||
if (stale && !lastSeen) {
|
||||
els.telMeta.textContent = I18N.telemetryWaiting;
|
||||
return;
|
||||
}
|
||||
const parts = [publishing ? I18N.telemetryPublisherOn : I18N.telemetryPublisherOff];
|
||||
const age = formatAge(lastSeen);
|
||||
if (age) parts.push(age);
|
||||
if (stale && !data.paused && !data.stream_closed) parts.push(I18N.telemetryStale);
|
||||
els.telMeta.textContent = parts.join(" · ");
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
|
||||
@@ -407,6 +598,8 @@
|
||||
const data = await res.json();
|
||||
applyScorePayload(data);
|
||||
setBadge(data);
|
||||
syncMinQuality(data);
|
||||
syncTelemetry(data);
|
||||
syncPreviewFromStatus(data);
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -419,7 +612,7 @@
|
||||
}
|
||||
|
||||
function startPreview() {
|
||||
if (cfg.streamClosed || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
||||
if (cfg.streamClosed || !previewWanted || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
||||
|
||||
previewStarted = true;
|
||||
const url = hlsUrlFresh();
|
||||
@@ -472,6 +665,8 @@
|
||||
const wasPaused = streamPaused;
|
||||
applyScorePayload(data);
|
||||
setBadge(data);
|
||||
syncMinQuality(data);
|
||||
syncTelemetry(data);
|
||||
syncPreviewFromStatus(data);
|
||||
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
|
||||
}
|
||||
@@ -510,6 +705,70 @@
|
||||
els.btnAudioMute?.addEventListener("click", () => toggleMuteAudio().catch((e) => toast(e.message)));
|
||||
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
|
||||
|
||||
let minQualityBusy = false;
|
||||
|
||||
function setQualityPanelOpen(open) {
|
||||
if (!els.qualityPanel || !els.telBitrate) return;
|
||||
els.qualityPanel.hidden = !open;
|
||||
els.telBitrate.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
els.telBitrate.classList.toggle("is-open", open);
|
||||
if (open && els.minQuality) {
|
||||
window.requestAnimationFrame(() => els.minQuality.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function syncMinQuality(data) {
|
||||
if (!els.minQuality || minQualityBusy) return;
|
||||
const value = data.min_quality_preset || "auto";
|
||||
if (els.minQuality.value !== value) els.minQuality.value = value;
|
||||
}
|
||||
|
||||
async function setMinQuality(preset) {
|
||||
if (!cfg.minQualityUrl) return;
|
||||
minQualityBusy = true;
|
||||
try {
|
||||
const res = await fetch(cfg.minQualityUrl, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ min_quality_preset: preset })
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || I18N.minQualityError);
|
||||
minQualityBusy = false;
|
||||
syncMinQuality(data);
|
||||
toast(I18N.minQualityUpdated);
|
||||
} catch (e) {
|
||||
toast(e.message || I18N.minQualityError);
|
||||
} finally {
|
||||
minQualityBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
els.minQuality?.addEventListener("change", () => {
|
||||
setMinQuality(els.minQuality.value);
|
||||
});
|
||||
|
||||
els.telBitrate?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const opening = !!(els.qualityPanel && els.qualityPanel.hidden);
|
||||
setQualityPanelOpen(opening);
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!els.qualityPanel || els.qualityPanel.hidden) return;
|
||||
if (els.qualityPanel.contains(e.target) || els.telBitrate?.contains(e.target)) return;
|
||||
setQualityPanelOpen(false);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") setQualityPanelOpen(false);
|
||||
});
|
||||
|
||||
els.btnTogglePreview?.addEventListener("click", () => setPreviewWanted(!previewWanted));
|
||||
previewWanted = readPreviewPref();
|
||||
syncPreviewToggleUi();
|
||||
if (!previewWanted) destroyPreview();
|
||||
|
||||
async function shareLink(url, title, text) {
|
||||
if (!url) {
|
||||
toast(I18N.linkUnavailable);
|
||||
|
||||
@@ -12,7 +12,18 @@ RSpec.describe "Public regia", type: :request do
|
||||
get public_regia_path(token)
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("Condividi diretta")
|
||||
expect(response.body).to include("Copia live")
|
||||
expect(response.body).to include("Condividi link regia")
|
||||
expect(response.body).to include("Copia regia")
|
||||
expect(response.body).to include(">Diretta<")
|
||||
expect(response.body).to include(">Regia<")
|
||||
expect(response.body).to include("regia-tool-group")
|
||||
expect(response.body).to include("Nascondi anteprima")
|
||||
expect(response.body).to include('id="btn-pause"')
|
||||
expect(response.body).to include('id="btn-stop"')
|
||||
expect(response.body).to include("Qualità minima di trasmissione")
|
||||
expect(response.body).to include('id="tel-bitrate"')
|
||||
expect(response.body).to include('aria-controls="regia-quality"')
|
||||
expect(response.body).to include("/live/#{session.id}")
|
||||
expect(response.body).not_to include("Aruba")
|
||||
end
|
||||
@@ -179,4 +190,39 @@ RSpec.describe "Public regia", type: :request do
|
||||
get public_regia_path(token)
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it "aggiorna la qualità minima di trasmissione" do
|
||||
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||
patch public_regia_min_quality_path(token), params: { min_quality_preset: "720p_30_1.5mbps" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(session.reload.min_quality_preset).to eq("720p_30_1.5mbps")
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["min_quality_preset"]).to eq("720p_30_1.5mbps")
|
||||
end
|
||||
|
||||
it "espone la telemetria della camera nello status" do
|
||||
session.device_states.create!(
|
||||
device_role: "camera",
|
||||
battery_level: 81,
|
||||
network_type: "WiFi",
|
||||
current_bitrate: 2_400_000,
|
||||
target_bitrate: 4_500_000,
|
||||
fps: 30,
|
||||
thermal_state: "nominal",
|
||||
last_seen_at: Time.current
|
||||
)
|
||||
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||
get public_regia_path(token)
|
||||
expect(response.body).to include('id="regia-telemetry"')
|
||||
|
||||
get public_regia_status_path(token)
|
||||
expect(response).to have_http_status(:ok)
|
||||
tel = response.parsed_body["telemetry"]
|
||||
expect(tel["battery"]).to eq(81)
|
||||
expect(tel["network"]).to eq("WiFi")
|
||||
expect(tel["fps"]).to eq(30)
|
||||
expect(tel["bitrate"]).to eq(2_400_000)
|
||||
expect(tel["thermal_state"]).to eq("nominal")
|
||||
expect(tel).to have_key("publisher_online")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,4 +11,11 @@ RSpec.describe Sessions::SelectQuality do
|
||||
|
||||
expect(preset[:id]).to eq("1080p_30_4.5mbps")
|
||||
end
|
||||
|
||||
it "offre 1080p come qualità minima solo se la sessione è 1080p" do
|
||||
expect(described_class.allowed_min_ids("720p_30_2.5mbps")).to eq(
|
||||
%w[auto 720p_30_1.5mbps 720p_30_2.5mbps 720p_30_4mbps]
|
||||
)
|
||||
expect(described_class.allowed_min_ids("1080p_30_4.5mbps")).to include("1080p_30_4.5mbps")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user