Compare commits

..
Author SHA1 Message Date
eminuxandCursor f92ab3a813 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>
2026-08-13 20:05:16 +02:00
eminuxandCursor bc2257efd2 Aggiunge la pagina pubblica /support compatibile con App Store Review.
Fornisce assistenza multilingua senza CTA commerciali e riusa l’email di supporto configurabile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 19:37:20 +02:00
Emiliano FrascaroandCursor 4af4fa68ac Prepara iOS build 32 per App Store: icona opaca e orientamenti iPad.
Rimuove l'alpha dall'AppIcon e dichiara tutte le orientazioni richieste dal multitasking iPad, così l'upload ASC non fallisce più.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 22:58:27 +02:00
Emiliano FrascaroandCursor 45bdda7c95 Semplifica la home partite a un solo CTA e sistema la nav iOS.
Rimuove il pulsante ridondante «Partita programmata», migliora il padding dei bottoni e fa di splash/login/matches root vere così non compare più il chevron indietro spurio.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 18:54:44 +02:00
Emiliano FrascaroandCursor b926531447 Allinea la versione iOS a Android 2.0.10 e aggiorna la doc di gap.
Bump marketing/build a 2.0.10/31, copy EN forgot password e checklist password policy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 18:39:43 +02:00
eminux da6fc1d523 Merge branch 'feature/password-policy'
Policy password, errori API localizzati, fix UI mobile marketing e handoff iOS.
2026-08-08 14:19:35 +02:00
100 changed files with 4051 additions and 623 deletions
+2
View File
@@ -55,6 +55,8 @@ class SessionChannel < ApplicationCable::Channel
Sessions::Pause.new(session).call
when "resume_stream"
Sessions::Resume.new(session).call
when "set_min_quality"
Sessions::SetMinQuality.new(session, preset: data["min_quality_preset"]).call
end
end
@@ -68,6 +68,7 @@ module Api
current_bitrate: params[:current_bitrate],
target_bitrate: params[:target_bitrate],
fps: params[:fps],
thermal_state: sanitized_thermal_state,
last_seen_at: Time.current
)
sync_publisher_when_streaming!(params[:fps].to_f)
@@ -141,6 +142,13 @@ module Api
}
end
def min_quality
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
render json: session_json(@session)
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def youtube_stats
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
Youtube::BroadcastService.new(@session.match.team)
@@ -160,6 +168,11 @@ module Api
.find(params[:id])
end
def sanitized_thermal_state
value = params[:thermal_state].to_s.downcase.presence
value if value.in?(%w[nominal fair serious critical])
end
def session_params
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
end
@@ -187,6 +200,7 @@ module Api
youtube_ready: session.youtube_ready?,
privacy_status: session.privacy_status,
quality_preset: session.quality_preset,
min_quality_preset: session.min_quality_preset,
target_bitrate: session.target_bitrate,
started_at: session.started_at,
disconnection_count: session.disconnection_count
@@ -11,17 +11,8 @@ module Public
load_club_billing_context_for_pricing
end
private
def load_club_billing_context_for_pricing
return unless logged_in?
@club = current_user.primary_club
return unless @club
@subscription = @club.subscription
@team = @club.teams.order(:name).first
@entitlements = @team&.entitlements
def support
@app_store_review_chrome = true
end
def privacy
@@ -38,5 +29,18 @@ module Public
def pallavolo
end
private
def load_club_billing_context_for_pricing
return unless logged_in?
@club = current_user.primary_club
return unless @club
@subscription = @club.subscription
@team = @club.teams.order(:name).first
@entitlements = @team&.entitlements
end
end
end
@@ -7,7 +7,7 @@ module Public
layout "regia"
protect_from_forgery with: :null_session
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop]
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop min_quality]
before_action :set_session_from_token
@@ -61,8 +61,20 @@ module Public
render json: status_payload
end
def min_quality
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
render json: status_payload
rescue ArgumentError => e
render json: status_payload.merge(error: e.message), status: :unprocessable_entity
end
private
def camera_telemetry_payload(publisher_online)
cam = @session.device_states.find_by(device_role: "camera")
(cam&.as_regia_payload || {}).merge(publisher_online: publisher_online)
end
def set_session_from_token
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
return if @session
@@ -86,7 +98,10 @@ module Public
home_name: @session.match.team.name,
away_name: @session.match.opponent_name,
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
score: score&.as_cable_payload
score: score&.as_cable_payload,
min_quality_preset: @session.min_quality_preset,
min_quality_options: Sessions::SelectQuality.allowed_min_ids(@session.quality_preset),
telemetry: camera_telemetry_payload(publisher_online)
}
end
end
@@ -11,6 +11,7 @@ module Public
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
]
+20 -1
View File
@@ -54,15 +54,34 @@ module Public
copyPrompt: t("regia.js.copy_prompt"),
shareRegiaText: t("regia.js.share_regia_text"),
shareLiveText: t("regia.js.share_live_text"),
minQualityUpdated: t("regia.js.min_quality_updated"),
minQualityError: t("regia.js.min_quality_error"),
minQualityAuto: t("regia.min_quality.auto"),
previewWaiting: t("regia.preview_waiting"),
streamEnded: t("regia.preview_ended"),
previewHide: t("regia.preview_hide"),
previewShow: t("regia.preview_show"),
previewOffHint: t("regia.preview_off_hint"),
resumeLabel: t("regia.resume"),
pauseLabel: t("regia.pause"),
endedBadge: t("regia.status.ended"),
pausedBadge: t("regia.status.paused"),
liveBadge: t("regia.status.live"),
waitingBadge: t("regia.status.waiting"),
subtitlePrefix: t("regia.subtitle_prefix")
subtitlePrefix: t("regia.subtitle_prefix"),
telemetryWaiting: t("regia.telemetry.waiting"),
telemetryStale: t("regia.telemetry.stale"),
telemetryJustNow: t("regia.telemetry.just_now"),
telemetrySecondsAgo: t("regia.telemetry.seconds_ago"),
telemetryPublisherOn: t("regia.telemetry.publisher_on"),
telemetryPublisherOff: t("regia.telemetry.publisher_off"),
telemetryThermalNominal: t("regia.telemetry.thermal_nominal"),
telemetryThermalFair: t("regia.telemetry.thermal_fair"),
telemetryThermalSerious: t("regia.telemetry.thermal_serious"),
telemetryThermalCritical: t("regia.telemetry.thermal_critical"),
telemetryBitrateMax: t("regia.telemetry.bitrate_max"),
telemetryBitrateTitle: t("regia.telemetry.bitrate_title"),
telemetryBitrateTap: t("regia.telemetry.bitrate_tap")
}.to_json
end
+13
View File
@@ -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
+1
View File
@@ -14,6 +14,7 @@ class StreamSession < ApplicationRecord
validates :platform, inclusion: { in: PLATFORMS }
validates :status, inclusion: { in: STATUSES }
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
validates :min_quality_preset, inclusion: { in: Sessions::SelectQuality::MIN_QUALITY_IDS }
before_validation :normalize_privacy_status
before_validation :ensure_publish_token, on: :create
+4 -3
View File
@@ -9,11 +9,12 @@ module Sessions
@session.pause! if @session.may_pause?
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
begin
Mediamtx::Client.new.set_path_recording(@session, enabled: false)
mtx = Mediamtx::Client.new
mtx.set_path_recording(@session, enabled: false)
mtx.set_always_available(@session, enabled: true)
rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}")
Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}")
end
Mediamtx::Client.new.set_always_available(@session, enabled: true)
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
log_event("paused")
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
@@ -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
+2 -2
View File
@@ -12,13 +12,13 @@
</head>
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
<%= render "shared/cookie_banner" %>
<%= render "shared/marketing_nav" %>
<%= render(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %>
<main>
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
<%= yield %>
</main>
<%= render "shared/marketing_footer" %>
<%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %>
<script src="/branding-form.js?v=1" defer></script>
<script src="/roster-form.js?v=1" defer></script>
<script src="/password-toggle.js?v=2" defer></script>
+1 -1
View File
@@ -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">
@@ -0,0 +1,38 @@
<% content_for :title, t("legal.support.title") %>
<% content_for :meta_description, t("legal.support.meta_description") %>
<% content_for :canonical_url, seo_absolute_url(public_support_path) %>
<div class="wrap legal-doc">
<h1><%= t("legal.support.h1") %></h1>
<p><%= t("legal.support.intro") %></p>
<p>
<%= t("legal.support.email_label") %>
<a href="mailto:<%= MatchLiveTv.support_email %>"><%= MatchLiveTv.support_email %></a>
</p>
<section>
<h2><%= t("legal.support.access_title") %></h2>
<p><%= t("legal.support.access_body") %></p>
</section>
<section>
<h2><%= t("legal.support.live_title") %></h2>
<p><%= t("legal.support.live_body") %></p>
</section>
<section>
<h2><%= t("legal.support.team_title") %></h2>
<p><%= t("legal.support.team_body") %></p>
</section>
<section>
<h2><%= t("legal.support.privacy_title") %></h2>
<p>
<%= raw t(
"legal.support.privacy_body_html",
privacy_link: link_to(t("legal.support.privacy_link_text"), public_privacy_path)
) %>
</p>
</section>
</div>
@@ -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>
+97 -32
View File
@@ -16,6 +16,8 @@
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
data-min-quality-url="<%= j public_regia_min_quality_path(params[:token]) %>"
data-min-quality="<%= j @session.min_quality_preset %>"
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
data-hls-url="<%= j @session.hls_playback_url %>"
data-home-name="<%= j @team.name %>"
@@ -29,55 +31,118 @@
data-initial-paused="<%= @session.paused? %>">
<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">&nbsp;</span>
<div class="regia-tool-group__btns">
<button type="button" class="regia-icon-btn" id="btn-toggle-preview" aria-label="<%= t("regia.preview_hide") %>" title="<%= t("regia.preview_hide") %>" aria-pressed="true">
<svg class="icon-fill icon-eye" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
<svg class="icon-fill icon-eye-off" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78 3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>
</button>
<button type="button" class="regia-icon-btn<%= " is-paused" if @session.paused? %>" id="btn-pause" aria-label="<%= @session.paused? ? t("regia.resume") : t("regia.pause") %>" title="<%= @session.paused? ? t("regia.resume") : t("regia.pause") %>">
<svg class="icon-fill icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
<svg class="icon-fill icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
</button>
<button type="button" class="regia-icon-btn regia-icon-btn--danger" id="btn-stop" aria-label="<%= t("regia.stop") %>" title="<%= t("regia.stop") %>">
<svg class="icon-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6h12v12H6z"/></svg>
</button>
</div>
</div>
<% end %>
<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--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>
@@ -94,4 +159,4 @@
</div>
</div>
<script src="/regia.js?v=8"></script>
<script src="/regia.js?v=18"></script>
@@ -4,6 +4,7 @@
<strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %>
</div>
<div>
<%= link_to t("common.support"), public_support_path %> ·
<%= link_to t("common.pricing"), public_prezzi_path %> ·
<%= link_to t("footer.live"), public_live_index_path %> ·
<%= link_to t("common.faq"), public_faq_path %> ·
@@ -0,0 +1,19 @@
<%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %>
<footer class="site-footer">
<div class="wrap">
<div>
<strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %>
</div>
<div>
<%= link_to t("common.support"), public_support_path %> ·
<%= link_to t("common.privacy"), public_privacy_path %> ·
<%= link_to t("common.cookies"), public_cookies_path %> ·
<%= link_to t("common.terms"), public_termini_path %>
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
</div>
<div class="site-footer__legal">
<p><%= t("footer.copyright") %></p>
<p><%= t("footer.responsibility") %></p>
</div>
</div>
</footer>
@@ -0,0 +1,86 @@
<%# Chrome minimale per App Store Review: branding, lingua, Privacy e Supporto — senza CTA commerciali. %>
<div class="site-chrome">
<div class="site-masthead">
<div class="wrap mast-inner">
<%= link_to public_support_path, class: "mast-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
<img class="mast-brand-logo" src="/logo.png?v=3" alt="Match Live TV" width="40" height="40" decoding="async">
<span class="brand" aria-hidden="true">Match <span>Live TV</span></span>
<% end %>
<div class="mast-tools">
<button type="button" class="nav-toggle" aria-label="<%= t('nav.open_menu') %>" aria-expanded="false" aria-controls="site-nav">
<span class="nav-toggle-bar" aria-hidden="true"></span>
<span class="nav-toggle-bar" aria-hidden="true"></span>
<span class="nav-toggle-bar" aria-hidden="true"></span>
</button>
</div>
</div>
</div>
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
<div class="nav-panel">
<div class="nav-mobile-head">
<%= link_to public_support_path, class: "nav-mobile-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
<img class="nav-mobile-brand-logo" src="/logo.png?v=3" alt="" width="40" height="40" decoding="async">
<span class="brand">Match <span>Live TV</span></span>
<% end %>
<div class="nav-lang">
<%= render "shared/language_switcher" %>
</div>
</div>
<%= link_to t("common.support"), public_support_path, class: "nav-active" %>
<%= link_to t("common.privacy"), public_privacy_path, class: (request.path == "/privacy" ? "nav-active" : nil) %>
</div>
</nav>
</div>
<div class="nav-backdrop" id="nav-backdrop" aria-hidden="true"></div>
<script>
(function () {
var chrome = document.querySelector(".site-chrome");
var toggle = document.querySelector(".nav-toggle");
var backdrop = document.getElementById("nav-backdrop");
var nav = document.getElementById("site-nav");
if (!chrome || !toggle || !nav) return;
var openLabel = <%= raw t("nav.open_menu").to_json %>;
var closeLabel = <%= raw t("nav.close_menu").to_json %>;
function setOpen(open) {
chrome.classList.toggle("nav-open", open);
document.body.classList.toggle("nav-menu-open", open);
toggle.setAttribute("aria-expanded", open ? "true" : "false");
toggle.setAttribute("aria-label", open ? closeLabel : openLabel);
nav.setAttribute("aria-hidden", open ? "false" : "true");
if (backdrop) backdrop.setAttribute("aria-hidden", open ? "false" : "true");
}
function closeMenu() { setOpen(false); }
toggle.addEventListener("click", function (e) {
e.stopPropagation();
document.body.classList.contains("nav-menu-open") ? closeMenu() : setOpen(true);
});
if (backdrop) backdrop.addEventListener("click", closeMenu);
function shouldCloseNavOnControl(el) {
if (!el.closest("[data-lang-switcher]")) return true;
return el.classList.contains("lang-switcher__option");
}
nav.querySelectorAll("a, button").forEach(function (el) {
el.addEventListener("click", function () {
if (shouldCloseNavOnControl(el)) closeMenu();
});
});
window.addEventListener("resize", function () {
if (window.matchMedia("(min-width: 900px)").matches) closeMenu();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") closeMenu();
});
})();
</script>
+2 -2
View File
@@ -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
@@ -81,6 +81,12 @@ module MatchLiveTv
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
end
# Email di supporto (App Store Support URL e contatti assistenza).
# Preferisce SUPPORT_CONTACT_EMAIL; default = contatto commerciale già usato nel sito.
def support_email
ENV["SUPPORT_CONTACT_EMAIL"].presence || "info@matchlivetv.it"
end
def privacy_controller_address
ENV.fetch("PRIVACY_CONTROLLER_ADDRESS", "Via Guido De Ruggiero, 89 - 20142 - Milano (MI)")
end
+39 -2
View File
@@ -432,16 +432,51 @@ de:
paused: Pausiert
live: Live
waiting: Wartet
live_share_label: "Live-Link (Zuschauer)"
live_share_label: Live
share_live: Livestream teilen
share_action: Teilen
copy_link: Link kopieren
regia_share_label: "Regie-Link (Punktestand)"
copy_action: Kopieren
copy_live: Live kopieren
copy_regia: Regie kopieren
links_nav: Schnellaktionen
regia_share_label: Regie
stream_controls_label: Live
share_regia: Regie-Link teilen
preview_ended: Livestream beendet
preview_waiting: Vorschau wartet auf Signal…
preview_hide: Vorschau ausblenden
preview_show: Vorschau anzeigen
preview_off_hint: Vorschau aus · kein Video-Download
pause: Pausieren
resume: Livestream fortsetzen
stop: Livestream beenden
min_quality:
label: Minimale Übertragungsqualität
summary: Qualität
hint: Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.
recommend: "Automatisch wird empfohlen: passt sich an Netz und Telefon an, ohne das Maximum zu erzwingen."
auto: Automatisch
telemetry:
label: Telefon
waiting: Warte auf das Telefon…
stale: Telefon antwortet nicht
just_now: jetzt
seconds_ago: "vor %{n}s"
publisher_on: RTMP ok
publisher_off: RTMP fehlt
battery: Akku
network: Netz
bitrate: Bitrate
bitrate_max: "max %{n}"
bitrate_title: "%{current} Mbps aktuell · max. %{target} Mbps"
bitrate_tap: Tippen, um die Mindestqualität festzulegen
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Warm
thermal_serious: Heiß
thermal_critical: Kritisch
modal:
set_won_title: Satz gewonnen
confirm: Satz beenden
@@ -486,6 +521,8 @@ de:
copy_prompt: "Link kopieren:"
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
share_live_text: "Sieh dir die Übertragung auf Match Live TV an:"
min_quality_updated: Mindestqualität aktualisiert
min_quality_error: Fehler beim Aktualisieren der Mindestqualität
recordings:
archive:
back_prefix: "←"
+39 -2
View File
@@ -427,16 +427,51 @@ en:
paused: Paused
live: Live
waiting: Waiting
live_share_label: "Live link (viewers)"
live_share_label: Live
share_live: Share live link
share_action: Share
copy_link: Copy link
regia_share_label: "Control room link (score)"
copy_action: Copy
copy_live: Copy live
copy_regia: Copy room
links_nav: Quick actions
regia_share_label: Control
stream_controls_label: Live
share_regia: Share control room link
preview_ended: Stream ended
preview_waiting: Waiting for signal…
preview_hide: Hide preview
preview_show: Show preview
preview_off_hint: Preview off · no video download
pause: Pause stream
resume: Resume stream
stop: End stream
min_quality:
label: Minimum streaming quality
summary: Quality
hint: The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.
recommend: "Automatic is recommended: it adapts to the network and the phone, without forcing maximum quality."
auto: Automatic
telemetry:
label: Phone
waiting: Waiting for the phone…
stale: Phone not responding
just_now: now
seconds_ago: "%{n}s ago"
publisher_on: RTMP ok
publisher_off: RTMP down
battery: Battery
network: Network
bitrate: Bitrate
bitrate_max: "max %{n}"
bitrate_title: "%{current} Mbps current · max %{target} Mbps"
bitrate_tap: Tap to set the minimum quality
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Warm
thermal_serious: Hot
thermal_critical: Critical
modal:
set_won_title: Set won
confirm: Close set
@@ -481,6 +516,8 @@ en:
copy_prompt: "Copy the link:"
share_regia_text: "Open this link to manage the live score:"
share_live_text: "Watch the live stream on Match Live TV:"
min_quality_updated: Minimum quality updated
min_quality_error: Error updating minimum quality
recordings:
archive:
back_prefix: "←"
+39 -2
View File
@@ -432,16 +432,51 @@ es:
paused: En pausa
live: En directo
waiting: En espera
live_share_label: "Enlace del directo (espectadores)"
live_share_label: Directo
share_live: Compartir directo
share_action: Compartir
copy_link: Copiar enlace
regia_share_label: "Enlace de control (marcador)"
copy_action: Copiar
copy_live: Copiar directo
copy_regia: Copiar control
links_nav: Acciones rápidas
regia_share_label: Control
stream_controls_label: Live
share_regia: Compartir enlace de control
preview_ended: Directo finalizado
preview_waiting: Vista previa a la espera de señal…
preview_hide: Ocultar vista previa
preview_show: Mostrar vista previa
preview_off_hint: Vista previa apagada · sin descarga de vídeo
pause: Pausar directo
resume: Reanudar directo
stop: Finalizar directo
min_quality:
label: Calidad mínima de transmisión
summary: Calidad
hint: El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.
recommend: "Se recomienda Automática: se adapta a la red y al teléfono, sin forzar el máximo."
auto: Automática
telemetry:
label: Teléfono
waiting: Esperando al teléfono…
stale: El teléfono no responde
just_now: ahora
seconds_ago: "hace %{n}s"
publisher_on: RTMP ok
publisher_off: RTMP ausente
battery: Batería
network: Red
bitrate: Bitrate
bitrate_max: "máx %{n}"
bitrate_title: "%{current} Mbps actuales · máximo %{target} Mbps"
bitrate_tap: Toca para definir la calidad mínima
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Caliente
thermal_serious: Muy caliente
thermal_critical: Crítica
modal:
set_won_title: Set ganado
confirm: Cerrar set
@@ -486,6 +521,8 @@ es:
copy_prompt: "Copia el enlace:"
share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
share_live_text: "Mira la retransmisión en Match Live TV:"
min_quality_updated: Calidad mínima actualizada
min_quality_error: Error al actualizar la calidad mínima
recordings:
archive:
back_prefix: "←"
+39 -2
View File
@@ -432,16 +432,51 @@ fr:
paused: En pause
live: En direct
waiting: En attente
live_share_label: "Lien direct (spectateurs)"
live_share_label: Direct
share_live: Partager le direct
share_action: Partager
copy_link: Copier le lien
regia_share_label: "Lien régie (score)"
copy_action: Copier
copy_live: Copier live
copy_regia: Copier régie
links_nav: Actions rapides
regia_share_label: Régie
stream_controls_label: Live
share_regia: Partager le lien régie
preview_ended: Direct terminé
preview_waiting: Aperçu en attente du signal…
preview_hide: Masquer l'aperçu
preview_show: Afficher l'aperçu
preview_off_hint: Aperçu désactivé · aucun téléchargement vidéo
pause: Mettre en pause
resume: Reprendre le direct
stop: Terminer le direct
min_quality:
label: Qualité minimale de transmission
summary: Qualité
hint: L'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.
recommend: "Automatique est conseillé : s'adapte au réseau et au téléphone, sans forcer le maximum."
auto: Automatique
telemetry:
label: Téléphone
waiting: En attente du téléphone…
stale: Téléphone sans réponse
just_now: maintenant
seconds_ago: "il y a %{n}s"
publisher_on: RTMP ok
publisher_off: RTMP absent
battery: Batterie
network: Réseau
bitrate: Débit
bitrate_max: "max %{n}"
bitrate_title: "%{current} Mbps actuels · max %{target} Mbps"
bitrate_tap: Touchez pour définir la qualité minimale
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Chaude
thermal_serious: Très chaude
thermal_critical: Critique
modal:
set_won_title: Set gagné
confirm: Terminer le set
@@ -486,6 +521,8 @@ fr:
copy_prompt: "Copiez le lien :"
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
share_live_text: "Regardez le direct sur Match Live TV :"
min_quality_updated: Qualité minimale mise à jour
min_quality_error: Erreur de mise à jour de la qualité minimale
recordings:
archive:
back_prefix: "←"
+39 -2
View File
@@ -427,16 +427,51 @@ it:
paused: In pausa
live: In onda
waiting: In attesa
live_share_label: "Link diretta (spettatori)"
live_share_label: Diretta
share_live: Condividi diretta
share_action: Condividi
copy_link: Copia link
regia_share_label: "Link regia (punteggio)"
copy_action: Copia
copy_live: Copia live
copy_regia: Copia regia
links_nav: Azioni rapide
regia_share_label: Regia
stream_controls_label: Live
share_regia: Condividi link regia
preview_ended: Diretta terminata
preview_waiting: Anteprima in attesa del segnale…
preview_hide: Nascondi anteprima
preview_show: Mostra anteprima
preview_off_hint: Anteprima spenta · nessun download video
pause: Metti in pausa
resume: Riprendi diretta
stop: Chiudi diretta
min_quality:
label: Qualità minima di trasmissione
summary: Qualità
hint: L'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.
recommend: "Consigliata Automatica: si adatta alla rete e al telefono, senza forzare il massimo."
auto: Automatica
telemetry:
label: Telefono
waiting: In attesa del telefono…
stale: Telefono non risponde
just_now: ora
seconds_ago: "%{n}s fa"
publisher_on: RTMP ok
publisher_off: RTMP assente
battery: Batteria
network: Rete
bitrate: Bitrate
bitrate_max: "max %{n}"
bitrate_title: "%{current} Mbps attuali · massimo %{target} Mbps"
bitrate_tap: Tocca per impostare la qualità minima
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Calda
thermal_serious: Molto calda
thermal_critical: Critica
modal:
set_won_title: Set vinto
confirm: Chiudi set
@@ -481,6 +516,8 @@ it:
copy_prompt: "Copia il link:"
share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
share_live_text: "Guarda la diretta su Match Live TV:"
min_quality_updated: Qualità minima aggiornata
min_quality_error: Errore aggiornamento qualità minima
recordings:
archive:
back_prefix: "←"
+15
View File
@@ -195,3 +195,18 @@ de:
s6_body_html: "Um die von der DSGVO vorgesehenen Rechte auszuüben (Zugang, Löschung, Widerspruch, Widerruf der Einwilligung), schreiben Sie an %{email_link}. Details finden Sie im %{privacy_doc_link}."
s6_privacy_doc_link_text: Datenschutzdokument
manage_button: Cookie-Einstellungen verwalten
support:
title: Match Live TV Support
meta_description: "Match Live TV Hilfe: Zugangsprobleme, Livestreams, Teamverwaltung und Support-Kontaktdaten."
h1: Match Live TV Support
intro: "Brauchen Sie Hilfe mit Match Live TV? Bei Problemen mit dem Zugang, der Einrichtung von Livestreams, der Spielverwaltung oder der Nutzung der App können Sie unseren Support kontaktieren."
email_label: "Support-E-Mail:"
access_title: Zugangsprobleme
access_body: "Wenn Sie sich nicht anmelden können, prüfen Sie die Zugangsdaten, die Sie von Ihrem Sportverein erhalten haben. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support."
live_title: Probleme während eines Livestreams
live_body: "Prüfen Sie Ihre Internetverbindung und versuchen Sie erneut, die Übertragung in der App zu starten. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support und geben Sie Gerät, App-Version und eine kurze Beschreibung des Problems an."
team_title: Teamverwaltung
team_body: "Konten und Übertragungsberechtigungen werden vom Sportverein verwaltet."
privacy_title: Datenschutz
privacy_body_html: "Informationen zur Verarbeitung personenbezogener Daten finden Sie auf der Seite %{privacy_link}."
privacy_link_text: Datenschutz
+15
View File
@@ -195,3 +195,18 @@ en:
s6_body_html: "To exercise the rights provided by the GDPR (access, erasure, objection, withdrawal of consent) write to %{email_link}. Details are in the %{privacy_doc_link}."
s6_privacy_doc_link_text: privacy document
manage_button: Manage cookie preferences
support:
title: Match Live TV Support
meta_description: "Match Live TV help: access issues, live streaming, team management and support contact details."
h1: Match Live TV Support
intro: "Need help with Match Live TV? For access issues, live stream setup, match management or app usage, you can contact our support team."
email_label: "Support email:"
access_title: Access issues
access_body: "If you cannot sign in, check the credentials provided by your sports club. If the problem persists, contact support."
live_title: Issues during a live stream
live_body: "Check your Internet connection and try starting the broadcast from the app again. If the problem persists, contact support and include your device, app version and a short description of the issue."
team_title: Team management
team_body: "Accounts and broadcasting permissions are managed by the sports club."
privacy_title: Privacy
privacy_body_html: "For information on personal data processing, see the %{privacy_link} page."
privacy_link_text: Privacy
+15
View File
@@ -195,3 +195,18 @@ es:
s6_body_html: "Para ejercer los derechos previstos por el RGPD (acceso, supresión, oposición, revocación del consentimiento) escribe a %{email_link}. Más detalles en el %{privacy_doc_link}."
s6_privacy_doc_link_text: documento de privacidad
manage_button: Gestionar preferencias de cookies
support:
title: Soporte Match Live TV
meta_description: "Ayuda de Match Live TV: problemas de acceso, directos, gestión del equipo y datos de contacto del soporte."
h1: Soporte Match Live TV
intro: "¿Necesitas ayuda con Match Live TV? Para problemas de acceso, configuración de directos, gestión de partidos o uso de la app puedes contactar con nuestro soporte."
email_label: "Correo de soporte:"
access_title: Problemas de acceso
access_body: "Si no puedes acceder, verifica las credenciales recibidas de tu club deportivo. Si el problema continúa, contacta con el soporte."
live_title: Problemas durante un directo
live_body: "Verifica la conexión a Internet e intenta de nuevo iniciar la transmisión desde la app. Si el problema continúa, contacta con el soporte indicando el dispositivo, la versión de la app y una breve descripción del problema."
team_title: Gestión del equipo
team_body: "Las cuentas y los permisos de transmisión los gestiona el club deportivo."
privacy_title: Privacidad
privacy_body_html: "Para información sobre el tratamiento de datos personales, consulta la página %{privacy_link}."
privacy_link_text: Privacidad
+15
View File
@@ -195,3 +195,18 @@ fr:
s6_body_html: "Pour exercer les droits prévus par le RGPD (accès, effacement, opposition, retrait du consentement), écrivez à %{email_link}. Détails dans le %{privacy_doc_link}."
s6_privacy_doc_link_text: document de confidentialité
manage_button: Gérer les préférences de cookies
support:
title: Support Match Live TV
meta_description: "Assistance Match Live TV : problèmes d'accès, directs, gestion d'équipe et coordonnées du support."
h1: Support Match Live TV
intro: "Besoin d'aide avec Match Live TV ? Pour les problèmes d'accès, la configuration des directs, la gestion des matchs ou l'utilisation de l'application, vous pouvez contacter notre support."
email_label: "E-mail du support :"
access_title: Problèmes d'accès
access_body: "Si vous ne parvenez pas à vous connecter, vérifiez les identifiants fournis par votre club sportif. Si le problème persiste, contactez le support."
live_title: Problèmes pendant un direct
live_body: "Vérifiez votre connexion Internet et réessayez de démarrer la diffusion depuis l'application. Si le problème persiste, contactez le support en indiquant l'appareil, la version de l'application et une brève description du problème."
team_title: Gestion de l'équipe
team_body: "Les comptes et les autorisations de diffusion sont gérés par le club sportif."
privacy_title: Confidentialité
privacy_body_html: "Pour les informations sur le traitement des données personnelles, consultez la page %{privacy_link}."
privacy_link_text: Confidentialité
+15
View File
@@ -195,3 +195,18 @@ it:
s6_body_html: "Per esercitare i diritti previsti dal GDPR (accesso, cancellazione, opposizione, revoca consenso) scrivi a %{email_link}. Dettagli nel %{privacy_doc_link}."
s6_privacy_doc_link_text: documento privacy
manage_button: Gestisci preferenze cookie
support:
title: Supporto Match Live TV
meta_description: "Assistenza Match Live TV: problemi di accesso, dirette live, gestione squadra e contatti del supporto."
h1: Supporto Match Live TV
intro: "Hai bisogno di assistenza con Match Live TV? Per problemi di accesso, configurazione delle dirette, gestione delle partite o utilizzo dellapp puoi contattare il nostro supporto."
email_label: "Email di supporto:"
access_title: Problemi di accesso
access_body: "Se non riesci ad accedere, verifica le credenziali ricevute dalla tua società sportiva. Se il problema persiste, contatta il supporto."
live_title: Problemi durante una diretta
live_body: "Verifica la connessione Internet e riprova ad avviare la trasmissione dallapp. Se il problema persiste, contatta il supporto indicando dispositivo, versione dellapp e una breve descrizione del problema."
team_title: Gestione della squadra
team_body: "Gli account e le autorizzazioni per la trasmissione sono gestiti dalla società sportiva."
privacy_title: Privacy
privacy_body_html: "Per informazioni sul trattamento dei dati personali consulta la pagina %{privacy_link}."
privacy_link_text: Privacy
+1
View File
@@ -133,6 +133,7 @@ de:
save_password: Passwort aktualisieren
common:
privacy: Datenschutz
support: Support
cookies: Cookies
terms: AGB
pricing: Preise
+1
View File
@@ -133,6 +133,7 @@ en:
save_password: Update password
common:
privacy: Privacy
support: Support
cookies: Cookies
terms: Terms
pricing: Pricing
+1
View File
@@ -133,6 +133,7 @@ es:
save_password: Actualizar contraseña
common:
privacy: Privacidad
support: Soporte
cookies: Cookies
terms: Términos
pricing: Precios
+1
View File
@@ -133,6 +133,7 @@ fr:
save_password: Mettre à jour le mot de passe
common:
privacy: Confidentialité
support: Support
cookies: Cookies
terms: Conditions
pricing: Tarifs
+1
View File
@@ -133,6 +133,7 @@ it:
save_password: Aggiorna password
common:
privacy: Privacy
support: Supporto
cookies: Cookie
terms: Termini
pricing: Prezzi
+3
View File
@@ -55,6 +55,7 @@ Rails.application.routes.draw do
post :claim_pairing
post :regia_link
post :network_test
patch :min_quality
get :youtube_stats
end
end
@@ -143,6 +144,7 @@ Rails.application.routes.draw do
post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause
post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
patch "regia/:token/min_quality", to: "public/regia#min_quality", as: :public_regia_min_quality
root to: "public/pages#home"
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
@@ -154,6 +156,7 @@ Rails.application.routes.draw do
get "prezzi", to: "pages#pricing", as: :prezzi
get "pricing", to: redirect("/prezzi")
get "privacy", to: "pages#privacy", as: :privacy
get "support", to: "pages#support", as: :support
get "cookie", to: "pages#cookies", as: :cookies
get "cookies", to: redirect("/cookie")
get "termini", to: "pages#terms", as: :termini
@@ -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
+29 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -138,6 +138,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
t.datetime "last_seen_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "thermal_state"
t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true
t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id"
end
@@ -255,6 +256,29 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id"
end
create_table "stream_nodes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "slug", null: false
t.string "hostname", null: false
t.string "role", default: "home", null: false
t.string "status", default: "ready", null: false
t.string "rtmp_base_url", null: false
t.string "hls_base_url", null: false
t.string "api_base_url", null: false
t.string "internal_rtmp_url"
t.string "internal_hls_url"
t.integer "max_publishers", default: 6, null: false
t.integer "max_relays", default: 6, null: false
t.string "provider", default: "local", null: false
t.string "provider_instance_id"
t.datetime "last_health_at"
t.jsonb "metadata", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["role"], name: "index_stream_nodes_on_role"
t.index ["slug"], name: "index_stream_nodes_on_slug", unique: true
t.index ["status"], name: "index_stream_nodes_on_status"
end
create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "match_id", null: false
t.uuid "user_id", null: false
@@ -280,10 +304,13 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
t.datetime "updated_at", null: false
t.string "regia_token_digest"
t.datetime "regia_token_expires_at"
t.uuid "stream_node_id"
t.string "min_quality_preset", default: "auto", null: false
t.index ["match_id"], name: "index_stream_sessions_on_match_id"
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
t.index ["status"], name: "index_stream_sessions_on_status"
t.index ["stream_node_id"], name: "index_stream_sessions_on_stream_node_id"
t.index ["user_id"], name: "index_stream_sessions_on_user_id"
end
@@ -411,6 +438,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
add_foreign_key "score_states", "stream_sessions"
add_foreign_key "stream_events", "stream_sessions"
add_foreign_key "stream_sessions", "matches"
add_foreign_key "stream_sessions", "stream_nodes"
add_foreign_key "stream_sessions", "users"
add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
add_foreign_key "subscriptions", "clubs"
+277 -41
View File
@@ -4,6 +4,8 @@
--regia-surface: #14141c;
--regia-red: #e53935;
--regia-yellow: #f9a825;
--regia-green: #43a047;
--regia-orange: #fb8c00;
--regia-text: #f5f5f5;
--regia-muted: #9ca3af;
--regia-safe-bottom: env(safe-area-inset-bottom, 0px);
@@ -22,37 +24,166 @@
.regia-page {
max-width: 480px;
margin: 0 auto;
padding: 12px 16px calc(24px + var(--regia-safe-bottom));
padding: 8px 12px calc(12px + var(--regia-safe-bottom));
}
.regia-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.regia-header__text {
min-width: 0;
flex: 1;
}
.regia-header h1 {
margin: 0;
font-size: 1.1rem;
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
}
.regia-header p {
margin: 4px 0 0;
font-size: 0.85rem;
margin: 2px 0 0;
font-size: 0.78rem;
color: var(--regia-muted);
}
.regia-badge {
display: inline-block;
margin-top: 8px;
padding: 4px 10px;
margin-top: 0;
padding: 3px 8px;
border-radius: 999px;
font-size: 0.7rem;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
flex-shrink: 0;
}
.regia-badge--live { background: #2e7d32; color: #fff; }
.regia-badge--wait { background: #444; color: #ddd; }
.regia-badge--ended { background: #374151; color: #ddd; }
.regia-toolbar {
margin: 8px 0 6px;
display: flex;
align-items: flex-end;
gap: 8px;
}
.regia-tool-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
min-width: 0;
}
.regia-tool-group__label {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--regia-muted);
line-height: 1;
white-space: nowrap;
}
.regia-tool-group__btns {
display: flex;
gap: 3px;
}
.regia-tool-group--stream {
margin-left: auto;
padding-left: 8px;
border-left: 1px solid #333;
}
.regia-icon-btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 1px solid #333;
border-radius: 10px;
background: #16161f;
color: var(--regia-text);
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.regia-icon-btn svg {
width: 20px;
height: 20px;
pointer-events: none;
}
.regia-icon-btn svg.icon-fill {
fill: currentColor;
}
.regia-icon-btn svg.icon-outline {
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.regia-icon-btn .icon-eye-off,
.regia-icon-btn .icon-play {
display: none;
}
.regia-icon-btn.is-off .icon-eye {
display: none;
}
.regia-icon-btn.is-off .icon-eye-off {
display: block;
}
.regia-icon-btn.is-paused .icon-pause {
display: none;
}
.regia-icon-btn.is-paused .icon-play {
display: block;
}
.regia-icon-btn:active { transform: scale(0.96); }
.regia-icon-btn.is-off {
color: var(--regia-yellow);
border-color: #5a4a1a;
}
.regia-icon-btn--danger {
color: var(--regia-red);
border-color: #7a2a28;
}
.regia-preview-wrap {
margin: 6px 0 10px;
}
.regia-preview-wrap.is-off {
margin: 0;
height: 0;
overflow: hidden;
}
.regia-preview {
margin: 14px 0;
margin: 0;
border-radius: 12px;
overflow: hidden;
background: #000;
@@ -90,29 +221,29 @@
.regia-scoreboard {
background: var(--regia-surface);
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
padding: 10px 12px;
margin-bottom: 8px;
}
.regia-sets {
text-align: center;
font-size: 0.82rem;
font-size: 0.78rem;
color: var(--regia-muted);
margin: 0 0 8px;
margin: 0 0 4px;
}
.regia-score-line {
text-align: center;
font-size: 1.5rem;
font-size: 1.2rem;
font-weight: 800;
margin: 0;
}
.regia-partials {
text-align: center;
font-size: 0.78rem;
font-size: 0.75rem;
color: var(--regia-muted);
margin: 8px 0 0;
margin: 4px 0 0;
}
.regia-team-row {
@@ -120,7 +251,7 @@
grid-template-columns: 1fr auto 1fr;
gap: 8px;
align-items: center;
margin-top: 16px;
margin-top: 8px;
}
.regia-team-name {
@@ -131,7 +262,7 @@
}
.regia-points {
font-size: 2rem;
font-size: 1.55rem;
font-weight: 800;
text-align: center;
}
@@ -146,7 +277,7 @@
.regia-btn {
border: none;
border-radius: 10px;
padding: 16px 12px;
padding: 10px 8px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
@@ -156,8 +287,8 @@
.regia-btn:active { transform: scale(0.98); }
.regia-btn--point { background: var(--regia-red); color: #fff; }
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 12px; }
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 10px; }
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 8px; }
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 8px; padding: 12px; }
.regia-btn--outline {
background: transparent;
border: 1px solid #444;
@@ -173,26 +304,6 @@
margin-top: 8px;
}
.regia-share-group {
margin: 12px 0;
}
.regia-share-label {
margin: 0 0 6px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--regia-muted);
}
.regia-share {
display: flex;
gap: 8px;
}
.regia-share .regia-btn { flex: 1; font-size: 0.9rem; padding: 12px; }
.regia-toast {
position: fixed;
bottom: calc(16px + var(--regia-safe-bottom));
@@ -240,7 +351,7 @@
text-align: center;
color: var(--regia-muted);
font-size: 0.72rem;
padding-top: 24px;
padding-top: 8px;
}
.regia-clock-line {
@@ -255,3 +366,128 @@
gap: 8px;
margin-top: 12px;
}
.regia-quality {
margin: 6px 0 0;
padding: 8px 10px 10px;
background: var(--regia-surface);
border-radius: 10px;
}
.regia-quality[hidden] { display: none !important; }
.regia-quality__hint {
margin: 0 0 8px;
font-size: 0.68rem;
line-height: 1.35;
color: var(--regia-muted);
}
.regia-quality label {
display: block;
font-size: 0.68rem;
color: var(--regia-muted);
margin: 0 0 4px;
}
.regia-quality select {
width: 100%;
background: #0a0a0e;
color: var(--regia-text);
border: 1px solid #2a2a35;
border-radius: 8px;
padding: 8px 10px;
font-size: 0.82rem;
}
.regia-telemetry {
margin-top: 8px;
}
.regia-telemetry__row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 4px;
}
.regia-chip {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 0;
text-align: center;
padding: 6px 2px 5px;
border-radius: 8px;
background: var(--regia-surface);
color: var(--regia-text);
border: 1px solid transparent;
}
.regia-chip--action {
appearance: none;
-webkit-appearance: none;
font: inherit;
cursor: pointer;
width: 100%;
}
.regia-chip--action:focus-visible {
outline: 2px solid var(--regia-yellow);
outline-offset: 1px;
}
.regia-chip--action.is-open {
border-color: #3a3a48;
}
.regia-chip svg {
width: 14px;
height: 14px;
fill: currentColor;
}
.regia-chip__label {
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--regia-muted);
line-height: 1;
white-space: nowrap;
}
.regia-chip__value {
font-size: 0.72rem;
font-weight: 800;
line-height: 1.15;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.regia-chip__hint {
font-size: 0.55rem;
font-weight: 600;
color: var(--regia-muted);
line-height: 1;
white-space: nowrap;
}
.regia-chip__hint[hidden] { display: none; }
.regia-chip.is-ok { color: var(--regia-green); }
.regia-chip.is-warn { color: var(--regia-orange); }
.regia-chip.is-bad { color: var(--regia-red); }
.regia-telemetry__meta {
margin: 4px 0 0;
font-size: 0.65rem;
color: var(--regia-muted);
text-align: center;
}
.regia-telemetry.is-stale .regia-telemetry__meta {
color: var(--regia-yellow);
}
+262 -5
View File
@@ -37,13 +37,32 @@
shareLiveText: "Guarda la diretta su Match Live TV:",
previewWaiting: "Anteprima in attesa del segnale…",
streamEnded: "Diretta terminata",
previewHide: "Nascondi anteprima",
previewShow: "Mostra anteprima",
previewOffHint: "Anteprima spenta · nessun download video",
resumeLabel: "Riprendi diretta",
pauseLabel: "Metti in pausa",
endedBadge: "Terminata",
pausedBadge: "In pausa",
liveBadge: "In onda",
waitingBadge: "In attesa",
subtitlePrefix: "Regia ·"
subtitlePrefix: "Regia ·",
minQualityUpdated: "Qualità minima aggiornata",
minQualityError: "Errore aggiornamento qualità minima",
minQualityAuto: "Automatica",
telemetryWaiting: "In attesa del telefono…",
telemetryStale: "Telefono non risponde",
telemetryJustNow: "ora",
telemetrySecondsAgo: "%{n}s fa",
telemetryPublisherOn: "RTMP ok",
telemetryPublisherOff: "RTMP assente",
telemetryThermalNominal: "OK",
telemetryThermalFair: "Calda",
telemetryThermalSerious: "Calda",
telemetryThermalCritical: "Critica",
telemetryBitrateMax: "max %{n}",
telemetryBitrateTitle: "%{current} Mbps attuali · massimo %{target} Mbps",
telemetryBitrateTap: "Tocca per impostare la qualità minima"
};
function loadI18n() {
@@ -77,6 +96,7 @@
pauseUrl: root.dataset.pauseUrl,
resumeUrl: root.dataset.resumeUrl,
stopUrl: root.dataset.stopUrl,
minQualityUrl: root.dataset.minQualityUrl,
cableUrl: root.dataset.cableUrl,
hlsUrl: root.dataset.hlsUrl,
homeName: root.dataset.homeName,
@@ -102,15 +122,28 @@
modalCancel: document.getElementById("modal-cancel"),
preview: document.getElementById("regia-preview"),
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
previewWrap: document.getElementById("regia-preview-wrap"),
btnTogglePreview: document.getElementById("btn-toggle-preview"),
btnPause: document.getElementById("btn-pause"),
subtitle: document.getElementById("regia-subtitle")
minQuality: document.getElementById("min-quality"),
subtitle: document.getElementById("regia-subtitle"),
telemetry: document.getElementById("regia-telemetry"),
telBattery: document.getElementById("tel-battery"),
telNetwork: document.getElementById("tel-network"),
telBitrate: document.getElementById("tel-bitrate"),
telFps: document.getElementById("tel-fps"),
telThermal: document.getElementById("tel-thermal"),
telMeta: document.getElementById("tel-meta"),
qualityPanel: document.getElementById("regia-quality")
};
let pendingAction = null;
let hls = null;
let previewStarted = false;
let previewWanted = true;
let wasPausedPreview = false;
let streamPaused = root.dataset.initialPaused === "true";
const PREVIEW_PREF_KEY = "mltv-regia-preview";
function hlsUrlFresh() {
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
@@ -139,8 +172,43 @@
}
}
function readPreviewPref() {
try {
return localStorage.getItem(PREVIEW_PREF_KEY) !== "0";
} catch (_) {
return true;
}
}
function writePreviewPref(on) {
try {
localStorage.setItem(PREVIEW_PREF_KEY, on ? "1" : "0");
} catch (_) {}
}
function syncPreviewToggleUi() {
if (!els.previewWrap || !els.btnTogglePreview) return;
const label = previewWanted ? I18N.previewHide : I18N.previewShow;
els.previewWrap.classList.toggle("is-off", !previewWanted);
els.btnTogglePreview.classList.toggle("is-off", !previewWanted);
els.btnTogglePreview.setAttribute("aria-pressed", previewWanted ? "true" : "false");
els.btnTogglePreview.setAttribute("aria-label", label);
els.btnTogglePreview.title = previewWanted ? I18N.previewHide : I18N.previewOffHint;
}
function setPreviewWanted(on) {
previewWanted = !!on;
writePreviewPref(previewWanted);
syncPreviewToggleUi();
if (previewWanted) {
startPreview();
} else {
destroyPreview();
}
}
function refreshPreviewForCover() {
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed) return;
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed || !previewWanted) return;
destroyPreview();
previewStarted = false;
startPreview();
@@ -243,7 +311,10 @@
if (!els.btnPause) return;
const paused = !!(data.paused || data.status === "paused");
streamPaused = paused;
els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel;
const label = paused ? I18N.resumeLabel : I18N.pauseLabel;
els.btnPause.classList.toggle("is-paused", paused);
els.btnPause.setAttribute("aria-label", label);
els.btnPause.title = label;
}
function setBadge(data) {
@@ -364,6 +435,11 @@
}
const paused = data.paused || data.status === "paused";
if (!previewWanted) {
destroyPreview();
wasPausedPreview = paused;
return;
}
if (data.on_air) {
if (!previewStarted) {
startPreview();
@@ -382,6 +458,119 @@
wasPausedPreview = paused;
}
function mbpsNumber(bps) {
const n = Number(bps);
if (!n || n <= 0) return null;
const mbps = n >= 10_000 ? n / 1e6 : n / 1e3;
return mbps >= 10 ? mbps.toFixed(0) : mbps.toFixed(1);
}
function formatAge(iso) {
if (!iso) return null;
const then = Date.parse(iso);
if (!then) return null;
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
if (secs < 8) return I18N.telemetryJustNow;
return fillTemplate(I18N.telemetrySecondsAgo, { n: secs });
}
function setChip(el, text, level, hint) {
if (!el) return;
const valueEl = el.querySelector(".regia-chip__value") || el;
valueEl.textContent = text;
el.classList.toggle("is-ok", level === "ok");
el.classList.toggle("is-warn", level === "warn");
el.classList.toggle("is-bad", level === "bad");
const hintEl = el.querySelector(".regia-chip__hint");
if (hintEl) {
hintEl.textContent = hint || "";
hintEl.hidden = !hint;
}
}
function formatThermal(state) {
const key = String(state || "").toLowerCase();
if (key === "nominal") return { text: I18N.telemetryThermalNominal, level: "ok" };
if (key === "fair") return { text: I18N.telemetryThermalFair, level: "warn" };
if (key === "serious") return { text: I18N.telemetryThermalSerious, level: "bad" };
if (key === "critical") return { text: I18N.telemetryThermalCritical, level: "bad" };
return { text: "—", level: null };
}
function bitrateLevel(current, target, paused) {
if (paused || !current || current <= 0) return null;
if (target && target > 0) {
const ratio = current / target;
if (ratio >= 0.7) return "ok";
if (ratio >= 0.4) return "warn";
return "bad";
}
const mbps = current >= 10_000 ? current / 1e6 : current / 1e3;
if (mbps >= 2.5) return "ok";
if (mbps >= 1.5) return "warn";
return "bad";
}
function bitrateTitle(currentMbps, targetMbps) {
const tap = I18N.telemetryBitrateTap;
if (currentMbps && targetMbps) {
return `${fillTemplate(I18N.telemetryBitrateTitle, { current: currentMbps, target: targetMbps })} · ${tap}`;
}
if (currentMbps) return `${currentMbps} Mbps · ${tap}`;
return tap;
}
function syncTelemetry(data) {
if (!els.telemetry) return;
const tel = data.telemetry || {};
const battery = tel.battery;
const fps = tel.fps;
const bitrate = tel.bitrate;
const target = tel.target_bitrate || tel.targetBitrate;
const lastSeen = tel.last_seen_at || tel.lastSeenAt;
const ageSecs = lastSeen ? Math.max(0, Math.round((Date.now() - Date.parse(lastSeen)) / 1000)) : null;
const stale = ageSecs == null || ageSecs > 25;
const publishing = !!tel.publisher_online || !!tel.publisherOnline;
let battLevel = null;
let battText = "—";
if (battery != null && battery !== "") {
battText = `${battery}%`;
if (battery <= 15) battLevel = "bad";
else if (battery <= 25) battLevel = "warn";
}
setChip(els.telBattery, battText, battLevel);
setChip(els.telNetwork, tel.network || "—");
const currentMbps = mbpsNumber(bitrate);
const targetMbps = mbpsNumber(target);
const rateText = currentMbps || "—";
const rateHint = currentMbps && targetMbps
? fillTemplate(I18N.telemetryBitrateMax, { n: targetMbps })
: null;
if (els.telBitrate) els.telBitrate.title = bitrateTitle(currentMbps, targetMbps);
setChip(els.telBitrate, rateText, bitrateLevel(Number(bitrate), Number(target), data.paused), rateHint);
let fpsLevel = null;
if (fps != null && fps !== "" && Number(fps) > 0 && Number(fps) < 20) fpsLevel = "warn";
setChip(els.telFps, fps && Number(fps) > 0 ? String(fps) : "—", fpsLevel);
const thermal = formatThermal(tel.thermal_state || tel.thermalState);
setChip(els.telThermal, thermal.text, thermal.level);
els.telemetry.classList.toggle("is-stale", stale && !data.paused && !data.stream_closed);
if (!els.telMeta) return;
if (stale && !lastSeen) {
els.telMeta.textContent = I18N.telemetryWaiting;
return;
}
const parts = [publishing ? I18N.telemetryPublisherOn : I18N.telemetryPublisherOff];
const age = formatAge(lastSeen);
if (age) parts.push(age);
if (stale && !data.paused && !data.stream_closed) parts.push(I18N.telemetryStale);
els.telMeta.textContent = parts.join(" · ");
}
async function pollStatus() {
try {
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
@@ -389,6 +578,8 @@
const data = await res.json();
applyScorePayload(data);
setBadge(data);
syncMinQuality(data);
syncTelemetry(data);
syncPreviewFromStatus(data);
} catch (_) {}
}
@@ -401,7 +592,7 @@
}
function startPreview() {
if (cfg.streamClosed || !els.preview || !cfg.hlsUrl || previewStarted) return;
if (cfg.streamClosed || !previewWanted || !els.preview || !cfg.hlsUrl || previewStarted) return;
previewStarted = true;
const url = hlsUrlFresh();
@@ -454,6 +645,8 @@
const wasPaused = streamPaused;
applyScorePayload(data);
setBadge(data);
syncMinQuality(data);
syncTelemetry(data);
syncPreviewFromStatus(data);
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
}
@@ -473,6 +666,70 @@
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
let minQualityBusy = false;
function setQualityPanelOpen(open) {
if (!els.qualityPanel || !els.telBitrate) return;
els.qualityPanel.hidden = !open;
els.telBitrate.setAttribute("aria-expanded", open ? "true" : "false");
els.telBitrate.classList.toggle("is-open", open);
if (open && els.minQuality) {
window.requestAnimationFrame(() => els.minQuality.focus());
}
}
function syncMinQuality(data) {
if (!els.minQuality || minQualityBusy) return;
const value = data.min_quality_preset || "auto";
if (els.minQuality.value !== value) els.minQuality.value = value;
}
async function setMinQuality(preset) {
if (!cfg.minQualityUrl) return;
minQualityBusy = true;
try {
const res = await fetch(cfg.minQualityUrl, {
method: "PATCH",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ min_quality_preset: preset })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || I18N.minQualityError);
minQualityBusy = false;
syncMinQuality(data);
toast(I18N.minQualityUpdated);
} catch (e) {
toast(e.message || I18N.minQualityError);
} finally {
minQualityBusy = false;
}
}
els.minQuality?.addEventListener("change", () => {
setMinQuality(els.minQuality.value);
});
els.telBitrate?.addEventListener("click", (e) => {
e.stopPropagation();
const opening = !!(els.qualityPanel && els.qualityPanel.hidden);
setQualityPanelOpen(opening);
});
document.addEventListener("click", (e) => {
if (!els.qualityPanel || els.qualityPanel.hidden) return;
if (els.qualityPanel.contains(e.target) || els.telBitrate?.contains(e.target)) return;
setQualityPanelOpen(false);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") setQualityPanelOpen(false);
});
els.btnTogglePreview?.addEventListener("click", () => setPreviewWanted(!previewWanted));
previewWanted = readPreviewPref();
syncPreviewToggleUi();
if (!previewWanted) destroyPreview();
async function shareLink(url, title, text) {
if (!url) {
toast(I18N.linkUnavailable);
+47 -1
View File
@@ -1,7 +1,7 @@
require "rails_helper"
RSpec.describe "Public regia", type: :request do
let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "password123", role: "coach") }
let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "Password123", role: "coach") }
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
@@ -12,7 +12,18 @@ RSpec.describe "Public regia", type: :request do
get public_regia_path(token)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Condividi diretta")
expect(response.body).to include("Copia live")
expect(response.body).to include("Condividi link regia")
expect(response.body).to include("Copia regia")
expect(response.body).to include(">Diretta<")
expect(response.body).to include(">Regia<")
expect(response.body).to include("regia-tool-group")
expect(response.body).to include("Nascondi anteprima")
expect(response.body).to include('id="btn-pause"')
expect(response.body).to include('id="btn-stop"')
expect(response.body).to include("Qualità minima di trasmissione")
expect(response.body).to include('id="tel-bitrate"')
expect(response.body).to include('aria-controls="regia-quality"')
expect(response.body).to include("/live/#{session.id}")
expect(response.body).not_to include("Aruba")
end
@@ -156,4 +167,39 @@ RSpec.describe "Public regia", type: :request do
get public_regia_path(token)
expect(response).to have_http_status(:ok)
end
it "aggiorna la qualità minima di trasmissione" do
token = Sessions::RegiaAccess.new(session).issue_token!
patch public_regia_min_quality_path(token), params: { min_quality_preset: "720p_30_1.5mbps" }
expect(response).to have_http_status(:ok)
expect(session.reload.min_quality_preset).to eq("720p_30_1.5mbps")
json = JSON.parse(response.body)
expect(json["min_quality_preset"]).to eq("720p_30_1.5mbps")
end
it "espone la telemetria della camera nello status" do
session.device_states.create!(
device_role: "camera",
battery_level: 81,
network_type: "WiFi",
current_bitrate: 2_400_000,
target_bitrate: 4_500_000,
fps: 30,
thermal_state: "nominal",
last_seen_at: Time.current
)
token = Sessions::RegiaAccess.new(session).issue_token!
get public_regia_path(token)
expect(response.body).to include('id="regia-telemetry"')
get public_regia_status_path(token)
expect(response).to have_http_status(:ok)
tel = response.parsed_body["telemetry"]
expect(tel["battery"]).to eq(81)
expect(tel["network"]).to eq("WiFi")
expect(tel["fps"]).to eq(30)
expect(tel["bitrate"]).to eq(2_400_000)
expect(tel["thermal_state"]).to eq("nominal")
expect(tel).to have_key("publisher_online")
end
end
@@ -0,0 +1,57 @@
require "rails_helper"
RSpec.describe "Public support page", type: :request do
it "è pubblica, indicizzabile e mostra l'email di supporto configurata" do
get public_support_path
expect(response).to have_http_status(:ok)
expect(response.body).to include(MatchLiveTv.support_email)
expect(response.body).to include("mailto:#{MatchLiveTv.support_email}")
expect(response.body).to include(I18n.t("legal.support.h1", locale: :it))
expect(response.body).to include('rel="canonical"')
expect(response.body).to include(public_support_path)
expect(response.body).to include(public_privacy_path)
end
it "non espone CTA o link commerciali (App Store Review)" do
get public_support_path
body = response.body
expect(body).not_to include(public_prezzi_path)
expect(body).not_to include(public_signup_path)
expect(body).not_to include('href="/prezzi"')
expect(body).not_to include('href="/signup"')
expect(body).not_to include(I18n.t("nav.signup", locale: :it))
expect(body).not_to include(I18n.t("nav.pricing", locale: :it))
expect(body).not_to include(I18n.t("common.pricing", locale: :it))
expect(body).not_to match(/\bPremium Light\b/i)
expect(body).not_to match(/\bPremium Full\b/i)
expect(body).not_to match(/\bpiano Free\b/i)
expect(body).not_to match(/\bAbbonati\b/i)
expect(body).not_to match(/\bAcquista\b/i)
end
{
"it" => "Supporto Match Live TV",
"en" => "Match Live TV Support",
"fr" => "Support Match Live TV",
"de" => "Match Live TV Support",
"es" => "Soporte Match Live TV"
}.each do |locale, heading|
it "renderizza correttamente in #{locale} senza translation missing" do
cookies[:mltv_locale] = locale
get public_support_path
expect(response).to have_http_status(:ok)
expect(response.body).to include(heading)
expect(response.body).not_to include("translation missing")
end
end
it "include /support nella sitemap" do
get "/sitemap.xml"
expect(response).to have_http_status(:ok)
expect(response.body).to include("#{MatchLiveTv.app_public_url.chomp('/')}/support")
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
+2 -2
View File
@@ -38,10 +38,10 @@ Solo se la società vuole il **proprio** canale invece di Match Live TV:
Nella home app (**Partite**):
- **Partita programmata** — scegli dal calendario una gara già inserita (sito o app)
- **Nuova partita** — programma data/ora oppure **Avvia subito** senza orario
- Oppure tocca una gara già in calendario (sito o app)
Poi tocca la card o conferma dal foglio: si apre il wizard (Partita → Trasmissione → …).
Poi conferma dal foglio o tocca la card: si apre il wizard (Partita → Trasmissione → …).
## 4. Avvia diretta
+7 -7
View File
@@ -1,12 +1,12 @@
# Gap Android → iOS: allineamento app nativa
Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare unapp **allineata ad Android `2.0.5-native`**.
Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare unapp **allineata ad Android**.
**Aggiornato:** 24 luglio 2026
**Riferimento Android (produzione / telefono / Play):** `2.0.5-native` (`versionCode` **26**), API `https://www.matchlivetv.it`
**Stato iOS attuale:** marketing `2.0.5`, build `26` — i18n **allineato** (Login, hub, sheet/dialog, wizard, broadcast)
**Aggiornato:** 8 agosto 2026
**Riferimento Android (produzione / telefono / Play):** `2.0.10-native` (`versionCode` **31**), API `https://www.matchlivetv.it`
**Stato iOS attuale:** marketing `2.0.10`, build `31` — i18n **allineato** (Login, hub, sheet/dialog, wizard, broadcast, account/forgot)
Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog, wizard, broadcast) + bump versione, **senza** i bug di sessione/crash già risolti su Android 2.0.5.
Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog, wizard, broadcast, account) + bump versione, **senza** i bug di sessione/crash già risolti su Android.
---
@@ -59,7 +59,7 @@ Queste sono regressioni/bug già visti su Android; **non ripeterli** su iOS.
| Broadcast + score dialog | Sì | `broadcast.*` / `score.*` | **Fatto** |
| Catalogo stringhe | `values*` (~230) | `AppLanguage.swift` L10n (~225, no FGS) | **Fatto** |
| Logout UI | Icona | Icona SF Symbol | **Fatto** |
| Versione | `2.0.5-native` / **26** | `2.0.5` / **26** | **Fatto** |
| Versione | `2.0.10-native` / **31** | `2.0.10` / **31** | **Fatto** |
| RTMP ingest | `RtmpIngestUrl.kt` | `MediaUrl.swift` | OK |
### Residui fuori scope Android (opzionali)
@@ -141,7 +141,7 @@ xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \
Nessuna modifica server richiesta.
Versione store target: **`2.0.5` / build `26`** (parità con Android `2.0.5-native` / versionCode `26`).
Versione store target: **`2.0.10` / build `31`** (parità con Android `2.0.10-native` / versionCode `31`).
---
+25 -73
View File
@@ -3,8 +3,8 @@
Documento operativo per **Cursor su Mac**: allineare lapp iOS a backend/Android dopo il ramo `feature/password-policy` (merge su `main`).
**Aggiornato:** 2026-08-08
**Android di riferimento (testato su emulatore → API locale):** `2.0.10-native` (`versionCode` **31**)
**iOS oggi:** marketing `2.0.5` / build `26`
**Android di riferimento:** `2.0.10-native` (`versionCode` **31**)
**iOS (allineato):** marketing `2.0.10` / build `31`
**API produzione:** `https://www.matchlivetv.it`
---
@@ -51,86 +51,38 @@ Chiavi rilevanti (`backend/config/locales/app.{it,en,fr,de,es}.yml`):
---
## Stato iOS vs Android (dopo il merge di questo branch)
## Stato iOS vs Android
| Area | Android 2.0.10 | iOS (repo dopo merge) | Da fare su Mac |
|------|----------------|------------------------|----------------|
| Header `Accept-Language` su tutte le request API | `AppContainer` OkHttp interceptor | `MatchLiveAPI.request` + `multipartPatch` | **Verificare** in debug che parta su login/account/password; se manca su altri helper HTTP, allineare |
| Hint UI nuova password | `account_new_password` in `values*` (5 lingue) | `account.new.password` in `AppLanguage.swift` (già allineato al testo policy) | OK — rivedi solo se cambi copy |
| Schermata Account | Mostra `error` dal body HTTP | `AccountScreen` + `APIError` estrae `error`/`message` | **Test manuale** (vedi sotto) |
| Parsing errori API | Regex su `"error"` | `APIError.friendlyHttpMessage` | OK |
| Client-side pre-check complessità | No (si affida al server) | No | Opzionale: stessa regola di `PasswordComplexity` prima della PATCH |
| Versione App Store | `2.0.10-native` / 31 | `2.0.5` / 26 | **Bump** marketing + build prima del submit TestFlight/App Store |
| Signup in-app | Non è il flusso principale | Nessuna UI register dedicata | N/A (signup resta web) |
| Area | Android 2.0.10 | iOS | Stato |
|------|----------------|-----|-------|
| Header `Accept-Language` su tutte le request API | OkHttp interceptor | `MatchLiveAPI.request` + `multipartPatch` | **OK** |
| Hint UI nuova password | `account_new_password` | `account.new.password` | **OK** |
| Schermata Account | AccountScreen | AccountScreen | **OK** |
| Forgot password | ForgotPasswordScreen | ForgotPasswordScreen | **OK** |
| Parsing errori API | body `error` | `APIError.friendlyHttpMessage` | **OK** |
| Client-side pre-check complessità | No | No | N/A (come Android) |
| Versione store | `2.0.10-native` / **31** | `2.0.10` / **31** | **OK** |
| Signup in-app | Non principale | Nessuna UI register | N/A (signup web) |
### File già toccati / da tenere
### File iOS
```
native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift # Accept-Language
native/ios/MatchLiveTv/Core/AppLanguage.swift # hint account.new.password (5 lingue)
native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift
native/ios/MatchLiveTv/Core/AppLanguage.swift
native/ios/MatchLiveTv/UI/Account/AccountScreen.swift
native/ios/MatchLiveTv/Core/UserFacingError.swift
native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift # enum APIError
native/ios/MatchLiveTv/UI/Login/ForgotPasswordScreen.swift
native/ios/MatchLiveTv/Resources/Info.plist
native/ios/generate_xcodeproj.py
```
Riferimento Android:
```
native/android/.../data/AppContainer.kt
native/android/.../ui/account/AccountScreen.kt
native/android/app/src/main/res/values*/strings.xml # account_new_password
```
---
## Checklist operativa su Mac
1. **Pull** `main` (dopo merge/push da Linux).
2. Apri `native/ios/MatchLiveTv.xcodeproj`.
3. Build debug con `API_BASE_URL=https://www.matchlivetv.it` (o locale se hai tunnel).
4. **Test Account** (credenziali demo se disponibili, altrimenti account reale di test):
- password debole (`password`) → messaggio *too_weak* nella lingua UI
- password = corrente → *same_as_current*
- conferma diversa → *mismatch*
- password valida nuova → successo; login successivo ok
5. Cambia lingua app (globo) e ripeti un errore: il testo API deve seguire `Accept-Language` / lingua scelta.
6. **Bump versione** allineata ad Android se rilasci store: es. marketing `2.0.10`, build `≥ 31`.
7. (Opzionale) Unit test Swift che replica `PasswordComplexity.strong_enough?` se aggiungi validazione client.
### Comandi utili
```bash
# Clone/pull
git checkout main && git pull
# Release iOS (script repo)
./scripts/build_ios_release.sh
# oppure API staging:
# API_BASE_URL=https://www.matchlivetv.it ./scripts/build_ios_release.sh
```
### Cosa non serve rifare su iOS
- Fix CSS/menu mobile web, tabelle scroll, logo nel drawer: **solo sito**.
- Redirect `GET /account/password``/account`: **solo web**.
- Seed password / concern Rails: già lato server.
---
## Retrocompatibilità (per release iOS)
- App iOS **vecchie** (senza `Accept-Language`): continuano a funzionare; messaggi API nella locale default del server.
- Login con password già esistenti: invariato.
- Solo **impostazione/cambio** password debole viene rifiutato (stesso JSON `error`).
---
## Criterio “fatto”
- [ ] `Accept-Language` presente su login, me, account, change password, forgot (Charles/Proxyman o log)
- [ ] Errori password in it/en/fr/de/es coerenti col backend
- [ ] Hint campo nuova password parla di “min. 8 / 3 tipi”
- [ ] Versione bumpata se lo store release è in scope
- [ ] Nessuna regressione login / lista partite / broadcast
- [x] `Accept-Language` su login, me, account, change password, forgot (`MatchLiveAPI`)
- [x] Hint campo nuova password parla di “min. 8 / 3 tipi”
- [x] Catalogo stringhe account/forgot in it/en/fr/de/es
- [x] Versione bumpata a `2.0.10` / `31`
- [ ] QA manuale errori password in lingua UI (weak / same / mismatch / success)
Quando completo, aggiorna questo file con data + versione iOS rilasciata.
Quando rilasci su TestFlight/App Store, conferma la build `31`.
+2
View File
@@ -15,6 +15,8 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
# Opzionale: email dedicata di supporto (default = info@matchlivetv.it)
# SUPPORT_CONTACT_EMAIL=info@matchlivetv.it
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
PASSWORD_RESET_EXPIRY_HOURS=2
+2
View File
@@ -39,6 +39,8 @@ RAILS_LOG_LEVEL=info
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
# Opzionale: email di supporto App Store (default = info@matchlivetv.it)
# SUPPORT_CONTACT_EMAIL=info@matchlivetv.it
PRIVACY_CONTROLLER_VAT=
# Email transazionali (reset password, inviti)
+1
View File
@@ -73,6 +73,7 @@ services:
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
SUPPORT_CONTACT_EMAIL: ${SUPPORT_CONTACT_EMAIL:-}
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-}
+3
View File
@@ -59,6 +59,8 @@ services:
REDIS_URL: redis://redis:6379/0
MEDIAMTX_API_URL: http://mediamtx:9997
MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://127.0.0.1:1935}
MEDIAMTX_HLS_URL: ${MEDIAMTX_HLS_URL:-http://mediamtx:8888}
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:3000/hls}
MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}
RAILS_WEBHOOK_URL: http://rails:3000
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min}
@@ -71,6 +73,7 @@ services:
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
SUPPORT_CONTACT_EMAIL: ${SUPPORT_CONTACT_EMAIL:-}
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlivetv.it>}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
@@ -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
}
}
@@ -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.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import com.matchlivetv.match_live_tv.core.AppLocale
import com.matchlivetv.match_live_tv.core.ProvideAppLocale
import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost
import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
@OptIn(ExperimentalComposeUiApi::class)
class MainActivity : ComponentActivity() {
private val container by lazy { (application as MatchLiveTvApplication).container }
@@ -33,7 +39,9 @@ class MainActivity : ComponentActivity() {
setContent {
ProvideAppLocale {
MatchLiveTheme {
AppNavHost(container)
Box(Modifier.semantics { testTagsAsResourceId = true }) {
AppNavHost(container)
}
}
}
}
@@ -183,6 +183,7 @@ data class StreamSessionDto(
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
@Json(name = "target_fps") val targetFps: Int? = null,
@Json(name = "quality_preset") val qualityPreset: String? = null,
@Json(name = "min_quality_preset") val minQualityPreset: String? = null,
@Json(name = "started_at") val startedAt: String? = null,
val score: ScoreStateDto? = null,
) {
@@ -201,6 +202,7 @@ data class StreamSessionDto(
targetBitrate = targetBitrate ?: 2_500_000,
targetFps = targetFps ?: 30,
qualityPreset = qualityPreset ?: "720p_30_2.5mbps",
minQualityPreset = minQualityPreset ?: "auto",
startedAt = startedAt,
score = score?.toDomain(),
)
@@ -285,6 +287,10 @@ data class CreateSessionRequest(
@Json(name = "youtube_channel") val youtubeChannel: String? = null,
)
data class MinQualityRequest(
@Json(name = "min_quality_preset") val minQualityPreset: String,
)
data class UpdateMatchBody(
@Json(name = "opponent_name") val opponentName: String? = null,
val location: String? = null,
@@ -392,4 +398,5 @@ data class TelemetryRequest(
@Json(name = "current_bitrate") val currentBitrate: Int? = null,
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
val fps: Int? = null,
@Json(name = "thermal_state") val thermalState: String? = null,
)
@@ -117,6 +117,12 @@ interface MatchLiveApi {
@PATCH("sessions/{sessionId}/resume")
suspend fun resumeSession(@Path("sessionId") sessionId: String): StreamSessionDto
@PATCH("sessions/{sessionId}/min_quality")
suspend fun setMinQuality(
@Path("sessionId") sessionId: String,
@Body body: MinQualityRequest,
): StreamSessionDto
@POST("sessions/{sessionId}/network_test")
suspend fun networkTest(
@Path("sessionId") sessionId: String,
@@ -33,6 +33,7 @@ class SessionCableService(
var onPauseStream: (() -> Unit)? = null
var onResumeStream: (() -> Unit)? = null
var onEndStream: (() -> Unit)? = null
var onMinQuality: ((String) -> Unit)? = null
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
intentionalDisconnect = false
@@ -109,6 +110,7 @@ class SessionCableService(
"pause_stream" -> onPauseStream?.invoke()
"resume_stream" -> onResumeStream?.invoke()
"stop_stream" -> onEndStream?.invoke()
"set_min_quality" -> (payload["min_quality_preset"] as? String)?.let { onMinQuality?.invoke(it) }
}
"stream_event" -> when (payload["event"] as? String) {
"paused" -> onPauseStream?.invoke()
@@ -2,6 +2,7 @@ package com.matchlivetv.match_live_tv.data.repository
import com.matchlivetv.match_live_tv.data.api.CreateSessionRequest
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.api.MinQualityRequest
import com.matchlivetv.match_live_tv.data.api.NetworkTestRequest
import com.matchlivetv.match_live_tv.data.api.NetworkTestResponse
import com.matchlivetv.match_live_tv.domain.StreamSession
@@ -38,6 +39,9 @@ class SessionRepository(
suspend fun resumeSession(sessionId: String): StreamSession =
api.resumeSession(sessionId).toDomain()
suspend fun setMinQuality(sessionId: String, preset: String): StreamSession =
api.setMinQuality(sessionId, MinQualityRequest(minQualityPreset = preset)).toDomain()
suspend fun submitNetworkTest(
sessionId: String,
downloadMbps: Double,
@@ -61,6 +65,7 @@ class SessionRepository(
currentBitrate: Int? = null,
targetBitrate: Int? = null,
fps: Int? = null,
thermalState: String? = null,
) {
api.postTelemetry(
sessionId,
@@ -72,6 +77,7 @@ class SessionRepository(
currentBitrate = currentBitrate,
targetBitrate = targetBitrate,
fps = fps,
thermalState = thermalState,
),
)
}
@@ -126,6 +126,7 @@ data class StreamSession(
val targetBitrate: Int = 2_500_000,
val targetFps: Int = 30,
val qualityPreset: String = "720p_30_2.5mbps",
val minQualityPreset: String = "auto",
val startedAt: String? = null,
val score: ScoreState? = null,
) {
@@ -133,6 +134,8 @@ data class StreamSession(
val isPaused: Boolean get() = status == "paused"
val isEnded: Boolean get() = status == "ended" || status == "error"
fun watchShareUrl(): String? = when {
platform == "youtube" && !youtubeWatchUrl.isNullOrBlank() -> youtubeWatchUrl
!shareUrl.isNullOrBlank() -> shareUrl
@@ -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
}
}
@@ -9,6 +9,7 @@ data class BroadcastConfig(
val width: Int = 1280,
val height: Int = 720,
val videoBitrate: Int = 2_500_000,
val minVideoBitrate: Int = 800_000,
val audioBitrate: Int = 128_000,
val fps: Int = 30,
val rotation: Int = 0,
@@ -39,6 +39,7 @@ class LiveBroadcastEngine(
private var publishInProgress = false
private var currentBitrateKbps: Long = 0
private var currentFps: Int = 0
private val adaptiveBitrate = AdaptiveBitrateController()
private val pausingIntentionally = AtomicBoolean(false)
private val reconnectAttempts = AtomicInteger(0)
private val overlayRenderer = OverlayRenderer(appContext)
@@ -133,6 +134,7 @@ class LiveBroadcastEngine(
fun startBroadcast(broadcastConfig: BroadcastConfig) {
mainHandler.post {
config = broadcastConfig
adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
reconnectAttempts.set(0)
streamStartPending = true
publishInProgress = false
@@ -250,6 +252,7 @@ class LiveBroadcastEngine(
fun resumeBroadcast(broadcastConfig: BroadcastConfig) {
mainHandler.post {
config = broadcastConfig
adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
reconnectAttempts.set(0)
streamStartPending = true
publishInProgress = false
@@ -264,21 +267,45 @@ class LiveBroadcastEngine(
}
}
/** Adattamento termico: riduce bitrate/FPS senza fermare lo stream. */
/** Adattamento termico: aggiorna il soffitto ABR e l'FPS senza fermare lo stream. */
fun applyThermalProfile(videoBitrate: Int, fps: Int) {
mainHandler.post {
val current = config ?: return@post
if (current.videoBitrate == videoBitrate && current.fps == fps) return@post
adaptiveBitrate.setCeiling(videoBitrate)
val applied = adaptiveBitrate.setFloor(current.minVideoBitrate)
if (current.videoBitrate == videoBitrate && current.fps == fps) {
applyAdaptiveBitrate(applied)
return@post
}
config = current.copy(videoBitrate = videoBitrate, fps = fps)
genericStream?.let { stream ->
runCatching { stream.setVideoBitrateOnFly(videoBitrate) }
.onFailure { Log.w(TAG, "applyThermalProfile bitrate: ${it.message}") }
applyAdaptiveBitrate(applied)
runCatching { stream.getGlInterface().setForceRender(true, fps) }
.onFailure { Log.w(TAG, "applyThermalProfile fps: ${it.message}") }
}
}
}
fun setMinQualityFloor(floorBitrate: Int, ceilingBitrate: Int? = null) {
mainHandler.post {
val current = config
if (current != null) {
config = current.copy(
videoBitrate = ceilingBitrate ?: current.videoBitrate,
minVideoBitrate = floorBitrate,
)
}
applyAdaptiveBitrate(adaptiveBitrate.setFloor(floorBitrate))
}
}
private fun applyAdaptiveBitrate(bitrate: Int) {
genericStream?.let { stream ->
runCatching { stream.setVideoBitrateOnFly(bitrate) }
.onFailure { Log.w(TAG, "ABR bitrate: ${it.message}") }
}
}
private fun obtainStream(cfg: BroadcastConfig): GenericStream {
val existing = genericStream
if (existing != null) return existing
@@ -552,6 +579,12 @@ class LiveBroadcastEngine(
override fun onNewBitrate(bitrate: Long) {
mainHandler.post {
currentBitrateKbps = bitrate / 1000
val congestion = runCatching {
genericStream?.getStreamClient()?.hasCongestion() == true
}.getOrDefault(false)
adaptiveBitrate.onMeasured(bitrate, congestion)?.let { next ->
applyAdaptiveBitrate(next)
}
emitMetrics()
}
}
@@ -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"
}
}
@@ -15,11 +15,14 @@ object StreamVideoPreset {
fun broadcastConfig(session: StreamSession, rtmpUrl: String): BroadcastConfig {
val dims = dimensions(session.qualityPreset)
val ceiling = StreamQualityLadder.encoderCeiling(session.targetBitrate, session.minQualityPreset)
val floor = StreamQualityLadder.encoderFloor(session.minQualityPreset, ceiling)
return BroadcastConfig(
rtmpUrl = rtmpUrl,
width = dims.width,
height = dims.height,
videoBitrate = session.targetBitrate,
videoBitrate = ceiling,
minVideoBitrate = floor,
fps = session.targetFps,
)
}
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -29,6 +30,7 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.Videocam
@@ -58,6 +60,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
import com.matchlivetv.match_live_tv.R
@@ -65,6 +69,7 @@ import com.matchlivetv.match_live_tv.core.resolveMediaUrl
import com.matchlivetv.match_live_tv.core.DeviceHealthSnapshot
import com.matchlivetv.match_live_tv.core.ThermalState
import com.matchlivetv.match_live_tv.domain.ScoreState
import com.matchlivetv.match_live_tv.streaming.StreamQualityLadder
import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@@ -109,9 +114,27 @@ fun BroadcastControlsOverlay(
bitrateKbps: Long,
networkType: String,
deviceHealth: DeviceHealthSnapshot,
minQualityPreset: String = StreamQualityLadder.AUTO,
sessionQualityPreset: String = "720p_30_2.5mbps",
onSelectMinQuality: (String) -> Unit = {},
modifier: Modifier = Modifier,
) {
var showTerminateConfirm by remember { mutableStateOf(false) }
var showMinQualityPicker by remember { mutableStateOf(false) }
val autoLabel = stringResource(R.string.broadcast_min_quality_auto)
if (showMinQualityPicker) {
MinQualityPickerDialog(
selectedId = minQualityPreset,
sessionQualityPreset = sessionQualityPreset,
autoLabel = autoLabel,
onSelect = { id ->
showMinQualityPicker = false
onSelectMinQuality(id)
},
onDismiss = { showMinQualityPicker = false },
)
}
if (showTerminateConfirm) {
AlertDialog(
@@ -174,6 +197,7 @@ fun BroadcastControlsOverlay(
bitrateKbps = bitrateKbps,
networkType = networkType,
deviceHealth = deviceHealth,
minQualityLabel = StreamQualityLadder.displayName(minQualityPreset, autoLabel),
)
}
@@ -195,6 +219,11 @@ fun BroadcastControlsOverlay(
contentDescription = stringResource(R.string.broadcast_share_regia_cd),
onClick = onShareRegia,
)
SideIconButton(
icon = Icons.Default.Tune,
contentDescription = stringResource(R.string.broadcast_min_quality_cd),
onClick = { showMinQualityPicker = true },
)
if (onCloseSet != null) {
SideIconButton(
icon = Icons.Default.Check,
@@ -287,6 +316,7 @@ private fun BroadcastTelemetryPanel(
bitrateKbps: Long,
networkType: String,
deviceHealth: DeviceHealthSnapshot,
minQualityLabel: String,
modifier: Modifier = Modifier,
) {
val labelStyle = MaterialTheme.typography.labelSmall
@@ -321,6 +351,11 @@ private fun BroadcastTelemetryPanel(
color = MatchColors.TextSecondary,
)
}
Text(
minQualityLabel,
style = labelStyle,
color = MatchColors.AccentYellow,
)
Text(
"${networkType} · ${deviceHealth.batteryPercent}%",
style = labelStyle,
@@ -604,6 +639,113 @@ private fun TeamLogoThumbnail(url: String) {
}
}
@Composable
private fun MinQualityPickerDialog(
selectedId: String,
sessionQualityPreset: String,
autoLabel: String,
onSelect: (String) -> Unit,
onDismiss: () -> Unit,
) {
val options = StreamQualityLadder.availableIds(sessionQualityPreset)
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Column(
modifier = Modifier
.padding(horizontal = 24.dp)
.widthIn(max = 720.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(MatchColors.Surface)
.border(1.dp, MatchColors.Outline, RoundedCornerShape(16.dp))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.broadcast_min_quality_title),
color = Color.White,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel), color = MatchColors.TextSecondary)
}
}
Text(
stringResource(R.string.broadcast_min_quality_hint),
color = MatchColors.TextSecondary,
style = MaterialTheme.typography.bodySmall,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
options.forEach { id ->
MinQualityOptionChip(
label = StreamQualityLadder.displayName(id, autoLabel),
selected = id == selectedId,
onClick = { onSelect(id) },
modifier = Modifier.weight(1f),
)
}
}
}
}
}
@Composable
private fun MinQualityOptionChip(
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(12.dp)
val border = if (selected) MatchColors.AccentYellow else MatchColors.Outline
val background = if (selected) {
MatchColors.AccentYellow.copy(alpha = 0.16f)
} else {
MatchColors.SurfaceElevated
}
Column(
modifier = modifier
.clip(shape)
.background(background)
.border(1.dp, border, shape)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (selected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MatchColors.AccentYellow,
modifier = Modifier.size(16.dp),
)
} else {
Spacer(Modifier.height(16.dp))
}
Text(
label,
color = if (selected) MatchColors.AccentYellow else Color.White,
style = MaterialTheme.typography.labelMedium,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SideIconButton(
@@ -44,6 +44,7 @@ import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.MatchScoringRules
import com.matchlivetv.match_live_tv.domain.StreamSession
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
import com.matchlivetv.match_live_tv.streaming.StreamQualityLadder
import com.matchlivetv.match_live_tv.streaming.StreamVideoPreset
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
@@ -78,6 +79,7 @@ fun BroadcastScreen(
var loading by remember { mutableStateOf(true) }
var pauseInFlight by remember { mutableStateOf(false) }
var resumeInFlight by remember { mutableStateOf(false) }
var minQualityInFlight by remember { mutableStateOf(false) }
var controlsVisible by remember { mutableStateOf(true) }
var logoReady by remember { mutableStateOf(0) }
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) }
@@ -156,10 +158,16 @@ fun BroadcastScreen(
}
suspend fun applyRemotePause() {
container.broadcastCoordinator.engine.pauseBroadcast()
session = runCatching { container.sessionRepository.fetchSession(sessionId) }
.getOrNull() ?: session?.copy(status = "paused")
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_paused_remote))
if (pauseInFlight) return
pauseInFlight = true
try {
container.broadcastCoordinator.engine.pauseBroadcast()
session = runCatching { container.sessionRepository.fetchSession(sessionId) }
.getOrNull() ?: session?.copy(status = "paused")
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_paused_remote))
} finally {
pauseInFlight = false
}
}
suspend fun applyRemoteResume() {
@@ -191,6 +199,38 @@ fun BroadcastScreen(
onFinished()
}
fun applyMinQualityLocally(preset: String, loaded: StreamSession) {
val updated = loaded.copy(minQualityPreset = preset)
session = updated
val cfg = broadcastConfig(updated)
container.broadcastCoordinator.engine.setMinQualityFloor(cfg.minVideoBitrate, cfg.videoBitrate)
thermalManager.updateBaseline(cfg)
}
suspend fun persistMinQuality(preset: String) {
val current = session ?: return
minQualityInFlight = true
runCatching { container.sessionRepository.setMinQuality(sessionId, preset) }
.onSuccess { updated ->
applyMinQualityLocally(updated.minQualityPreset, updated)
snackbar.showSnackbar(
context.getString(
R.string.broadcast_min_quality_applied,
StreamQualityLadder.displayName(
updated.minQualityPreset,
context.getString(R.string.broadcast_min_quality_auto),
),
),
)
}
.onFailure {
snackbar.showSnackbar(
it.message ?: context.getString(R.string.broadcast_min_quality_error),
)
}
minQualityInFlight = false
}
LaunchedEffect(sessionId, permissions.granted) {
if (!permissions.granted) return@LaunchedEffect
loading = true
@@ -215,6 +255,15 @@ fun BroadcastScreen(
container.sessionCable.onEndStream = {
scope.launch { applyRemoteStop() }
}
container.sessionCable.onMinQuality = { preset ->
scope.launch {
val current = session
if (current != null && current.minQualityPreset != preset) {
applyMinQualityLocally(preset, current)
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_min_quality_remote))
}
}
}
container.sessionCable.connect(sessionId, token, deviceRole = "camera")
}
container.broadcastCoordinator.startService()
@@ -329,12 +378,28 @@ fun BroadcastScreen(
LaunchedEffect(sessionId) {
while (true) {
delay(4_000)
runCatching {
container.sessionRepository.fetchSession(sessionId).score
}.getOrNull()?.let { remote ->
container.scoreController.applyRemote(remote)
}
delay(2_000)
if (minQualityInFlight || pauseInFlight || resumeInFlight) continue
runCatching { container.sessionRepository.fetchSession(sessionId) }
.getOrNull()
?.let { fresh ->
fresh.score?.let { container.scoreController.applyRemote(it) }
val current = session ?: return@let
if (fresh.minQualityPreset != current.minQualityPreset) {
applyMinQualityLocally(fresh.minQualityPreset, current)
}
val phase = container.broadcastCoordinator.metrics.value.phase
val publishing = phase == BroadcastPhase.LIVE ||
phase == BroadcastPhase.CONNECTING ||
phase == BroadcastPhase.RECONNECTING
when {
fresh.isEnded && phase != BroadcastPhase.IDLE -> applyRemoteStop()
fresh.isPaused && publishing -> applyRemotePause()
!fresh.isPaused && !fresh.isEnded && phase == BroadcastPhase.PAUSED ->
applyRemoteResume()
else -> session = fresh
}
}
}
}
@@ -342,7 +407,6 @@ fun BroadcastScreen(
while (true) {
delay(10_000)
val current = session ?: continue
if (current.isPaused) continue
runCatching {
container.sessionRepository.postTelemetry(
sessionId = sessionId,
@@ -352,6 +416,7 @@ fun BroadcastScreen(
currentBitrate = (metrics.bitrateKbps * 1000).toInt().takeIf { it > 0 },
targetBitrate = current.targetBitrate,
fps = metrics.fps.takeIf { it > 0 },
thermalState = thermalManager.state.value.name.lowercase(),
)
}
}
@@ -364,6 +429,7 @@ fun BroadcastScreen(
container.sessionCable.onPauseStream = null
container.sessionCable.onResumeStream = null
container.sessionCable.onEndStream = null
container.sessionCable.onMinQuality = null
container.sessionCable.disconnect()
container.broadcastCoordinator.stopService()
container.broadcastCoordinator.engine.stopBroadcast()
@@ -578,6 +644,11 @@ fun BroadcastScreen(
bitrateKbps = metrics.bitrateKbps,
networkType = DeviceTelemetry.networkType(context),
deviceHealth = DeviceTelemetry.snapshot(context, thermalState),
minQualityPreset = currentSession?.minQualityPreset ?: StreamQualityLadder.AUTO,
sessionQualityPreset = currentSession?.qualityPreset ?: "720p_30_2.5mbps",
onSelectMinQuality = { preset ->
scope.launch { persistMinQuality(preset) }
},
)
}
}
@@ -29,11 +29,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -50,7 +54,7 @@ import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun LoginScreen(
container: AppContainer,
@@ -130,7 +134,10 @@ fun LoginScreen(
onValueChange = { email = it },
label = { Text(stringResource(R.string.login_email)) },
placeholder = { Text(stringResource(R.string.login_email_placeholder)) },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.testTag("login_email")
.semantics { testTagsAsResourceId = true },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
@@ -146,7 +153,10 @@ fun LoginScreen(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.login_password)) },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.testTag("login_password")
.semantics { testTagsAsResourceId = true },
singleLine = true,
visualTransformation = if (passwordVisible) {
VisualTransformation.None
@@ -198,6 +208,9 @@ fun LoginScreen(
loading = loading,
enabled = email.isNotBlank() && password.isNotBlank(),
onClick = { submitLogin() },
modifier = Modifier
.testTag("login_submit")
.semantics { testTagsAsResourceId = true },
)
}
}
@@ -57,7 +57,6 @@ import com.matchlivetv.match_live_tv.ui.components.LanguagePickerDialog
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
import kotlinx.coroutines.launch
@@ -237,18 +236,11 @@ fun MatchesScreen(
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MatchSecondaryButton(
label = stringResource(R.string.matches_schedule).uppercase(),
onClick = { showScheduleSheet = true },
modifier = Modifier.weight(1f),
)
MatchPrimaryButton(
label = stringResource(R.string.matches_new).uppercase(),
onClick = { showNewMatchSheet = true },
modifier = Modifier.weight(1f),
)
}
MatchPrimaryButton(
label = stringResource(R.string.matches_new).uppercase(),
onClick = { showNewMatchSheet = true },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
activeTeam?.let { team ->
TeamPickerBar(
@@ -21,13 +21,12 @@
<string name="login_error_generic">Anmeldung fehlgeschlagen</string>
<string name="matches_title">Spiele</string>
<string name="matches_hello">Hallo, %1$s</string>
<string name="matches_subtitle">Nimm einen laufenden Livestream wieder auf oder starte ein geplantes Spiel.</string>
<string name="matches_schedule">Geplantes Spiel</string>
<string name="matches_subtitle">Nimm einen laufenden Livestream wieder auf oder starte ein neues Spiel.</string>
<string name="matches_new">Neues Spiel</string>
<string name="matches_empty_title">Keine Spiele im Kalender</string>
<string name="matches_scheduled_title">Geplante Spiele</string>
<string name="matches_ready_title">Bereit zum Start</string>
<string name="matches_empty_hint">Plane ein Spiel oder starte ein neues mit «Neues Spiel».</string>
<string name="matches_empty_hint">Tippe auf «Neues Spiel», um im Voraus zu planen oder sofort zu starten.</string>
<string name="matches_active_team">Aktives Team: %1$s.</string>
<string name="matches_multi_team_hint">Du hast mehrere Teams: prüfe die oben ausgewählte.</string>
<string name="matches_no_other">Keine weiteren Spiele im Kalender.</string>
@@ -165,6 +164,13 @@
<string name="broadcast_resume_cd">Livestream fortsetzen</string>
<string name="broadcast_pause_cd">Livestream pausieren</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_away_label">GAST</string>
<string name="broadcast_scoreboard_connected">Anzeigetafel OK</string>
@@ -21,13 +21,12 @@
<string name="login_error_generic">Login failed</string>
<string name="matches_title">Matches</string>
<string name="matches_hello">Hi, %1$s</string>
<string name="matches_subtitle">Resume a live stream or start a scheduled match.</string>
<string name="matches_schedule">Scheduled match</string>
<string name="matches_subtitle">Resume a live stream or start a new match.</string>
<string name="matches_new">New match</string>
<string name="matches_empty_title">No matches on the calendar</string>
<string name="matches_scheduled_title">Scheduled matches</string>
<string name="matches_ready_title">Ready to start</string>
<string name="matches_empty_hint">Schedule a match or start a new one with «New match».</string>
<string name="matches_empty_hint">Tap «New match» to schedule ahead or start right away.</string>
<string name="matches_active_team">Active team: %1$s.</string>
<string name="matches_multi_team_hint">You have multiple teams: check the one selected above.</string>
<string name="matches_no_other">No other matches on the calendar.</string>
@@ -165,6 +164,13 @@
<string name="broadcast_resume_cd">Resume live</string>
<string name="broadcast_pause_cd">Pause 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_away_label">AWAY</string>
<string name="broadcast_scoreboard_connected">Scoreboard OK</string>
@@ -21,13 +21,12 @@
<string name="login_error_generic">Error de acceso</string>
<string name="matches_title">Partidos</string>
<string name="matches_hello">Hola, %1$s</string>
<string name="matches_subtitle">Reanuda un directo en curso o inicia un partido programado.</string>
<string name="matches_schedule">Partido programado</string>
<string name="matches_subtitle">Reanuda un directo en curso o inicia un nuevo partido.</string>
<string name="matches_new">Nuevo partido</string>
<string name="matches_empty_title">Ningún partido en el calendario</string>
<string name="matches_scheduled_title">Partidos programados</string>
<string name="matches_ready_title">Listos para empezar</string>
<string name="matches_empty_hint">Programa un partido o inicia uno nuevo con «Nuevo partido».</string>
<string name="matches_empty_hint">Toca «Nuevo partido» para programar con antelación o empezar ya.</string>
<string name="matches_active_team">Equipo activo: %1$s.</string>
<string name="matches_multi_team_hint">Tienes varios equipos: comprueba el seleccionado arriba.</string>
<string name="matches_no_other">Ningún otro partido en el calendario.</string>
@@ -165,6 +164,13 @@
<string name="broadcast_resume_cd">Reanudar directo</string>
<string name="broadcast_pause_cd">Pausar 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_away_label">VISITANTE</string>
<string name="broadcast_scoreboard_connected">Marcador OK</string>
@@ -21,13 +21,12 @@
<string name="login_error_generic">Échec de la connexion</string>
<string name="matches_title">Matchs</string>
<string name="matches_hello">Bonjour, %1$s</string>
<string name="matches_subtitle">Reprenez un direct en cours ou démarrez un match programmé.</string>
<string name="matches_schedule">Match programmé</string>
<string name="matches_subtitle">Reprenez un direct en cours ou démarrez un nouveau match.</string>
<string name="matches_new">Nouveau match</string>
<string name="matches_empty_title">Aucun match au calendrier</string>
<string name="matches_scheduled_title">Matchs programmés</string>
<string name="matches_ready_title">Prêts à démarrer</string>
<string name="matches_empty_hint">Programmez un match ou démarrez-en un avec « Nouveau match ».</string>
<string name="matches_empty_hint">Touchez « Nouveau match » pour planifier à l\'avance ou démarrer tout de suite.</string>
<string name="matches_active_team">Équipe active : %1$s.</string>
<string name="matches_multi_team_hint">Vous avez plusieurs équipes : vérifiez celle sélectionnée ci-dessus.</string>
<string name="matches_no_other">Aucun autre match au calendrier.</string>
@@ -165,6 +164,13 @@
<string name="broadcast_resume_cd">Reprendre le direct</string>
<string name="broadcast_pause_cd">Mettre le direct en pause</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_away_label">EXTÉRIEUR</string>
<string name="broadcast_scoreboard_connected">Tableau OK</string>
@@ -21,13 +21,12 @@
<string name="login_error_generic">Login fallito</string>
<string name="matches_title">Partite</string>
<string name="matches_hello">Ciao, %1$s</string>
<string name="matches_subtitle">Riprendi una diretta in corso o avvia una partita programmata.</string>
<string name="matches_schedule">Partita programmata</string>
<string name="matches_subtitle">Riprendi una diretta in corso o avvia una nuova partita.</string>
<string name="matches_new">Nuova partita</string>
<string name="matches_empty_title">Nessuna partita in calendario</string>
<string name="matches_scheduled_title">Partite programmate</string>
<string name="matches_ready_title">Pronte da avviare</string>
<string name="matches_empty_hint">Programma una partita o avviane una nuova con «Nuova partita».</string>
<string name="matches_empty_hint">Tocca «Nuova partita» per programmare in anticipo o avviare subito.</string>
<string name="matches_active_team">Squadra attiva: %1$s.</string>
<string name="matches_multi_team_hint">Hai più squadre: verifica quella selezionata sopra.</string>
<string name="matches_no_other">Nessuna altra partita in calendario.</string>
@@ -165,6 +164,13 @@
<string name="broadcast_resume_cd">Riprendi diretta</string>
<string name="broadcast_pause_cd">Pausa 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_away_label">OSPITE</string>
<string name="broadcast_scoreboard_connected">Tabellone OK</string>
@@ -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),
)
}
}
File diff suppressed because it is too large Load Diff
@@ -1,32 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion="1600" version="1.7">
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
<Scheme
LastUpgradeVersion = "2660"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="768EC47B451F4731A05BE1F9" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F54F6C97361C4E8A96AEDD11"
BuildableName = "MatchLiveTv.app"
BlueprintName = "MatchLiveTv"
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F6079500E62048DB89AE3866" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "20C695DBFB4042879308B0F5"
BuildableName = "MatchLiveTvTests.xctest"
BlueprintName = "MatchLiveTvTests"
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference skipped="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F6079500E62048DB89AE3866" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "20C695DBFB4042879308B0F5"
BuildableName = "MatchLiveTvTests.xctest"
BlueprintName = "MatchLiveTvTests"
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="768EC47B451F4731A05BE1F9" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F54F6C97361C4E8A96AEDD11"
BuildableName = "MatchLiveTv.app"
BlueprintName = "MatchLiveTv"
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="768EC47B451F4731A05BE1F9" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F54F6C97361C4E8A96AEDD11"
BuildableName = "MatchLiveTv.app"
BlueprintName = "MatchLiveTv"
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration="Debug"/>
<ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+56 -26
View File
@@ -118,7 +118,13 @@ enum L10n {
"broadcast.error.rtmp.session.create": "Impossibile creare sessione RTMP",
"broadcast.error.rtmp.url.invalid": "URL RTMP non valido",
"broadcast.error.session.unavailable": "Sessione non disponibile",
"broadcast.hide.controls.cd": "Nascondi controlli",
"broadcast.hide.controls.cd": "Nascondi controlli",
"broadcast.min.quality.applied": "Qualità minima: %1$@",
"broadcast.min.quality.auto": "Automatica",
"broadcast.min.quality.cd": "Qualità minima di trasmissione",
"broadcast.min.quality.error": "Impossibile impostare la qualità minima",
"broadcast.min.quality.hint": "L'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.",
"broadcast.min.quality.title": "Qualità minima",
"broadcast.next.period.cd": "Periodo successivo",
"broadcast.pause.cd": "Pausa diretta",
"broadcast.period.label.timed": "%1$d° tempo",
@@ -139,7 +145,8 @@ enum L10n {
"broadcast.show.controls.cd": "Mostra controlli",
"broadcast.side.away": "ospite",
"broadcast.side.home": "casa",
"broadcast.snackbar.closed.remote": "Diretta chiusa dalla regia",
"broadcast.snackbar.closed.remote": "Diretta chiusa dalla regia",
"broadcast.snackbar.min.quality.remote": "Qualità minima aggiornata dalla regia",
"broadcast.snackbar.pause.error": "Pausa: %1$@",
"broadcast.snackbar.paused": "Diretta in pausa",
"broadcast.snackbar.paused.remote": "Pausa dalla regia",
@@ -194,7 +201,7 @@ enum L10n {
"match.status.start": "AVVIA",
"matches.active.team": "Squadra attiva: %1$@.",
"matches.change": "Cambia",
"matches.empty.hint": "Programma una partita o avviane una nuova con «Nuova partita».",
"matches.empty.hint": "Tocca «Nuova partita» per programmare in anticipo o avviare subito.",
"matches.empty.title": "Nessuna partita in calendario",
"matches.hello": "Ciao, %1$@",
"matches.load.error": "Errore caricamento",
@@ -212,9 +219,8 @@ enum L10n {
"matches.no.team.title": "Nessuna squadra assegnata",
"matches.ready.title": "Pronte da avviare",
"matches.retry": "Riprova",
"matches.schedule": "Partita programmata",
"matches.scheduled.title": "Partite programmate",
"matches.subtitle": "Riprendi una diretta in corso o avvia una partita programmata.",
"matches.subtitle": "Riprendi una diretta in corso o avvia una nuova partita.",
"matches.tap.change.team": "Tocca per cambiare squadra",
"matches.team.for.live": "Squadra per la diretta",
"matches.title": "Partite",
@@ -381,7 +387,7 @@ enum L10n {
"account.save.password": "Update password",
"account.save.profile": "Save profile",
"account.title": "Account",
"forgot.password.lead": "Enter your account email: we'll send you a link to reset your password.",
"forgot.password.lead": "Enter your account email: we will send you a link to reset your password.",
"forgot.password.submit": "Send reset link",
"forgot.password.success": "If the email is registered, you will receive a password reset link shortly.",
"forgot.password.title": "Forgot password",
@@ -409,7 +415,13 @@ enum L10n {
"broadcast.error.rtmp.session.create": "Unable to create RTMP session",
"broadcast.error.rtmp.url.invalid": "Invalid RTMP URL",
"broadcast.error.session.unavailable": "Session not available",
"broadcast.hide.controls.cd": "Hide controls",
"broadcast.hide.controls.cd": "Hide controls",
"broadcast.min.quality.applied": "Minimum quality: %1$@",
"broadcast.min.quality.auto": "Automatic",
"broadcast.min.quality.cd": "Minimum streaming quality",
"broadcast.min.quality.error": "Unable to set minimum quality",
"broadcast.min.quality.hint": "The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.",
"broadcast.min.quality.title": "Minimum quality",
"broadcast.next.period.cd": "Next period",
"broadcast.pause.cd": "Pause live",
"broadcast.period.label.timed": "Period %1$d",
@@ -430,7 +442,8 @@ enum L10n {
"broadcast.show.controls.cd": "Show controls",
"broadcast.side.away": "away",
"broadcast.side.home": "home",
"broadcast.snackbar.closed.remote": "Live closed from control room",
"broadcast.snackbar.closed.remote": "Live closed from control room",
"broadcast.snackbar.min.quality.remote": "Minimum quality updated from control room",
"broadcast.snackbar.pause.error": "Pause: %1$@",
"broadcast.snackbar.paused": "Live paused",
"broadcast.snackbar.paused.remote": "Paused from control room",
@@ -485,7 +498,7 @@ enum L10n {
"match.status.start": "START",
"matches.active.team": "Active team: %1$@.",
"matches.change": "Change",
"matches.empty.hint": "Schedule a match or start a new one with «New match».",
"matches.empty.hint": "Tap «New match» to schedule ahead or start right away.",
"matches.empty.title": "No matches on the calendar",
"matches.hello": "Hi, %1$@",
"matches.load.error": "Loading error",
@@ -503,9 +516,8 @@ enum L10n {
"matches.no.team.title": "No team assigned",
"matches.ready.title": "Ready to start",
"matches.retry": "Retry",
"matches.schedule": "Scheduled match",
"matches.scheduled.title": "Scheduled matches",
"matches.subtitle": "Resume a live stream or start a scheduled match.",
"matches.subtitle": "Resume a live stream or start a new match.",
"matches.tap.change.team": "Tap to change team",
"matches.team.for.live": "Team for the live stream",
"matches.title": "Matches",
@@ -700,7 +712,13 @@ enum L10n {
"broadcast.error.rtmp.session.create": "Impossible de créer la session RTMP",
"broadcast.error.rtmp.url.invalid": "URL RTMP non valide",
"broadcast.error.session.unavailable": "Session non disponible",
"broadcast.hide.controls.cd": "Masquer les commandes",
"broadcast.hide.controls.cd": "Masquer les commandes",
"broadcast.min.quality.applied": "Qualité minimale : %1$@",
"broadcast.min.quality.auto": "Automatique",
"broadcast.min.quality.cd": "Qualité minimale de transmission",
"broadcast.min.quality.error": "Impossible de définir la qualité minimale",
"broadcast.min.quality.hint": "L'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.",
"broadcast.min.quality.title": "Qualité minimale",
"broadcast.next.period.cd": "Période suivante",
"broadcast.pause.cd": "Mettre le direct en pause",
"broadcast.period.label.timed": "Période %1$d",
@@ -721,7 +739,8 @@ enum L10n {
"broadcast.show.controls.cd": "Afficher les commandes",
"broadcast.side.away": "extérieur",
"broadcast.side.home": "domicile",
"broadcast.snackbar.closed.remote": "Direct fermé depuis la régie",
"broadcast.snackbar.closed.remote": "Direct fermé depuis la régie",
"broadcast.snackbar.min.quality.remote": "Qualité minimale mise à jour depuis la régie",
"broadcast.snackbar.pause.error": "Pause : %1$@",
"broadcast.snackbar.paused": "Direct en pause",
"broadcast.snackbar.paused.remote": "Mis en pause depuis la régie",
@@ -776,7 +795,7 @@ enum L10n {
"match.status.start": "DÉMARRER",
"matches.active.team": "Équipe active : %1$@.",
"matches.change": "Changer",
"matches.empty.hint": "Programmez un match ou démarrez-en un avec « Nouveau match ».",
"matches.empty.hint": "Touchez « Nouveau match » pour planifier à l'avance ou démarrer tout de suite.",
"matches.empty.title": "Aucun match au calendrier",
"matches.hello": "Bonjour, %1$@",
"matches.load.error": "Erreur de chargement",
@@ -794,9 +813,8 @@ enum L10n {
"matches.no.team.title": "Aucune équipe assignée",
"matches.ready.title": "Prêts à démarrer",
"matches.retry": "Réessayer",
"matches.schedule": "Match programmé",
"matches.scheduled.title": "Matchs programmés",
"matches.subtitle": "Reprenez un direct en cours ou démarrez un match programmé.",
"matches.subtitle": "Reprenez un direct en cours ou démarrez un nouveau match.",
"matches.tap.change.team": "Appuyez pour changer d'équipe",
"matches.team.for.live": "Équipe pour le direct",
"matches.title": "Matchs",
@@ -991,7 +1009,13 @@ enum L10n {
"broadcast.error.rtmp.session.create": "RTMP-Sitzung konnte nicht erstellt werden",
"broadcast.error.rtmp.url.invalid": "Ungültige RTMP-URL",
"broadcast.error.session.unavailable": "Sitzung nicht verfügbar",
"broadcast.hide.controls.cd": "Steuerung ausblenden",
"broadcast.hide.controls.cd": "Steuerung ausblenden",
"broadcast.min.quality.applied": "Mindestqualität: %1$@",
"broadcast.min.quality.auto": "Automatisch",
"broadcast.min.quality.cd": "Minimale Übertragungsqualität",
"broadcast.min.quality.error": "Mindestqualität konnte nicht gesetzt werden",
"broadcast.min.quality.hint": "Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.",
"broadcast.min.quality.title": "Mindestqualität",
"broadcast.next.period.cd": "Nächste Periode",
"broadcast.pause.cd": "Livestream pausieren",
"broadcast.period.label.timed": "Halbzeit %1$d",
@@ -1012,7 +1036,8 @@ enum L10n {
"broadcast.show.controls.cd": "Steuerung einblenden",
"broadcast.side.away": "Gast",
"broadcast.side.home": "Heim",
"broadcast.snackbar.closed.remote": "Livestream von der Regie beendet",
"broadcast.snackbar.closed.remote": "Livestream von der Regie beendet",
"broadcast.snackbar.min.quality.remote": "Mindestqualität von der Regie aktualisiert",
"broadcast.snackbar.pause.error": "Pause: %1$@",
"broadcast.snackbar.paused": "Livestream pausiert",
"broadcast.snackbar.paused.remote": "Von der Regie pausiert",
@@ -1067,7 +1092,7 @@ enum L10n {
"match.status.start": "STARTEN",
"matches.active.team": "Aktives Team: %1$@.",
"matches.change": "Wechseln",
"matches.empty.hint": "Plane ein Spiel oder starte ein neues mit «Neues Spiel».",
"matches.empty.hint": "Tippe auf «Neues Spiel», um im Voraus zu planen oder sofort zu starten.",
"matches.empty.title": "Keine Spiele im Kalender",
"matches.hello": "Hallo, %1$@",
"matches.load.error": "Ladefehler",
@@ -1085,9 +1110,8 @@ enum L10n {
"matches.no.team.title": "Kein Team zugewiesen",
"matches.ready.title": "Bereit zum Start",
"matches.retry": "Erneut versuchen",
"matches.schedule": "Geplantes Spiel",
"matches.scheduled.title": "Geplante Spiele",
"matches.subtitle": "Nimm einen laufenden Livestream wieder auf oder starte ein geplantes Spiel.",
"matches.subtitle": "Nimm einen laufenden Livestream wieder auf oder starte ein neues Spiel.",
"matches.tap.change.team": "Tippen zum Teamwechsel",
"matches.team.for.live": "Team für den Livestream",
"matches.title": "Spiele",
@@ -1282,7 +1306,13 @@ enum L10n {
"broadcast.error.rtmp.session.create": "No se pudo crear la sesión RTMP",
"broadcast.error.rtmp.url.invalid": "URL RTMP no válida",
"broadcast.error.session.unavailable": "Sesión no disponible",
"broadcast.hide.controls.cd": "Ocultar controles",
"broadcast.hide.controls.cd": "Ocultar controles",
"broadcast.min.quality.applied": "Calidad mínima: %1$@",
"broadcast.min.quality.auto": "Automática",
"broadcast.min.quality.cd": "Calidad mínima de transmisión",
"broadcast.min.quality.error": "No se pudo fijar la calidad mínima",
"broadcast.min.quality.hint": "El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.",
"broadcast.min.quality.title": "Calidad mínima",
"broadcast.next.period.cd": "Periodo siguiente",
"broadcast.pause.cd": "Pausar directo",
"broadcast.period.label.timed": "Tiempo %1$d",
@@ -1303,7 +1333,8 @@ enum L10n {
"broadcast.show.controls.cd": "Mostrar controles",
"broadcast.side.away": "visitante",
"broadcast.side.home": "local",
"broadcast.snackbar.closed.remote": "Directo cerrado desde regie",
"broadcast.snackbar.closed.remote": "Directo cerrado desde regie",
"broadcast.snackbar.min.quality.remote": "Calidad mínima actualizada desde la regie",
"broadcast.snackbar.pause.error": "Pausa: %1$@",
"broadcast.snackbar.paused": "Directo en pausa",
"broadcast.snackbar.paused.remote": "Pausado desde regie",
@@ -1358,7 +1389,7 @@ enum L10n {
"match.status.start": "INICIAR",
"matches.active.team": "Equipo activo: %1$@.",
"matches.change": "Cambiar",
"matches.empty.hint": "Programa un partido o inicia uno nuevo con «Nuevo partido».",
"matches.empty.hint": "Toca «Nuevo partido» para programar con antelación o empezar ya.",
"matches.empty.title": "Ningún partido en el calendario",
"matches.hello": "Hola, %1$@",
"matches.load.error": "Error de carga",
@@ -1376,9 +1407,8 @@ enum L10n {
"matches.no.team.title": "Ningún equipo asignado",
"matches.ready.title": "Listos para empezar",
"matches.retry": "Reintentar",
"matches.schedule": "Partido programado",
"matches.scheduled.title": "Partidos programados",
"matches.subtitle": "Reanuda un directo en curso o inicia un partido programado.",
"matches.subtitle": "Reanuda un directo en curso o inicia un nuevo partido.",
"matches.tap.change.team": "Toca para cambiar de equipo",
"matches.team.for.live": "Equipo para el directo",
"matches.title": "Partidos",
@@ -39,4 +39,13 @@ enum ThermalState: Int, Sendable, Equatable, Comparable {
case .critical: return "🔴"
}
}
var apiValue: String {
switch self {
case .nominal: return "nominal"
case .fair: return "fair"
case .serious: return "serious"
case .critical: return "critical"
}
}
}
@@ -202,6 +202,7 @@ struct StreamSessionDto: Decodable {
let targetBitrate: Int?
let targetFps: Int?
let qualityPreset: String?
let minQualityPreset: String?
let startedAt: String?
let score: ScoreStateDto?
@@ -221,6 +222,7 @@ struct StreamSessionDto: Decodable {
targetBitrate: targetBitrate ?? 2_500_000,
targetFps: targetFps ?? 30,
qualityPreset: qualityPreset ?? "720p_30_2.5mbps",
minQualityPreset: minQualityPreset ?? "auto",
startedAt: startedAt,
score: score?.toDomain()
)
@@ -414,6 +416,10 @@ struct CreateSessionRequest: Encodable {
let youtubeChannel: String?
}
struct MinQualityRequest: Encodable {
let minQualityPreset: String
}
struct ScoringRulesBody: Codable {
let setsToWin: Int?
let pointsPerSet: Int?
@@ -499,6 +505,7 @@ struct TelemetryRequest: Encodable {
let currentBitrate: Int?
let targetBitrate: Int?
let fps: Int?
let thermalState: String?
}
extension ScoringRules {
@@ -186,6 +186,10 @@ final class MatchLiveAPI: @unchecked Sendable {
try await patch("sessions/\(id)/resume", body: EmptyBody())
}
func setMinQuality(id: String, preset: String) async throws -> StreamSessionDto {
try await patch("sessions/\(id)/min_quality", body: MinQualityRequest(minQualityPreset: preset))
}
func networkTest(sessionId: String, body: NetworkTestRequest) async throws -> NetworkTestResponse {
try await post("sessions/\(sessionId)/network_test", body: body)
}
@@ -8,6 +8,7 @@ final class SessionCableService: ObservableObject {
var onPauseStream: (() -> Void)?
var onResumeStream: (() -> Void)?
var onStopStream: (() -> Void)?
var onMinQuality: ((String) -> Void)?
private var client: ActionCableClient?
private var intentionalDisconnect = false
@@ -93,6 +94,10 @@ extension SessionCableService: ActionCableClient.Listener {
case "pause_stream": onPauseStream?()
case "resume_stream": onResumeStream?()
case "stop_stream": onStopStream?()
case "set_min_quality":
if let preset = payload["min_quality_preset"] as? String {
onMinQuality?(preset)
}
default: break
}
case "stream_event":
@@ -50,6 +50,10 @@ final class SessionRepository {
try await api.resumeSession(id: id).toDomain()
}
func setMinQuality(id: String, preset: String) async throws -> StreamSession {
try await api.setMinQuality(id: id, preset: preset).toDomain()
}
func submitNetworkTest(
sessionId: String,
downloadMbps: Double,
@@ -88,7 +92,8 @@ final class SessionRepository {
signalStrength: nil,
currentBitrate: currentBitrate,
targetBitrate: targetBitrate,
fps: fps
fps: fps,
thermalState: health.thermalState.apiValue
)
)
}
@@ -119,11 +119,35 @@ struct StreamSession: Identifiable, Equatable, Sendable {
let targetBitrate: Int
let targetFps: Int
let qualityPreset: String
let minQualityPreset: String
let startedAt: String?
let score: ScoreState?
var isLive: Bool { status == "live" || status == "reconnecting" }
var isPaused: Bool { status == "paused" }
var isEnded: Bool { status == "ended" || status == "error" }
func with(minQualityPreset: String) -> StreamSession {
StreamSession(
id: id,
matchId: matchId,
status: status,
platform: platform,
rtmpIngestUrl: rtmpIngestUrl,
hlsPlaybackUrl: hlsPlaybackUrl,
watchPageUrl: watchPageUrl,
shareUrl: shareUrl,
youtubeWatchUrl: youtubeWatchUrl,
youtubeReady: youtubeReady,
privacyStatus: privacyStatus,
targetBitrate: targetBitrate,
targetFps: targetFps,
qualityPreset: qualityPreset,
minQualityPreset: minQualityPreset,
startedAt: startedAt,
score: score
)
}
func watchShareUrl() -> String? {
if platform == "youtube", let url = youtubeWatchUrl, !url.isEmpty { return url }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 216 KiB

+10 -2
View File
@@ -19,7 +19,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.0.5</string>
<string>2.0.10</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -32,7 +32,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>26</string>
<string>32</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
@@ -55,6 +55,14 @@
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
@@ -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 height: Int = 720
var videoBitrate: Int = 2_500_000
var minVideoBitrate: Int = 800_000
var audioBitrate: Int = 128_000
var fps: Int = 30
var rotation: Int = 0
@@ -5,18 +5,37 @@ import RTMPHaishinKit
import UIKit
import VideoToolbox
private struct BroadcastBitrateMonitor: StreamBitRateStrategy {
let mamimumVideoBitRate: Int = 0
let mamimumAudioBitRate: Int = 0
private final class AdaptiveBroadcastBitRateStrategy: StreamBitRateStrategy, @unchecked Sendable {
let controller: AdaptiveBitrateController
let onUpdate: @Sendable (Int) -> Void
var mamimumVideoBitRate: Int { controller.ceiling }
let mamimumAudioBitRate: Int = 0
init(controller: AdaptiveBitrateController, onUpdate: @escaping @Sendable (Int) -> Void) {
self.controller = controller
self.onUpdate = onUpdate
}
func adjustBitrate(_ event: NetworkMonitorEvent, stream: some StreamConvertible) async {
let next: Int?
switch event {
case .status(let report), .publishInsufficientBWOccured(let report):
case .status(let report):
let measuredBits = report.currentBytesOutPerSecond * 8
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
next = controller.onMeasured(measuredBitsPerSecond: measuredBits, congested: false)
case .publishInsufficientBWOccured(let report):
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
next = controller.onCongestion(measuredBitsPerSecond: report.currentBytesOutPerSecond * 8)
case .reset:
break
next = controller.resetToCeiling()
@unknown default:
next = nil
}
guard let bitrate = next else { return }
var videoSettings = await stream.videoSettings
guard videoSettings.bitRate != bitrate else { return }
videoSettings.bitRate = bitrate
try? await stream.setVideoSettings(videoSettings)
}
}
@@ -47,6 +66,7 @@ final class LiveBroadcastEngine: ObservableObject {
private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
private var orientationDebounceTask: Task<Void, Never>?
private var rtmpSession: (any Session)?
private let adaptiveBitrate = AdaptiveBitrateController()
private static var rtmpFactoryRegistered = false
private static func ensureRTMPFactoryRegistered() async {
@@ -101,6 +121,7 @@ final class LiveBroadcastEngine: ObservableObject {
func preparePreview(config: BroadcastConfig) async throws {
self.config = config
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
publishPending = false
try await configurePipeline(config)
setPhase(.preview)
@@ -109,6 +130,7 @@ final class LiveBroadcastEngine: ObservableObject {
/// Configura camera/mixer e mette in coda il publish RTMP (eseguito quando l'anteprima è pronta).
func prepareForBroadcast(config: BroadcastConfig) async throws {
self.config = config
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
publishPending = true
try await configurePipeline(config)
setPhase(.connecting)
@@ -135,6 +157,7 @@ final class LiveBroadcastEngine: ObservableObject {
func resumeBroadcast(config: BroadcastConfig) async throws {
self.config = config
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
reconnectAttempts = 0
reconnectTask?.cancel()
reconnectTask = nil
@@ -179,18 +202,40 @@ final class LiveBroadcastEngine: ObservableObject {
await stopBroadcast()
}
/// Adattamento termico: aggiorna profilo video senza interrompere la sessione RTMP attiva.
/// Adattamento termico: aggiorna il soffitto ABR e l'FPS senza interrompere la sessione RTMP.
func applyThermalProfile(videoBitrate: Int, fps: Int) async {
guard var current = config else { return }
guard current.videoBitrate != videoBitrate || current.fps != fps else { return }
adaptiveBitrate.setCeiling(videoBitrate)
let applied = adaptiveBitrate.setFloor(current.minVideoBitrate)
guard current.videoBitrate != videoBitrate || current.fps != fps else {
await applyEncoderBitrate(applied)
return
}
current.videoBitrate = videoBitrate
current.fps = fps
config = current
if pipelineConfigured, let stream = await rtmpSession?.stream {
try? await applyCodecSettings(to: stream, config: current)
try? await applyCodecSettings(to: stream, config: current, videoBitrate: applied)
}
}
func setMinQualityFloor(_ floorBitrate: Int, ceiling: Int? = nil) async {
if var current = config {
if let ceiling { current.videoBitrate = ceiling }
current.minVideoBitrate = floorBitrate
config = current
}
await applyEncoderBitrate(adaptiveBitrate.setFloor(floorBitrate))
}
private func applyEncoderBitrate(_ bitrate: Int) async {
guard pipelineConfigured, let stream = await rtmpSession?.stream else { return }
var videoSettings = await stream.videoSettings
guard videoSettings.bitRate != bitrate else { return }
videoSettings.bitRate = bitrate
try? await stream.setVideoSettings(videoSettings)
}
private func configurePipeline(_ config: BroadcastConfig) async throws {
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized,
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
@@ -316,7 +361,7 @@ final class LiveBroadcastEngine: ObservableObject {
let stream = await session.stream
try await applyCodecSettings(to: stream, config: config)
await stream.setBitRateStrategy(BroadcastBitrateMonitor { [weak self] kbps in
await stream.setBitRateStrategy(AdaptiveBroadcastBitRateStrategy(controller: adaptiveBitrate) { [weak self] kbps in
Task { @MainActor in
guard let self else { return }
self.metrics = BroadcastMetrics(
@@ -349,10 +394,10 @@ final class LiveBroadcastEngine: ObservableObject {
setPhase(.live)
}
private func applyCodecSettings(to stream: any StreamConvertible, config: BroadcastConfig) async throws {
private func applyCodecSettings(to stream: any StreamConvertible, config: BroadcastConfig, videoBitrate: Int? = nil) async throws {
var videoSettings = await stream.videoSettings
videoSettings.videoSize = .init(width: config.width, height: config.height)
videoSettings.bitRate = config.videoBitrate
videoSettings.bitRate = videoBitrate ?? adaptiveBitrate.currentBitrate
videoSettings.maxKeyFrameIntervalDuration = 2
videoSettings.profileLevel = config.height > 720
? (kVTProfileLevel_H264_Baseline_4_1 as String)
@@ -15,11 +15,17 @@ enum StreamVideoPreset {
static func broadcastConfig(for session: StreamSession, rtmpUrl: String) -> BroadcastConfig {
let dims = dimensions(for: session.qualityPreset)
let ceiling = StreamQualityLadder.encoderCeiling(
sessionTarget: session.targetBitrate,
minPresetId: session.minQualityPreset
)
let floor = StreamQualityLadder.encoderFloor(minPresetId: session.minQualityPreset, ceiling: ceiling)
return BroadcastConfig(
rtmpUrl: rtmpUrl,
width: dims.width,
height: dims.height,
videoBitrate: session.targetBitrate,
videoBitrate: ceiling,
minVideoBitrate: floor,
audioBitrate: 128_000,
fps: session.targetFps
)
@@ -40,8 +40,12 @@ struct BroadcastControlsOverlay: View {
let bitrateKbps: Int
let networkType: String
let deviceHealth: DeviceHealth
var minQualityPreset: String = StreamQualityLadder.auto
var sessionQualityPreset: String = "720p_30_2.5mbps"
var onSelectMinQuality: (String) -> Void = { _ in }
@State private var showTerminateConfirm = false
@State private var showMinQualityPicker = false
var body: some View {
Color.clear
@@ -69,7 +73,11 @@ struct BroadcastControlsOverlay: View {
targetFps: targetFps,
bitrateKbps: bitrateKbps,
networkType: networkType,
deviceHealth: deviceHealth
deviceHealth: deviceHealth,
minQualityLabel: StreamQualityLadder.displayName(
id: minQualityPreset,
autoLabel: L10n.t("broadcast.min.quality.auto")
)
)
}
.padding(.trailing, 8)
@@ -94,6 +102,19 @@ struct BroadcastControlsOverlay: View {
.padding(.bottom, 8)
}
}
.overlay {
if showMinQualityPicker {
MinQualityPickerOverlay(
selectedId: minQualityPreset,
sessionQualityPreset: sessionQualityPreset,
onSelect: { id in
showMinQualityPicker = false
onSelectMinQuality(id)
},
onDismiss: { showMinQualityPicker = false }
)
}
}
.alert(L10n.t("broadcast.terminate.title"), isPresented: $showTerminateConfirm) {
Button(L10n.t("action.cancel"), role: .cancel) {}
Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: onTerminate)
@@ -115,6 +136,11 @@ struct BroadcastControlsOverlay: View {
accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
action: onShareRegia
)
SideIconButton(
systemName: "slider.horizontal.3",
accessibilityLabel: L10n.t("broadcast.min.quality.cd"),
action: { showMinQualityPicker = true }
)
if let onCloseSet {
SideIconButton(
systemName: "checkmark",
@@ -188,6 +214,85 @@ struct BroadcastControlsOverlay: View {
}
}
private struct MinQualityPickerOverlay: View {
let selectedId: String
let sessionQualityPreset: String
let onSelect: (String) -> Void
let onDismiss: () -> Void
private var options: [String] {
StreamQualityLadder.availableIds(sessionQualityPreset: sessionQualityPreset)
}
var body: some View {
ZStack {
Color.black.opacity(0.55)
.ignoresSafeArea()
.onTapGesture(perform: onDismiss)
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(L10n.t("broadcast.min.quality.title"))
.font(.headline)
.foregroundStyle(.white)
Spacer()
Button(L10n.t("action.cancel"), action: onDismiss)
.foregroundStyle(MatchColors.textSecondary)
}
Text(L10n.t("broadcast.min.quality.hint"))
.font(.footnote)
.foregroundStyle(MatchColors.textSecondary)
HStack(spacing: 8) {
ForEach(options, id: \.self) { id in
let selected = id == selectedId
Button {
onSelect(id)
} label: {
VStack(spacing: 4) {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(selected ? MatchColors.accentYellow : .clear)
Text(
StreamQualityLadder.displayName(
id: id,
autoLabel: L10n.t("broadcast.min.quality.auto")
)
)
.font(.system(size: 13, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? MatchColors.accentYellow : .white)
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 8)
.padding(.vertical, 12)
.background(
selected
? MatchColors.accentYellow.opacity(0.16)
: MatchColors.surfaceElevated,
in: RoundedRectangle(cornerRadius: 12)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(selected ? MatchColors.accentYellow : MatchColors.outline, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
}
.padding(16)
.frame(maxWidth: 720)
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(MatchColors.outline, lineWidth: 1)
)
.padding(.horizontal, 24)
}
}
}
private struct BroadcastTelemetryPanel: View {
let cableConnected: Bool
let fps: Int
@@ -195,6 +300,7 @@ private struct BroadcastTelemetryPanel: View {
let bitrateKbps: Int
let networkType: String
let deviceHealth: DeviceHealth
let minQualityLabel: String
var body: some View {
VStack(alignment: .trailing, spacing: 2) {
@@ -211,6 +317,9 @@ private struct BroadcastTelemetryPanel: View {
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
}
Text(minQualityLabel)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.accentYellow)
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
@@ -27,6 +27,7 @@ struct BroadcastScreen: View {
@State private var logoReady = 0
@State private var pauseInFlight = false
@State private var resumeInFlight = false
@State private var minQualityInFlight = false
@State private var deviceHealth = DeviceTelemetry.snapshot()
@State private var snackbarMessage: String?
@State private var shareItem: ShareItem?
@@ -83,9 +84,15 @@ struct BroadcastScreen: View {
.task(id: sessionId) {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 2_000_000_000)
if let remote = try? await container.sessionRepository.fetchSession(id: sessionId).score {
container.scoreController.applyRemote(remote, source: .polling)
guard !minQualityInFlight, !pauseInFlight, !resumeInFlight else { continue }
guard let remote = try? await container.sessionRepository.fetchSession(id: sessionId) else { continue }
if let score = remote.score {
container.scoreController.applyRemote(score, source: .polling)
}
if session?.minQualityPreset != remote.minQualityPreset {
applyMinQualityLocally(preset: remote.minQualityPreset, fromRemote: true, notify: false)
}
await syncBroadcastToRemoteSession(remote)
}
}
.task(id: sessionId) {
@@ -290,7 +297,12 @@ struct BroadcastScreen: View {
targetFps: session.targetFps,
bitrateKbps: metrics.bitrateKbps,
networkType: deviceHealth.networkType,
deviceHealth: DeviceTelemetry.snapshot(thermalState: thermalManager.state)
deviceHealth: DeviceTelemetry.snapshot(thermalState: thermalManager.state),
minQualityPreset: session.minQualityPreset,
sessionQualityPreset: session.qualityPreset,
onSelectMinQuality: { preset in
Task { await persistMinQuality(preset) }
}
)
}
@@ -418,6 +430,12 @@ struct BroadcastScreen: View {
container.sessionCable.onStopStream = { [weak container] in
Task { @MainActor in await stopStream() }
}
container.sessionCable.onMinQuality = { [weak container] preset in
Task { @MainActor in
guard let container else { return }
applyMinQualityLocally(preset: preset, fromRemote: true)
}
}
container.sessionCable.connect(sessionId: sessionId, accessToken: token)
}
@@ -504,6 +522,33 @@ struct BroadcastScreen: View {
}
}
/// Allinea encoder locale allo stato sessione se Cable non ha consegnato il comando.
private func syncBroadcastToRemoteSession(_ remote: StreamSession) async {
let phase = container.broadcastCoordinator.metrics.phase
let publishing = phase == .live || phase == .connecting || phase == .reconnecting
if remote.isEnded {
if phase != .idle && phase != .preview {
await stopStream()
} else {
session = remote
}
return
}
if remote.isPaused {
if publishing {
await applyRemotePause(container: container)
} else {
session = remote
}
return
}
if phase == .paused {
await applyRemoteResume(container: container)
return
}
session = remote
}
/// Pausa dalla regia / echo cable: solo RTMP locale, senza PATCH pause.
private func applyRemotePause(container: AppContainer) async {
guard !pauseInFlight else { return }
@@ -541,6 +586,43 @@ struct BroadcastScreen: View {
)
}
private func applyMinQualityLocally(preset: String, fromRemote: Bool, notify: Bool = true) {
guard var current = session else { return }
if current.minQualityPreset == preset && fromRemote { return }
current = current.with(minQualityPreset: preset)
session = current
guard let url = current.rtmpIngestUrl, !url.isEmpty else { return }
let config = broadcastConfig(for: current, rtmpUrl: url)
Task {
await container.broadcastCoordinator.engine.setMinQualityFloor(
config.minVideoBitrate,
ceiling: config.videoBitrate
)
thermalManager.updateBaseline(config)
}
if fromRemote && notify {
snackbarMessage = L10n.t("broadcast.snackbar.min.quality.remote")
}
}
private func persistMinQuality(_ preset: String) async {
minQualityInFlight = true
defer { minQualityInFlight = false }
do {
let updated = try await container.sessionRepository.setMinQuality(id: sessionId, preset: preset)
applyMinQualityLocally(preset: updated.minQualityPreset, fromRemote: false)
snackbarMessage = L10n.t(
"broadcast.min.quality.applied",
StreamQualityLadder.displayName(
id: updated.minQualityPreset,
autoLabel: L10n.t("broadcast.min.quality.auto")
)
)
} catch {
snackbarMessage = UserFacingError.message(for: error) ?? L10n.t("broadcast.min.quality.error")
}
}
private func retryBroadcast() async {
error = nil
guard let session, let url = session.rtmpIngestUrl, !url.isEmpty else { return }
@@ -567,6 +649,7 @@ struct BroadcastScreen: View {
container.sessionCable.onPauseStream = nil
container.sessionCable.onResumeStream = nil
container.sessionCable.onStopStream = nil
container.sessionCable.onMinQuality = nil
container.sessionCable.disconnect()
await container.broadcastCoordinator.stopBroadcast(sessionId: sessionId)
}
@@ -17,6 +17,7 @@ struct MatchPrimaryButton: View {
.font(MatchTypography.labelLarge)
.lineLimit(1)
.minimumScaleFactor(0.7)
.padding(.horizontal, 14)
}
}
.frame(maxWidth: .infinity)
@@ -21,5 +21,8 @@ struct MatchScreenScaffold<Content: View, TopBar: View>: View {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(MatchColors.background)
.foregroundStyle(.white)
// Chrome custom: nasconde la navigation bar di sistema (evita chevron indietro spurî).
.toolbar(.hidden, for: .navigationBar)
.navigationBarBackButtonHidden(true)
}
}
@@ -11,6 +11,7 @@ struct MatchSecondaryButton: View {
.font(MatchTypography.labelLarge)
.lineLimit(1)
.minimumScaleFactor(0.7)
.padding(.horizontal, 14)
.frame(maxWidth: .infinity)
.frame(height: 52)
}
@@ -77,10 +77,7 @@ struct MatchesScreen: View {
Text(L10n.t("matches.subtitle"))
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
HStack(spacing: 12) {
MatchSecondaryButton(label: L10n.t("matches.schedule").uppercased(), action: { showSchedule = true })
MatchPrimaryButton(label: L10n.t("matches.new").uppercased(), action: { showNewMatch = true })
}
MatchPrimaryButton(label: L10n.t("matches.new").uppercased(), action: { showNewMatch = true })
if let activeTeam {
TeamPickerBar(
team: activeTeam,
@@ -1,7 +1,15 @@
import SwiftUI
struct AppNavHost: View {
private enum RootScreen {
case splash
case login
case matches
}
@StateObject private var container = AppContainer()
@State private var root: RootScreen = .splash
/// Solo destinazioni sopra la root (account, recupero password).
@State private var path: [Routes] = []
@State private var wizardRoute: WizardRoute?
@State private var broadcastRoute: BroadcastRoute?
@@ -17,49 +25,27 @@ struct AppNavHost: View {
var body: some View {
NavigationStack(path: $path) {
SplashScreen(
container: container,
onAuthenticated: { path = [.matches] },
onUnauthenticated: { path = [.login] }
)
.navigationDestination(for: Routes.self) { route in
switch route {
case .splash:
EmptyView()
case .login:
LoginScreen(
container: container,
onLoggedIn: { path = [.matches] },
onForgotPassword: { path.append(.forgotPassword) }
)
case .forgotPassword:
ForgotPasswordScreen(
container: container,
onBack: { path.removeLast() }
)
case .matches:
MatchesScreen(
container: container,
refreshToken: matchesRefreshToken,
onOpenSetup: { matchId in
wizardRoute = WizardRoute(matchId: matchId, step: 1)
},
onOpenBroadcast: { sessionId in
broadcastRoute = BroadcastRoute(sessionId: sessionId)
},
onOpenAccount: { path.append(.account) }
)
.lockPortraitOrientation()
case .account:
AccountScreen(
container: container,
onBack: { path.removeLast() },
onLoggedOut: { path = [.login] }
)
case .setup, .broadcast:
EmptyView()
rootContent
.navigationDestination(for: Routes.self) { route in
switch route {
case .forgotPassword:
ForgotPasswordScreen(
container: container,
onBack: { path.removeLast() }
)
case .account:
AccountScreen(
container: container,
onBack: { path.removeLast() },
onLoggedOut: {
path = []
root = .login
}
)
case .splash, .login, .matches, .setup, .broadcast:
EmptyView()
}
}
}
}
.environment(\.locale, appLocale)
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
@@ -98,7 +84,8 @@ struct AppNavHost: View {
AppOrientation.lockPortrait()
container.wizardSession.reset()
broadcastRoute = nil
path = [.matches]
path = []
root = .matches
}
.keepScreenOn()
}
@@ -109,4 +96,44 @@ struct AppNavHost: View {
}
.environmentObject(container)
}
@ViewBuilder
private var rootContent: some View {
switch root {
case .splash:
SplashScreen(
container: container,
onAuthenticated: {
path = []
root = .matches
},
onUnauthenticated: {
path = []
root = .login
}
)
case .login:
LoginScreen(
container: container,
onLoggedIn: {
path = []
root = .matches
},
onForgotPassword: { path.append(.forgotPassword) }
)
case .matches:
MatchesScreen(
container: container,
refreshToken: matchesRefreshToken,
onOpenSetup: { matchId in
wizardRoute = WizardRoute(matchId: matchId, step: 1)
},
onOpenBroadcast: { sessionId in
broadcastRoute = BroadcastRoute(sessionId: sessionId)
},
onOpenAccount: { path.append(.account) }
)
.lockPortraitOrientation()
}
}
}
@@ -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)
}
}
+4 -4
View File
@@ -96,7 +96,7 @@ lines += [
app_settings = """
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 32;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
@@ -107,7 +107,7 @@ app_settings = """
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.0.5;
MARKETING_VERSION = 2.0.10;
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
PRODUCT_NAME = "$(TARGET_NAME)";
API_BASE_URL = "https://www.matchlivetv.it";
@@ -122,10 +122,10 @@ app_debug_settings = app_settings + """
test_settings = """
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 32;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 2.0.5;
MARKETING_VERSION = 2.0.10;
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;