Compare commits

..
Author SHA1 Message Date
eminuxandCursor 3ed70b6999 Aggiorna marketing: prezzi lancio, Funzionalità narrativa e link store.
Allinea i piani Light/Full ai prezzi di lancio, ripensa /funzionalita per vendere il prodotto e aggiunge i badge App Store/Play nella home.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 23:00:17 +02:00
102 changed files with 2263 additions and 3012 deletions
-2
View File
@@ -55,8 +55,6 @@ class SessionChannel < ApplicationCable::Channel
Sessions::Pause.new(session).call Sessions::Pause.new(session).call
when "resume_stream" when "resume_stream"
Sessions::Resume.new(session).call Sessions::Resume.new(session).call
when "set_min_quality"
Sessions::SetMinQuality.new(session, preset: data["min_quality_preset"]).call
end end
end end
@@ -68,7 +68,6 @@ module Api
current_bitrate: params[:current_bitrate], current_bitrate: params[:current_bitrate],
target_bitrate: params[:target_bitrate], target_bitrate: params[:target_bitrate],
fps: params[:fps], fps: params[:fps],
thermal_state: sanitized_thermal_state,
last_seen_at: Time.current last_seen_at: Time.current
) )
sync_publisher_when_streaming!(params[:fps].to_f) sync_publisher_when_streaming!(params[:fps].to_f)
@@ -142,13 +141,6 @@ module Api
} }
end end
def min_quality
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
render json: session_json(@session)
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def youtube_stats def youtube_stats
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present? count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
Youtube::BroadcastService.new(@session.match.team) Youtube::BroadcastService.new(@session.match.team)
@@ -168,11 +160,6 @@ module Api
.find(params[:id]) .find(params[:id])
end end
def sanitized_thermal_state
value = params[:thermal_state].to_s.downcase.presence
value if value.in?(%w[nominal fair serious critical])
end
def session_params def session_params
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel) params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
end end
@@ -200,7 +187,6 @@ module Api
youtube_ready: session.youtube_ready?, youtube_ready: session.youtube_ready?,
privacy_status: session.privacy_status, privacy_status: session.privacy_status,
quality_preset: session.quality_preset, quality_preset: session.quality_preset,
min_quality_preset: session.min_quality_preset,
target_bitrate: session.target_bitrate, target_bitrate: session.target_bitrate,
started_at: session.started_at, started_at: session.started_at,
disconnection_count: session.disconnection_count disconnection_count: session.disconnection_count
@@ -7,7 +7,7 @@ module Public
layout "regia" layout "regia"
protect_from_forgery with: :null_session protect_from_forgery with: :null_session
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop min_quality] skip_before_action :verify_authenticity_token, only: %i[score pause resume stop]
before_action :set_session_from_token before_action :set_session_from_token
@@ -61,20 +61,8 @@ module Public
render json: status_payload render json: status_payload
end end
def min_quality
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
render json: status_payload
rescue ArgumentError => e
render json: status_payload.merge(error: e.message), status: :unprocessable_entity
end
private private
def camera_telemetry_payload(publisher_online)
cam = @session.device_states.find_by(device_role: "camera")
(cam&.as_regia_payload || {}).merge(publisher_online: publisher_online)
end
def set_session_from_token def set_session_from_token
@session = Sessions::RegiaAccess.find_session_by_token(params[:token]) @session = Sessions::RegiaAccess.find_session_by_token(params[:token])
return if @session return if @session
@@ -98,10 +86,7 @@ module Public
home_name: @session.match.team.name, home_name: @session.match.team.name,
away_name: @session.match.opponent_name, away_name: @session.match.opponent_name,
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1), points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
score: score&.as_cable_payload, score: score&.as_cable_payload
min_quality_preset: @session.min_quality_preset,
min_quality_options: Sessions::SelectQuality.allowed_min_ids(@session.quality_preset),
telemetry: camera_telemetry_payload(publisher_online)
} }
end end
end end
@@ -57,6 +57,10 @@ module Public
Billing::Stripe::PlanChangeMessages.button_label(plan: plan, interval: interval, kind: btn_kind) Billing::Stripe::PlanChangeMessages.button_label(plan: plan, interval: interval, kind: btn_kind)
end end
def plan_cta_name(plan)
I18n.t("pages.plans.cta_name.#{plan.slug}", default: plan.name)
end
def billing_plan_change_info_lines(subscription: nil) def billing_plan_change_info_lines(subscription: nil)
Billing::Stripe::PlanChangeMessages.billing_info_lines(subscription: subscription) Billing::Stripe::PlanChangeMessages.billing_info_lines(subscription: subscription)
end end
+1 -20
View File
@@ -54,34 +54,15 @@ module Public
copyPrompt: t("regia.js.copy_prompt"), copyPrompt: t("regia.js.copy_prompt"),
shareRegiaText: t("regia.js.share_regia_text"), shareRegiaText: t("regia.js.share_regia_text"),
shareLiveText: t("regia.js.share_live_text"), shareLiveText: t("regia.js.share_live_text"),
minQualityUpdated: t("regia.js.min_quality_updated"),
minQualityError: t("regia.js.min_quality_error"),
minQualityAuto: t("regia.min_quality.auto"),
previewWaiting: t("regia.preview_waiting"), previewWaiting: t("regia.preview_waiting"),
streamEnded: t("regia.preview_ended"), streamEnded: t("regia.preview_ended"),
previewHide: t("regia.preview_hide"),
previewShow: t("regia.preview_show"),
previewOffHint: t("regia.preview_off_hint"),
resumeLabel: t("regia.resume"), resumeLabel: t("regia.resume"),
pauseLabel: t("regia.pause"), pauseLabel: t("regia.pause"),
endedBadge: t("regia.status.ended"), endedBadge: t("regia.status.ended"),
pausedBadge: t("regia.status.paused"), pausedBadge: t("regia.status.paused"),
liveBadge: t("regia.status.live"), liveBadge: t("regia.status.live"),
waitingBadge: t("regia.status.waiting"), waitingBadge: t("regia.status.waiting"),
subtitlePrefix: t("regia.subtitle_prefix"), subtitlePrefix: t("regia.subtitle_prefix")
telemetryWaiting: t("regia.telemetry.waiting"),
telemetryStale: t("regia.telemetry.stale"),
telemetryJustNow: t("regia.telemetry.just_now"),
telemetrySecondsAgo: t("regia.telemetry.seconds_ago"),
telemetryPublisherOn: t("regia.telemetry.publisher_on"),
telemetryPublisherOff: t("regia.telemetry.publisher_off"),
telemetryThermalNominal: t("regia.telemetry.thermal_nominal"),
telemetryThermalFair: t("regia.telemetry.thermal_fair"),
telemetryThermalSerious: t("regia.telemetry.thermal_serious"),
telemetryThermalCritical: t("regia.telemetry.thermal_critical"),
telemetryBitrateMax: t("regia.telemetry.bitrate_max"),
telemetryBitrateTitle: t("regia.telemetry.bitrate_title"),
telemetryBitrateTap: t("regia.telemetry.bitrate_tap")
}.to_json }.to_json
end end
-13
View File
@@ -16,17 +16,4 @@ class DeviceState < ApplicationRecord
status: stream_session.status status: stream_session.status
} }
end end
def as_regia_payload
{
battery: battery_level,
network: network_type,
signal: signal_strength,
bitrate: current_bitrate,
target_bitrate: target_bitrate,
fps: fps,
thermal_state: thermal_state,
last_seen_at: last_seen_at&.iso8601
}
end
end end
-1
View File
@@ -14,7 +14,6 @@ class StreamSession < ApplicationRecord
validates :platform, inclusion: { in: PLATFORMS } validates :platform, inclusion: { in: PLATFORMS }
validates :status, inclusion: { in: STATUSES } validates :status, inclusion: { in: STATUSES }
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES } validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
validates :min_quality_preset, inclusion: { in: Sessions::SelectQuality::MIN_QUALITY_IDS }
before_validation :normalize_privacy_status before_validation :normalize_privacy_status
before_validation :ensure_publish_token, on: :create before_validation :ensure_publish_token, on: :create
@@ -5,9 +5,13 @@ module Billing
AMOUNT_HINTS = { AMOUNT_HINTS = {
500 => %w[premium_light monthly], 500 => %w[premium_light monthly],
790 => %w[premium_light monthly],
4000 => %w[premium_light yearly], 4000 => %w[premium_light yearly],
5900 => %w[premium_light yearly],
2000 => %w[premium_full monthly], 2000 => %w[premium_full monthly],
20_000 => %w[premium_full yearly] 2490 => %w[premium_full monthly],
20_000 => %w[premium_full yearly],
19_900 => %w[premium_full yearly]
}.freeze }.freeze
class << self class << self
@@ -23,10 +23,11 @@ module Billing
def button_label(plan:, interval:, kind:) def button_label(plan:, interval:, kind:)
price = PriceCatalog.label(plan_slug: plan.slug, interval: interval) price = PriceCatalog.label(plan_slug: plan.slug, interval: interval)
name = I18n.t("pages.plans.cta_name.#{plan.slug}", default: plan.name)
if kind == :checkout if kind == :checkout
I18n.t("billing.messages.activate_button", plan: plan.name, price: price) I18n.t("billing.messages.activate_button", plan: name, price: price)
else else
I18n.t("billing.messages.switch_button", plan: plan.name, price: price) I18n.t("billing.messages.switch_button", plan: name, price: price)
end end
end end
@@ -4,9 +4,16 @@ module Billing
INTERVALS = %w[monthly yearly].freeze INTERVALS = %w[monthly yearly].freeze
DEFAULT_INTERVAL = "yearly" DEFAULT_INTERVAL = "yearly"
PRICING = { # Importi in centesimi (EUR). Il yearly è il prezzo lancio addebitato.
"premium_light" => { "yearly" => "€40/anno", "monthly" => "€5/mese" }, AMOUNTS = {
"premium_full" => { "yearly" => "€200/anno", "monthly" => "€20/mese" } "premium_light" => {
"yearly" => { charge: 5900, list: 7900, equivalent_monthly: 492 },
"monthly" => { charge: 790 }
},
"premium_full" => {
"yearly" => { charge: 19_900, list: 24_900, equivalent_monthly: 1658 },
"monthly" => { charge: 2490 }
}
}.freeze }.freeze
class << self class << self
@@ -30,7 +37,23 @@ module Billing
end end
def label(plan_slug:, interval:) def label(plan_slug:, interval:)
PRICING.dig(plan_slug.to_s, normalize_interval(interval)) || normalize_interval(interval) interval = normalize_interval(interval)
format_interval_price(charge_cents(plan_slug, interval), interval)
end
def list_label(plan_slug:, interval: "yearly")
interval = normalize_interval(interval)
cents = list_cents(plan_slug, interval)
return nil if cents.blank?
format_interval_price(cents, interval)
end
def equivalent_monthly_label(plan_slug:)
cents = AMOUNTS.dig(plan_slug.to_s, "yearly", :equivalent_monthly)
return nil if cents.blank?
format_interval_price(cents, "monthly")
end end
def available_intervals(plan_slug:) def available_intervals(plan_slug:)
@@ -52,8 +75,33 @@ module Billing
nil nil
end end
def format_eur(cents)
cents = cents.to_i
whole, frac = cents.divmod(100)
if frac.zero?
"#{whole}"
else
sep = I18n.locale.to_s == "en" ? "." : ","
"#{whole}#{sep}#{frac.to_s.rjust(2, '0')}"
end
end
private private
def charge_cents(plan_slug, interval)
AMOUNTS.dig(plan_slug.to_s, interval, :charge)
end
def list_cents(plan_slug, interval)
AMOUNTS.dig(plan_slug.to_s, interval, :list)
end
def format_interval_price(cents, interval)
return nil if cents.blank?
I18n.t("billing.prices.#{interval}", amount: format_eur(cents))
end
def price_id_for(plan_slug, interval) def price_id_for(plan_slug, interval)
case [plan_slug, interval] case [plan_slug, interval]
when %w[premium_light monthly] when %w[premium_light monthly]
+3 -4
View File
@@ -9,12 +9,11 @@ module Sessions
@session.pause! if @session.may_pause? @session.pause! if @session.may_pause?
Mediamtx::PublisherSync.forget_recording_state!(@session.id) Mediamtx::PublisherSync.forget_recording_state!(@session.id)
begin begin
mtx = Mediamtx::Client.new Mediamtx::Client.new.set_path_recording(@session, enabled: false)
mtx.set_path_recording(@session, enabled: false)
mtx.set_always_available(@session, enabled: true)
rescue Mediamtx::Client::Error => e rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}") Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}")
end end
Mediamtx::Client.new.set_always_available(@session, enabled: true)
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app. # Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
log_event("paused") log_event("paused")
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" }) SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
@@ -3,8 +3,6 @@ module Sessions
class SelectQuality class SelectQuality
UPLOAD_HEADROOM = 0.8 UPLOAD_HEADROOM = 0.8
UPLOAD_HEADROOM_1080P = 1.0 UPLOAD_HEADROOM_1080P = 1.0
AUTO_ID = "auto"
AUTO_FLOOR_BITRATE = 800_000
PRESETS = [ PRESETS = [
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 }, { id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
@@ -13,32 +11,6 @@ module Sessions
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 } { id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
].freeze ].freeze
MIN_QUALITY_IDS = ([AUTO_ID] + PRESETS.map { |preset| preset[:id] }).freeze
def self.floor_bitrate(preset_id)
return AUTO_FLOOR_BITRATE if preset_id.blank? || preset_id == AUTO_ID
PRESETS.find { |preset| preset[:id] == preset_id }&.fetch(:target_bitrate) || AUTO_FLOOR_BITRATE
end
def self.allowed_min_ids(session_preset)
ids = [AUTO_ID] + PRESETS.reject { |preset| preset[:id].start_with?("1080p") }.reverse.map { |preset| preset[:id] }
ids << "1080p_30_4.5mbps" if session_preset.to_s.start_with?("1080p")
ids
end
def self.display_name(preset_id)
return AUTO_ID if preset_id.blank? || preset_id == AUTO_ID
preset = PRESETS.find { |item| item[:id] == preset_id }
return preset_id unless preset
resolution = preset[:id].start_with?("1080p") ? "1080p" : "720p"
mbps = preset[:target_bitrate] / 1_000_000.0
mbps_text = (mbps == mbps.to_i) ? mbps.to_i.to_s : format("%.1f", mbps)
"#{resolution} · #{mbps_text} Mbps"
end
def self.call(upload_mbps:) def self.call(upload_mbps:)
new(upload_mbps: upload_mbps).call new(upload_mbps: upload_mbps).call
end end
@@ -1,39 +0,0 @@
# 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
+1 -1
View File
@@ -8,7 +8,7 @@
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<%= yield :head %> <%= yield :head %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=50"> <link rel="stylesheet" href="/marketing.css?v=63">
</head> </head>
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> <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/cookie_banner" %>
@@ -6,7 +6,7 @@
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title> <title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=50"> <link rel="stylesheet" href="/marketing.css?v=63">
<link rel="stylesheet" href="/live.css?v=26"> <link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %> <%= yield :head %>
</head> </head>
+1 -1
View File
@@ -7,7 +7,7 @@
<meta name="theme-color" content="#0a0a0e"> <meta name="theme-color" content="#0a0a0e">
<title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title> <title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title>
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<link rel="stylesheet" href="/regia.css?v=11"> <link rel="stylesheet" href="/regia.css?v=1">
<%= yield :head %> <%= yield :head %>
</head> </head>
<body class="regia-body"> <body class="regia-body">
+264 -44
View File
@@ -2,63 +2,283 @@
<% content_for :meta_description, t("pages.features.meta_description") %> <% content_for :meta_description, t("pages.features.meta_description") %>
<% content_for :canonical_url, seo_absolute_url(public_features_path) %> <% content_for :canonical_url, seo_absolute_url(public_features_path) %>
<div class="wrap"> <article class="features-page">
<h1 style="margin-top:24px"><%= t("pages.features.title") %></h1> <header class="features-hero">
<p style="color:#aaa;max-width:640px"><%= t("pages.features.lead") %></p> <div class="wrap features-hero__grid">
<div class="features-hero__copy">
<p class="features-hero__eyebrow">Match Live TV</p>
<h1><%= t("pages.features.hero_title") %></h1>
<p class="features-hero__lead"><%= t("pages.features.hero_lead") %></p>
<section class="section"> <ol class="features-flow" aria-label="<%= t("pages.features.flow_aria") %>">
<h2><%= t("pages.features.staff_section_title") %></h2> <li class="features-flow__item">
<div class="features-grid"> <span class="features-flow__icon" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
<div class="feature-card"> <span class="features-flow__label"><%= t("pages.features.flow_phone") %></span>
<h3><%= t("pages.features.staff_invite_title") %></h3> </li>
<p><%= t("pages.features.staff_invite_body") %></p> <li class="features-flow__arrow" aria-hidden="true"><i class="fa-solid fa-arrow-right"></i></li>
<li class="features-flow__item">
<span class="features-flow__icon features-flow__icon--blue" aria-hidden="true"><i class="fa-solid fa-user"></i></span>
<span class="features-flow__label"><%= t("pages.features.flow_account") %></span>
</li>
<li class="features-flow__arrow" aria-hidden="true"><i class="fa-solid fa-arrow-right"></i></li>
<li class="features-flow__item features-flow__item--live">
<span class="features-flow__icon features-flow__icon--live" aria-hidden="true">
<span class="features-flow__dot"></span>
</span>
<span class="features-flow__label"><%= t("pages.features.flow_live") %></span>
</li>
</ol>
</div> </div>
<div class="feature-card"> <div class="features-hero__visual">
<h3><%= t("pages.features.staff_access_title") %></h3> <%= image_tag "/hero-devices.png",
<p><%= t("pages.features.staff_access_body") %></p> alt: t("pages.features.hero_alt"),
class: "features-hero__img",
loading: "eager",
fetchpriority: "high" %>
</div> </div>
<div class="feature-card"> </div>
<h3><%= t("pages.features.staff_plans_title") %></h3> </header>
<p><%= t("pages.features.staff_plans_body") %></p>
<section class="section wrap features-how" aria-labelledby="features-how-title">
<h2 id="features-how-title"><%= t("pages.features.how_title") %></h2>
<ol class="features-how__steps">
<li class="features-how__step">
<span class="features-how__num" aria-hidden="true">1</span>
<h3><%= t("pages.features.how_1_title") %></h3>
<p><%= t("pages.features.how_1_body") %></p>
</li>
<li class="features-how__step">
<span class="features-how__num" aria-hidden="true">2</span>
<h3><%= t("pages.features.how_2_title") %></h3>
<p><%= t("pages.features.how_2_body") %></p>
</li>
<li class="features-how__step">
<span class="features-how__num" aria-hidden="true">3</span>
<h3><%= t("pages.features.how_3_title") %></h3>
<p><%= t("pages.features.how_3_body") %></p>
</li>
<li class="features-how__step">
<span class="features-how__num" aria-hidden="true">4</span>
<h3><%= t("pages.features.how_4_title") %></h3>
<p><%= t("pages.features.how_4_body") %></p>
</li>
<li class="features-how__step">
<span class="features-how__num" aria-hidden="true">5</span>
<h3><%= t("pages.features.how_5_title") %></h3>
<p><%= t("pages.features.how_5_body") %></p>
</li>
</ol>
</section>
<section class="section wrap features-accounts" aria-labelledby="features-accounts-title">
<div class="features-panel">
<h2 id="features-accounts-title"><%= t("pages.features.accounts_title") %></h2>
<p class="features-panel__lead"><%= t("pages.features.accounts_lead") %></p>
<div class="features-accounts__grid">
<div class="features-accounts__card">
<span class="feature-card__icon" aria-hidden="true"><i class="fa-solid fa-user-plus"></i></span>
<h3><%= t("pages.features.accounts_invite_title") %></h3>
<p><%= t("pages.features.accounts_invite_body") %></p>
</div>
<div class="features-accounts__card">
<span class="feature-card__icon feature-card__icon--blue" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
<h3><%= t("pages.features.accounts_transmit_title") %></h3>
<p><%= t("pages.features.accounts_transmit_body") %></p>
</div>
<div class="features-accounts__card">
<span class="feature-card__icon feature-card__icon--gold" aria-hidden="true"><i class="fa-solid fa-user-shield"></i></span>
<h3><%= t("pages.features.accounts_revoke_title") %></h3>
<p><%= t("pages.features.accounts_revoke_body") %></p>
</div>
</div>
<p class="features-highlight"><%= t("pages.features.accounts_highlight") %></p>
</div>
</section>
<section class="section wrap features-score" aria-labelledby="features-score-title">
<div class="features-split">
<div class="features-split__copy">
<h2 id="features-score-title"><%= t("pages.features.score_title") %></h2>
<p class="features-panel__lead features-panel__lead--left"><%= t("pages.features.score_lead") %></p>
<ul class="features-checklist">
<li><%= t("pages.features.score_item_app") %></li>
<li><%= t("pages.features.score_item_signup") %></li>
<li><%= t("pages.features.score_item_password") %></li>
<li><%= t("pages.features.score_item_link") %></li>
</ul>
</div>
<aside class="features-score__card" aria-label="<%= t("pages.features.score_card_aria") %>">
<div class="features-score__visual" aria-hidden="true">
<div class="features-score__phone">
<div class="features-score__phone-top">
<span class="features-score__live"><span class="features-score__live-dot"></span> REGIA</span>
<span class="features-score__set">3° set</span>
</div>
<div class="features-score__board">
<div class="features-score__team">
<span class="features-score__team-name">Casa</span>
<span class="features-score__points">21</span>
</div>
<span class="features-score__sep"></span>
<div class="features-score__team features-score__team--away">
<span class="features-score__team-name">Ospiti</span>
<span class="features-score__points">18</span>
</div>
</div>
<div class="features-score__actions">
<span class="features-score__btn">+1</span>
<span class="features-score__btn features-score__btn--muted">+1</span>
</div>
</div>
<div class="features-score__linkchip">
<i class="fa-solid fa-link" aria-hidden="true"></i>
<span>matchlivetv.it/regia/…</span>
</div>
</div>
<div class="features-score__body">
<span class="feature-card__badge feature-card__badge--link"><%= t("pages.features.access_link") %></span>
<p class="features-score__card-title"><%= t("pages.features.score_card_title") %></p>
<p><%= t("pages.features.score_card_body") %></p>
</div>
</aside>
</div>
</section>
<section class="section wrap features-distribute" aria-labelledby="features-distribute-title">
<h2 id="features-distribute-title"><%= t("pages.features.distribute_title") %></h2>
<p class="features-panel__lead"><%= t("pages.features.distribute_lead") %></p>
<ol class="features-pipeline" aria-label="<%= t("pages.features.distribute_aria") %>">
<li class="features-pipeline__node">
<span class="features-pipeline__icon" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
<span class="features-pipeline__label"><%= t("pages.features.distribute_phone") %></span>
</li>
<li class="features-pipeline__join" aria-hidden="true"></li>
<li class="features-pipeline__node features-pipeline__node--hub">
<span class="features-pipeline__icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
<span class="features-pipeline__label"><%= t("pages.features.distribute_mltv") %></span>
</li>
<li class="features-pipeline__join" aria-hidden="true"></li>
<li class="features-pipeline__targets">
<div class="features-pipeline__node">
<span class="features-pipeline__icon features-pipeline__icon--blue" aria-hidden="true"><i class="fa-solid fa-users"></i></span>
<span class="features-pipeline__label"><%= t("pages.features.distribute_viewers") %></span>
</div>
<div class="features-pipeline__node">
<span class="features-pipeline__icon" aria-hidden="true"><i class="fa-brands fa-youtube"></i></span>
<span class="features-pipeline__label"><%= t("pages.features.distribute_youtube") %></span>
</div>
</li>
</ol>
</section>
<section class="section wrap features-youtube" aria-labelledby="features-youtube-title">
<div class="features-panel features-panel--accent features-youtube__panel">
<div class="features-youtube__copy">
<span class="feature-card__badge feature-card__badge--invite"><%= t("pages.features.youtube_badge") %></span>
<h2 id="features-youtube-title"><%= t("pages.features.youtube_title") %></h2>
<p class="features-panel__lead features-panel__lead--left"><%= t("pages.features.youtube_body") %></p>
</div>
<div class="features-youtube__visual" aria-hidden="true">
<div class="features-yt-mock">
<div class="features-yt-mock__player">
<span class="features-yt-mock__live"><span class="features-yt-mock__dot"></span> LIVE</span>
<span class="features-yt-mock__play"><i class="fa-brands fa-youtube"></i></span>
</div>
<div class="features-yt-mock__meta">
<span class="features-yt-mock__avatar"><i class="fa-solid fa-shield-halved"></i></span>
<div class="features-yt-mock__text">
<strong><%= t("pages.features.youtube_mock_channel") %></strong>
<span><%= t("pages.features.youtube_mock_subs") %></span>
</div>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>
<section class="section"> <section class="section wrap features-replay" aria-labelledby="features-replay-title">
<h2><%= t("pages.features.live_section_title") %></h2> <div class="features-split features-split--reverse">
<div class="features-grid"> <div class="features-split__copy">
<h2 id="features-replay-title"><%= t("pages.features.replay_title") %></h2>
<p class="features-panel__lead features-panel__lead--left"><%= t("pages.features.replay_lead") %></p>
<ul class="features-checklist">
<li><%= t("pages.features.replay_item_server") %></li>
<li><%= t("pages.features.replay_item_plan") %></li>
<li><%= t("pages.features.replay_item_download") %></li>
</ul>
</div>
<aside class="features-replay__card" aria-label="<%= t("pages.features.replay_card_title") %>">
<div class="features-replay__visual" aria-hidden="true">
<div class="features-archive-mock">
<div class="features-archive-mock__head">
<i class="fa-solid fa-cloud" aria-hidden="true"></i>
<span><%= t("pages.features.replay_card_title") %></span>
</div>
<ul class="features-archive-mock__list">
<li>
<span class="features-archive-mock__thumb features-archive-mock__thumb--a"></span>
<span class="features-archive-mock__info">
<strong><%= t("pages.features.replay_mock_1_title") %></strong>
<em><%= t("pages.features.replay_mock_1_meta") %></em>
</span>
<i class="fa-solid fa-download" aria-hidden="true"></i>
</li>
<li>
<span class="features-archive-mock__thumb features-archive-mock__thumb--b"></span>
<span class="features-archive-mock__info">
<strong><%= t("pages.features.replay_mock_2_title") %></strong>
<em><%= t("pages.features.replay_mock_2_meta") %></em>
</span>
<i class="fa-solid fa-download" aria-hidden="true"></i>
</li>
<li>
<span class="features-archive-mock__thumb features-archive-mock__thumb--c"></span>
<span class="features-archive-mock__info">
<strong><%= t("pages.features.replay_mock_3_title") %></strong>
<em><%= t("pages.features.replay_mock_3_meta") %></em>
</span>
<i class="fa-solid fa-download" aria-hidden="true"></i>
</li>
</ul>
</div>
</div>
<div class="features-replay__body">
<p class="features-score__card-title"><%= t("pages.features.replay_card_title") %></p>
<p><%= t("pages.features.replay_card_body") %></p>
</div>
</aside>
</div>
</section>
<section class="section wrap features-more" aria-labelledby="features-more-title">
<h2 id="features-more-title"><%= t("pages.features.more_title") %></h2>
<div class="features-grid features-grid--compact">
<div class="feature-card"> <div class="feature-card">
<h3><%= t("pages.features.live_phone_title") %></h3> <span class="feature-card__icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
<p><%= t("pages.features.live_phone_body") %></p> <h3><%= t("pages.features.more_stable_title") %></h3>
<p><%= t("pages.features.more_stable_body") %></p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<h3><%= t("pages.features.live_score_title") %></h3> <span class="feature-card__icon feature-card__icon--blue" aria-hidden="true"><i class="fa-solid fa-share-nodes"></i></span>
<p><%= t("pages.features.live_score_body") %></p> <h3><%= t("pages.features.more_link_title") %></h3>
<p><%= t("pages.features.more_link_body") %></p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<h3><%= t("pages.features.live_link_title") %></h3> <span class="feature-card__icon" aria-hidden="true"><i class="fa-brands fa-youtube"></i></span>
<p><%= t("pages.features.live_link_body") %></p> <h3><%= t("pages.features.more_yt_mltv_title") %></h3>
</div> <p><%= t("pages.features.more_yt_mltv_body") %></p>
<div class="feature-card">
<h3><%= t("pages.features.live_archive_title") %></h3>
<p><%= t("pages.features.live_archive_body") %></p>
</div>
<div class="feature-card">
<h3><%= t("pages.features.live_youtube_mltv_title") %></h3>
<p><%= t("pages.features.live_youtube_mltv_body") %></p>
</div>
<div class="feature-card">
<h3><%= t("pages.features.live_youtube_club_title") %></h3>
<p><%= t("pages.features.live_youtube_club_body") %></p>
</div>
<div class="feature-card">
<h3><%= t("pages.features.live_website_title") %></h3>
<p><%= t("pages.features.live_website_body") %></p>
</div> </div>
</div> </div>
</section> </section>
<p style="text-align:center;margin:40px 0"> <section class="section wrap features-cta" aria-labelledby="features-cta-title">
<%= link_to t("pages.features.cta_signup"), public_signup_path, class: "btn btn-primary" %> <div class="features-cta__box">
</p> <h2 id="features-cta-title"><%= t("pages.features.cta_title") %></h2>
</div> <p><%= t("pages.features.cta_body") %></p>
<div class="features-cta__actions">
<%= link_to t("pages.features.cta_primary"), public_signup_path, class: "btn btn-primary" %>
<%= link_to t("pages.features.cta_secondary"), public_prezzi_path, class: "btn btn-outline" %>
</div>
</div>
</section>
</article>
+6 -1
View File
@@ -10,7 +10,8 @@
"url" => seo_absolute_url(root_path), "url" => seo_absolute_url(root_path),
"applicationCategory" => "SportsApplication", "applicationCategory" => "SportsApplication",
"operatingSystem" => "Web, Android, iOS", "operatingSystem" => "Web, Android, iOS",
"description" => t("home.schema_description") "description" => t("home.schema_description"),
"sameAs" => [MatchLiveTv.app_store_url, MatchLiveTv.play_store_url]
}.to_json) %> }.to_json) %>
</script> </script>
<% end %> <% end %>
@@ -26,6 +27,10 @@
<%= link_to t("home.cta_signup"), public_signup_path, class: "btn btn-primary" %> <%= link_to t("home.cta_signup"), public_signup_path, class: "btn btn-primary" %>
<%= link_to t("home.cta_live"), public_live_index_path, class: "btn btn-secondary" %> <%= link_to t("home.cta_live"), public_live_index_path, class: "btn btn-secondary" %>
</div> </div>
<div class="hero-stores">
<p class="hero-stores-label"><%= t("home.stores_label") %></p>
<%= render "shared/store_badges", variant: "hero" %>
</div>
<ul class="hero-features" aria-label="<%= t('home.features_aria') %>"> <ul class="hero-features" aria-label="<%= t('home.features_aria') %>">
<li class="hero-feature-item"> <li class="hero-feature-item">
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span> <span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
@@ -22,19 +22,60 @@
<%= render "shared/plan_cards" %> <%= render "shared/plan_cards" %>
<div class="table-scroll"> <div class="table-scroll compare-table-wrap">
<table class="compare-table"> <table class="compare-table">
<colgroup>
<col class="compare-table__feature">
<col class="compare-table__plan">
<col class="compare-table__plan">
<col class="compare-table__plan compare-table__plan--featured">
</colgroup>
<thead> <thead>
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr> <tr>
<th></th>
<th>Free</th>
<th>Premium Light</th>
<th>Premium Full</th>
</tr>
</thead> </thead>
<tbody> <tbody>
<tr><td><%= t("pages.pricing.table_staff") %></td><td>1</td><td>5</td><td><%= t("pages.pricing.table_unlimited") %></td></tr> <tr>
<td><%= t("pages.pricing.table_staff") %></td>
<td>1</td>
<td>5</td>
<td><%= t("pages.pricing.table_unlimited") %></td>
</tr>
<tr><td><%= t("pages.pricing.table_matches") %></td><td>1</td><td>3</td><td>10</td></tr> <tr><td><%= t("pages.pricing.table_matches") %></td><td>1</td><td>3</td><td>10</td></tr>
<tr><td><%= t("pages.pricing.table_live_mltv") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr> <tr><td><%= t("pages.pricing.table_live_mltv") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
<tr><td><%= t("pages.pricing.table_youtube") %></td><td><%= t("pages.pricing.table_no") %></td><td>Match Live TV</td><td><%= t("pages.pricing.table_youtube_club") %></td></tr> <tr><td><%= t("pages.pricing.table_youtube") %></td><td><%= t("pages.pricing.table_no") %></td><td>Match Live TV</td><td><%= t("pages.pricing.table_youtube_club") %></td></tr>
<tr><td><%= t("pages.pricing.table_replay") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.plans.replay_days", count: 30) %></td><td><%= t("pages.plans.replay_days", count: 90) %></td></tr> <tr><td><%= t("pages.pricing.table_replay") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.plans.replay_days", count: 30) %></td><td><%= t("pages.plans.replay_days", count: 90) %></td></tr>
<tr><td><%= t("pages.pricing.table_download") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr> <tr><td><%= t("pages.pricing.table_download") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
<tr><td><%= t("pages.pricing.table_price") %></td><td><%= t("pages.pricing.table_price_free") %></td><td><%= raw t("pages.pricing.table_price_light_html") %></td><td><%= raw t("pages.pricing.table_price_full_html") %></td></tr> <tr>
<td><%= t("pages.pricing.table_price") %></td>
<td><%= t("pages.pricing.table_price_free") %></td>
<td>
<div class="compare-price">
<strong><%= Billing::Stripe::PriceCatalog.label(plan_slug: "premium_light", interval: "yearly") %></strong>
<span class="compare-price__launch"><%= t("pages.plans.launch_price") %></span>
<small class="compare-price__note"><%= t(
"pages.pricing.table_price_note",
list: Billing::Stripe::PriceCatalog.list_label(plan_slug: "premium_light", interval: "yearly"),
monthly: Billing::Stripe::PriceCatalog.label(plan_slug: "premium_light", interval: "monthly")
) %></small>
</div>
</td>
<td>
<div class="compare-price">
<strong><%= Billing::Stripe::PriceCatalog.label(plan_slug: "premium_full", interval: "yearly") %></strong>
<span class="compare-price__launch"><%= t("pages.plans.launch_price") %></span>
<small class="compare-price__note"><%= t(
"pages.pricing.table_price_note",
list: Billing::Stripe::PriceCatalog.list_label(plan_slug: "premium_full", interval: "yearly"),
monthly: Billing::Stripe::PriceCatalog.label(plan_slug: "premium_full", interval: "monthly")
) %></small>
</div>
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </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> <button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px"></button>
</div> </div>
</div> </div>
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set"><%= t("regia.board.close_set") %></button> <button type="button" class="regia-btn regia-btn--yellow" data-action="close_set" style="margin-top:12px"><%= t("regia.board.close_set") %></button>
+33 -98
View File
@@ -16,8 +16,6 @@
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>" data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>" data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>" data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
data-min-quality-url="<%= j public_regia_min_quality_path(params[:token]) %>"
data-min-quality="<%= j @session.min_quality_preset %>"
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>" data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
data-hls-url="<%= j @session.hls_playback_url %>" data-hls-url="<%= j @session.hls_playback_url %>"
data-home-name="<%= j @team.name %>" data-home-name="<%= j @team.name %>"
@@ -31,118 +29,55 @@
data-initial-paused="<%= @session.paused? %>"> data-initial-paused="<%= @session.paused? %>">
<header class="regia-header"> <header class="regia-header">
<div class="regia-header__text"> <h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1> <p id="regia-subtitle"><%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %></p>
<p id="regia-subtitle"><%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %></p>
</div>
<span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>"> <span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>">
<%= @stream_closed ? t("regia.status.ended") : (@session.paused? ? t("regia.status.paused") : (@on_air ? t("regia.status.live") : t("regia.status.waiting"))) %> <%= @stream_closed ? t("regia.status.ended") : (@session.paused? ? t("regia.status.paused") : (@on_air ? t("regia.status.live") : t("regia.status.waiting"))) %>
</span> </span>
</header> </header>
<nav class="regia-toolbar" aria-label="<%= t("regia.links_nav") %>"> <% if @live_share_url.present? %>
<% if @live_share_url.present? %> <div class="regia-share-group">
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.live_share_label") %>"> <p class="regia-share-label"><%= t("regia.live_share_label") %></p>
<span class="regia-tool-group__label"><%= t("regia.live_share_label") %></span> <div class="regia-share">
<div class="regia-tool-group__btns"> <button type="button" class="regia-btn regia-btn--outline" id="btn-share-live" style="margin:0">
<button type="button" class="regia-icon-btn" id="btn-share-live" aria-label="<%= t("regia.share_live") %>" title="<%= t("regia.share_live") %>"> <%= t("regia.share_live") %>
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
</button>
<button type="button" class="regia-icon-btn" id="btn-copy-live" aria-label="<%= t("regia.copy_live") %>" title="<%= t("regia.copy_live") %>">
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
</div>
<% end %>
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.regia_share_label") %>">
<span class="regia-tool-group__label"><%= t("regia.regia_share_label") %></span>
<div class="regia-tool-group__btns">
<button type="button" class="regia-icon-btn" id="btn-share-regia" aria-label="<%= t("regia.share_regia") %>" title="<%= t("regia.share_regia") %>">
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
</button> </button>
<button type="button" class="regia-icon-btn" id="btn-copy-regia" aria-label="<%= t("regia.copy_regia") %>" title="<%= t("regia.copy_regia") %>"> <button type="button" class="regia-btn regia-btn--outline" id="btn-copy-live" style="margin:0">
<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> <%= t("regia.copy_link") %>
</button> </button>
</div> </div>
</div> </div>
<% unless @stream_closed %> <% end %>
<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 %>
</nav>
<section class="regia-preview-wrap" id="regia-preview-wrap"> <div class="regia-share-group">
<div class="regia-preview" id="regia-preview-box"> <p class="regia-share-label"><%= t("regia.regia_share_label") %></p>
<% unless @stream_closed %> <div class="regia-share">
<video id="regia-preview" playsinline muted autoplay></video> <button type="button" class="regia-btn regia-btn--outline" id="btn-share-regia" style="margin:0">
<% end %> <%= t("regia.share_regia") %>
<div id="regia-preview-placeholder" class="regia-preview__placeholder"> </button>
<%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %> <button type="button" class="regia-btn regia-btn--outline" id="btn-copy-regia" style="margin:0">
</div> <%= t("regia.copy_link") %>
</button>
</div> </div>
</section> </div>
<div class="regia-preview">
<% 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>
<section class="regia-scoreboard"> <section class="regia-scoreboard">
<%= render regia_board_partial(@match) %> <%= render regia_board_partial(@match) %>
</section> </section>
<% unless @stream_closed %> <% unless @stream_closed %>
<section class="regia-telemetry" id="regia-telemetry" aria-label="<%= t("regia.telemetry.label") %>"> <button type="button" class="regia-btn regia-btn--outline" id="btn-pause"><%= @session.paused? ? t("regia.resume") : t("regia.pause") %></button>
<div class="regia-telemetry__row"> <button type="button" class="regia-btn regia-btn--danger" id="btn-stop"><%= t("regia.stop") %></button>
<span class="regia-chip" id="tel-battery" title="<%= t("regia.telemetry.battery") %>">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 4h-1V3h-6v1H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H8V6h8v14z"/></svg>
<span class="regia-chip__label"><%= t("regia.telemetry.battery") %></span>
<span class="regia-chip__value"></span>
</span>
<span class="regia-chip" id="tel-network" title="<%= t("regia.telemetry.network") %>">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/></svg>
<span class="regia-chip__label"><%= t("regia.telemetry.network") %></span>
<span class="regia-chip__value"></span>
</span>
<button type="button" class="regia-chip regia-chip--action" id="tel-bitrate" aria-expanded="false" aria-controls="regia-quality" title="<%= t("regia.telemetry.bitrate_tap") %>">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 13h2v-2H3v2zm4 4h2v-6H7v6zm4 4h2V7h-2v14zm4-8h2V9h-2v4zm4-4h2V5h-2v4z"/></svg>
<span class="regia-chip__label"><%= t("regia.telemetry.bitrate") %></span>
<span class="regia-chip__value"></span>
<span class="regia-chip__hint" hidden></span>
</button>
<span class="regia-chip" id="tel-fps" title="<%= t("regia.telemetry.fps") %>">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
<span class="regia-chip__label"><%= t("regia.telemetry.fps") %></span>
<span class="regia-chip__value"></span>
</span>
<span class="regia-chip" id="tel-thermal" title="<%= t("regia.telemetry.thermal") %>">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 13V5c0-1.66-1.34-3-3-3S9 3.34 9 5v8c-1.21.91-2 2.37-2 4 0 2.76 2.24 5 5 5s5-2.24 5-5c0-1.63-.79-3.09-2-4zm-3 8c-1.66 0-3-1.34-3-3 0-1.19.7-2.23 1.71-2.72L11 14.73V5c0-.55.45-1 1-1s1 .45 1 1v9.73l.29.18C14.3 15.77 15 16.81 15 18c0 1.66-1.34 3-3 3z"/></svg>
<span class="regia-chip__label"><%= t("regia.telemetry.thermal") %></span>
<span class="regia-chip__value"></span>
</span>
</div>
<div class="regia-quality" id="regia-quality" hidden>
<p class="regia-quality__hint"><%= t("regia.min_quality.recommend") %></p>
<label for="min-quality"><%= t("regia.min_quality.label") %></label>
<select id="min-quality" title="<%= t("regia.min_quality.hint") %>">
<% Sessions::SelectQuality.allowed_min_ids(@session.quality_preset).each do |id| %>
<option value="<%= id %>" <%= "selected" if @session.min_quality_preset == id %>>
<%= id == Sessions::SelectQuality::AUTO_ID ? t("regia.min_quality.auto") : Sessions::SelectQuality.display_name(id) %>
</option>
<% end %>
</select>
</div>
<p class="regia-telemetry__meta" id="tel-meta"><%= t("regia.telemetry.waiting") %></p>
</section>
<% end %> <% end %>
</div> </div>
@@ -159,4 +94,4 @@
</div> </div>
</div> </div>
<script src="/regia.js?v=18"></script> <script src="/regia.js?v=8"></script>
@@ -2,6 +2,7 @@
<div class="wrap"> <div class="wrap">
<div> <div>
<strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %> <strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %>
<%= render "shared/store_badges", variant: "footer" %>
</div> </div>
<div> <div>
<%= link_to t("common.support"), public_support_path %> · <%= link_to t("common.support"), public_support_path %> ·
+30 -14
View File
@@ -15,23 +15,33 @@
current_interval: current_interval, current_interval: current_interval,
subscription: subscription subscription: subscription
) : nil %> ) : nil %>
<div class="plan-card"> <% featured = plan.slug == "premium_full" %>
<div class="plan-card<%= " plan-card--featured" if featured %>">
<% if featured %>
<p class="plan-card__recommend"><%= t("pages.plans.recommended_badge") %></p>
<% end %>
<%= render "shared/plan_title", plan: plan %> <%= render "shared/plan_title", plan: plan %>
<% if plan.slug == "free" %> <% if plan.slug == "free" %>
<div class="plan-price">€0</div> <div class="plan-price">€0</div>
<% elsif plan.slug == "premium_light" %>
<% light_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
<% light_monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
<div class="plan-price" style="color:#e53935"><%= light_yearly %></div>
<p class="plan-price-alt"><%= raw t("pages.plans.price_alt_html", price: light_monthly) %></p>
<% else %> <% else %>
<% full_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %> <% yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
<% full_monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %> <% monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
<div class="plan-price"><%= full_yearly %></div> <% list = Billing::Stripe::PriceCatalog.list_label(plan_slug: plan.slug, interval: "yearly") %>
<p class="plan-price-alt"><%= raw t("pages.plans.price_alt_html", price: full_monthly) %></p> <% equiv = Billing::Stripe::PriceCatalog.equivalent_monthly_label(plan_slug: plan.slug) %>
<div class="plan-price-block">
<% if list.present? %>
<div class="plan-price-list"><s><%= list %></s></div>
<% end %>
<div class="plan-price<%= " plan-price--featured" if featured %>"><%= yearly %></div>
<span class="plan-price-badge"><%= t("pages.plans.launch_price") %></span>
<% if equiv.present? %>
<p class="plan-price-equiv"><%= t("pages.plans.price_equiv", price: equiv) %></p>
<% end %>
</div>
<p class="plan-price-alt"><%= raw t("pages.plans.price_alt_html", price: monthly) %></p>
<% end %> <% end %>
<ul> <ul>
<li><%= raw t("pages.plans.staff_html", count: plan.max_staff_transmission || t("pages.plans.staff_unlimited")) %></li> <li><%= raw(plan.max_staff_transmission ? t("pages.plans.staff_html", count: plan.max_staff_transmission) : t("pages.plans.staff_unlimited_html")) %></li>
<li><%= raw t("pages.plans.matches_html", count: plan.concurrent_streams_limit || t("pages.plans.matches_unlimited")) %></li> <li><%= raw t("pages.plans.matches_html", count: plan.concurrent_streams_limit || t("pages.plans.matches_unlimited")) %></li>
<li><%= raw t( <li><%= raw t(
"pages.plans.replay_html", "pages.plans.replay_html",
@@ -48,6 +58,9 @@
end end
) %></li> ) %></li>
</ul> </ul>
<% if plan.slug == "premium_full" %>
<p class="plan-staff-note"><%= t("pages.plans.staff_note_full") %></p>
<% end %>
<% if billing_mode %> <% if billing_mode %>
<% action_kind = action[:kind] %> <% action_kind = action[:kind] %>
<% if action_kind == :current %> <% if action_kind == :current %>
@@ -78,8 +91,12 @@
<%= link_to t("pages.plans.start_free"), public_signup_path, class: "btn btn-secondary" %> <%= link_to t("pages.plans.start_free"), public_signup_path, class: "btn btn-secondary" %>
<% else %> <% else %>
<div class="plan-interval-actions"> <div class="plan-interval-actions">
<% plan_intervals_for_display(Billing::Stripe::PriceCatalog.available_intervals(plan_slug: plan.slug)).each do |interval| %> <% %w[yearly monthly].each do |interval| %>
<%= link_to t("pages.plans.register_with_price", price: Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: interval)), <%= link_to t(
"billing.messages.activate_button",
plan: plan_cta_name(plan),
price: Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: interval)
),
public_signup_path(plan: plan.slug, interval: interval), public_signup_path(plan: plan.slug, interval: interval),
class: plan_interval_button_class(interval) %> class: plan_interval_button_class(interval) %>
<% end %> <% end %>
@@ -88,4 +105,3 @@
</div> </div>
<% end %> <% end %>
</div> </div>
@@ -0,0 +1,31 @@
<% variant = local_assigns.fetch(:variant, "hero") %>
<div class="store-badges store-badges--<%= variant %>">
<%= link_to MatchLiveTv.app_store_url,
class: "store-badge",
target: "_blank",
rel: "noopener noreferrer",
aria: { label: t("home.store_app_store_aria") } do %>
<%= image_tag "/images/store/apple.svg",
alt: "",
class: "store-badge__logo store-badge__logo--apple",
aria: { hidden: true } %>
<span>
<span class="store-badge__kicker"><%= t("home.store_app_store_kicker") %></span>
<span class="store-badge__name"><%= t("home.store_app_store_name") %></span>
</span>
<% end %>
<%= link_to MatchLiveTv.play_store_url,
class: "store-badge",
target: "_blank",
rel: "noopener noreferrer",
aria: { label: t("home.store_play_aria") } do %>
<%= image_tag "/images/store/google-play.svg",
alt: "",
class: "store-badge__logo store-badge__logo--play",
aria: { hidden: true } %>
<span>
<span class="store-badge__kicker"><%= t("home.store_play_kicker") %></span>
<span class="store-badge__name"><%= t("home.store_play_name") %></span>
</span>
<% end %>
</div>
+2 -2
View File
@@ -62,8 +62,8 @@ Rails.application.configure do
# Annotate rendered view with file names. # Annotate rendered view with file names.
config.action_view.annotate_rendered_view_with_filenames = true config.action_view.annotate_rendered_view_with_filenames = true
# Permette Action Cable dall'emulatore Android (10.0.2.2) e da altri host locali. # Uncomment if you wish to allow Action Cable access from any origin.
config.action_cable.disable_request_forgery_protection = true # config.action_cable.disable_request_forgery_protection = true
# Raise error when a before_action's only/except options reference missing actions. # Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true config.action_controller.raise_on_missing_callback_actions = true
@@ -180,5 +180,13 @@ module MatchLiveTv
def google_analytics_configured? def google_analytics_configured?
google_analytics_measurement_id.present? google_analytics_measurement_id.present?
end end
def app_store_url
"https://apps.apple.com/us/app/matchlivetv/id6799510705"
end
def play_store_url
"https://play.google.com/store/apps/details?id=com.matchlivetv.match_live_tv"
end
end end
end end
+8 -42
View File
@@ -82,12 +82,12 @@ de:
first_team_name_label: Teamname first_team_name_label: Teamname
first_team_name_placeholder: "z. B. Under 15" first_team_name_placeholder: "z. B. Under 15"
plan_label: "Starttarif (für den gesamten Verein)" plan_label: "Starttarif (für den gesamten Verein)"
plan_free: "Free — 1 Übertragungsverantwortlicher pro Team, 1 Livestream" plan_free: "Free — 1 Streaming-Konto pro Team, 1 Livestream"
plan_light: Premium Light plan_light: Premium Light
plan_full: Premium Full plan_full: Premium Full
interval_label: Premium-Abrechnung interval_label: Premium-Abrechnung
interval_yearly: "Jährlich — 40 €/Jahr (Light) oder 200 €/Jahr (Full)" interval_yearly: "Jährlich — 59 €/Jahr (Light) oder 199 €/Jahr (Full)"
interval_monthly: "Monatlich — 5 €/Monat (Light) oder 20 €/Monat (Full)" interval_monthly: "Monatlich — 7,90 €/Monat (Light) oder 24,90 €/Monat (Full)"
submit: Verein erstellen submit: Verein erstellen
billing: billing:
title: "Abonnement — %{name}" title: "Abonnement — %{name}"
@@ -423,6 +423,9 @@ de:
upgrade_info_line: "Upgrade (höherer Tarif oder Wechsel zur jährlichen Abrechnung): Die Differenz wird sofort von der gespeicherten Stripe-Karte abgebucht." upgrade_info_line: "Upgrade (höherer Tarif oder Wechsel zur jährlichen Abrechnung): Die Differenz wird sofort von der gespeicherten Stripe-Karte abgebucht."
downgrade_info_line: "Downgrade zwischen kostenpflichtigen Tarifen (z. B. Full → Light): Der neue Tarif gilt ab der nächsten Verlängerung; bis dahin bleiben die Funktionen des aktuellen Tarifs ohne zusätzliche Kosten erhalten." downgrade_info_line: "Downgrade zwischen kostenpflichtigen Tarifen (z. B. Full → Light): Der neue Tarif gilt ab der nächsten Verlängerung; bis dahin bleiben die Funktionen des aktuellen Tarifs ohne zusätzliche Kosten erhalten."
already_requested_line: "Du hast %{plan} bereits ab dem %{when} angefordert: Bis dahin bleibt %{current_plan} aktiv." already_requested_line: "Du hast %{plan} bereits ab dem %{when} angefordert: Bis dahin bleibt %{current_plan} aktiv."
prices:
yearly: "%{amount}/Jahr"
monthly: "%{amount}/Monat"
regia: regia:
meta_title_fallback: "Regie — Match Live TV" meta_title_fallback: "Regie — Match Live TV"
title: "Regie — %{home} vs %{away}" title: "Regie — %{home} vs %{away}"
@@ -432,51 +435,16 @@ de:
paused: Pausiert paused: Pausiert
live: Live live: Live
waiting: Wartet waiting: Wartet
live_share_label: Live live_share_label: "Live-Link (Zuschauer)"
share_live: Livestream teilen share_live: Livestream teilen
share_action: Teilen
copy_link: Link kopieren copy_link: Link kopieren
copy_action: Kopieren regia_share_label: "Regie-Link (Punktestand)"
copy_live: Live kopieren
copy_regia: Regie kopieren
links_nav: Schnellaktionen
regia_share_label: Regie
stream_controls_label: Live
share_regia: Regie-Link teilen share_regia: Regie-Link teilen
preview_ended: Livestream beendet preview_ended: Livestream beendet
preview_waiting: Vorschau wartet auf Signal… preview_waiting: Vorschau wartet auf Signal…
preview_hide: Vorschau ausblenden
preview_show: Vorschau anzeigen
preview_off_hint: Vorschau aus · kein Video-Download
pause: Pausieren pause: Pausieren
resume: Livestream fortsetzen resume: Livestream fortsetzen
stop: Livestream beenden stop: Livestream beenden
min_quality:
label: Minimale Übertragungsqualität
summary: Qualität
hint: Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.
recommend: "Automatisch wird empfohlen: passt sich an Netz und Telefon an, ohne das Maximum zu erzwingen."
auto: Automatisch
telemetry:
label: Telefon
waiting: Warte auf das Telefon…
stale: Telefon antwortet nicht
just_now: jetzt
seconds_ago: "vor %{n}s"
publisher_on: RTMP ok
publisher_off: RTMP fehlt
battery: Akku
network: Netz
bitrate: Bitrate
bitrate_max: "max %{n}"
bitrate_title: "%{current} Mbps aktuell · max. %{target} Mbps"
bitrate_tap: Tippen, um die Mindestqualität festzulegen
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Warm
thermal_serious: Heiß
thermal_critical: Kritisch
modal: modal:
set_won_title: Satz gewonnen set_won_title: Satz gewonnen
confirm: Satz beenden confirm: Satz beenden
@@ -521,8 +489,6 @@ de:
copy_prompt: "Link kopieren:" copy_prompt: "Link kopieren:"
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:" share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
share_live_text: "Sieh dir die Übertragung auf Match Live TV an:" share_live_text: "Sieh dir die Übertragung auf Match Live TV an:"
min_quality_updated: Mindestqualität aktualisiert
min_quality_error: Fehler beim Aktualisieren der Mindestqualität
recordings: recordings:
archive: archive:
back_prefix: "←" back_prefix: "←"
+8 -42
View File
@@ -77,12 +77,12 @@ en:
first_team_name_label: Team name first_team_name_label: Team name
first_team_name_placeholder: "e.g. Under 15" first_team_name_placeholder: "e.g. Under 15"
plan_label: "Starting plan (for the whole club)" plan_label: "Starting plan (for the whole club)"
plan_free: "Free — 1 broadcast manager per team, 1 live stream" plan_free: "Free — 1 transmitter account per team, 1 live stream"
plan_light: Premium Light plan_light: Premium Light
plan_full: Premium Full plan_full: Premium Full
interval_label: Premium billing interval_label: Premium billing
interval_yearly: "Yearly — €40/year (Light) or €200/year (Full)" interval_yearly: "Yearly — €59/year (Light) or €199/year (Full)"
interval_monthly: "Monthly — €5/month (Light) or €20/month (Full)" interval_monthly: "Monthly — €7.90/month (Light) or €24.90/month (Full)"
submit: Create club submit: Create club
billing: billing:
title: "Subscription — %{name}" title: "Subscription — %{name}"
@@ -418,6 +418,9 @@ en:
upgrade_info_line: "Upgrade (higher plan or switch to annual billing): the difference is charged immediately to the saved Stripe card." upgrade_info_line: "Upgrade (higher plan or switch to annual billing): the difference is charged immediately to the saved Stripe card."
downgrade_info_line: "Downgrade between paid plans (e.g. Full → Light): the new plan takes effect from the next renewal; until then the current plan's features remain, with no additional charge now." downgrade_info_line: "Downgrade between paid plans (e.g. Full → Light): the new plan takes effect from the next renewal; until then the current plan's features remain, with no additional charge now."
already_requested_line: "You already requested %{plan} from %{when}: until then %{current_plan} stays active." already_requested_line: "You already requested %{plan} from %{when}: until then %{current_plan} stays active."
prices:
yearly: "%{amount}/year"
monthly: "%{amount}/month"
regia: regia:
meta_title_fallback: "Control room — Match Live TV" meta_title_fallback: "Control room — Match Live TV"
title: "Control room — %{home} vs %{away}" title: "Control room — %{home} vs %{away}"
@@ -427,51 +430,16 @@ en:
paused: Paused paused: Paused
live: Live live: Live
waiting: Waiting waiting: Waiting
live_share_label: Live live_share_label: "Live link (viewers)"
share_live: Share live link share_live: Share live link
share_action: Share
copy_link: Copy link copy_link: Copy link
copy_action: Copy regia_share_label: "Control room link (score)"
copy_live: Copy live
copy_regia: Copy room
links_nav: Quick actions
regia_share_label: Control
stream_controls_label: Live
share_regia: Share control room link share_regia: Share control room link
preview_ended: Stream ended preview_ended: Stream ended
preview_waiting: Waiting for signal… preview_waiting: Waiting for signal…
preview_hide: Hide preview
preview_show: Show preview
preview_off_hint: Preview off · no video download
pause: Pause stream pause: Pause stream
resume: Resume stream resume: Resume stream
stop: End stream stop: End stream
min_quality:
label: Minimum streaming quality
summary: Quality
hint: The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.
recommend: "Automatic is recommended: it adapts to the network and the phone, without forcing maximum quality."
auto: Automatic
telemetry:
label: Phone
waiting: Waiting for the phone…
stale: Phone not responding
just_now: now
seconds_ago: "%{n}s ago"
publisher_on: RTMP ok
publisher_off: RTMP down
battery: Battery
network: Network
bitrate: Bitrate
bitrate_max: "max %{n}"
bitrate_title: "%{current} Mbps current · max %{target} Mbps"
bitrate_tap: Tap to set the minimum quality
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Warm
thermal_serious: Hot
thermal_critical: Critical
modal: modal:
set_won_title: Set won set_won_title: Set won
confirm: Close set confirm: Close set
@@ -516,8 +484,6 @@ en:
copy_prompt: "Copy the link:" copy_prompt: "Copy the link:"
share_regia_text: "Open this link to manage the live score:" share_regia_text: "Open this link to manage the live score:"
share_live_text: "Watch the live stream on Match Live TV:" share_live_text: "Watch the live stream on Match Live TV:"
min_quality_updated: Minimum quality updated
min_quality_error: Error updating minimum quality
recordings: recordings:
archive: archive:
back_prefix: "←" back_prefix: "←"
+8 -42
View File
@@ -82,12 +82,12 @@ es:
first_team_name_label: Nombre del equipo first_team_name_label: Nombre del equipo
first_team_name_placeholder: "ej. Sub-15" first_team_name_placeholder: "ej. Sub-15"
plan_label: "Plan inicial (para todo el club)" plan_label: "Plan inicial (para todo el club)"
plan_free: "Free — 1 responsable de transmisión por equipo, 1 directo" plan_free: "Free — 1 cuenta de emisión por equipo, 1 directo"
plan_light: Premium Light plan_light: Premium Light
plan_full: Premium Full plan_full: Premium Full
interval_label: Facturación premium interval_label: Facturación premium
interval_yearly: "Anual — 40 €/año (Light) o 200 €/año (Full)" interval_yearly: "Anual — 59 €/año (Light) o 199 €/año (Full)"
interval_monthly: "Mensual — 5 €/mes (Light) o 20 €/mes (Full)" interval_monthly: "Mensual — 7,90 €/mes (Light) o 24,90 €/mes (Full)"
submit: Crear club submit: Crear club
billing: billing:
title: "Suscripción — %{name}" title: "Suscripción — %{name}"
@@ -423,6 +423,9 @@ es:
upgrade_info_line: "Mejora (plan superior o cambio a facturación anual): la diferencia se cobra de inmediato en la tarjeta guardada en Stripe." upgrade_info_line: "Mejora (plan superior o cambio a facturación anual): la diferencia se cobra de inmediato en la tarjeta guardada en Stripe."
downgrade_info_line: "Downgrade entre planes de pago (p. ej. Full → Light): el nuevo plan se activa desde la próxima renovación; hasta entonces se mantienen las funciones del plan actual, sin cargo adicional ahora." downgrade_info_line: "Downgrade entre planes de pago (p. ej. Full → Light): el nuevo plan se activa desde la próxima renovación; hasta entonces se mantienen las funciones del plan actual, sin cargo adicional ahora."
already_requested_line: "Ya has solicitado %{plan} desde el %{when}: hasta entonces sigue activo %{current_plan}." already_requested_line: "Ya has solicitado %{plan} desde el %{when}: hasta entonces sigue activo %{current_plan}."
prices:
yearly: "%{amount}/año"
monthly: "%{amount}/mes"
regia: regia:
meta_title_fallback: "Control — Match Live TV" meta_title_fallback: "Control — Match Live TV"
title: "Control — %{home} vs %{away}" title: "Control — %{home} vs %{away}"
@@ -432,51 +435,16 @@ es:
paused: En pausa paused: En pausa
live: En directo live: En directo
waiting: En espera waiting: En espera
live_share_label: Directo live_share_label: "Enlace del directo (espectadores)"
share_live: Compartir directo share_live: Compartir directo
share_action: Compartir
copy_link: Copiar enlace copy_link: Copiar enlace
copy_action: Copiar regia_share_label: "Enlace de control (marcador)"
copy_live: Copiar directo
copy_regia: Copiar control
links_nav: Acciones rápidas
regia_share_label: Control
stream_controls_label: Live
share_regia: Compartir enlace de control share_regia: Compartir enlace de control
preview_ended: Directo finalizado preview_ended: Directo finalizado
preview_waiting: Vista previa a la espera de señal… preview_waiting: Vista previa a la espera de señal…
preview_hide: Ocultar vista previa
preview_show: Mostrar vista previa
preview_off_hint: Vista previa apagada · sin descarga de vídeo
pause: Pausar directo pause: Pausar directo
resume: Reanudar directo resume: Reanudar directo
stop: Finalizar directo stop: Finalizar directo
min_quality:
label: Calidad mínima de transmisión
summary: Calidad
hint: El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.
recommend: "Se recomienda Automática: se adapta a la red y al teléfono, sin forzar el máximo."
auto: Automática
telemetry:
label: Teléfono
waiting: Esperando al teléfono…
stale: El teléfono no responde
just_now: ahora
seconds_ago: "hace %{n}s"
publisher_on: RTMP ok
publisher_off: RTMP ausente
battery: Batería
network: Red
bitrate: Bitrate
bitrate_max: "máx %{n}"
bitrate_title: "%{current} Mbps actuales · máximo %{target} Mbps"
bitrate_tap: Toca para definir la calidad mínima
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Caliente
thermal_serious: Muy caliente
thermal_critical: Crítica
modal: modal:
set_won_title: Set ganado set_won_title: Set ganado
confirm: Cerrar set confirm: Cerrar set
@@ -521,8 +489,6 @@ es:
copy_prompt: "Copia el enlace:" copy_prompt: "Copia el enlace:"
share_regia_text: "Abre este enlace para gestionar el marcador en directo:" share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
share_live_text: "Mira la retransmisión en Match Live TV:" share_live_text: "Mira la retransmisión en Match Live TV:"
min_quality_updated: Calidad mínima actualizada
min_quality_error: Error al actualizar la calidad mínima
recordings: recordings:
archive: archive:
back_prefix: "←" back_prefix: "←"
+8 -42
View File
@@ -82,12 +82,12 @@ fr:
first_team_name_label: Nom de l'équipe first_team_name_label: Nom de l'équipe
first_team_name_placeholder: "ex. Under 15" first_team_name_placeholder: "ex. Under 15"
plan_label: "Forfait de départ (pour tout le club)" plan_label: "Forfait de départ (pour tout le club)"
plan_free: "Gratuit — 1 responsable diffusion par équipe, 1 direct" plan_free: "Gratuit — 1 compte diffuseur par équipe, 1 direct"
plan_light: Premium Light plan_light: Premium Light
plan_full: Premium Full plan_full: Premium Full
interval_label: Facturation premium interval_label: Facturation premium
interval_yearly: "Annuel — 40 €/an (Light) ou 200 €/an (Full)" interval_yearly: "Annuel — 59 €/an (Light) ou 199 €/an (Full)"
interval_monthly: "Mensuel — 5 €/mois (Light) ou 20 €/mois (Full)" interval_monthly: "Mensuel — 7,90 €/mois (Light) ou 24,90 €/mois (Full)"
submit: Créer le club submit: Créer le club
billing: billing:
title: "Abonnement — %{name}" title: "Abonnement — %{name}"
@@ -423,6 +423,9 @@ fr:
upgrade_info_line: "Upgrade (forfait supérieur ou passage à la facturation annuelle) : la différence est débitée immédiatement sur la carte Stripe enregistrée." upgrade_info_line: "Upgrade (forfait supérieur ou passage à la facturation annuelle) : la différence est débitée immédiatement sur la carte Stripe enregistrée."
downgrade_info_line: "Downgrade entre forfaits payants (ex. Full → Light) : le nouveau forfait est actif dès le prochain renouvellement ; jusque-là, les fonctionnalités du forfait actuel restent actives, sans débit supplémentaire pour l'instant." downgrade_info_line: "Downgrade entre forfaits payants (ex. Full → Light) : le nouveau forfait est actif dès le prochain renouvellement ; jusque-là, les fonctionnalités du forfait actuel restent actives, sans débit supplémentaire pour l'instant."
already_requested_line: "Vous avez déjà demandé %{plan} à partir du %{when} : jusque-là, %{current_plan} reste actif." already_requested_line: "Vous avez déjà demandé %{plan} à partir du %{when} : jusque-là, %{current_plan} reste actif."
prices:
yearly: "%{amount}/an"
monthly: "%{amount}/mois"
regia: regia:
meta_title_fallback: "Régie — Match Live TV" meta_title_fallback: "Régie — Match Live TV"
title: "Régie — %{home} vs %{away}" title: "Régie — %{home} vs %{away}"
@@ -432,51 +435,16 @@ fr:
paused: En pause paused: En pause
live: En direct live: En direct
waiting: En attente waiting: En attente
live_share_label: Direct live_share_label: "Lien direct (spectateurs)"
share_live: Partager le direct share_live: Partager le direct
share_action: Partager
copy_link: Copier le lien copy_link: Copier le lien
copy_action: Copier regia_share_label: "Lien régie (score)"
copy_live: Copier live
copy_regia: Copier régie
links_nav: Actions rapides
regia_share_label: Régie
stream_controls_label: Live
share_regia: Partager le lien régie share_regia: Partager le lien régie
preview_ended: Direct terminé preview_ended: Direct terminé
preview_waiting: Aperçu en attente du signal… preview_waiting: Aperçu en attente du signal…
preview_hide: Masquer l'aperçu
preview_show: Afficher l'aperçu
preview_off_hint: Aperçu désactivé · aucun téléchargement vidéo
pause: Mettre en pause pause: Mettre en pause
resume: Reprendre le direct resume: Reprendre le direct
stop: Terminer le direct stop: Terminer le direct
min_quality:
label: Qualité minimale de transmission
summary: Qualité
hint: L'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.
recommend: "Automatique est conseillé : s'adapte au réseau et au téléphone, sans forcer le maximum."
auto: Automatique
telemetry:
label: Téléphone
waiting: En attente du téléphone…
stale: Téléphone sans réponse
just_now: maintenant
seconds_ago: "il y a %{n}s"
publisher_on: RTMP ok
publisher_off: RTMP absent
battery: Batterie
network: Réseau
bitrate: Débit
bitrate_max: "max %{n}"
bitrate_title: "%{current} Mbps actuels · max %{target} Mbps"
bitrate_tap: Touchez pour définir la qualité minimale
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Chaude
thermal_serious: Très chaude
thermal_critical: Critique
modal: modal:
set_won_title: Set gagné set_won_title: Set gagné
confirm: Terminer le set confirm: Terminer le set
@@ -521,8 +489,6 @@ fr:
copy_prompt: "Copiez le lien :" copy_prompt: "Copiez le lien :"
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :" share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
share_live_text: "Regardez le direct sur Match Live TV :" share_live_text: "Regardez le direct sur Match Live TV :"
min_quality_updated: Qualité minimale mise à jour
min_quality_error: Erreur de mise à jour de la qualité minimale
recordings: recordings:
archive: archive:
back_prefix: "←" back_prefix: "←"
+8 -42
View File
@@ -77,12 +77,12 @@ it:
first_team_name_label: Nome squadra first_team_name_label: Nome squadra
first_team_name_placeholder: "es. Under 15" first_team_name_placeholder: "es. Under 15"
plan_label: "Piano iniziale (per tutta la società)" plan_label: "Piano iniziale (per tutta la società)"
plan_free: "Free — 1 responsabile trasmissione per squadra, 1 live" plan_free: "Free — 1 account trasmettitore per squadra, 1 live"
plan_light: Premium Light plan_light: Premium Light
plan_full: Premium Full plan_full: Premium Full
interval_label: Fatturazione premium interval_label: Fatturazione premium
interval_yearly: "Annuale — €40/anno (Light) o €200/anno (Full)" interval_yearly: "Annuale — €59/anno (Light) o €199/anno (Full)"
interval_monthly: "Mensile — €5/mese (Light) o €20/mese (Full)" interval_monthly: "Mensile — €7,90/mese (Light) o €24,90/mese (Full)"
submit: Crea società submit: Crea società
billing: billing:
title: "Abbonamento — %{name}" title: "Abbonamento — %{name}"
@@ -418,6 +418,9 @@ it:
upgrade_info_line: "Upgrade (piano superiore o passaggio alla fatturazione annuale): la differenza viene addebitata subito sulla carta salvata in Stripe." upgrade_info_line: "Upgrade (piano superiore o passaggio alla fatturazione annuale): la differenza viene addebitata subito sulla carta salvata in Stripe."
downgrade_info_line: "Downgrade tra piani a pagamento (es. Full → Light): il nuovo piano è attivo dal prossimo rinnovo; fino ad allora restano le funzioni del piano attuale, senza addebito aggiuntivo ora." downgrade_info_line: "Downgrade tra piani a pagamento (es. Full → Light): il nuovo piano è attivo dal prossimo rinnovo; fino ad allora restano le funzioni del piano attuale, senza addebito aggiuntivo ora."
already_requested_line: "Hai già richiesto %{plan} dal %{when}: fino ad allora resta attivo %{current_plan}." already_requested_line: "Hai già richiesto %{plan} dal %{when}: fino ad allora resta attivo %{current_plan}."
prices:
yearly: "%{amount}/anno"
monthly: "%{amount}/mese"
regia: regia:
meta_title_fallback: "Regia — Match Live TV" meta_title_fallback: "Regia — Match Live TV"
title: "Regia — %{home} vs %{away}" title: "Regia — %{home} vs %{away}"
@@ -427,51 +430,16 @@ it:
paused: In pausa paused: In pausa
live: In onda live: In onda
waiting: In attesa waiting: In attesa
live_share_label: Diretta live_share_label: "Link diretta (spettatori)"
share_live: Condividi diretta share_live: Condividi diretta
share_action: Condividi
copy_link: Copia link copy_link: Copia link
copy_action: Copia regia_share_label: "Link regia (punteggio)"
copy_live: Copia live
copy_regia: Copia regia
links_nav: Azioni rapide
regia_share_label: Regia
stream_controls_label: Live
share_regia: Condividi link regia share_regia: Condividi link regia
preview_ended: Diretta terminata preview_ended: Diretta terminata
preview_waiting: Anteprima in attesa del segnale… preview_waiting: Anteprima in attesa del segnale…
preview_hide: Nascondi anteprima
preview_show: Mostra anteprima
preview_off_hint: Anteprima spenta · nessun download video
pause: Metti in pausa pause: Metti in pausa
resume: Riprendi diretta resume: Riprendi diretta
stop: Chiudi diretta stop: Chiudi diretta
min_quality:
label: Qualità minima di trasmissione
summary: Qualità
hint: L'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.
recommend: "Consigliata Automatica: si adatta alla rete e al telefono, senza forzare il massimo."
auto: Automatica
telemetry:
label: Telefono
waiting: In attesa del telefono…
stale: Telefono non risponde
just_now: ora
seconds_ago: "%{n}s fa"
publisher_on: RTMP ok
publisher_off: RTMP assente
battery: Batteria
network: Rete
bitrate: Bitrate
bitrate_max: "max %{n}"
bitrate_title: "%{current} Mbps attuali · massimo %{target} Mbps"
bitrate_tap: Tocca per impostare la qualità minima
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Calda
thermal_serious: Molto calda
thermal_critical: Critica
modal: modal:
set_won_title: Set vinto set_won_title: Set vinto
confirm: Chiudi set confirm: Chiudi set
@@ -516,8 +484,6 @@ it:
copy_prompt: "Copia il link:" copy_prompt: "Copia il link:"
share_regia_text: "Apri questo link per gestire il punteggio della diretta:" share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
share_live_text: "Guarda la diretta su Match Live TV:" share_live_text: "Guarda la diretta su Match Live TV:"
min_quality_updated: Qualità minima aggiornata
min_quality_error: Errore aggiornamento qualità minima
recordings: recordings:
archive: archive:
back_prefix: "←" back_prefix: "←"
+86 -31
View File
@@ -2,32 +2,80 @@ de:
pages: pages:
features: features:
meta_title: "Streaming-Funktionen für Jugendspiele — Match Live TV" meta_title: "Streaming-Funktionen für Jugendspiele — Match Live TV"
meta_description: "App für Livestreams vom Handy: Spiel filmen, Spielstand aktualisieren, Link mit Eltern und Großeltern teilen. Spielarchiv und Pläne für Jugend-Sportvereine." meta_description: "Übertragen Sie Vereinsspiele vom Smartphone mit persönlichen Konten, Spielstand per Link, Live auf Match Live TV und YouTube sowie Replay und Archiv. Ohne komplizierte Technik."
title: Funktionen hero_title: "Die Livestreams Ihres Vereins. Ohne geteilte Passwörter, ohne komplizierte Technik."
lead: "Alles, was nötig ist, damit auch die, die nicht in der Halle sein können, das Spiel verfolgen: einfach für Trainer und Helfer, praktisch für Eltern, Großeltern und Verwandte." hero_lead: "Laden Sie Trainer, Verantwortliche und Eltern ein, mit dem eigenen Konto zu streamen. Ein Smartphone reicht: Match Live TV übernimmt Zugänge, Spielstand, Live, Replay und Veröffentlichung."
staff_section_title: Für Verein und Team hero_alt: Smartphone auf Stativ für den Livestream und zweites Handy für den Spielstand in der Halle
staff_invite_title: Einladungen mit eigener E-Mail flow_aria: Ablauf der Übertragung
staff_invite_body: "Der Vorsitzende oder Inhaber lädt ein, wer filmt und wer den Spielstand aktualisiert: jeder meldet sich mit seiner eigenen E-Mail an, ohne Passwörter zu teilen." flow_phone: Ein Smartphone
staff_access_title: Zugriffsverwaltung flow_account: Ein persönliches Konto
staff_access_body: Sie können einem Helfer den Zugriff entziehen oder eine noch offene Einladung vom Team-Dashboard aus stornieren. flow_live: Sie sind live
staff_plans_title: Maßgeschneiderte Pläne how_title: Von der Halle zu den Zuschauern in wenigen Minuten
staff_plans_body: "Mit Free, Premium Light oder Premium Full erhöhen Sie, wie viele Personen live helfen können und wie viele Spiele parallel laufen können." how_1_title: Spiel anlegen
live_section_title: Auf dem Platz und live how_1_body: Der Verein bereitet das Spiel mit Team, Gegner, Datum und Uhrzeit vor.
live_phone_title: Vom Handy filmen how_2_title: App öffnen
live_phone_body: "Handy aufstellen und den Livestream starten: auch bei schwachem 4G bleibt die Übertragung so stabil wie möglich, ohne Kabelsalat oder Mischpult." how_2_body: Die beauftragte Person meldet sich in der Match-Live-TV-App mit dem eigenen persönlichen Konto an.
live_score_title: Spielstand vom zweiten Handy how_3_title: Livestream starten
live_score_body: "Ein weiterer Helfer aktualisiert Sätze und Punkte von einem zweiten Handy aus: einfach den Code scannen, der auf dem Bildschirm des Filmenden erscheint." how_3_body: Ein Smartphone reicht. Keine spezielle Ausrüstung und keine komplizierte Einrichtung.
live_link_title: Link für Familien und Fans how_4_title: Spielstand führen
live_link_body: "Teilen Sie einen Link: Eltern, Großeltern und Freunde schauen im Browser von Handy oder Computer, ohne App-Installation." how_4_body: Eine andere Person kann Ergebnis und Spielstand über einen einfachen Link aktualisieren ohne App und ohne Konto.
live_archive_title: Spielarchiv how_5_title: Replay
live_archive_body: "Mit Premium Light oder Full bleiben die Spiele auf dem Server gespeichert (30 oder 90 Tage) und Sie können sie bei Bedarf aufs Handy herunterladen." how_5_body: Nach dem Livestream kann das Spiel gespeichert und als Replay verfügbar gemacht werden je nach Plan.
live_youtube_mltv_title: Auch auf YouTube (Match Live TV) accounts_title: Jeder mit dem eigenen Konto. Das Vereins-Passwort bleibt beim Verein.
live_youtube_mltv_body: "Mit Premium Light ist der Livestream zusätzlich zum Link auf der Website auch auf dem YouTube-Kanal von Match Live TV zu sehen." accounts_lead: Username und Passwort des Hauptkontos müssen nicht mehr geteilt werden. Verantwortliche, Trainer und beauftragte Eltern erhalten einen persönlichen, widerrufbaren Zugang.
live_youtube_club_title: YouTube-Kanal des Vereins accounts_invite_title: Einladen
live_youtube_club_body: "Mit Premium Full können Sie denselben Livestream auch auf den offiziellen YouTube-Kanal des Vereins senden." accounts_invite_body: Der Verein lädt Trainer, Verantwortliche oder Eltern ein, die die Übertragung übernehmen.
live_website_title: Auf der Website des Vereins accounts_transmit_title: Übertragen
live_website_body: "Integration zum Einbetten des Players auf der Vereinswebsite — demnächst mit Premium Full." accounts_transmit_body: Jeder meldet sich in der App mit den eigenen Zugangsdaten an.
cta_signup: Team registrieren accounts_revoke_title: Widerrufen
accounts_revoke_body: Wenn jemand nicht mehr mit Team oder Verein zusammenarbeitet, kann der Zugang widerrufen werden, ohne das Passwort der anderen zu ändern.
accounts_highlight: Keine geteilten Passwörter. Jede beauftragte Person hat den eigenen persönlichen Zugang.
score_title: Wer filmt, filmt. Wer den Spielstand führt, führt den Spielstand.
score_lead: Wer überträgt, kann sich auf den Bildausschnitt konzentrieren. Eine andere Person auch von der Tribüne öffnet einen einfachen Link und aktualisiert den Spielstand in Echtzeit.
score_item_app: Keine App-Installation
score_item_signup: Keine Registrierung
score_item_password: Kein Passwort
score_item_link: Einfach den geteilten Link öffnen
score_card_aria: Zugang zur Spielstand-Regie
score_card_title: Regie per Link
score_card_body: Wer Sätze und Punkte aktualisiert, öffnet einen geteilten Link. Kein Konto, keine Einladung.
access_link: Zugang per Link
distribute_title: Ein Spiel, mehrere Wege es zu verfolgen
distribute_lead: Vom Smartphone in der Halle erreicht der Livestream die Zuschauer auf Match Live TV und je nach Plan auch YouTube.
distribute_aria: Wohin der Livestream geht
distribute_phone: Smartphone
distribute_mltv: Match Live TV
distribute_viewers: Zuschauer
distribute_youtube: YouTube
youtube_badge: Premium Full
youtube_title: Der Livestream bleibt bei Ihrem Verein
youtube_body: Mit Premium Full können Sie auch auf den offiziellen YouTube-Kanal des Vereins streamen. So behält der Verein Publikum, Abonnenten, Aufrufe, Spielarchiv und Sponsorensichtbarkeit.
youtube_mock_channel: Offizieller Vereinskanal
youtube_mock_subs: Abonnenten und Aufrufe des Vereins
replay_title: Das Spiel endet. Die Erinnerung bleibt.
replay_lead: Bei Plänen mit Replay bleiben Spiele auf den Match-Live-TV-Servern gespeichert und können später angesehen werden. Die Aufbewahrungsdauer hängt vom Plan ab.
replay_item_server: Speicherung auf Match-Live-TV-Servern
replay_item_plan: Archivdauer je nach Plan
replay_item_download: Video bei Bedarf aufs Handy herunterladen
replay_card_title: Spielarchiv
replay_card_body: Spiele nach dem Schlusspfiff noch einmal ansehen und bei Bedarf herunterladen.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15"
more_title: Auch das gehört zum Produkt
more_stable_title: Livestream vom Smartphone
more_stable_body: Handy aufstellen und Übertragung starten. Auch bei schwachem 4G bleibt der Livestream so stabil wie möglich ohne Kabel oder Mischpult.
more_link_title: Link für Familien und Fans
more_link_body: "Einen Link teilen: Eltern, Großeltern und Freunde schauen im Browser von Handy oder Computer ohne App."
more_yt_mltv_title: Auch auf YouTube Match Live TV
more_yt_mltv_body: Der Livestream kann zusätzlich zum Link auf der Website auch auf dem YouTube-Kanal von Match Live TV sichtbar sein.
cta_title: Bereit für das nächste Spiel?
cta_body: Probieren Sie Match Live TV kostenlos. Keine spezielle Ausrüstung nötig.
cta_primary: Kostenlos starten
cta_secondary: Pläne vergleichen
pricing: pricing:
meta_title: "Preise für Jugendspiel-Streaming — Match Live TV" meta_title: "Preise für Jugendspiel-Streaming — Match Live TV"
meta_description: "Free-, Premium-Light- und Premium-Full-Pläne für Livestreams und Spielarchiv. Jahresabo für Vereine: mehr Team-Mitglieder, mehr parallele Spiele, Replay und YouTube." meta_description: "Free-, Premium-Light- und Premium-Full-Pläne für Livestreams und Spielarchiv. Jahresabo für Vereine: mehr Team-Mitglieder, mehr parallele Spiele, Replay und YouTube."
@@ -35,8 +83,8 @@ de:
club_info_html: "Verein: <strong>%{name}</strong> · %{billing_link}" club_info_html: "Verein: <strong>%{name}</strong> · %{billing_link}"
manage_subscription: Abo verwalten manage_subscription: Abo verwalten
team_title: Pläne für Ihr Team team_title: Pläne für Ihr Team
team_lead: "Jahresabo für Vereine: die Streaming-Verantwortlichen starten den Livestream aus der App; der Spielstand wird über einen teilbaren Link verwaltet." team_lead: "Jahresabo für Vereine: die Streaming-Konten starten den Livestream aus der App; der Spielstand wird über einen teilbaren Link verwaltet."
table_staff: Streaming-Verantwortliche / Jahr table_staff: Streaming-Konten
table_matches: Gleichzeitige Spiele table_matches: Gleichzeitige Spiele
table_live_mltv: Live auf Match Live TV table_live_mltv: Live auf Match Live TV
table_youtube: YouTube table_youtube: YouTube
@@ -44,8 +92,7 @@ de:
table_download: Handy-Download table_download: Handy-Download
table_price: Preis table_price: Preis
table_price_free: "€0" table_price_free: "€0"
table_price_light_html: "€40/Jahr oder €5/Monat" table_price_note: "Listenpreis %{list} — %{monthly}"
table_price_full_html: "€200/Jahr oder €20/Monat"
table_yes: Ja table_yes: Ja
table_no: Nein table_no: Nein
table_unlimited: Unbegrenzt table_unlimited: Unbegrenzt
@@ -55,8 +102,16 @@ de:
contact_button: Kontaktieren Sie uns contact_button: Kontaktieren Sie uns
plans: plans:
price_alt_html: "oder <strong>%{price}</strong>" price_alt_html: "oder <strong>%{price}</strong>"
staff_html: "Streaming-Verantwortliche: <strong>%{count}</strong> / Jahr pro Team" price_equiv: "entspricht %{price}"
launch_price: Einführungspreis
recommended_badge: Empfohlen für Vereine
cta_name:
premium_light: Light
premium_full: Full
staff_html: "Streaming-Konten: maximal <strong>%{count}</strong> pro Abonnement"
staff_unlimited_html: "Streaming-Konten: <strong>unbegrenzt</strong> pro Abonnement"
staff_unlimited: unbegrenzt staff_unlimited: unbegrenzt
staff_note_full: "Keine geteilten Passwörter: jede Person nutzt den eigenen Zugang, jederzeit widerrufbar."
matches_html: "Spiele: <strong>%{count}</strong> gleichzeitig (ganzer Verein)" matches_html: "Spiele: <strong>%{count}</strong> gleichzeitig (ganzer Verein)"
matches_unlimited: unbegrenzt matches_unlimited: unbegrenzt
replay_html: "Replay: %{value}" replay_html: "Replay: %{value}"
+86 -31
View File
@@ -2,32 +2,80 @@ en:
pages: pages:
features: features:
meta_title: "Youth match streaming features — Match Live TV" meta_title: "Youth match streaming features — Match Live TV"
meta_description: "App for live streaming from your phone: film the match, update the score, share the link with parents and grandparents. Match archive and plans for youth sports clubs." meta_description: "Stream your club's matches from a smartphone with personal accounts, score via link, live on Match Live TV and YouTube, plus replay and archive. No complicated equipment."
title: Features hero_title: "Your club's live streams. No shared passwords, no complicated gear."
lead: "Everything you need so people who can't be at the gym can follow the match: simple for coaches and volunteers, convenient for parents, grandparents and relatives." hero_lead: "Invite coaches, managers and parents to stream with their own account. All you need is a smartphone: Match Live TV handles access, scoring, the live stream, replay and publishing."
staff_section_title: For the club and staff hero_alt: Smartphone on a tripod for the live stream and a second phone for the gym scoreboard
staff_invite_title: Invites with their own email flow_aria: Streaming flow
staff_invite_body: "The president or owner invites whoever films and whoever updates the score: each person signs in with their own email, no shared passwords." flow_phone: One smartphone
staff_access_title: Access management flow_account: One personal account
staff_access_body: You can remove a volunteer's access or cancel a pending invite from the team dashboard. flow_live: You're live
staff_plans_title: Plans to fit your needs how_title: From the gym to viewers in a few minutes
staff_plans_body: With Free, Premium Light or Premium Full you increase how many people can help stream live and how many matches can run in parallel. how_1_title: Create the match
live_section_title: On the court and live how_1_body: The club sets up the match with team, opponent, date and time.
live_phone_title: Film from your phone how_2_title: Open the app
live_phone_body: "Prop up the phone and start the stream: even with weak 4G the broadcast stays as stable as possible, with no cables or mixers to worry about." how_2_body: The assigned person signs into the Match Live TV app with their own personal account.
live_score_title: Score from a second phone how_3_title: Start the stream
live_score_body: "Another volunteer updates sets and points from a second phone: just scan the code shown on the streamer's screen." how_3_body: A smartphone is enough. No dedicated equipment or complicated setup.
live_link_title: Link for families and fans how_4_title: Manage the score
live_link_body: "Share a link: parents, grandparents and friends watch from their phone or computer browser, no app to install." how_4_body: Someone else can update the result and score through a simple link, without installing an app or creating an account.
live_archive_title: Match archive how_5_title: Replay
live_archive_body: "With Premium Light or Full, matches stay saved on the server (30 or 90 days) and you can download them to your phone whenever you need." how_5_body: After the stream ends, the match can be kept and made available as a replay, depending on the plan.
live_youtube_mltv_title: Also on YouTube (Match Live TV) accounts_title: Everyone with their own account. The club password stays with the club.
live_youtube_mltv_body: With Premium Light the stream is also visible on the Match Live TV YouTube channel, in addition to the link on the site. accounts_lead: You no longer need to share the main club username and password. Managers, coaches and assigned parents get a personal, revocable login.
live_youtube_club_title: Your club's YouTube channel accounts_invite_title: Invite
live_youtube_club_body: With Premium Full you can send the same stream to your club's official YouTube channel. accounts_invite_body: The club invites coaches, managers or parents assigned to stream.
live_website_title: On the club's website accounts_transmit_title: Stream
live_website_body: Integration to embed the player on the club's website — coming with Premium Full. accounts_transmit_body: Each person signs into the app with their own credentials.
cta_signup: Register your team accounts_revoke_title: Revoke
accounts_revoke_body: When someone no longer works with the team or club, their access can be revoked without changing everyone else's password.
accounts_highlight: No shared passwords. Every assigned person has their own personal access.
score_title: Who films, films. Who keeps score, keeps score.
score_lead: The person streaming can focus on the framing. Someone else, even from the stands, can open a simple link and update the score in real time.
score_item_app: No app to install
score_item_signup: No sign-up
score_item_password: No password
score_item_link: Just open the shared link
score_card_aria: Score control access
score_card_title: Score control via a link
score_card_body: Whoever updates sets and points opens a shared link. No account and no invite.
access_link: Open with a link
distribute_title: One match, more ways to follow it
distribute_lead: From the smartphone in the gym, the stream reaches viewers on Match Live TV and, when included in the plan, also on YouTube.
distribute_aria: Where the stream goes
distribute_phone: Smartphone
distribute_mltv: Match Live TV
distribute_viewers: Viewers
distribute_youtube: YouTube
youtube_badge: Premium Full
youtube_title: The stream stays with your club
youtube_body: With Premium Full you can also stream to the club's official YouTube channel. The club keeps its audience, subscribers, views, match archive and sponsor visibility.
youtube_mock_channel: Official club channel
youtube_mock_subs: Club subscribers and views
replay_title: The match ends. The memory stays.
replay_lead: On plans that include replay, matches are stored on Match Live TV servers and can be watched later. How long they are kept depends on the plan.
replay_item_server: Stored on Match Live TV servers
replay_item_plan: Archive length based on the plan
replay_item_download: Download the video to your phone when you need it
replay_card_title: Match archive
replay_card_body: Rewatch matches after the final whistle and download them when you need them.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15"
more_title: Also part of the product
more_stable_title: Stream from your smartphone
more_stable_body: Prop up the phone and start streaming. Even with weak 4G the broadcast stays as stable as possible, with no cables or mixers.
more_link_title: Link for families and fans
more_link_body: "Share a link: parents, grandparents and friends watch from their phone or computer browser, no app to install."
more_yt_mltv_title: Also on Match Live TV YouTube
more_yt_mltv_body: You can also make the stream visible on the Match Live TV YouTube channel, in addition to the link on the site.
cta_title: Ready for the next match?
cta_body: Try Match Live TV for free. No dedicated equipment needed.
cta_primary: Start for free
cta_secondary: Compare plans
pricing: pricing:
meta_title: "Youth match streaming pricing — Match Live TV" meta_title: "Youth match streaming pricing — Match Live TV"
meta_description: "Free, Premium Light and Premium Full plans for live streaming and match archive. Annual subscription for clubs: more staff, more concurrent matches, replay and YouTube." meta_description: "Free, Premium Light and Premium Full plans for live streaming and match archive. Annual subscription for clubs: more staff, more concurrent matches, replay and YouTube."
@@ -35,8 +83,8 @@ en:
club_info_html: "Club: <strong>%{name}</strong> · %{billing_link}" club_info_html: "Club: <strong>%{name}</strong> · %{billing_link}"
manage_subscription: Manage subscription manage_subscription: Manage subscription
team_title: Plans for your team team_title: Plans for your team
team_lead: "Annual subscription for clubs: streaming staff start the broadcast from the app; the score is managed with a shareable link." team_lead: "Annual subscription for clubs: transmitter accounts start the broadcast from the app; the score is managed with a shareable link."
table_staff: Streaming staff / year table_staff: Transmitter accounts
table_matches: Concurrent matches table_matches: Concurrent matches
table_live_mltv: Live on Match Live TV table_live_mltv: Live on Match Live TV
table_youtube: YouTube table_youtube: YouTube
@@ -44,8 +92,7 @@ en:
table_download: Phone download table_download: Phone download
table_price: Price table_price: Price
table_price_free: "€0" table_price_free: "€0"
table_price_light_html: "€40/year or €5/month" table_price_note: "list %{list} — %{monthly}"
table_price_full_html: "€200/year or €20/month"
table_yes: "Yes" table_yes: "Yes"
table_no: "No" table_no: "No"
table_unlimited: Unlimited table_unlimited: Unlimited
@@ -55,8 +102,16 @@ en:
contact_button: Contact us contact_button: Contact us
plans: plans:
price_alt_html: "or <strong>%{price}</strong>" price_alt_html: "or <strong>%{price}</strong>"
staff_html: "Streaming staff: <strong>%{count}</strong> / year per team" price_equiv: "equivalent to %{price}"
launch_price: Launch price
recommended_badge: Recommended for clubs
cta_name:
premium_light: Light
premium_full: Full
staff_html: "Transmitter accounts: max <strong>%{count}</strong> per subscription"
staff_unlimited_html: "Transmitter accounts: <strong>unlimited</strong> per subscription"
staff_unlimited: unlimited staff_unlimited: unlimited
staff_note_full: "No shared passwords: each person uses their own login, which can be revoked at any time."
matches_html: "Matches: <strong>%{count}</strong> concurrent (whole club)" matches_html: "Matches: <strong>%{count}</strong> concurrent (whole club)"
matches_unlimited: unlimited matches_unlimited: unlimited
replay_html: "Replay: %{value}" replay_html: "Replay: %{value}"
+87 -32
View File
@@ -2,32 +2,80 @@ es:
pages: pages:
features: features:
meta_title: "Funciones de streaming de partidos juveniles — Match Live TV" meta_title: "Funciones de streaming de partidos juveniles — Match Live TV"
meta_description: "App para el directo desde el móvil: graba el partido, actualiza el marcador, comparte el enlace con padres y abuelos. Archivo de partidos y planes para clubes deportivos juveniles." meta_description: "Emite los partidos del club desde el móvil con cuentas personales, marcador por enlace, directo en Match Live TV y YouTube, más repetición y archivo. Sin equipo complicado."
title: Funciones hero_title: "Los directos de tu club. Sin contraseñas compartidas, sin equipo complicado."
lead: "Todo lo necesario para que quienes no pueden estar en el pabellón sigan el partido: sencillo para entrenadores y voluntarios, cómodo para padres, abuelos y familiares." hero_lead: "Invita a entrenadores, directivos y padres a emitir con su propia cuenta. Basta un smartphone: Match Live TV gestiona accesos, marcador, directo, repetición y publicación."
staff_section_title: Para el club y el staff hero_alt: Smartphone en trípode para el directo y segundo móvil para el marcador en el pabellón
staff_invite_title: Invitaciones con su propio email flow_aria: Flujo de la emisión
staff_invite_body: "El presidente o propietario invita a quien graba y a quien actualiza el marcador: cada uno accede con su propio email, sin compartir contraseñas." flow_phone: Un smartphone
staff_access_title: Gestión de accesos flow_account: Una cuenta personal
staff_access_body: Puedes retirar el acceso a un voluntario o cancelar una invitación pendiente desde el panel del equipo. flow_live: Estás en directo
staff_plans_title: Planes a medida how_title: Del pabellón a los espectadores en pocos minutos
staff_plans_body: Con Free, Premium Light o Premium Full aumentas cuántas personas pueden ayudar en directo y cuántos partidos pueden ir en paralelo. how_1_title: Crea el partido
live_section_title: En la cancha y en directo how_1_body: El club prepara el partido indicando equipo, rival, fecha y hora.
live_phone_title: Graba desde el móvil how_2_title: Abre la app
live_phone_body: "Apoyas el móvil e inicias el directo: incluso con 4G débil, la transmisión se mantiene lo más estable posible, sin líos de cables ni mezcladores." how_2_body: La persona encargada accede a la app Match Live TV con su propia cuenta personal.
live_score_title: Marcador desde un segundo móvil how_3_title: Empieza el directo
live_score_body: "Otro voluntario actualiza sets y puntos desde un segundo móvil: solo hay que escanear el código que aparece en la pantalla de quien graba." how_3_body: Basta el smartphone. Sin equipo dedicado ni configuración complicada.
live_link_title: Enlace para familias y aficionados how_4_title: Gestiona el marcador
live_link_body: "Comparte un enlace: padres, abuelos y amigos miran desde el navegador del móvil o del ordenador, sin instalar apps." how_4_body: Otra persona puede actualizar resultado y marcador con un enlace sencillo, sin instalar apps ni crear una cuenta.
live_archive_title: Archivo de partidos how_5_title: Repetición
live_archive_body: "Con Premium Light o Full los partidos quedan guardados en el servidor (30 o 90 días) y puedes descargarlos al móvil cuando quieras." how_5_body: Al terminar el directo, el partido puede conservarse y quedar disponible como repetición, según el plan elegido.
live_youtube_mltv_title: También en YouTube (Match Live TV) accounts_title: Cada uno con su propia cuenta. La contraseña del club se queda en el club.
live_youtube_mltv_body: "Con Premium Light el directo también se ve en el canal de YouTube de Match Live TV, además del enlace en el sitio." accounts_lead: Ya no hace falta compartir el usuario y la contraseña de la cuenta principal. Directivos, entrenadores y padres encargados reciben un acceso personal y revocable.
live_youtube_club_title: Canal de YouTube del club accounts_invite_title: Invita
live_youtube_club_body: "Con Premium Full puedes enviar el mismo directo también al canal oficial de YouTube del club." accounts_invite_body: El club invita a entrenadores, directivos o padres encargados de la emisión.
live_website_title: En el sitio web del club accounts_transmit_title: Emite
live_website_body: "Integración para incrustar el reproductor en el sitio web del club — próximamente con Premium Full." accounts_transmit_body: Cada uno accede a la app con sus propias credenciales.
cta_signup: Registra tu equipo accounts_revoke_title: Revoca
accounts_revoke_body: Cuando una persona deja de colaborar con el equipo o el club, su acceso puede revocarse sin cambiar la contraseña a los demás.
accounts_highlight: Sin contraseñas compartidas. Cada encargado tiene su propio acceso personal.
score_title: Quien graba, graba. Quien lleva el marcador, lleva el marcador.
score_lead: Quien emite puede centrarse en el encuadre. Otra persona, incluso desde la grada, puede abrir un enlace sencillo y actualizar el marcador en tiempo real.
score_item_app: Sin app que instalar
score_item_signup: Sin registro
score_item_password: Sin contraseña
score_item_link: Basta abrir el enlace compartido
score_card_aria: Acceso a la regia del marcador
score_card_title: Regia con un enlace
score_card_body: Quien actualiza sets y puntos abre un enlace compartido. Sin cuenta y sin invitación.
access_link: Acceso con enlace
distribute_title: Un partido, más formas de seguirlo
distribute_lead: Desde el smartphone en el pabellón, el directo llega a los espectadores en Match Live TV y, según el plan, también a YouTube.
distribute_aria: Dónde llega el directo
distribute_phone: Smartphone
distribute_mltv: Match Live TV
distribute_viewers: Espectadores
distribute_youtube: YouTube
youtube_badge: Premium Full
youtube_title: El directo se queda en tu club
youtube_body: Con Premium Full también puedes emitir en el canal oficial de YouTube del club. Así el club conserva su público, suscriptores, visualizaciones, archivo de partidos y visibilidad de patrocinadores.
youtube_mock_channel: Canal oficial del club
youtube_mock_subs: Suscriptores y visualizaciones del club
replay_title: El partido termina. El recuerdo permanece.
replay_lead: En los planes con repetición, los partidos se guardan en los servidores de Match Live TV y pueden verse después. La duración del archivo depende del plan.
replay_item_server: Conservación en servidores Match Live TV
replay_item_plan: Duración del archivo según el plan
replay_item_download: Descarga del vídeo al móvil cuando haga falta
replay_card_title: Archivo de partidos
replay_card_body: Vuelve a ver los partidos tras el pitido final y descárgalos cuando los necesites.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15"
more_title: También forma parte del producto
more_stable_title: Directo desde el smartphone
more_stable_body: Apoyas el móvil e inicias la emisión. Incluso con 4G débil, el directo se mantiene lo más estable posible, sin cables ni mezcladores.
more_link_title: Enlace para familias y aficionados
more_link_body: "Comparte un enlace: padres, abuelos y amigos miran desde el navegador del móvil o del ordenador, sin instalar apps."
more_yt_mltv_title: También en YouTube Match Live TV
more_yt_mltv_body: También puedes hacer visible el directo en el canal de YouTube de Match Live TV, además del enlace en el sitio.
cta_title: ¿Listos para el próximo partido?
cta_body: Prueba Match Live TV gratis. No hace falta equipo dedicado.
cta_primary: Empieza gratis
cta_secondary: Compara los planes
pricing: pricing:
meta_title: "Precios de streaming de partidos juveniles — Match Live TV" meta_title: "Precios de streaming de partidos juveniles — Match Live TV"
meta_description: "Planes Free, Premium Light y Premium Full para directos y archivo de partidos. Suscripción anual para clubes: más staff, más partidos en paralelo, repetición y YouTube." meta_description: "Planes Free, Premium Light y Premium Full para directos y archivo de partidos. Suscripción anual para clubes: más staff, más partidos en paralelo, repetición y YouTube."
@@ -35,8 +83,8 @@ es:
club_info_html: "Club: <strong>%{name}</strong> · %{billing_link}" club_info_html: "Club: <strong>%{name}</strong> · %{billing_link}"
manage_subscription: Gestionar suscripción manage_subscription: Gestionar suscripción
team_title: Planes para tu equipo team_title: Planes para tu equipo
team_lead: "Suscripción anual para clubes: los responsables de emisión inician el directo desde la app; el marcador se gestiona con un enlace para compartir." team_lead: "Suscripción anual para clubes: las cuentas de emisión inician el directo desde la app; el marcador se gestiona con un enlace para compartir."
table_staff: Responsables de emisión / año table_staff: Cuentas de emisión
table_matches: Partidos simultáneos table_matches: Partidos simultáneos
table_live_mltv: Directo en Match Live TV table_live_mltv: Directo en Match Live TV
table_youtube: YouTube table_youtube: YouTube
@@ -44,8 +92,7 @@ es:
table_download: Descarga al móvil table_download: Descarga al móvil
table_price: Precio table_price: Precio
table_price_free: "€0" table_price_free: "€0"
table_price_light_html: "€40/año o €5/mes" table_price_note: "tarifa %{list} — %{monthly}"
table_price_full_html: "€200/año o €20/mes"
table_yes: "Sí" table_yes: "Sí"
table_no: "No" table_no: "No"
table_unlimited: Ilimitado table_unlimited: Ilimitado
@@ -55,8 +102,16 @@ es:
contact_button: Contáctanos contact_button: Contáctanos
plans: plans:
price_alt_html: "o <strong>%{price}</strong>" price_alt_html: "o <strong>%{price}</strong>"
staff_html: "Responsables de emisión: <strong>%{count}</strong> / año por equipo" price_equiv: "equivale a %{price}"
staff_unlimited: ilimitado launch_price: Precio de lanzamiento
recommended_badge: Recomendado para clubes
cta_name:
premium_light: Light
premium_full: Full
staff_html: "Cuentas de emisión: máximo <strong>%{count}</strong> por suscripción"
staff_unlimited_html: "Cuentas de emisión: <strong>ilimitadas</strong> por suscripción"
staff_unlimited: ilimitadas
staff_note_full: "Sin contraseñas compartidas: cada persona usa su propio acceso, revocable en cualquier momento."
matches_html: "Partidos: <strong>%{count}</strong> simultáneos (todo el club)" matches_html: "Partidos: <strong>%{count}</strong> simultáneos (todo el club)"
matches_unlimited: ilimitados matches_unlimited: ilimitados
replay_html: "Repetición: %{value}" replay_html: "Repetición: %{value}"
+87 -32
View File
@@ -2,32 +2,80 @@ fr:
pages: pages:
features: features:
meta_title: "Fonctionnalités de streaming des matchs jeunes — Match Live TV" meta_title: "Fonctionnalités de streaming des matchs jeunes — Match Live TV"
meta_description: "Application pour le direct depuis le téléphone : filmez le match, mettez à jour le score, partagez le lien avec parents et grands-parents. Archive des matchs et offres pour les clubs sportifs jeunes." meta_description: "Diffusez les matchs du club depuis un smartphone avec des comptes personnels, le score via un lien, le direct sur Match Live TV et YouTube, plus le replay et l'archive. Sans matériel compliqué."
title: Fonctionnalités hero_title: "Les directs de votre club. Sans mots de passe partagés, sans matériel compliqué."
lead: "Tout ce qu'il faut pour faire suivre les matchs à ceux qui ne peuvent pas être en salle : simple pour les coachs et bénévoles, pratique pour parents, grands-parents et proches." hero_lead: "Invitez entraîneurs, dirigeants et parents à diffuser avec leur propre compte. Un smartphone suffit : Match Live TV gère les accès, le score, le direct, le replay et la publication."
staff_section_title: Pour le club et le staff hero_alt: Smartphone sur trépied pour le direct et second téléphone pour le score en salle
staff_invite_title: Invitations avec leur propre email flow_aria: Déroulement de la diffusion
staff_invite_body: "Le président ou le propriétaire invite qui filme et qui met à jour le score : chacun se connecte avec son propre email, sans partager de mot de passe." flow_phone: Un smartphone
staff_access_title: Gestion des accès flow_account: Un compte personnel
staff_access_body: Vous pouvez retirer l'accès à un bénévole ou annuler une invitation encore en attente depuis le tableau de bord de l'équipe. flow_live: Vous êtes en direct
staff_plans_title: Des offres sur mesure how_title: De la salle aux spectateurs en quelques minutes
staff_plans_body: "Avec Free, Premium Light ou Premium Full, augmentez le nombre de personnes qui peuvent aider en direct et le nombre de matchs en parallèle." how_1_title: Créez le match
live_section_title: Sur le terrain et en direct how_1_body: Le club prépare le match en indiquant l'équipe, l'adversaire, la date et l'heure.
live_phone_title: Filmez depuis votre téléphone how_2_title: Ouvrez l'application
live_phone_body: "Posez le téléphone et lancez le direct : même avec un 4G faible, la diffusion reste aussi stable que possible, sans câbles ni mixeur." how_2_body: La personne désignée se connecte à l'application Match Live TV avec son propre compte personnel.
live_score_title: Score depuis un second téléphone how_3_title: Lancez le direct
live_score_body: "Un autre bénévole met à jour les sets et les points depuis un second téléphone : il suffit de scanner le code affiché sur l'écran de celui qui filme." how_3_body: Un smartphone suffit. Pas de matériel dédié ni de configuration compliquée.
live_link_title: Lien pour les familles et supporters how_4_title: Gérez le score
live_link_body: "Partagez un lien : parents, grands-parents et amis regardent depuis le navigateur du téléphone ou de l'ordinateur, sans installer d'application." how_4_body: Une autre personne peut mettre à jour le résultat et le score via un simple lien, sans installer d'application ni créer de compte.
live_archive_title: Archive des matchs how_5_title: Replay
live_archive_body: "Avec Premium Light ou Full, les matchs restent enregistrés sur le serveur (30 ou 90 jours) et vous pouvez les télécharger sur le téléphone quand vous voulez." how_5_body: À la fin du direct, le match peut être conservé et rendu disponible en replay, selon l'offre choisie.
live_youtube_mltv_title: Aussi sur YouTube (Match Live TV) accounts_title: Chacun avec son propre compte. Le mot de passe du club reste au club.
live_youtube_mltv_body: "Avec Premium Light, le direct est aussi visible sur la chaîne YouTube Match Live TV, en plus du lien sur le site." accounts_lead: Plus besoin de partager l'identifiant et le mot de passe du compte principal. Dirigeants, entraîneurs et parents désignés reçoivent un accès personnel et révocable.
live_youtube_club_title: Chaîne YouTube du club accounts_invite_title: Invitez
live_youtube_club_body: "Avec Premium Full, vous pouvez diffuser le même direct sur la chaîne YouTube officielle du club." accounts_invite_body: Le club invite les entraîneurs, dirigeants ou parents chargés de la diffusion.
live_website_title: Sur le site du club accounts_transmit_title: Diffusez
live_website_body: "Intégration pour intégrer le lecteur sur le site du club — bientôt disponible avec Premium Full." accounts_transmit_body: Chacun se connecte à l'application avec ses propres identifiants.
cta_signup: Inscrire l'équipe accounts_revoke_title: Révoquez
accounts_revoke_body: Quand une personne ne collabore plus avec l'équipe ou le club, son accès peut être révoqué sans changer le mot de passe des autres.
accounts_highlight: Pas de mots de passe partagés. Chaque personne désignée a son propre accès personnel.
score_title: Qui filme, filme. Qui tient le score, tient le score.
score_lead: La personne qui diffuse peut se concentrer sur le cadrage. Une autre, même depuis les tribunes, peut ouvrir un simple lien et mettre à jour le score en temps réel.
score_item_app: Aucune application à installer
score_item_signup: Aucune inscription
score_item_password: Aucun mot de passe
score_item_link: Il suffit d'ouvrir le lien partagé
score_card_aria: Accès à la régie du score
score_card_title: Régie via un lien
score_card_body: Celui qui met à jour sets et points ouvre un lien partagé. Aucun compte, aucune invitation.
access_link: Accès par lien
distribute_title: Un match, plusieurs façons de le suivre
distribute_lead: Depuis le smartphone en salle, le direct arrive aux spectateurs sur Match Live TV et, selon l'offre, aussi sur YouTube.
distribute_aria: Où va le direct
distribute_phone: Smartphone
distribute_mltv: Match Live TV
distribute_viewers: Spectateurs
distribute_youtube: YouTube
youtube_badge: Premium Full
youtube_title: Le direct reste celui de votre club
youtube_body: Avec Premium Full, vous pouvez aussi diffuser sur la chaîne YouTube officielle du club. Le club conserve ainsi son public, ses abonnés, ses vues, l'archive des matchs et la visibilité des sponsors.
youtube_mock_channel: Chaîne officielle du club
youtube_mock_subs: Abonnés et vues du club
replay_title: Le match se termine. Le souvenir reste.
replay_lead: Sur les offres qui incluent le replay, les matchs restent enregistrés sur les serveurs Match Live TV et peuvent être revus ensuite. La durée de conservation dépend de l'offre.
replay_item_server: Conservation sur les serveurs Match Live TV
replay_item_plan: Durée d'archive selon l'offre
replay_item_download: Téléchargement de la vidéo sur le téléphone si besoin
replay_card_title: Archive des matchs
replay_card_body: Revoyez les matchs après le coup de sifflet final et téléchargez-les quand vous en avez besoin.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15"
more_title: Cela fait aussi partie du produit
more_stable_title: Direct depuis le smartphone
more_stable_body: Posez le téléphone et lancez la diffusion. Même avec un 4G faible, le direct reste aussi stable que possible, sans câbles ni mixeur.
more_link_title: Lien pour familles et supporters
more_link_body: "Partagez un lien : parents, grands-parents et amis regardent depuis le navigateur du téléphone ou de l'ordinateur, sans installer d'application."
more_yt_mltv_title: Aussi sur YouTube Match Live TV
more_yt_mltv_body: Vous pouvez aussi rendre le direct visible sur la chaîne YouTube Match Live TV, en plus du lien sur le site.
cta_title: Prêts pour le prochain match ?
cta_body: Essayez Match Live TV gratuitement. Aucun matériel dédié nécessaire.
cta_primary: Commencer gratuitement
cta_secondary: Comparer les offres
pricing: pricing:
meta_title: "Tarifs streaming des matchs jeunes — Match Live TV" meta_title: "Tarifs streaming des matchs jeunes — Match Live TV"
meta_description: "Offres Free, Premium Light et Premium Full pour le direct et l'archive des matchs. Abonnement annuel pour les clubs : plus de staff, plus de matchs en parallèle, replay et YouTube." meta_description: "Offres Free, Premium Light et Premium Full pour le direct et l'archive des matchs. Abonnement annuel pour les clubs : plus de staff, plus de matchs en parallèle, replay et YouTube."
@@ -35,8 +83,8 @@ fr:
club_info_html: "Club : <strong>%{name}</strong> · %{billing_link}" club_info_html: "Club : <strong>%{name}</strong> · %{billing_link}"
manage_subscription: Gérer l'abonnement manage_subscription: Gérer l'abonnement
team_title: Offres pour votre équipe team_title: Offres pour votre équipe
team_lead: "Abonnement annuel pour les clubs : les responsables de diffusion démarrent le direct depuis l'application ; le score se gère avec un lien à partager." team_lead: "Abonnement annuel pour les clubs : les comptes diffuseurs démarrent le direct depuis l'application ; le score se gère avec un lien à partager."
table_staff: Responsables de diffusion / an table_staff: Comptes diffuseurs
table_matches: Matchs simultanés table_matches: Matchs simultanés
table_live_mltv: Direct sur Match Live TV table_live_mltv: Direct sur Match Live TV
table_youtube: YouTube table_youtube: YouTube
@@ -44,8 +92,7 @@ fr:
table_download: Téléchargement téléphone table_download: Téléchargement téléphone
table_price: Prix table_price: Prix
table_price_free: "€0" table_price_free: "€0"
table_price_light_html: "€40/an ou €5/mois" table_price_note: "tarif %{list} — %{monthly}"
table_price_full_html: "€200/an ou €20/mois"
table_yes: Oui table_yes: Oui
table_no: Non table_no: Non
table_unlimited: Illimité table_unlimited: Illimité
@@ -55,8 +102,16 @@ fr:
contact_button: Contactez-nous contact_button: Contactez-nous
plans: plans:
price_alt_html: "ou <strong>%{price}</strong>" price_alt_html: "ou <strong>%{price}</strong>"
staff_html: "Responsables de diffusion : <strong>%{count}</strong> / an par équipe" price_equiv: "soit %{price}"
staff_unlimited: illimité launch_price: Prix de lancement
recommended_badge: Conseillé pour les clubs
cta_name:
premium_light: Light
premium_full: Full
staff_html: "Comptes diffuseurs : maximum <strong>%{count}</strong> par abonnement"
staff_unlimited_html: "Comptes diffuseurs : <strong>illimités</strong> par abonnement"
staff_unlimited: illimités
staff_note_full: "Pas de mot de passe partagé : chaque personne utilise son propre accès, révocable à tout moment."
matches_html: "Matchs : <strong>%{count}</strong> simultanés (tout le club)" matches_html: "Matchs : <strong>%{count}</strong> simultanés (tout le club)"
matches_unlimited: illimités matches_unlimited: illimités
replay_html: "Replay : %{value}" replay_html: "Replay : %{value}"
+87 -32
View File
@@ -2,32 +2,80 @@ it:
pages: pages:
features: features:
meta_title: "Funzionalità streaming partite giovanili — Match Live TV" meta_title: "Funzionalità streaming partite giovanili — Match Live TV"
meta_description: "App per diretta live da telefono: filma la partita, aggiorna il punteggio, condividi il link con genitori e nonni. Archivio gare e piani per società sportive giovanili." meta_description: "Trasmetti le partite della società dallo smartphone con account personali, punteggio via link, diretta su Match Live TV e YouTube, replay e archivio. Senza attrezzatura complicata."
title: Funzionalità hero_title: "Le dirette della tua società. Senza password condivise, senza attrezzatura complicata."
lead: "Tutto ciò che serve per far seguire le partite a chi non può essere in palestra: semplice per coach e volontari, comodo per genitori, nonni e parenti." hero_lead: "Invita allenatori, dirigenti e genitori a trasmettere con il proprio account. Basta uno smartphone: Match Live TV gestisce accessi, punteggio, diretta, replay e pubblicazione."
staff_section_title: Per la società e lo staff hero_alt: Smartphone su treppiede per la diretta e secondo telefono per il punteggio in palestra
staff_invite_title: Inviti con la propria email flow_aria: Flusso della trasmissione
staff_invite_body: "Il presidente o l'owner invita chi filma e chi aggiorna il punteggio: ognuno accede con la sua email, senza condividere password." flow_phone: Uno smartphone
staff_access_title: Gestione accessi flow_account: Un account personale
staff_access_body: Puoi togliere l'accesso a un volontario o annullare un invito ancora in sospeso dalla dashboard della squadra. flow_live: Sei in diretta
staff_plans_title: Piani su misura how_title: Dalla palestra agli spettatori in pochi minuti
staff_plans_body: Con Free, Premium Light o Premium Full aumenti quante persone possono aiutare in diretta e quante partite possono andare in parallelo. how_1_title: Crea la partita
live_section_title: In campo e in diretta how_1_body: La società prepara la partita indicando squadra, avversario, data e ora.
live_phone_title: Filma dal telefono how_2_title: Apri l'app
live_phone_body: "Appoggi il telefono e avvii la diretta: anche con il 4G debole la trasmissione resta stabile il più possibile, senza impazzire con cavi o mixer." how_2_body: L'incaricato accede dall'app Match Live TV con il proprio account personale.
live_score_title: Punteggio da un secondo telefono how_3_title: Inizia la diretta
live_score_body: "Un altro volontario aggiorna set e punti da un secondo cellulare: basta inquadrare il codice che compare sullo schermo di chi filma." how_3_body: Basta lo smartphone. Nessuna attrezzatura dedicata o configurazione complicata.
live_link_title: Link per famiglie e tifosi how_4_title: Gestisci il punteggio
live_link_body: "Condividi un link: genitori, nonni e amici guardano dal browser del telefono o del computer, senza installare app." how_4_body: Un'altra persona può aggiornare risultato e punteggio tramite un semplice link, senza installare app e senza creare un account.
live_archive_title: Archivio partite how_5_title: Replay
live_archive_body: "Con Premium Light o Full le gare restano salvate sul server (30 o 90 giorni) e puoi scaricarle sul telefono quando serve." how_5_body: Al termine della diretta la partita può essere conservata e resa disponibile come replay, in base al piano scelto.
live_youtube_mltv_title: Anche su YouTube (Match Live TV) accounts_title: Ognuno con il proprio account. La password della società resta alla società.
live_youtube_mltv_body: Con Premium Light la diretta è visibile anche sul canale YouTube Match Live TV, oltre al link sul sito. accounts_lead: Non serve più condividere username e password dell'account principale. Dirigenti, allenatori e genitori incaricati ricevono un accesso personale e revocabile.
live_youtube_club_title: Canale YouTube della società accounts_invite_title: Invita
live_youtube_club_body: Con Premium Full puoi mandare la stessa diretta anche sul canale YouTube ufficiale del club. accounts_invite_body: La società invita allenatori, dirigenti o genitori incaricati della trasmissione.
live_website_title: Sul sito della società accounts_transmit_title: Trasmetti
live_website_body: Integrazione per incorporare il player sul sito del club — in arrivo con Premium Full. accounts_transmit_body: Ognuno accede all'app con le proprie credenziali personali.
cta_signup: Registra la squadra accounts_revoke_title: Revoca
accounts_revoke_body: Quando una persona non collabora più con la squadra o con la società, il suo accesso può essere revocato senza cambiare la password agli altri.
accounts_highlight: Niente password condivise. Ogni incaricato ha il proprio accesso personale.
score_title: Chi filma, filma. Chi tiene il punteggio, tiene il punteggio.
score_lead: La persona che trasmette può concentrarsi sull'inquadratura. Un'altra persona, anche dagli spalti, può aprire un semplice link e aggiornare il punteggio in tempo reale.
score_item_app: Nessuna app da installare
score_item_signup: Nessuna registrazione
score_item_password: Nessuna password
score_item_link: Basta aprire il link condiviso
score_card_aria: Accesso alla regia
score_card_title: Regia con un link
score_card_body: Chi aggiorna set e punti apre un link condiviso. Nessun account e nessun invito.
access_link: Accesso con link
distribute_title: Una partita, più modi di seguirla
distribute_lead: Dallo smartphone in palestra la diretta arriva agli spettatori su Match Live TV e, quando previsto dal piano, anche su YouTube.
distribute_aria: Dove arriva la diretta
distribute_phone: Smartphone
distribute_mltv: Match Live TV
distribute_viewers: Spettatori
distribute_youtube: YouTube
youtube_badge: Premium Full
youtube_title: La diretta resta della tua società
youtube_body: Con Premium Full puoi trasmettere anche sul canale YouTube ufficiale del club. La società mantiene così il proprio pubblico, gli iscritti, le visualizzazioni, l'archivio delle partite e la visibilità degli sponsor.
youtube_mock_channel: Canale ufficiale società
youtube_mock_subs: Iscritti e visualizzazioni del club
replay_title: La partita finisce. Il ricordo resta.
replay_lead: Nei piani che prevedono il replay, le gare restano salvate sui server Match Live TV e possono essere riviste in seguito. La durata della conservazione dipende dal piano.
replay_item_server: Conservazione sui server Match Live TV
replay_item_plan: Durata dell'archivio in base al piano
replay_item_download: Download del video sul telefono quando serve
replay_card_title: Archivio partite
replay_card_body: Rivedi le gare dopo il fischio finale e scaricale quando ti servono.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15"
more_title: Anche questo fa parte del prodotto
more_stable_title: Diretta dallo smartphone
more_stable_body: Appoggi il telefono e avvii la trasmissione. Anche con il 4G debole la diretta resta stabile il più possibile, senza cavi o mixer.
more_link_title: Link per famiglie e tifosi
more_link_body: "Condividi un link: genitori, nonni e amici guardano dal browser del telefono o del computer, senza installare app."
more_yt_mltv_title: Anche su YouTube Match Live TV
more_yt_mltv_body: Puoi rendere la diretta visibile anche sul canale YouTube Match Live TV, oltre al link sul sito.
cta_title: Pronti per la prossima partita?
cta_body: Prova Match Live TV gratuitamente. Non serve attrezzatura dedicata.
cta_primary: Inizia gratis
cta_secondary: Confronta i piani
pricing: pricing:
meta_title: "Prezzi streaming partite giovanili — Match Live TV" meta_title: "Prezzi streaming partite giovanili — Match Live TV"
meta_description: "Piani Free, Premium Light e Premium Full per dirette live e archivio partite. Abbonamento annuale per società: più staff, più partite in parallelo, replay e YouTube." meta_description: "Piani Free, Premium Light e Premium Full per dirette live e archivio partite. Abbonamento annuale per società: più staff, più partite in parallelo, replay e YouTube."
@@ -35,8 +83,8 @@ it:
club_info_html: "Società: <strong>%{name}</strong> · %{billing_link}" club_info_html: "Società: <strong>%{name}</strong> · %{billing_link}"
manage_subscription: Gestisci abbonamento manage_subscription: Gestisci abbonamento
team_title: Piani per la tua squadra team_title: Piani per la tua squadra
team_lead: "Abbonamento annuale per società: i responsabili trasmissione avviano la diretta dall'app; il punteggio si gestisce con un link condivisibile." team_lead: "Abbonamento annuale per società: gli account trasmettitori avviano la diretta dall'app; il punteggio si gestisce con un link condivisibile."
table_staff: Responsabili trasmissione / anno table_staff: Account trasmettitori
table_matches: Partite in contemporanea table_matches: Partite in contemporanea
table_live_mltv: Live su Match Live TV table_live_mltv: Live su Match Live TV
table_youtube: YouTube table_youtube: YouTube
@@ -44,8 +92,7 @@ it:
table_download: Download telefono table_download: Download telefono
table_price: Prezzo table_price: Prezzo
table_price_free: "€0" table_price_free: "€0"
table_price_light_html: "€40/anno o €5/mese" table_price_note: "listino %{list} — %{monthly}"
table_price_full_html: "€200/anno o €20/mese"
table_yes: "Sì" table_yes: "Sì"
table_no: "No" table_no: "No"
table_unlimited: Illimitato table_unlimited: Illimitato
@@ -55,8 +102,16 @@ it:
contact_button: Contattaci contact_button: Contattaci
plans: plans:
price_alt_html: "oppure <strong>%{price}</strong>" price_alt_html: "oppure <strong>%{price}</strong>"
staff_html: "Responsabili trasmissione: <strong>%{count}</strong> / anno per squadra" price_equiv: "equivale a %{price}"
staff_unlimited: illimitato launch_price: Prezzo lancio
recommended_badge: Consigliato per le società
cta_name:
premium_light: Light
premium_full: Full
staff_html: "Account trasmettitori: massimo <strong>%{count}</strong> per abbonamento"
staff_unlimited_html: "Account trasmettitori: <strong>illimitati</strong> per abbonamento"
staff_unlimited: illimitati
staff_note_full: "Niente password condivise: ogni incaricato utilizza il proprio accesso personale, revocabile in qualsiasi momento."
matches_html: "Partite: <strong>%{count}</strong> in contemporanea (tutta la società)" matches_html: "Partite: <strong>%{count}</strong> in contemporanea (tutta la società)"
matches_unlimited: illimitate matches_unlimited: illimitate
replay_html: "Replay: %{value}" replay_html: "Replay: %{value}"
+7
View File
@@ -43,6 +43,13 @@ de:
tagline: "Streamen Sie live vom Smartphone. Großeltern, entfernte Verwandte, Freunde — schauen im Browser, ohne Installation. Und wenn Sie es verpasst haben: es ist im Archiv." tagline: "Streamen Sie live vom Smartphone. Großeltern, entfernte Verwandte, Freunde — schauen im Browser, ohne Installation. Und wenn Sie es verpasst haben: es ist im Archiv."
cta_signup: Team registrieren cta_signup: Team registrieren
cta_live: Live-Spiele ansehen cta_live: Live-Spiele ansehen
stores_label: Live gehen vom Smartphone
store_app_store_kicker: Laden im
store_app_store_name: App Store
store_app_store_aria: Match Live TV im App Store laden
store_play_kicker: Jetzt bei
store_play_name: Google Play
store_play_aria: Match Live TV bei Google Play laden
features_aria: Wichtige Vorteile features_aria: Wichtige Vorteile
feature_stable: Stabile Streams feature_stable: Stabile Streams
feature_archive: Sicheres Archiv feature_archive: Sicheres Archiv
+7
View File
@@ -43,6 +43,13 @@ en:
tagline: Stream live from your smartphone. Grandparents, distant relatives, friends — they watch in the browser, with nothing to install. And if you missed it, it's in the archive. tagline: Stream live from your smartphone. Grandparents, distant relatives, friends — they watch in the browser, with nothing to install. And if you missed it, it's in the archive.
cta_signup: Register your team cta_signup: Register your team
cta_live: Watch live matches cta_live: Watch live matches
stores_label: To go live from your smartphone
store_app_store_kicker: Download on the
store_app_store_name: App Store
store_app_store_aria: Download Match Live TV on the App Store
store_play_kicker: Get it on
store_play_name: Google Play
store_play_aria: Get Match Live TV on Google Play
features_aria: Key benefits features_aria: Key benefits
feature_stable: Reliable streams feature_stable: Reliable streams
feature_archive: Secure archive feature_archive: Secure archive
+7
View File
@@ -43,6 +43,13 @@ es:
tagline: Emite en directo desde el smartphone. Abuelos, familiares lejanos, amigos — miran en el navegador, sin instalar nada. Y si te lo perdiste, está en el archivo. tagline: Emite en directo desde el smartphone. Abuelos, familiares lejanos, amigos — miran en el navegador, sin instalar nada. Y si te lo perdiste, está en el archivo.
cta_signup: Registra tu equipo cta_signup: Registra tu equipo
cta_live: Ver directos cta_live: Ver directos
stores_label: Para emitir en directo desde el smartphone
store_app_store_kicker: Consíguelo en
store_app_store_name: App Store
store_app_store_aria: Descarga Match Live TV en App Store
store_play_kicker: Disponible en
store_play_name: Google Play
store_play_aria: Descarga Match Live TV en Google Play
features_aria: Ventajas principales features_aria: Ventajas principales
feature_stable: Directos estables feature_stable: Directos estables
feature_archive: Archivo seguro feature_archive: Archivo seguro
+7
View File
@@ -43,6 +43,13 @@ fr:
tagline: Diffusez en direct depuis votre smartphone. Les grands-parents, la famille éloignée, les amis — regardent dans le navigateur, sans rien installer. Et si vous l'avez manqué, c'est dans l'archive. tagline: Diffusez en direct depuis votre smartphone. Les grands-parents, la famille éloignée, les amis — regardent dans le navigateur, sans rien installer. Et si vous l'avez manqué, c'est dans l'archive.
cta_signup: Inscrire votre équipe cta_signup: Inscrire votre équipe
cta_live: Voir les directs cta_live: Voir les directs
stores_label: Pour passer en direct depuis le smartphone
store_app_store_kicker: Télécharger dans
store_app_store_name: App Store
store_app_store_aria: Télécharger Match Live TV dans l'App Store
store_play_kicker: Disponible sur
store_play_name: Google Play
store_play_aria: Télécharger Match Live TV sur Google Play
features_aria: Avantages principaux features_aria: Avantages principaux
feature_stable: Directs stables feature_stable: Directs stables
feature_archive: Archive sécurisée feature_archive: Archive sécurisée
+7
View File
@@ -43,6 +43,13 @@ it:
tagline: Trasmetti in diretta dallo smartphone. I nonni, i parenti lontani, gli amici — guardano dal browser, senza installare nulla. E se te la sei persa, la ritrovi nell'archivio. tagline: Trasmetti in diretta dallo smartphone. I nonni, i parenti lontani, gli amici — guardano dal browser, senza installare nulla. E se te la sei persa, la ritrovi nell'archivio.
cta_signup: Registra la tua squadra cta_signup: Registra la tua squadra
cta_live: Guarda le dirette cta_live: Guarda le dirette
stores_label: Per andare in diretta dallo smartphone
store_app_store_kicker: Scarica su
store_app_store_name: App Store
store_app_store_aria: Scarica Match Live TV su App Store
store_play_kicker: Disponibile su
store_play_name: Google Play
store_play_aria: Scarica Match Live TV su Google Play
features_aria: Vantaggi principali features_aria: Vantaggi principali
feature_stable: Dirette stabili feature_stable: Dirette stabili
feature_archive: Archivio sicuro feature_archive: Archivio sicuro
-2
View File
@@ -55,7 +55,6 @@ Rails.application.routes.draw do
post :claim_pairing post :claim_pairing
post :regia_link post :regia_link
post :network_test post :network_test
patch :min_quality
get :youtube_stats get :youtube_stats
end end
end end
@@ -144,7 +143,6 @@ Rails.application.routes.draw do
post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause
post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
patch "regia/:token/min_quality", to: "public/regia#min_quality", as: :public_regia_min_quality
root to: "public/pages#home" root to: "public/pages#home"
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml } get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
@@ -1,7 +0,0 @@
# 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
@@ -1,5 +0,0 @@
class AddThermalStateToDeviceStates < ActiveRecord::Migration[7.2]
def change
add_column :device_states, :thermal_state, :string
end
end
+1 -29
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -138,7 +138,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
t.datetime "last_seen_at" t.datetime "last_seen_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "thermal_state"
t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true
t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id" t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id"
end end
@@ -256,29 +255,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id" t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id"
end end
create_table "stream_nodes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "slug", null: false
t.string "hostname", null: false
t.string "role", default: "home", null: false
t.string "status", default: "ready", null: false
t.string "rtmp_base_url", null: false
t.string "hls_base_url", null: false
t.string "api_base_url", null: false
t.string "internal_rtmp_url"
t.string "internal_hls_url"
t.integer "max_publishers", default: 6, null: false
t.integer "max_relays", default: 6, null: false
t.string "provider", default: "local", null: false
t.string "provider_instance_id"
t.datetime "last_health_at"
t.jsonb "metadata", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["role"], name: "index_stream_nodes_on_role"
t.index ["slug"], name: "index_stream_nodes_on_slug", unique: true
t.index ["status"], name: "index_stream_nodes_on_status"
end
create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "match_id", null: false t.uuid "match_id", null: false
t.uuid "user_id", null: false t.uuid "user_id", null: false
@@ -304,13 +280,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "regia_token_digest" t.string "regia_token_digest"
t.datetime "regia_token_expires_at" t.datetime "regia_token_expires_at"
t.uuid "stream_node_id"
t.string "min_quality_preset", default: "auto", null: false
t.index ["match_id"], name: "index_stream_sessions_on_match_id" t.index ["match_id"], name: "index_stream_sessions_on_match_id"
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
t.index ["status"], name: "index_stream_sessions_on_status" t.index ["status"], name: "index_stream_sessions_on_status"
t.index ["stream_node_id"], name: "index_stream_sessions_on_stream_node_id"
t.index ["user_id"], name: "index_stream_sessions_on_user_id" t.index ["user_id"], name: "index_stream_sessions_on_user_id"
end end
@@ -438,7 +411,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
add_foreign_key "score_states", "stream_sessions" add_foreign_key "score_states", "stream_sessions"
add_foreign_key "stream_events", "stream_sessions" add_foreign_key "stream_events", "stream_sessions"
add_foreign_key "stream_sessions", "matches" add_foreign_key "stream_sessions", "matches"
add_foreign_key "stream_sessions", "stream_nodes"
add_foreign_key "stream_sessions", "users" add_foreign_key "stream_sessions", "users"
add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id" add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
add_foreign_key "subscriptions", "clubs" add_foreign_key "subscriptions", "clubs"
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-hidden="true">
<path fill="#ffffff" d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
</svg>

After

Width:  |  Height:  |  Size: 569 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 283" role="img" aria-hidden="true">
<path fill="#EA4335" d="M119.55 134.92 1.06 259.06c2.7 9.56 9.66 17.33 18.86 21.06 9.2 3.73 19.61 2.99 28.2-1.99l133.33-75.93-61.9-67.28z"/>
<path fill="#FBBC04" d="M239.37 113.81 181.71 80.79l-64.9 56.95 65.16 64.28 57.22-32.67c10.33-5.41 16.81-16.11 16.81-27.77s-6.48-22.36-16.81-27.77z"/>
<path fill="#4285F4" d="M1.06 23.49C.34 26.13-.01 28.87 0 31.61v219.33c.01 2.74.36 5.47 1.06 8.12L123.61 138.1 1.06 23.49z"/>
<path fill="#34A853" d="m120.44 141.27 61.28-60.48L48.56 4.5C43.55 1.57 37.86.02 32.05 0 17.64-.03 4.98 9.53 1.06 23.4l119.38 117.87z"/>
</svg>

After

Width:  |  Height:  |  Size: 663 B

File diff suppressed because it is too large Load Diff
+41 -277
View File
@@ -4,8 +4,6 @@
--regia-surface: #14141c; --regia-surface: #14141c;
--regia-red: #e53935; --regia-red: #e53935;
--regia-yellow: #f9a825; --regia-yellow: #f9a825;
--regia-green: #43a047;
--regia-orange: #fb8c00;
--regia-text: #f5f5f5; --regia-text: #f5f5f5;
--regia-muted: #9ca3af; --regia-muted: #9ca3af;
--regia-safe-bottom: env(safe-area-inset-bottom, 0px); --regia-safe-bottom: env(safe-area-inset-bottom, 0px);
@@ -24,166 +22,37 @@
.regia-page { .regia-page {
max-width: 480px; max-width: 480px;
margin: 0 auto; margin: 0 auto;
padding: 8px 12px calc(12px + var(--regia-safe-bottom)); padding: 12px 16px calc(24px + var(--regia-safe-bottom));
}
.regia-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.regia-header__text {
min-width: 0;
flex: 1;
} }
.regia-header h1 { .regia-header h1 {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1.1rem;
font-weight: 700; font-weight: 700;
line-height: 1.25;
} }
.regia-header p { .regia-header p {
margin: 2px 0 0; margin: 4px 0 0;
font-size: 0.78rem; font-size: 0.85rem;
color: var(--regia-muted); color: var(--regia-muted);
} }
.regia-badge { .regia-badge {
display: inline-block; display: inline-block;
margin-top: 0; margin-top: 8px;
padding: 3px 8px; padding: 4px 10px;
border-radius: 999px; border-radius: 999px;
font-size: 0.65rem; font-size: 0.7rem;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
flex-shrink: 0;
} }
.regia-badge--live { background: #2e7d32; color: #fff; } .regia-badge--live { background: #2e7d32; color: #fff; }
.regia-badge--wait { background: #444; color: #ddd; } .regia-badge--wait { background: #444; color: #ddd; }
.regia-badge--ended { background: #374151; color: #ddd; } .regia-badge--ended { background: #374151; color: #ddd; }
.regia-toolbar {
margin: 8px 0 6px;
display: flex;
align-items: flex-end;
gap: 8px;
}
.regia-tool-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
min-width: 0;
}
.regia-tool-group__label {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--regia-muted);
line-height: 1;
white-space: nowrap;
}
.regia-tool-group__btns {
display: flex;
gap: 3px;
}
.regia-tool-group--stream {
margin-left: auto;
padding-left: 8px;
border-left: 1px solid #333;
}
.regia-icon-btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 1px solid #333;
border-radius: 10px;
background: #16161f;
color: var(--regia-text);
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.regia-icon-btn svg {
width: 20px;
height: 20px;
pointer-events: none;
}
.regia-icon-btn svg.icon-fill {
fill: currentColor;
}
.regia-icon-btn svg.icon-outline {
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.regia-icon-btn .icon-eye-off,
.regia-icon-btn .icon-play {
display: none;
}
.regia-icon-btn.is-off .icon-eye {
display: none;
}
.regia-icon-btn.is-off .icon-eye-off {
display: block;
}
.regia-icon-btn.is-paused .icon-pause {
display: none;
}
.regia-icon-btn.is-paused .icon-play {
display: block;
}
.regia-icon-btn:active { transform: scale(0.96); }
.regia-icon-btn.is-off {
color: var(--regia-yellow);
border-color: #5a4a1a;
}
.regia-icon-btn--danger {
color: var(--regia-red);
border-color: #7a2a28;
}
.regia-preview-wrap {
margin: 6px 0 10px;
}
.regia-preview-wrap.is-off {
margin: 0;
height: 0;
overflow: hidden;
}
.regia-preview { .regia-preview {
margin: 0; margin: 14px 0;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: #000; background: #000;
@@ -221,29 +90,29 @@
.regia-scoreboard { .regia-scoreboard {
background: var(--regia-surface); background: var(--regia-surface);
border-radius: 12px; border-radius: 12px;
padding: 10px 12px; padding: 16px;
margin-bottom: 8px; margin-bottom: 12px;
} }
.regia-sets { .regia-sets {
text-align: center; text-align: center;
font-size: 0.78rem; font-size: 0.82rem;
color: var(--regia-muted); color: var(--regia-muted);
margin: 0 0 4px; margin: 0 0 8px;
} }
.regia-score-line { .regia-score-line {
text-align: center; text-align: center;
font-size: 1.2rem; font-size: 1.5rem;
font-weight: 800; font-weight: 800;
margin: 0; margin: 0;
} }
.regia-partials { .regia-partials {
text-align: center; text-align: center;
font-size: 0.75rem; font-size: 0.78rem;
color: var(--regia-muted); color: var(--regia-muted);
margin: 4px 0 0; margin: 8px 0 0;
} }
.regia-team-row { .regia-team-row {
@@ -251,7 +120,7 @@
grid-template-columns: 1fr auto 1fr; grid-template-columns: 1fr auto 1fr;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
margin-top: 8px; margin-top: 16px;
} }
.regia-team-name { .regia-team-name {
@@ -262,7 +131,7 @@
} }
.regia-points { .regia-points {
font-size: 1.55rem; font-size: 2rem;
font-weight: 800; font-weight: 800;
text-align: center; text-align: center;
} }
@@ -277,7 +146,7 @@
.regia-btn { .regia-btn {
border: none; border: none;
border-radius: 10px; border-radius: 10px;
padding: 10px 8px; padding: 16px 12px;
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
@@ -287,8 +156,8 @@
.regia-btn:active { transform: scale(0.98); } .regia-btn:active { transform: scale(0.98); }
.regia-btn--point { background: var(--regia-red); color: #fff; } .regia-btn--point { background: var(--regia-red); color: #fff; }
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 8px; } .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: 8px; padding: 12px; } .regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 10px; }
.regia-btn--outline { .regia-btn--outline {
background: transparent; background: transparent;
border: 1px solid #444; border: 1px solid #444;
@@ -304,6 +173,26 @@
margin-top: 8px; margin-top: 8px;
} }
.regia-share-group {
margin: 12px 0;
}
.regia-share-label {
margin: 0 0 6px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--regia-muted);
}
.regia-share {
display: flex;
gap: 8px;
}
.regia-share .regia-btn { flex: 1; font-size: 0.9rem; padding: 12px; }
.regia-toast { .regia-toast {
position: fixed; position: fixed;
bottom: calc(16px + var(--regia-safe-bottom)); bottom: calc(16px + var(--regia-safe-bottom));
@@ -351,7 +240,7 @@
text-align: center; text-align: center;
color: var(--regia-muted); color: var(--regia-muted);
font-size: 0.72rem; font-size: 0.72rem;
padding-top: 8px; padding-top: 24px;
} }
.regia-clock-line { .regia-clock-line {
@@ -366,128 +255,3 @@
gap: 8px; gap: 8px;
margin-top: 12px; margin-top: 12px;
} }
.regia-quality {
margin: 6px 0 0;
padding: 8px 10px 10px;
background: var(--regia-surface);
border-radius: 10px;
}
.regia-quality[hidden] { display: none !important; }
.regia-quality__hint {
margin: 0 0 8px;
font-size: 0.68rem;
line-height: 1.35;
color: var(--regia-muted);
}
.regia-quality label {
display: block;
font-size: 0.68rem;
color: var(--regia-muted);
margin: 0 0 4px;
}
.regia-quality select {
width: 100%;
background: #0a0a0e;
color: var(--regia-text);
border: 1px solid #2a2a35;
border-radius: 8px;
padding: 8px 10px;
font-size: 0.82rem;
}
.regia-telemetry {
margin-top: 8px;
}
.regia-telemetry__row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 4px;
}
.regia-chip {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 0;
text-align: center;
padding: 6px 2px 5px;
border-radius: 8px;
background: var(--regia-surface);
color: var(--regia-text);
border: 1px solid transparent;
}
.regia-chip--action {
appearance: none;
-webkit-appearance: none;
font: inherit;
cursor: pointer;
width: 100%;
}
.regia-chip--action:focus-visible {
outline: 2px solid var(--regia-yellow);
outline-offset: 1px;
}
.regia-chip--action.is-open {
border-color: #3a3a48;
}
.regia-chip svg {
width: 14px;
height: 14px;
fill: currentColor;
}
.regia-chip__label {
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--regia-muted);
line-height: 1;
white-space: nowrap;
}
.regia-chip__value {
font-size: 0.72rem;
font-weight: 800;
line-height: 1.15;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.regia-chip__hint {
font-size: 0.55rem;
font-weight: 600;
color: var(--regia-muted);
line-height: 1;
white-space: nowrap;
}
.regia-chip__hint[hidden] { display: none; }
.regia-chip.is-ok { color: var(--regia-green); }
.regia-chip.is-warn { color: var(--regia-orange); }
.regia-chip.is-bad { color: var(--regia-red); }
.regia-telemetry__meta {
margin: 4px 0 0;
font-size: 0.65rem;
color: var(--regia-muted);
text-align: center;
}
.regia-telemetry.is-stale .regia-telemetry__meta {
color: var(--regia-yellow);
}
+5 -262
View File
@@ -37,32 +37,13 @@
shareLiveText: "Guarda la diretta su Match Live TV:", shareLiveText: "Guarda la diretta su Match Live TV:",
previewWaiting: "Anteprima in attesa del segnale…", previewWaiting: "Anteprima in attesa del segnale…",
streamEnded: "Diretta terminata", streamEnded: "Diretta terminata",
previewHide: "Nascondi anteprima",
previewShow: "Mostra anteprima",
previewOffHint: "Anteprima spenta · nessun download video",
resumeLabel: "Riprendi diretta", resumeLabel: "Riprendi diretta",
pauseLabel: "Metti in pausa", pauseLabel: "Metti in pausa",
endedBadge: "Terminata", endedBadge: "Terminata",
pausedBadge: "In pausa", pausedBadge: "In pausa",
liveBadge: "In onda", liveBadge: "In onda",
waitingBadge: "In attesa", waitingBadge: "In attesa",
subtitlePrefix: "Regia ·", subtitlePrefix: "Regia ·"
minQualityUpdated: "Qualità minima aggiornata",
minQualityError: "Errore aggiornamento qualità minima",
minQualityAuto: "Automatica",
telemetryWaiting: "In attesa del telefono…",
telemetryStale: "Telefono non risponde",
telemetryJustNow: "ora",
telemetrySecondsAgo: "%{n}s fa",
telemetryPublisherOn: "RTMP ok",
telemetryPublisherOff: "RTMP assente",
telemetryThermalNominal: "OK",
telemetryThermalFair: "Calda",
telemetryThermalSerious: "Calda",
telemetryThermalCritical: "Critica",
telemetryBitrateMax: "max %{n}",
telemetryBitrateTitle: "%{current} Mbps attuali · massimo %{target} Mbps",
telemetryBitrateTap: "Tocca per impostare la qualità minima"
}; };
function loadI18n() { function loadI18n() {
@@ -96,7 +77,6 @@
pauseUrl: root.dataset.pauseUrl, pauseUrl: root.dataset.pauseUrl,
resumeUrl: root.dataset.resumeUrl, resumeUrl: root.dataset.resumeUrl,
stopUrl: root.dataset.stopUrl, stopUrl: root.dataset.stopUrl,
minQualityUrl: root.dataset.minQualityUrl,
cableUrl: root.dataset.cableUrl, cableUrl: root.dataset.cableUrl,
hlsUrl: root.dataset.hlsUrl, hlsUrl: root.dataset.hlsUrl,
homeName: root.dataset.homeName, homeName: root.dataset.homeName,
@@ -122,28 +102,15 @@
modalCancel: document.getElementById("modal-cancel"), modalCancel: document.getElementById("modal-cancel"),
preview: document.getElementById("regia-preview"), preview: document.getElementById("regia-preview"),
previewPlaceholder: document.getElementById("regia-preview-placeholder"), previewPlaceholder: document.getElementById("regia-preview-placeholder"),
previewWrap: document.getElementById("regia-preview-wrap"),
btnTogglePreview: document.getElementById("btn-toggle-preview"),
btnPause: document.getElementById("btn-pause"), btnPause: document.getElementById("btn-pause"),
minQuality: document.getElementById("min-quality"), subtitle: document.getElementById("regia-subtitle")
subtitle: document.getElementById("regia-subtitle"),
telemetry: document.getElementById("regia-telemetry"),
telBattery: document.getElementById("tel-battery"),
telNetwork: document.getElementById("tel-network"),
telBitrate: document.getElementById("tel-bitrate"),
telFps: document.getElementById("tel-fps"),
telThermal: document.getElementById("tel-thermal"),
telMeta: document.getElementById("tel-meta"),
qualityPanel: document.getElementById("regia-quality")
}; };
let pendingAction = null; let pendingAction = null;
let hls = null; let hls = null;
let previewStarted = false; let previewStarted = false;
let previewWanted = true;
let wasPausedPreview = false; let wasPausedPreview = false;
let streamPaused = root.dataset.initialPaused === "true"; let streamPaused = root.dataset.initialPaused === "true";
const PREVIEW_PREF_KEY = "mltv-regia-preview";
function hlsUrlFresh() { function hlsUrlFresh() {
const sep = cfg.hlsUrl.includes("?") ? "&" : "?"; const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
@@ -172,43 +139,8 @@
} }
} }
function readPreviewPref() {
try {
return localStorage.getItem(PREVIEW_PREF_KEY) !== "0";
} catch (_) {
return true;
}
}
function writePreviewPref(on) {
try {
localStorage.setItem(PREVIEW_PREF_KEY, on ? "1" : "0");
} catch (_) {}
}
function syncPreviewToggleUi() {
if (!els.previewWrap || !els.btnTogglePreview) return;
const label = previewWanted ? I18N.previewHide : I18N.previewShow;
els.previewWrap.classList.toggle("is-off", !previewWanted);
els.btnTogglePreview.classList.toggle("is-off", !previewWanted);
els.btnTogglePreview.setAttribute("aria-pressed", previewWanted ? "true" : "false");
els.btnTogglePreview.setAttribute("aria-label", label);
els.btnTogglePreview.title = previewWanted ? I18N.previewHide : I18N.previewOffHint;
}
function setPreviewWanted(on) {
previewWanted = !!on;
writePreviewPref(previewWanted);
syncPreviewToggleUi();
if (previewWanted) {
startPreview();
} else {
destroyPreview();
}
}
function refreshPreviewForCover() { function refreshPreviewForCover() {
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed || !previewWanted) return; if (!els.preview || !cfg.hlsUrl || cfg.streamClosed) return;
destroyPreview(); destroyPreview();
previewStarted = false; previewStarted = false;
startPreview(); startPreview();
@@ -311,10 +243,7 @@
if (!els.btnPause) return; if (!els.btnPause) return;
const paused = !!(data.paused || data.status === "paused"); const paused = !!(data.paused || data.status === "paused");
streamPaused = paused; streamPaused = paused;
const label = paused ? I18N.resumeLabel : I18N.pauseLabel; els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel;
els.btnPause.classList.toggle("is-paused", paused);
els.btnPause.setAttribute("aria-label", label);
els.btnPause.title = label;
} }
function setBadge(data) { function setBadge(data) {
@@ -435,11 +364,6 @@
} }
const paused = data.paused || data.status === "paused"; const paused = data.paused || data.status === "paused";
if (!previewWanted) {
destroyPreview();
wasPausedPreview = paused;
return;
}
if (data.on_air) { if (data.on_air) {
if (!previewStarted) { if (!previewStarted) {
startPreview(); startPreview();
@@ -458,119 +382,6 @@
wasPausedPreview = paused; wasPausedPreview = paused;
} }
function mbpsNumber(bps) {
const n = Number(bps);
if (!n || n <= 0) return null;
const mbps = n >= 10_000 ? n / 1e6 : n / 1e3;
return mbps >= 10 ? mbps.toFixed(0) : mbps.toFixed(1);
}
function formatAge(iso) {
if (!iso) return null;
const then = Date.parse(iso);
if (!then) return null;
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
if (secs < 8) return I18N.telemetryJustNow;
return fillTemplate(I18N.telemetrySecondsAgo, { n: secs });
}
function setChip(el, text, level, hint) {
if (!el) return;
const valueEl = el.querySelector(".regia-chip__value") || el;
valueEl.textContent = text;
el.classList.toggle("is-ok", level === "ok");
el.classList.toggle("is-warn", level === "warn");
el.classList.toggle("is-bad", level === "bad");
const hintEl = el.querySelector(".regia-chip__hint");
if (hintEl) {
hintEl.textContent = hint || "";
hintEl.hidden = !hint;
}
}
function formatThermal(state) {
const key = String(state || "").toLowerCase();
if (key === "nominal") return { text: I18N.telemetryThermalNominal, level: "ok" };
if (key === "fair") return { text: I18N.telemetryThermalFair, level: "warn" };
if (key === "serious") return { text: I18N.telemetryThermalSerious, level: "bad" };
if (key === "critical") return { text: I18N.telemetryThermalCritical, level: "bad" };
return { text: "—", level: null };
}
function bitrateLevel(current, target, paused) {
if (paused || !current || current <= 0) return null;
if (target && target > 0) {
const ratio = current / target;
if (ratio >= 0.7) return "ok";
if (ratio >= 0.4) return "warn";
return "bad";
}
const mbps = current >= 10_000 ? current / 1e6 : current / 1e3;
if (mbps >= 2.5) return "ok";
if (mbps >= 1.5) return "warn";
return "bad";
}
function bitrateTitle(currentMbps, targetMbps) {
const tap = I18N.telemetryBitrateTap;
if (currentMbps && targetMbps) {
return `${fillTemplate(I18N.telemetryBitrateTitle, { current: currentMbps, target: targetMbps })} · ${tap}`;
}
if (currentMbps) return `${currentMbps} Mbps · ${tap}`;
return tap;
}
function syncTelemetry(data) {
if (!els.telemetry) return;
const tel = data.telemetry || {};
const battery = tel.battery;
const fps = tel.fps;
const bitrate = tel.bitrate;
const target = tel.target_bitrate || tel.targetBitrate;
const lastSeen = tel.last_seen_at || tel.lastSeenAt;
const ageSecs = lastSeen ? Math.max(0, Math.round((Date.now() - Date.parse(lastSeen)) / 1000)) : null;
const stale = ageSecs == null || ageSecs > 25;
const publishing = !!tel.publisher_online || !!tel.publisherOnline;
let battLevel = null;
let battText = "—";
if (battery != null && battery !== "") {
battText = `${battery}%`;
if (battery <= 15) battLevel = "bad";
else if (battery <= 25) battLevel = "warn";
}
setChip(els.telBattery, battText, battLevel);
setChip(els.telNetwork, tel.network || "—");
const currentMbps = mbpsNumber(bitrate);
const targetMbps = mbpsNumber(target);
const rateText = currentMbps || "—";
const rateHint = currentMbps && targetMbps
? fillTemplate(I18N.telemetryBitrateMax, { n: targetMbps })
: null;
if (els.telBitrate) els.telBitrate.title = bitrateTitle(currentMbps, targetMbps);
setChip(els.telBitrate, rateText, bitrateLevel(Number(bitrate), Number(target), data.paused), rateHint);
let fpsLevel = null;
if (fps != null && fps !== "" && Number(fps) > 0 && Number(fps) < 20) fpsLevel = "warn";
setChip(els.telFps, fps && Number(fps) > 0 ? String(fps) : "—", fpsLevel);
const thermal = formatThermal(tel.thermal_state || tel.thermalState);
setChip(els.telThermal, thermal.text, thermal.level);
els.telemetry.classList.toggle("is-stale", stale && !data.paused && !data.stream_closed);
if (!els.telMeta) return;
if (stale && !lastSeen) {
els.telMeta.textContent = I18N.telemetryWaiting;
return;
}
const parts = [publishing ? I18N.telemetryPublisherOn : I18N.telemetryPublisherOff];
const age = formatAge(lastSeen);
if (age) parts.push(age);
if (stale && !data.paused && !data.stream_closed) parts.push(I18N.telemetryStale);
els.telMeta.textContent = parts.join(" · ");
}
async function pollStatus() { async function pollStatus() {
try { try {
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } }); const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
@@ -578,8 +389,6 @@
const data = await res.json(); const data = await res.json();
applyScorePayload(data); applyScorePayload(data);
setBadge(data); setBadge(data);
syncMinQuality(data);
syncTelemetry(data);
syncPreviewFromStatus(data); syncPreviewFromStatus(data);
} catch (_) {} } catch (_) {}
} }
@@ -592,7 +401,7 @@
} }
function startPreview() { function startPreview() {
if (cfg.streamClosed || !previewWanted || !els.preview || !cfg.hlsUrl || previewStarted) return; if (cfg.streamClosed || !els.preview || !cfg.hlsUrl || previewStarted) return;
previewStarted = true; previewStarted = true;
const url = hlsUrlFresh(); const url = hlsUrlFresh();
@@ -645,8 +454,6 @@
const wasPaused = streamPaused; const wasPaused = streamPaused;
applyScorePayload(data); applyScorePayload(data);
setBadge(data); setBadge(data);
syncMinQuality(data);
syncTelemetry(data);
syncPreviewFromStatus(data); syncPreviewFromStatus(data);
toast(wasPaused ? I18N.resumed : I18N.pausedCover); toast(wasPaused ? I18N.resumed : I18N.pausedCover);
} }
@@ -666,70 +473,6 @@
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message))); els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message))); document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
let minQualityBusy = false;
function setQualityPanelOpen(open) {
if (!els.qualityPanel || !els.telBitrate) return;
els.qualityPanel.hidden = !open;
els.telBitrate.setAttribute("aria-expanded", open ? "true" : "false");
els.telBitrate.classList.toggle("is-open", open);
if (open && els.minQuality) {
window.requestAnimationFrame(() => els.minQuality.focus());
}
}
function syncMinQuality(data) {
if (!els.minQuality || minQualityBusy) return;
const value = data.min_quality_preset || "auto";
if (els.minQuality.value !== value) els.minQuality.value = value;
}
async function setMinQuality(preset) {
if (!cfg.minQualityUrl) return;
minQualityBusy = true;
try {
const res = await fetch(cfg.minQualityUrl, {
method: "PATCH",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ min_quality_preset: preset })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || I18N.minQualityError);
minQualityBusy = false;
syncMinQuality(data);
toast(I18N.minQualityUpdated);
} catch (e) {
toast(e.message || I18N.minQualityError);
} finally {
minQualityBusy = false;
}
}
els.minQuality?.addEventListener("change", () => {
setMinQuality(els.minQuality.value);
});
els.telBitrate?.addEventListener("click", (e) => {
e.stopPropagation();
const opening = !!(els.qualityPanel && els.qualityPanel.hidden);
setQualityPanelOpen(opening);
});
document.addEventListener("click", (e) => {
if (!els.qualityPanel || els.qualityPanel.hidden) return;
if (els.qualityPanel.contains(e.target) || els.telBitrate?.contains(e.target)) return;
setQualityPanelOpen(false);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") setQualityPanelOpen(false);
});
els.btnTogglePreview?.addEventListener("click", () => setPreviewWanted(!previewWanted));
previewWanted = readPreviewPref();
syncPreviewToggleUi();
if (!previewWanted) destroyPreview();
async function shareLink(url, title, text) { async function shareLink(url, title, text) {
if (!url) { if (!url) {
toast(I18N.linkUnavailable); toast(I18N.linkUnavailable);
@@ -32,7 +32,7 @@ RSpec.describe Public::BillingHelper, type: :helper do
current_interval: "yearly" current_interval: "yearly"
) )
expect(action[:kind]).to eq(:current) expect(action[:kind]).to eq(:current)
expect(action[:label]).to include("40/anno") expect(action[:label]).to include("59/anno")
end end
it "propone cambio intervallo sullo stesso piano" do it "propone cambio intervallo sullo stesso piano" do
@@ -72,7 +72,7 @@ RSpec.describe "Public club billing", type: :request do
get public_club_billing_path(club) get public_club_billing_path(club)
expect(response.body).to include("Completa dati di fatturazione") expect(response.body).to include("Completa dati di fatturazione")
expect(response.body).not_to include("Attiva Premium Light — €40/anno") expect(response.body).not_to include("Attiva Light — €59/anno")
end end
it "dopo profilo completo reindirizza al checkout" do it "dopo profilo completo reindirizza al checkout" do
@@ -111,8 +111,8 @@ RSpec.describe "Public club billing", type: :request do
login! login!
get public_club_billing_path(club) get public_club_billing_path(club)
expect(response.body).to include("Premium Light — €40/anno") expect(response.body).to include("Attiva Light — €59/anno")
expect(response.body).to include("Premium Light — €5/mese") expect(response.body).to include("Attiva Light — €7,90/mese")
allow(Billing::Stripe::CheckoutSession).to receive(:new).and_return( allow(Billing::Stripe::CheckoutSession).to receive(:new).and_return(
instance_double(Billing::Stripe::CheckoutSession, url: "https://checkout.stripe.com/test") instance_double(Billing::Stripe::CheckoutSession, url: "https://checkout.stripe.com/test")
@@ -0,0 +1,26 @@
require "rails_helper"
RSpec.describe "Public features page", type: :request do
it "racconta il prodotto con sezioni narrative e CTA" do
get public_features_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("Le dirette della tua società")
expect(response.body).to include("Senza password condivise")
expect(response.body).to include("Uno smartphone")
expect(response.body).to include("Un account personale")
expect(response.body).to include("Sei in diretta")
expect(response.body).to include("Dalla palestra agli spettatori")
expect(response.body).to include("Ognuno con il proprio account")
expect(response.body).to include("Niente password condivise")
expect(response.body).to include("Chi filma, filma")
expect(response.body).to include("Accesso con link")
expect(response.body).to include("La diretta resta della tua società")
expect(response.body).to include("La partita finisce. Il ricordo resta.")
expect(response.body).to include("Inizia gratis")
expect(response.body).to include("Confronta i piani")
expect(response.body).to include(public_signup_path)
expect(response.body).to include(public_prezzi_path)
expect(response.body).not_to include("translation missing")
end
end
@@ -0,0 +1,41 @@
require "rails_helper"
RSpec.describe "Public home page", type: :request do
it "mostra i link agli store nell'hero e nel footer" do
get root_path
expect(response).to have_http_status(:ok)
expect(response.body).to include(MatchLiveTv.app_store_url)
expect(response.body).to include(MatchLiveTv.play_store_url)
expect(response.body).to include(I18n.t("home.stores_label", locale: :it))
expect(response.body).to include(I18n.t("home.store_app_store_name", locale: :it))
expect(response.body).to include(I18n.t("home.store_play_name", locale: :it))
expect(response.body.scan(MatchLiveTv.app_store_url).size).to eq(3)
expect(response.body.scan(MatchLiveTv.play_store_url).size).to eq(3)
end
{
"it" => "Per andare in diretta dallo smartphone",
"en" => "To go live from your smartphone",
"fr" => "Pour passer en direct depuis le smartphone",
"de" => "Live gehen vom Smartphone",
"es" => "Para emitir en directo desde el smartphone"
}.each do |locale, label|
it "traduce i badge store in #{locale} senza translation missing" do
cookies[:mltv_locale] = locale
get root_path
expect(response).to have_http_status(:ok)
expect(response.body).to include(label)
expect(response.body).not_to include("translation missing")
end
end
it "non mostra i badge store nel chrome App Store Review" do
get public_support_path
expect(response).to have_http_status(:ok)
expect(response.body).not_to include(MatchLiveTv.app_store_url)
expect(response.body).not_to include(MatchLiveTv.play_store_url)
end
end
@@ -0,0 +1,32 @@
require "rails_helper"
RSpec.describe "Public pricing page", type: :request do
before { load Rails.root.join("db/seeds/plans.rb") }
it "mostra prezzi lancio, listino barrato e Full in evidenza" do
get public_prezzi_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("€59/anno")
expect(response.body).to include("€79/anno")
expect(response.body).to include("€199/anno")
expect(response.body).to include("€249/anno")
expect(response.body).to include("€7,90/mese")
expect(response.body).to include("€24,90/mese")
expect(response.body).to include("equivale a €4,92/mese")
expect(response.body).to include("equivale a €16,58/mese")
expect(response.body).to include("Prezzo lancio")
expect(response.body).to include("Consigliato per le società")
expect(response.body).to include("Attiva Light — €59/anno")
expect(response.body).to include("Attiva Light — €7,90/mese")
expect(response.body).to include("Attiva Full — €199/anno")
expect(response.body).to include("Attiva Full — €24,90/mese")
expect(response.body).to include("Account trasmettitori: massimo")
expect(response.body).to include("per abbonamento")
expect(response.body).to include("Account trasmettitori")
expect(response.body).to include("Niente password condivise")
expect(response.body).to include("plan-card--featured")
expect(response.body).not_to include("a vita")
expect(response.body).not_to include("Più popolare")
end
end
+1 -47
View File
@@ -1,7 +1,7 @@
require "rails_helper" require "rails_helper"
RSpec.describe "Public regia", type: :request do RSpec.describe "Public regia", type: :request do
let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "Password123", role: "coach") } let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "password123", role: "coach") }
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") } let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") } let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
@@ -12,18 +12,7 @@ RSpec.describe "Public regia", type: :request do
get public_regia_path(token) get public_regia_path(token)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.body).to include("Condividi diretta") expect(response.body).to include("Condividi diretta")
expect(response.body).to include("Copia live")
expect(response.body).to include("Condividi link regia") expect(response.body).to include("Condividi link regia")
expect(response.body).to include("Copia regia")
expect(response.body).to include(">Diretta<")
expect(response.body).to include(">Regia<")
expect(response.body).to include("regia-tool-group")
expect(response.body).to include("Nascondi anteprima")
expect(response.body).to include('id="btn-pause"')
expect(response.body).to include('id="btn-stop"')
expect(response.body).to include("Qualità minima di trasmissione")
expect(response.body).to include('id="tel-bitrate"')
expect(response.body).to include('aria-controls="regia-quality"')
expect(response.body).to include("/live/#{session.id}") expect(response.body).to include("/live/#{session.id}")
expect(response.body).not_to include("Aruba") expect(response.body).not_to include("Aruba")
end end
@@ -167,39 +156,4 @@ RSpec.describe "Public regia", type: :request do
get public_regia_path(token) get public_regia_path(token)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
end end
it "aggiorna la qualità minima di trasmissione" do
token = Sessions::RegiaAccess.new(session).issue_token!
patch public_regia_min_quality_path(token), params: { min_quality_preset: "720p_30_1.5mbps" }
expect(response).to have_http_status(:ok)
expect(session.reload.min_quality_preset).to eq("720p_30_1.5mbps")
json = JSON.parse(response.body)
expect(json["min_quality_preset"]).to eq("720p_30_1.5mbps")
end
it "espone la telemetria della camera nello status" do
session.device_states.create!(
device_role: "camera",
battery_level: 81,
network_type: "WiFi",
current_bitrate: 2_400_000,
target_bitrate: 4_500_000,
fps: 30,
thermal_state: "nominal",
last_seen_at: Time.current
)
token = Sessions::RegiaAccess.new(session).issue_token!
get public_regia_path(token)
expect(response.body).to include('id="regia-telemetry"')
get public_regia_status_path(token)
expect(response).to have_http_status(:ok)
tel = response.parsed_body["telemetry"]
expect(tel["battery"]).to eq(81)
expect(tel["network"]).to eq("WiFi")
expect(tel["fps"]).to eq(30)
expect(tel["bitrate"]).to eq(2_400_000)
expect(tel["thermal_state"]).to eq("nominal")
expect(tel).to have_key("publisher_online")
end
end end
@@ -10,7 +10,7 @@ RSpec.describe Billing::PaymentDescription do
lines: double(data: []) lines: double(data: [])
) )
expect(described_class.for_stripe_invoice(invoice)).to eq("Premium Light — €5/mese") expect(described_class.for_stripe_invoice(invoice)).to eq("Premium Light — €7,90/mese")
end end
it "ignora descrizioni di test nel pagamento salvato" do it "ignora descrizioni di test nel pagamento salvato" do
@@ -23,6 +23,6 @@ RSpec.describe Billing::PaymentDescription do
club: Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") club: Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff")
) )
expect(payment.display_description).to eq("Premium Light — €40/anno") expect(payment.display_description).to eq("Premium Light — €59/anno")
end end
end end
@@ -23,6 +23,15 @@ RSpec.describe Billing::Stripe::PriceCatalog do
expect { described_class.normalize_interval("weekly") }.to raise_error(ArgumentError) expect { described_class.normalize_interval("weekly") }.to raise_error(ArgumentError)
end end
it "etichetta il prezzo lancio annuale e il mensile" do
expect(described_class.label(plan_slug: "premium_light", interval: "yearly")).to eq("€59/anno")
expect(described_class.label(plan_slug: "premium_light", interval: "monthly")).to eq("€7,90/mese")
expect(described_class.list_label(plan_slug: "premium_light", interval: "yearly")).to eq("€79/anno")
expect(described_class.equivalent_monthly_label(plan_slug: "premium_light")).to eq("€4,92/mese")
expect(described_class.label(plan_slug: "premium_full", interval: "yearly")).to eq("€199/anno")
expect(described_class.label(plan_slug: "premium_full", interval: "monthly")).to eq("€24,90/mese")
end
it "elenca intervalli configurati" do it "elenca intervalli configurati" do
expect(described_class.available_intervals(plan_slug: "premium_light")).to eq(%w[monthly yearly]) expect(described_class.available_intervals(plan_slug: "premium_light")).to eq(%w[monthly yearly])
end end
@@ -38,6 +38,6 @@ RSpec.describe Billing::Stripe::RecordPayment do
expect(payment.club).to eq(club) expect(payment.club).to eq(club)
expect(payment.amount_cents).to eq(4000) expect(payment.amount_cents).to eq(4000)
expect(payment.stripe_invoice_id).to eq("in_test_1") expect(payment.stripe_invoice_id).to eq("in_test_1")
expect(payment.description).to eq("Premium Light — €40/anno") expect(payment.description).to eq("Premium Light — €59/anno")
end end
end end
@@ -11,11 +11,4 @@ RSpec.describe Sessions::SelectQuality do
expect(preset[:id]).to eq("1080p_30_4.5mbps") expect(preset[:id]).to eq("1080p_30_4.5mbps")
end end
it "offre 1080p come qualità minima solo se la sessione è 1080p" do
expect(described_class.allowed_min_ids("720p_30_2.5mbps")).to eq(
%w[auto 720p_30_1.5mbps 720p_30_2.5mbps 720p_30_4mbps]
)
expect(described_class.allowed_min_ids("1080p_30_4.5mbps")).to include("1080p_30_4.5mbps")
end
end end
@@ -1,41 +0,0 @@
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
+3 -2
View File
@@ -4,13 +4,14 @@
| | Free | Premium Light | Premium Full | | | Free | Premium Light | Premium Full |
|---|------|---------------|----------------| |---|------|---------------|----------------|
| Responsabili trasmissione / anno | 1 | 5 | Illimitato | | Account trasmettitori | 1 | 5 | Illimitati |
| Partite in contemporanea | 1 | 3 | 10 | | Partite in contemporanea | 1 | 3 | 10 |
| Live Match Live TV | Sì | Sì | Sì | | Live Match Live TV | Sì | Sì | Sì |
| YouTube | No | Match Live TV | Canale società | | YouTube | No | Match Live TV | Canale società |
| Replay server | No | 30 giorni | 90 giorni | | Replay server | No | 30 giorni | 90 giorni |
| Download telefono | No | Sì | Sì | | Download telefono | No | Sì | Sì |
| Prezzo | €0 | €40/anno o €5/mese | €200/anno o €20/mese | | Prezzo lancio | €0 | €59/anno o €7,90/mese | €199/anno o €24,90/mese |
| Listino | — | €79/anno | €249/anno |
| Embed sito società | No | No | In arrivo | | Embed sito società | No | No | In arrivo |
Il punteggio in diretta si gestisce tramite **link regia** condivisibile (WhatsApp, email, SMS…): non serve un account per chi fa il tabellone. Il punteggio in diretta si gestisce tramite **link regia** condivisibile (WhatsApp, email, SMS…): non serve un account per chi fa il tabellone.
+4 -4
View File
@@ -5,12 +5,12 @@
### Prodotti e prezzi ### Prodotti e prezzi
Crea **2 prodotti** in Stripe, ciascuno con **2 prezzi ricorrenti**: Crea **2 prodotti** in Stripe, ciascuno con **2 prezzi ricorrenti**:
| Piano app | Prodotto Stripe (Dashboard) | Mensile | Annuale | Variabile `.env` | | Piano app | Prodotto Stripe (Dashboard) | Mensile | Annuale (lancio) | Variabile `.env` |
|-----------|------------------------------|---------|---------|------------------| |-----------|------------------------------|---------|---------|------------------|
| Premium Light | **Match Live TV** | €5 | €40 | `STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID` / `..._YEARLY_...` | | Premium Light | **Match Live TV** | €7,90 | €59 | `STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID` / `..._YEARLY_...` |
| Premium Full | **Match Live TV PRO** | €20 | €200 | `STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID` / `..._YEARLY_...` | | Premium Full | **Match Live TV PRO** | €24,90 | €199 | `STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID` / `..._YEARLY_...` |
Gli importi in Stripe Dashboard devono coincidere con il sito (€5/€40 e €20/€200). Gli importi in Stripe Dashboard devono coincidere con i **prezzi lancio** addebitati sul sito (€7,90/€59 e €24,90/€199). I listini barrati (€79 e €249) sono solo display, non vanno creati come Price Stripe.
In Stripe apri ogni prezzo e copia l**ID prezzo** (`price_...`), non lID prodotto. In Stripe apri ogni prezzo e copia l**ID prezzo** (`price_...`), non lID prodotto.
+2
View File
@@ -26,6 +26,8 @@ PASSWORD_RESET_EXPIRY_HOURS=2
# Stripe (test: sk_test_... / price_... da Dashboard modalità Test) # Stripe (test: sk_test_... / price_... da Dashboard modalità Test)
STRIPE_SECRET_KEY= STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
# Price ID dei prezzi LANCIO da addebitare:
# Light yearly €59 · Light monthly €7,90 · Full yearly €199 · Full monthly €24,90
# Un price per prodotto e intervallo (Dashboard → Prodotto → Prezzi) # Un price per prodotto e intervallo (Dashboard → Prodotto → Prezzi)
STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID= STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID=
STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID= STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID=
-2
View File
@@ -59,8 +59,6 @@ services:
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
MEDIAMTX_API_URL: http://mediamtx:9997 MEDIAMTX_API_URL: http://mediamtx:9997
MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://127.0.0.1:1935} MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://127.0.0.1:1935}
MEDIAMTX_HLS_URL: ${MEDIAMTX_HLS_URL:-http://mediamtx:8888}
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:3000/hls}
MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret} MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}
RAILS_WEBHOOK_URL: http://rails:3000 RAILS_WEBHOOK_URL: http://rails:3000
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min} SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min}
@@ -1,71 +0,0 @@
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
}
}
@@ -1,265 +0,0 @@
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,17 +6,11 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import com.matchlivetv.match_live_tv.core.AppLocale import com.matchlivetv.match_live_tv.core.AppLocale
import com.matchlivetv.match_live_tv.core.ProvideAppLocale import com.matchlivetv.match_live_tv.core.ProvideAppLocale
import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost
import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
@OptIn(ExperimentalComposeUiApi::class)
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val container by lazy { (application as MatchLiveTvApplication).container } private val container by lazy { (application as MatchLiveTvApplication).container }
@@ -39,9 +33,7 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
ProvideAppLocale { ProvideAppLocale {
MatchLiveTheme { MatchLiveTheme {
Box(Modifier.semantics { testTagsAsResourceId = true }) { AppNavHost(container)
AppNavHost(container)
}
} }
} }
} }
@@ -183,7 +183,6 @@ data class StreamSessionDto(
@Json(name = "target_bitrate") val targetBitrate: Int? = null, @Json(name = "target_bitrate") val targetBitrate: Int? = null,
@Json(name = "target_fps") val targetFps: Int? = null, @Json(name = "target_fps") val targetFps: Int? = null,
@Json(name = "quality_preset") val qualityPreset: String? = null, @Json(name = "quality_preset") val qualityPreset: String? = null,
@Json(name = "min_quality_preset") val minQualityPreset: String? = null,
@Json(name = "started_at") val startedAt: String? = null, @Json(name = "started_at") val startedAt: String? = null,
val score: ScoreStateDto? = null, val score: ScoreStateDto? = null,
) { ) {
@@ -202,7 +201,6 @@ data class StreamSessionDto(
targetBitrate = targetBitrate ?: 2_500_000, targetBitrate = targetBitrate ?: 2_500_000,
targetFps = targetFps ?: 30, targetFps = targetFps ?: 30,
qualityPreset = qualityPreset ?: "720p_30_2.5mbps", qualityPreset = qualityPreset ?: "720p_30_2.5mbps",
minQualityPreset = minQualityPreset ?: "auto",
startedAt = startedAt, startedAt = startedAt,
score = score?.toDomain(), score = score?.toDomain(),
) )
@@ -287,10 +285,6 @@ data class CreateSessionRequest(
@Json(name = "youtube_channel") val youtubeChannel: String? = null, @Json(name = "youtube_channel") val youtubeChannel: String? = null,
) )
data class MinQualityRequest(
@Json(name = "min_quality_preset") val minQualityPreset: String,
)
data class UpdateMatchBody( data class UpdateMatchBody(
@Json(name = "opponent_name") val opponentName: String? = null, @Json(name = "opponent_name") val opponentName: String? = null,
val location: String? = null, val location: String? = null,
@@ -398,5 +392,4 @@ data class TelemetryRequest(
@Json(name = "current_bitrate") val currentBitrate: Int? = null, @Json(name = "current_bitrate") val currentBitrate: Int? = null,
@Json(name = "target_bitrate") val targetBitrate: Int? = null, @Json(name = "target_bitrate") val targetBitrate: Int? = null,
val fps: Int? = null, val fps: Int? = null,
@Json(name = "thermal_state") val thermalState: String? = null,
) )
@@ -117,12 +117,6 @@ interface MatchLiveApi {
@PATCH("sessions/{sessionId}/resume") @PATCH("sessions/{sessionId}/resume")
suspend fun resumeSession(@Path("sessionId") sessionId: String): StreamSessionDto suspend fun resumeSession(@Path("sessionId") sessionId: String): StreamSessionDto
@PATCH("sessions/{sessionId}/min_quality")
suspend fun setMinQuality(
@Path("sessionId") sessionId: String,
@Body body: MinQualityRequest,
): StreamSessionDto
@POST("sessions/{sessionId}/network_test") @POST("sessions/{sessionId}/network_test")
suspend fun networkTest( suspend fun networkTest(
@Path("sessionId") sessionId: String, @Path("sessionId") sessionId: String,
@@ -33,7 +33,6 @@ class SessionCableService(
var onPauseStream: (() -> Unit)? = null var onPauseStream: (() -> Unit)? = null
var onResumeStream: (() -> Unit)? = null var onResumeStream: (() -> Unit)? = null
var onEndStream: (() -> Unit)? = null var onEndStream: (() -> Unit)? = null
var onMinQuality: ((String) -> Unit)? = null
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") { fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
intentionalDisconnect = false intentionalDisconnect = false
@@ -110,7 +109,6 @@ class SessionCableService(
"pause_stream" -> onPauseStream?.invoke() "pause_stream" -> onPauseStream?.invoke()
"resume_stream" -> onResumeStream?.invoke() "resume_stream" -> onResumeStream?.invoke()
"stop_stream" -> onEndStream?.invoke() "stop_stream" -> onEndStream?.invoke()
"set_min_quality" -> (payload["min_quality_preset"] as? String)?.let { onMinQuality?.invoke(it) }
} }
"stream_event" -> when (payload["event"] as? String) { "stream_event" -> when (payload["event"] as? String) {
"paused" -> onPauseStream?.invoke() "paused" -> onPauseStream?.invoke()
@@ -2,7 +2,6 @@ package com.matchlivetv.match_live_tv.data.repository
import com.matchlivetv.match_live_tv.data.api.CreateSessionRequest import com.matchlivetv.match_live_tv.data.api.CreateSessionRequest
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.api.MinQualityRequest
import com.matchlivetv.match_live_tv.data.api.NetworkTestRequest import com.matchlivetv.match_live_tv.data.api.NetworkTestRequest
import com.matchlivetv.match_live_tv.data.api.NetworkTestResponse import com.matchlivetv.match_live_tv.data.api.NetworkTestResponse
import com.matchlivetv.match_live_tv.domain.StreamSession import com.matchlivetv.match_live_tv.domain.StreamSession
@@ -39,9 +38,6 @@ class SessionRepository(
suspend fun resumeSession(sessionId: String): StreamSession = suspend fun resumeSession(sessionId: String): StreamSession =
api.resumeSession(sessionId).toDomain() api.resumeSession(sessionId).toDomain()
suspend fun setMinQuality(sessionId: String, preset: String): StreamSession =
api.setMinQuality(sessionId, MinQualityRequest(minQualityPreset = preset)).toDomain()
suspend fun submitNetworkTest( suspend fun submitNetworkTest(
sessionId: String, sessionId: String,
downloadMbps: Double, downloadMbps: Double,
@@ -65,7 +61,6 @@ class SessionRepository(
currentBitrate: Int? = null, currentBitrate: Int? = null,
targetBitrate: Int? = null, targetBitrate: Int? = null,
fps: Int? = null, fps: Int? = null,
thermalState: String? = null,
) { ) {
api.postTelemetry( api.postTelemetry(
sessionId, sessionId,
@@ -77,7 +72,6 @@ class SessionRepository(
currentBitrate = currentBitrate, currentBitrate = currentBitrate,
targetBitrate = targetBitrate, targetBitrate = targetBitrate,
fps = fps, fps = fps,
thermalState = thermalState,
), ),
) )
} }
@@ -126,7 +126,6 @@ data class StreamSession(
val targetBitrate: Int = 2_500_000, val targetBitrate: Int = 2_500_000,
val targetFps: Int = 30, val targetFps: Int = 30,
val qualityPreset: String = "720p_30_2.5mbps", val qualityPreset: String = "720p_30_2.5mbps",
val minQualityPreset: String = "auto",
val startedAt: String? = null, val startedAt: String? = null,
val score: ScoreState? = null, val score: ScoreState? = null,
) { ) {
@@ -134,8 +133,6 @@ data class StreamSession(
val isPaused: Boolean get() = status == "paused" val isPaused: Boolean get() = status == "paused"
val isEnded: Boolean get() = status == "ended" || status == "error"
fun watchShareUrl(): String? = when { fun watchShareUrl(): String? = when {
platform == "youtube" && !youtubeWatchUrl.isNullOrBlank() -> youtubeWatchUrl platform == "youtube" && !youtubeWatchUrl.isNullOrBlank() -> youtubeWatchUrl
!shareUrl.isNullOrBlank() -> shareUrl !shareUrl.isNullOrBlank() -> shareUrl
@@ -1,115 +0,0 @@
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,7 +9,6 @@ data class BroadcastConfig(
val width: Int = 1280, val width: Int = 1280,
val height: Int = 720, val height: Int = 720,
val videoBitrate: Int = 2_500_000, val videoBitrate: Int = 2_500_000,
val minVideoBitrate: Int = 800_000,
val audioBitrate: Int = 128_000, val audioBitrate: Int = 128_000,
val fps: Int = 30, val fps: Int = 30,
val rotation: Int = 0, val rotation: Int = 0,
@@ -39,7 +39,6 @@ class LiveBroadcastEngine(
private var publishInProgress = false private var publishInProgress = false
private var currentBitrateKbps: Long = 0 private var currentBitrateKbps: Long = 0
private var currentFps: Int = 0 private var currentFps: Int = 0
private val adaptiveBitrate = AdaptiveBitrateController()
private val pausingIntentionally = AtomicBoolean(false) private val pausingIntentionally = AtomicBoolean(false)
private val reconnectAttempts = AtomicInteger(0) private val reconnectAttempts = AtomicInteger(0)
private val overlayRenderer = OverlayRenderer(appContext) private val overlayRenderer = OverlayRenderer(appContext)
@@ -134,7 +133,6 @@ class LiveBroadcastEngine(
fun startBroadcast(broadcastConfig: BroadcastConfig) { fun startBroadcast(broadcastConfig: BroadcastConfig) {
mainHandler.post { mainHandler.post {
config = broadcastConfig config = broadcastConfig
adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
reconnectAttempts.set(0) reconnectAttempts.set(0)
streamStartPending = true streamStartPending = true
publishInProgress = false publishInProgress = false
@@ -252,7 +250,6 @@ class LiveBroadcastEngine(
fun resumeBroadcast(broadcastConfig: BroadcastConfig) { fun resumeBroadcast(broadcastConfig: BroadcastConfig) {
mainHandler.post { mainHandler.post {
config = broadcastConfig config = broadcastConfig
adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
reconnectAttempts.set(0) reconnectAttempts.set(0)
streamStartPending = true streamStartPending = true
publishInProgress = false publishInProgress = false
@@ -267,45 +264,21 @@ class LiveBroadcastEngine(
} }
} }
/** Adattamento termico: aggiorna il soffitto ABR e l'FPS senza fermare lo stream. */ /** Adattamento termico: riduce bitrate/FPS senza fermare lo stream. */
fun applyThermalProfile(videoBitrate: Int, fps: Int) { fun applyThermalProfile(videoBitrate: Int, fps: Int) {
mainHandler.post { mainHandler.post {
val current = config ?: return@post val current = config ?: return@post
adaptiveBitrate.setCeiling(videoBitrate) if (current.videoBitrate == videoBitrate && current.fps == fps) return@post
val applied = adaptiveBitrate.setFloor(current.minVideoBitrate)
if (current.videoBitrate == videoBitrate && current.fps == fps) {
applyAdaptiveBitrate(applied)
return@post
}
config = current.copy(videoBitrate = videoBitrate, fps = fps) config = current.copy(videoBitrate = videoBitrate, fps = fps)
genericStream?.let { stream -> genericStream?.let { stream ->
applyAdaptiveBitrate(applied) runCatching { stream.setVideoBitrateOnFly(videoBitrate) }
.onFailure { Log.w(TAG, "applyThermalProfile bitrate: ${it.message}") }
runCatching { stream.getGlInterface().setForceRender(true, fps) } runCatching { stream.getGlInterface().setForceRender(true, fps) }
.onFailure { Log.w(TAG, "applyThermalProfile fps: ${it.message}") } .onFailure { Log.w(TAG, "applyThermalProfile fps: ${it.message}") }
} }
} }
} }
fun setMinQualityFloor(floorBitrate: Int, ceilingBitrate: Int? = null) {
mainHandler.post {
val current = config
if (current != null) {
config = current.copy(
videoBitrate = ceilingBitrate ?: current.videoBitrate,
minVideoBitrate = floorBitrate,
)
}
applyAdaptiveBitrate(adaptiveBitrate.setFloor(floorBitrate))
}
}
private fun applyAdaptiveBitrate(bitrate: Int) {
genericStream?.let { stream ->
runCatching { stream.setVideoBitrateOnFly(bitrate) }
.onFailure { Log.w(TAG, "ABR bitrate: ${it.message}") }
}
}
private fun obtainStream(cfg: BroadcastConfig): GenericStream { private fun obtainStream(cfg: BroadcastConfig): GenericStream {
val existing = genericStream val existing = genericStream
if (existing != null) return existing if (existing != null) return existing
@@ -579,12 +552,6 @@ class LiveBroadcastEngine(
override fun onNewBitrate(bitrate: Long) { override fun onNewBitrate(bitrate: Long) {
mainHandler.post { mainHandler.post {
currentBitrateKbps = bitrate / 1000 currentBitrateKbps = bitrate / 1000
val congestion = runCatching {
genericStream?.getStreamClient()?.hasCongestion() == true
}.getOrDefault(false)
adaptiveBitrate.onMeasured(bitrate, congestion)?.let { next ->
applyAdaptiveBitrate(next)
}
emitMetrics() emitMetrics()
} }
} }
@@ -1,72 +0,0 @@
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,14 +15,11 @@ object StreamVideoPreset {
fun broadcastConfig(session: StreamSession, rtmpUrl: String): BroadcastConfig { fun broadcastConfig(session: StreamSession, rtmpUrl: String): BroadcastConfig {
val dims = dimensions(session.qualityPreset) val dims = dimensions(session.qualityPreset)
val ceiling = StreamQualityLadder.encoderCeiling(session.targetBitrate, session.minQualityPreset)
val floor = StreamQualityLadder.encoderFloor(session.minQualityPreset, ceiling)
return BroadcastConfig( return BroadcastConfig(
rtmpUrl = rtmpUrl, rtmpUrl = rtmpUrl,
width = dims.width, width = dims.width,
height = dims.height, height = dims.height,
videoBitrate = ceiling, videoBitrate = session.targetBitrate,
minVideoBitrate = floor,
fps = session.targetFps, fps = session.targetFps,
) )
} }
@@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -30,7 +29,6 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.filled.Videocam
@@ -60,8 +58,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.matchlivetv.match_live_tv.R import com.matchlivetv.match_live_tv.R
@@ -69,7 +65,6 @@ import com.matchlivetv.match_live_tv.core.resolveMediaUrl
import com.matchlivetv.match_live_tv.core.DeviceHealthSnapshot import com.matchlivetv.match_live_tv.core.DeviceHealthSnapshot
import com.matchlivetv.match_live_tv.core.ThermalState import com.matchlivetv.match_live_tv.core.ThermalState
import com.matchlivetv.match_live_tv.domain.ScoreState import com.matchlivetv.match_live_tv.domain.ScoreState
import com.matchlivetv.match_live_tv.streaming.StreamQualityLadder
import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge
import com.matchlivetv.match_live_tv.ui.theme.MatchColors import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@@ -114,27 +109,9 @@ fun BroadcastControlsOverlay(
bitrateKbps: Long, bitrateKbps: Long,
networkType: String, networkType: String,
deviceHealth: DeviceHealthSnapshot, deviceHealth: DeviceHealthSnapshot,
minQualityPreset: String = StreamQualityLadder.AUTO,
sessionQualityPreset: String = "720p_30_2.5mbps",
onSelectMinQuality: (String) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var showTerminateConfirm by remember { mutableStateOf(false) } var showTerminateConfirm by remember { mutableStateOf(false) }
var showMinQualityPicker by remember { mutableStateOf(false) }
val autoLabel = stringResource(R.string.broadcast_min_quality_auto)
if (showMinQualityPicker) {
MinQualityPickerDialog(
selectedId = minQualityPreset,
sessionQualityPreset = sessionQualityPreset,
autoLabel = autoLabel,
onSelect = { id ->
showMinQualityPicker = false
onSelectMinQuality(id)
},
onDismiss = { showMinQualityPicker = false },
)
}
if (showTerminateConfirm) { if (showTerminateConfirm) {
AlertDialog( AlertDialog(
@@ -197,7 +174,6 @@ fun BroadcastControlsOverlay(
bitrateKbps = bitrateKbps, bitrateKbps = bitrateKbps,
networkType = networkType, networkType = networkType,
deviceHealth = deviceHealth, deviceHealth = deviceHealth,
minQualityLabel = StreamQualityLadder.displayName(minQualityPreset, autoLabel),
) )
} }
@@ -219,11 +195,6 @@ fun BroadcastControlsOverlay(
contentDescription = stringResource(R.string.broadcast_share_regia_cd), contentDescription = stringResource(R.string.broadcast_share_regia_cd),
onClick = onShareRegia, onClick = onShareRegia,
) )
SideIconButton(
icon = Icons.Default.Tune,
contentDescription = stringResource(R.string.broadcast_min_quality_cd),
onClick = { showMinQualityPicker = true },
)
if (onCloseSet != null) { if (onCloseSet != null) {
SideIconButton( SideIconButton(
icon = Icons.Default.Check, icon = Icons.Default.Check,
@@ -316,7 +287,6 @@ private fun BroadcastTelemetryPanel(
bitrateKbps: Long, bitrateKbps: Long,
networkType: String, networkType: String,
deviceHealth: DeviceHealthSnapshot, deviceHealth: DeviceHealthSnapshot,
minQualityLabel: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val labelStyle = MaterialTheme.typography.labelSmall val labelStyle = MaterialTheme.typography.labelSmall
@@ -351,11 +321,6 @@ private fun BroadcastTelemetryPanel(
color = MatchColors.TextSecondary, color = MatchColors.TextSecondary,
) )
} }
Text(
minQualityLabel,
style = labelStyle,
color = MatchColors.AccentYellow,
)
Text( Text(
"${networkType} · ${deviceHealth.batteryPercent}%", "${networkType} · ${deviceHealth.batteryPercent}%",
style = labelStyle, style = labelStyle,
@@ -639,113 +604,6 @@ private fun TeamLogoThumbnail(url: String) {
} }
} }
@Composable
private fun MinQualityPickerDialog(
selectedId: String,
sessionQualityPreset: String,
autoLabel: String,
onSelect: (String) -> Unit,
onDismiss: () -> Unit,
) {
val options = StreamQualityLadder.availableIds(sessionQualityPreset)
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Column(
modifier = Modifier
.padding(horizontal = 24.dp)
.widthIn(max = 720.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(MatchColors.Surface)
.border(1.dp, MatchColors.Outline, RoundedCornerShape(16.dp))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.broadcast_min_quality_title),
color = Color.White,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel), color = MatchColors.TextSecondary)
}
}
Text(
stringResource(R.string.broadcast_min_quality_hint),
color = MatchColors.TextSecondary,
style = MaterialTheme.typography.bodySmall,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
options.forEach { id ->
MinQualityOptionChip(
label = StreamQualityLadder.displayName(id, autoLabel),
selected = id == selectedId,
onClick = { onSelect(id) },
modifier = Modifier.weight(1f),
)
}
}
}
}
}
@Composable
private fun MinQualityOptionChip(
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(12.dp)
val border = if (selected) MatchColors.AccentYellow else MatchColors.Outline
val background = if (selected) {
MatchColors.AccentYellow.copy(alpha = 0.16f)
} else {
MatchColors.SurfaceElevated
}
Column(
modifier = modifier
.clip(shape)
.background(background)
.border(1.dp, border, shape)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (selected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MatchColors.AccentYellow,
modifier = Modifier.size(16.dp),
)
} else {
Spacer(Modifier.height(16.dp))
}
Text(
label,
color = if (selected) MatchColors.AccentYellow else Color.White,
style = MaterialTheme.typography.labelMedium,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun SideIconButton( private fun SideIconButton(
@@ -44,7 +44,6 @@ import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.MatchScoringRules import com.matchlivetv.match_live_tv.domain.MatchScoringRules
import com.matchlivetv.match_live_tv.domain.StreamSession import com.matchlivetv.match_live_tv.domain.StreamSession
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
import com.matchlivetv.match_live_tv.streaming.StreamQualityLadder
import com.matchlivetv.match_live_tv.streaming.StreamVideoPreset import com.matchlivetv.match_live_tv.streaming.StreamVideoPreset
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
import com.matchlivetv.match_live_tv.streaming.LivePreviewView import com.matchlivetv.match_live_tv.streaming.LivePreviewView
@@ -79,7 +78,6 @@ fun BroadcastScreen(
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var pauseInFlight by remember { mutableStateOf(false) } var pauseInFlight by remember { mutableStateOf(false) }
var resumeInFlight by remember { mutableStateOf(false) } var resumeInFlight by remember { mutableStateOf(false) }
var minQualityInFlight by remember { mutableStateOf(false) }
var controlsVisible by remember { mutableStateOf(true) } var controlsVisible by remember { mutableStateOf(true) }
var logoReady by remember { mutableStateOf(0) } var logoReady by remember { mutableStateOf(0) }
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) } var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) }
@@ -158,16 +156,10 @@ fun BroadcastScreen(
} }
suspend fun applyRemotePause() { suspend fun applyRemotePause() {
if (pauseInFlight) return container.broadcastCoordinator.engine.pauseBroadcast()
pauseInFlight = true session = runCatching { container.sessionRepository.fetchSession(sessionId) }
try { .getOrNull() ?: session?.copy(status = "paused")
container.broadcastCoordinator.engine.pauseBroadcast() snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_paused_remote))
session = runCatching { container.sessionRepository.fetchSession(sessionId) }
.getOrNull() ?: session?.copy(status = "paused")
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_paused_remote))
} finally {
pauseInFlight = false
}
} }
suspend fun applyRemoteResume() { suspend fun applyRemoteResume() {
@@ -199,38 +191,6 @@ fun BroadcastScreen(
onFinished() onFinished()
} }
fun applyMinQualityLocally(preset: String, loaded: StreamSession) {
val updated = loaded.copy(minQualityPreset = preset)
session = updated
val cfg = broadcastConfig(updated)
container.broadcastCoordinator.engine.setMinQualityFloor(cfg.minVideoBitrate, cfg.videoBitrate)
thermalManager.updateBaseline(cfg)
}
suspend fun persistMinQuality(preset: String) {
val current = session ?: return
minQualityInFlight = true
runCatching { container.sessionRepository.setMinQuality(sessionId, preset) }
.onSuccess { updated ->
applyMinQualityLocally(updated.minQualityPreset, updated)
snackbar.showSnackbar(
context.getString(
R.string.broadcast_min_quality_applied,
StreamQualityLadder.displayName(
updated.minQualityPreset,
context.getString(R.string.broadcast_min_quality_auto),
),
),
)
}
.onFailure {
snackbar.showSnackbar(
it.message ?: context.getString(R.string.broadcast_min_quality_error),
)
}
minQualityInFlight = false
}
LaunchedEffect(sessionId, permissions.granted) { LaunchedEffect(sessionId, permissions.granted) {
if (!permissions.granted) return@LaunchedEffect if (!permissions.granted) return@LaunchedEffect
loading = true loading = true
@@ -255,15 +215,6 @@ fun BroadcastScreen(
container.sessionCable.onEndStream = { container.sessionCable.onEndStream = {
scope.launch { applyRemoteStop() } scope.launch { applyRemoteStop() }
} }
container.sessionCable.onMinQuality = { preset ->
scope.launch {
val current = session
if (current != null && current.minQualityPreset != preset) {
applyMinQualityLocally(preset, current)
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_min_quality_remote))
}
}
}
container.sessionCable.connect(sessionId, token, deviceRole = "camera") container.sessionCable.connect(sessionId, token, deviceRole = "camera")
} }
container.broadcastCoordinator.startService() container.broadcastCoordinator.startService()
@@ -378,28 +329,12 @@ fun BroadcastScreen(
LaunchedEffect(sessionId) { LaunchedEffect(sessionId) {
while (true) { while (true) {
delay(2_000) delay(4_000)
if (minQualityInFlight || pauseInFlight || resumeInFlight) continue runCatching {
runCatching { container.sessionRepository.fetchSession(sessionId) } container.sessionRepository.fetchSession(sessionId).score
.getOrNull() }.getOrNull()?.let { remote ->
?.let { fresh -> container.scoreController.applyRemote(remote)
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
}
}
} }
} }
@@ -407,6 +342,7 @@ fun BroadcastScreen(
while (true) { while (true) {
delay(10_000) delay(10_000)
val current = session ?: continue val current = session ?: continue
if (current.isPaused) continue
runCatching { runCatching {
container.sessionRepository.postTelemetry( container.sessionRepository.postTelemetry(
sessionId = sessionId, sessionId = sessionId,
@@ -416,7 +352,6 @@ fun BroadcastScreen(
currentBitrate = (metrics.bitrateKbps * 1000).toInt().takeIf { it > 0 }, currentBitrate = (metrics.bitrateKbps * 1000).toInt().takeIf { it > 0 },
targetBitrate = current.targetBitrate, targetBitrate = current.targetBitrate,
fps = metrics.fps.takeIf { it > 0 }, fps = metrics.fps.takeIf { it > 0 },
thermalState = thermalManager.state.value.name.lowercase(),
) )
} }
} }
@@ -429,7 +364,6 @@ fun BroadcastScreen(
container.sessionCable.onPauseStream = null container.sessionCable.onPauseStream = null
container.sessionCable.onResumeStream = null container.sessionCable.onResumeStream = null
container.sessionCable.onEndStream = null container.sessionCable.onEndStream = null
container.sessionCable.onMinQuality = null
container.sessionCable.disconnect() container.sessionCable.disconnect()
container.broadcastCoordinator.stopService() container.broadcastCoordinator.stopService()
container.broadcastCoordinator.engine.stopBroadcast() container.broadcastCoordinator.engine.stopBroadcast()
@@ -644,11 +578,6 @@ fun BroadcastScreen(
bitrateKbps = metrics.bitrateKbps, bitrateKbps = metrics.bitrateKbps,
networkType = DeviceTelemetry.networkType(context), networkType = DeviceTelemetry.networkType(context),
deviceHealth = DeviceTelemetry.snapshot(context, thermalState), deviceHealth = DeviceTelemetry.snapshot(context, thermalState),
minQualityPreset = currentSession?.minQualityPreset ?: StreamQualityLadder.AUTO,
sessionQualityPreset = currentSession?.qualityPreset ?: "720p_30_2.5mbps",
onSelectMinQuality = { preset ->
scope.launch { persistMinQuality(preset) }
},
) )
} }
} }
@@ -29,15 +29,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -54,7 +50,7 @@ import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
import com.matchlivetv.match_live_tv.ui.theme.MatchColors import com.matchlivetv.match_live_tv.ui.theme.MatchColors
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LoginScreen( fun LoginScreen(
container: AppContainer, container: AppContainer,
@@ -134,10 +130,7 @@ fun LoginScreen(
onValueChange = { email = it }, onValueChange = { email = it },
label = { Text(stringResource(R.string.login_email)) }, label = { Text(stringResource(R.string.login_email)) },
placeholder = { Text(stringResource(R.string.login_email_placeholder)) }, placeholder = { Text(stringResource(R.string.login_email_placeholder)) },
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth()
.testTag("login_email")
.semantics { testTagsAsResourceId = true },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email, keyboardType = KeyboardType.Email,
@@ -153,10 +146,7 @@ fun LoginScreen(
value = password, value = password,
onValueChange = { password = it }, onValueChange = { password = it },
label = { Text(stringResource(R.string.login_password)) }, label = { Text(stringResource(R.string.login_password)) },
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth()
.testTag("login_password")
.semantics { testTagsAsResourceId = true },
singleLine = true, singleLine = true,
visualTransformation = if (passwordVisible) { visualTransformation = if (passwordVisible) {
VisualTransformation.None VisualTransformation.None
@@ -208,9 +198,6 @@ fun LoginScreen(
loading = loading, loading = loading,
enabled = email.isNotBlank() && password.isNotBlank(), enabled = email.isNotBlank() && password.isNotBlank(),
onClick = { submitLogin() }, onClick = { submitLogin() },
modifier = Modifier
.testTag("login_submit")
.semantics { testTagsAsResourceId = true },
) )
} }
} }
@@ -164,13 +164,6 @@
<string name="broadcast_resume_cd">Livestream fortsetzen</string> <string name="broadcast_resume_cd">Livestream fortsetzen</string>
<string name="broadcast_pause_cd">Livestream pausieren</string> <string name="broadcast_pause_cd">Livestream pausieren</string>
<string name="broadcast_terminate_cd">Livestream beenden</string> <string name="broadcast_terminate_cd">Livestream beenden</string>
<string name="broadcast_min_quality_title">Mindestqualität</string>
<string name="broadcast_min_quality_cd">Minimale Übertragungsqualität</string>
<string name="broadcast_min_quality_hint">Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.</string>
<string name="broadcast_min_quality_auto">Automatisch</string>
<string name="broadcast_min_quality_applied">Mindestqualität: %1$s</string>
<string name="broadcast_min_quality_error">Mindestqualität konnte nicht gesetzt werden</string>
<string name="broadcast_snackbar_min_quality_remote">Mindestqualität von der Regie aktualisiert</string>
<string name="broadcast_team_home_label">HEIM</string> <string name="broadcast_team_home_label">HEIM</string>
<string name="broadcast_team_away_label">GAST</string> <string name="broadcast_team_away_label">GAST</string>
<string name="broadcast_scoreboard_connected">Anzeigetafel OK</string> <string name="broadcast_scoreboard_connected">Anzeigetafel OK</string>
@@ -164,13 +164,6 @@
<string name="broadcast_resume_cd">Resume live</string> <string name="broadcast_resume_cd">Resume live</string>
<string name="broadcast_pause_cd">Pause live</string> <string name="broadcast_pause_cd">Pause live</string>
<string name="broadcast_terminate_cd">End live</string> <string name="broadcast_terminate_cd">End live</string>
<string name="broadcast_min_quality_title">Minimum quality</string>
<string name="broadcast_min_quality_cd">Minimum streaming quality</string>
<string name="broadcast_min_quality_hint">The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.</string>
<string name="broadcast_min_quality_auto">Automatic</string>
<string name="broadcast_min_quality_applied">Minimum quality: %1$s</string>
<string name="broadcast_min_quality_error">Unable to set minimum quality</string>
<string name="broadcast_snackbar_min_quality_remote">Minimum quality updated from control room</string>
<string name="broadcast_team_home_label">HOME</string> <string name="broadcast_team_home_label">HOME</string>
<string name="broadcast_team_away_label">AWAY</string> <string name="broadcast_team_away_label">AWAY</string>
<string name="broadcast_scoreboard_connected">Scoreboard OK</string> <string name="broadcast_scoreboard_connected">Scoreboard OK</string>
@@ -164,13 +164,6 @@
<string name="broadcast_resume_cd">Reanudar directo</string> <string name="broadcast_resume_cd">Reanudar directo</string>
<string name="broadcast_pause_cd">Pausar directo</string> <string name="broadcast_pause_cd">Pausar directo</string>
<string name="broadcast_terminate_cd">Terminar directo</string> <string name="broadcast_terminate_cd">Terminar directo</string>
<string name="broadcast_min_quality_title">Calidad mínima</string>
<string name="broadcast_min_quality_cd">Calidad mínima de transmisión</string>
<string name="broadcast_min_quality_hint">El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.</string>
<string name="broadcast_min_quality_auto">Automática</string>
<string name="broadcast_min_quality_applied">Calidad mínima: %1$s</string>
<string name="broadcast_min_quality_error">No se pudo fijar la calidad mínima</string>
<string name="broadcast_snackbar_min_quality_remote">Calidad mínima actualizada desde la regie</string>
<string name="broadcast_team_home_label">LOCAL</string> <string name="broadcast_team_home_label">LOCAL</string>
<string name="broadcast_team_away_label">VISITANTE</string> <string name="broadcast_team_away_label">VISITANTE</string>
<string name="broadcast_scoreboard_connected">Marcador OK</string> <string name="broadcast_scoreboard_connected">Marcador OK</string>
@@ -164,13 +164,6 @@
<string name="broadcast_resume_cd">Reprendre le direct</string> <string name="broadcast_resume_cd">Reprendre le direct</string>
<string name="broadcast_pause_cd">Mettre le direct en pause</string> <string name="broadcast_pause_cd">Mettre le direct en pause</string>
<string name="broadcast_terminate_cd">Terminer le direct</string> <string name="broadcast_terminate_cd">Terminer le direct</string>
<string name="broadcast_min_quality_title">Qualité minimale</string>
<string name="broadcast_min_quality_cd">Qualité minimale de transmission</string>
<string name="broadcast_min_quality_hint">L\'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.</string>
<string name="broadcast_min_quality_auto">Automatique</string>
<string name="broadcast_min_quality_applied">Qualité minimale : %1$s</string>
<string name="broadcast_min_quality_error">Impossible de définir la qualité minimale</string>
<string name="broadcast_snackbar_min_quality_remote">Qualité minimale mise à jour depuis la régie</string>
<string name="broadcast_team_home_label">DOMICILE</string> <string name="broadcast_team_home_label">DOMICILE</string>
<string name="broadcast_team_away_label">EXTÉRIEUR</string> <string name="broadcast_team_away_label">EXTÉRIEUR</string>
<string name="broadcast_scoreboard_connected">Tableau OK</string> <string name="broadcast_scoreboard_connected">Tableau OK</string>
@@ -164,13 +164,6 @@
<string name="broadcast_resume_cd">Riprendi diretta</string> <string name="broadcast_resume_cd">Riprendi diretta</string>
<string name="broadcast_pause_cd">Pausa diretta</string> <string name="broadcast_pause_cd">Pausa diretta</string>
<string name="broadcast_terminate_cd">Termina diretta</string> <string name="broadcast_terminate_cd">Termina diretta</string>
<string name="broadcast_min_quality_title">Qualità minima</string>
<string name="broadcast_min_quality_cd">Qualità minima di trasmissione</string>
<string name="broadcast_min_quality_hint">L\'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.</string>
<string name="broadcast_min_quality_auto">Automatica</string>
<string name="broadcast_min_quality_applied">Qualità minima: %1$s</string>
<string name="broadcast_min_quality_error">Impossibile impostare la qualità minima</string>
<string name="broadcast_snackbar_min_quality_remote">Qualità minima aggiornata dalla regia</string>
<string name="broadcast_team_home_label">CASA</string> <string name="broadcast_team_home_label">CASA</string>
<string name="broadcast_team_away_label">OSPITE</string> <string name="broadcast_team_away_label">OSPITE</string>
<string name="broadcast_scoreboard_connected">Tabellone OK</string> <string name="broadcast_scoreboard_connected">Tabellone OK</string>
@@ -1,106 +0,0 @@
package com.matchlivetv.match_live_tv.streaming
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class AdaptiveBitrateControllerTest {
private fun controller() = AdaptiveBitrateController().also {
it.configure(ceiling = 2_500_000, floor = AdaptiveBitrateController.AUTO_FLOOR)
}
@Test
fun congestionDropsTowardMeasuredAndRespectsFloor() {
val abr = controller()
val next = abr.onCongestion(1_200_000)
assertEquals(1_200_000 - 128_000, next)
assertEquals(1_072_000, abr.currentBitrate)
}
@Test
fun congestionDoesNotGoBelowFloor() {
val abr = controller()
abr.setFloor(1_500_000)
val next = abr.onCongestion(400_000)
assertEquals(1_500_000, next)
}
@Test
fun healthyRampsUpAfterEnoughSamples() {
val abr = controller()
abr.onCongestion(1_200_000)
repeat(7) { assertNull(abr.onHealthy()) }
val raised = abr.onHealthy()
assertEquals(1_072_000 + 250_000, raised)
}
@Test
fun thermalCeilingClampsCurrent() {
val abr = controller()
val applied = abr.setCeiling(1_250_000)
assertEquals(1_250_000, applied)
assertEquals(1_250_000, abr.ceiling)
assertTrue(abr.floor <= abr.ceiling)
}
@Test
fun minFloorBumpsCurrentUp() {
val abr = controller()
abr.onCongestion(1_200_000)
val applied = abr.setFloor(2_000_000)
assertEquals(2_000_000, applied)
}
@Test
fun autoOptionsExclude1080On720Session() {
val ids = StreamQualityLadder.availableIds("720p_30_2.5mbps")
assertEquals(
listOf(
StreamQualityLadder.AUTO,
"720p_30_1.5mbps",
"720p_30_2.5mbps",
"720p_30_4mbps",
),
ids,
)
}
@Test
fun measuredZeroWithoutCongestionDoesNotDrop() {
val abr = controller()
assertNull(abr.onMeasured(0L, congested = false))
assertEquals(2_500_000, abr.currentBitrate)
}
@Test
fun measuredLowThroughputDropsWithoutCongestionFlag() {
val abr = controller()
val next = abr.onMeasured(1_200_000L, congested = false)
assertEquals(1_200_000 - 128_000, next)
}
@Test
fun thermalDropThenRecoverRestoresUserFloor() {
val abr = controller()
abr.setFloor(1_500_000)
abr.setCeiling(1_000_000)
assertEquals(1_000_000, abr.floor)
abr.setCeiling(2_500_000)
assertEquals(1_000_000, abr.floor)
assertEquals(1_500_000, abr.setFloor(1_500_000))
}
@Test
fun encoderCeilingRisesWhenMinExceedsSessionTarget() {
val ceiling = StreamQualityLadder.encoderCeiling(
sessionTarget = 2_500_000,
minPresetId = "720p_30_4mbps",
)
assertEquals(4_000_000, ceiling)
assertEquals(
4_000_000,
StreamQualityLadder.encoderFloor("720p_30_4mbps", ceiling),
)
}
}
@@ -28,7 +28,6 @@
39335ED5DE5847E5904222AB /* MatchSessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813463F3E17D453EAA801739 /* MatchSessionLauncher.swift */; }; 39335ED5DE5847E5904222AB /* MatchSessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813463F3E17D453EAA801739 /* MatchSessionLauncher.swift */; };
39F7D49D44244F51907E803D /* TeamColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7001BB5D65B34280B306ECCF /* TeamColorPicker.swift */; }; 39F7D49D44244F51907E803D /* TeamColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7001BB5D65B34280B306ECCF /* TeamColorPicker.swift */; };
451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */; }; 451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */; };
5F9B2C3D4E5A4B72C8D9E0F1 /* AdaptiveBitrateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8A1B2C3D4F4A61B7C8D9E0 /* AdaptiveBitrateController.swift */; };
454725D14B224276B06BCC67 /* MatchHubFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */; }; 454725D14B224276B06BCC67 /* MatchHubFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */; };
45776A6A76D04EF5A0C0D187 /* TeamBrandingEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBBE0F1399840FFA0E7EF2E /* TeamBrandingEditor.swift */; }; 45776A6A76D04EF5A0C0D187 /* TeamBrandingEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBBE0F1399840FFA0E7EF2E /* TeamBrandingEditor.swift */; };
470957DE79A84D9889F0663C /* ScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC8ECFD24A14054BDD984C3 /* ScoreRepository.swift */; }; 470957DE79A84D9889F0663C /* ScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC8ECFD24A14054BDD984C3 /* ScoreRepository.swift */; };
@@ -69,7 +68,6 @@
AA18DECE8D08461B9197EB71 /* MatchScoringRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */; }; AA18DECE8D08461B9197EB71 /* MatchScoringRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */; };
AA3A68B2C1784294ADB89605 /* MatchLiveTv/Resources/it.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 857C036B5B5C43368506DD8B /* MatchLiveTv/Resources/it.lproj */; }; AA3A68B2C1784294ADB89605 /* MatchLiveTv/Resources/it.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 857C036B5B5C43368506DD8B /* MatchLiveTv/Resources/it.lproj */; };
AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */; }; AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */; };
7B1D4E5F6A7C4D94E0F1A2B3 /* AdaptiveBitrateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A0C3D4E5F6B4C83D9E0F1A2 /* AdaptiveBitrateTests.swift */; };
ABC5853FEC554FA4AFBDCD2E /* OverlayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DDC7305F3B549D0B7646B14 /* OverlayState.swift */; }; ABC5853FEC554FA4AFBDCD2E /* OverlayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DDC7305F3B549D0B7646B14 /* OverlayState.swift */; };
ACC54AA19C5747CFA118C70B /* ApiInstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FEB862C3A34BF8AC24052E /* ApiInstantTests.swift */; }; ACC54AA19C5747CFA118C70B /* ApiInstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FEB862C3A34BF8AC24052E /* ApiInstantTests.swift */; };
AECC36D80A0C411AB47D581A /* MatchRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537FC2C063D341DF97A391B9 /* MatchRepository.swift */; }; AECC36D80A0C411AB47D581A /* MatchRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537FC2C063D341DF97A391B9 /* MatchRepository.swift */; };
@@ -129,7 +127,6 @@
31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = "<group>"; }; 31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = "<group>"; };
34BB2251A67E401A9F1E789E /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; }; 34BB2251A67E401A9F1E789E /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; };
377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThermalAdaptationTests.swift; path = MatchLiveTvTests/ThermalAdaptationTests.swift; sourceTree = "<group>"; }; 377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThermalAdaptationTests.swift; path = MatchLiveTvTests/ThermalAdaptationTests.swift; sourceTree = "<group>"; };
6A0C3D4E5F6B4C83D9E0F1A2 /* AdaptiveBitrateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AdaptiveBitrateTests.swift; path = MatchLiveTvTests/AdaptiveBitrateTests.swift; sourceTree = "<group>"; };
39F3C00B63534E6AA6D8A90A /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; }; 39F3C00B63534E6AA6D8A90A /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; };
3A6F9AAB5A1249BBB4FC02E6 /* MatchLiveTv/Resources/en.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/en.lproj; sourceTree = "<group>"; }; 3A6F9AAB5A1249BBB4FC02E6 /* MatchLiveTv/Resources/en.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/en.lproj; sourceTree = "<group>"; };
3BFB2F18615747B1A1C9E9B2 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = "<group>"; }; 3BFB2F18615747B1A1C9E9B2 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = "<group>"; };
@@ -163,7 +160,6 @@
87C01CC031C34343B3EBFF08 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = "<group>"; }; 87C01CC031C34343B3EBFF08 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = "<group>"; };
8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = "<group>"; }; 8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = "<group>"; };
8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamVideoPreset.swift; path = MatchLiveTv/Streaming/StreamVideoPreset.swift; sourceTree = "<group>"; }; 8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamVideoPreset.swift; path = MatchLiveTv/Streaming/StreamVideoPreset.swift; sourceTree = "<group>"; };
4E8A1B2C3D4F4A61B7C8D9E0 /* AdaptiveBitrateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AdaptiveBitrateController.swift; path = MatchLiveTv/Streaming/AdaptiveBitrateController.swift; sourceTree = "<group>"; };
902831E3D87C46359F0D0A21 /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = "<group>"; }; 902831E3D87C46359F0D0A21 /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = "<group>"; };
951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = "<group>"; }; 951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = "<group>"; };
98526C26B2584F55AADF0FFF /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = "<group>"; }; 98526C26B2584F55AADF0FFF /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = "<group>"; };
@@ -241,7 +237,6 @@
10AD0F18190542818572CAEF /* ScoreActionDecodeTests.swift */, 10AD0F18190542818572CAEF /* ScoreActionDecodeTests.swift */,
6DF142EBF33B45739BA8E42C /* ScoreControllerTests.swift */, 6DF142EBF33B45739BA8E42C /* ScoreControllerTests.swift */,
377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */, 377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */,
6A0C3D4E5F6B4C83D9E0F1A2 /* AdaptiveBitrateTests.swift */,
); );
name = MatchLiveTvTests; name = MatchLiveTvTests;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -301,7 +296,6 @@
31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */, 31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */,
46CE696CAB994E2C8E399776 /* LivePreviewView.swift */, 46CE696CAB994E2C8E399776 /* LivePreviewView.swift */,
8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */, 8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */,
4E8A1B2C3D4F4A61B7C8D9E0 /* AdaptiveBitrateController.swift */,
4419E10415414556B7C58458 /* AccountScreen.swift */, 4419E10415414556B7C58458 /* AccountScreen.swift */,
E860A0E35362427DA84F8972 /* BroadcastControlsOverlay.swift */, E860A0E35362427DA84F8972 /* BroadcastControlsOverlay.swift */,
71816F0D99E44551839474CE /* BroadcastScreen.swift */, 71816F0D99E44551839474CE /* BroadcastScreen.swift */,
@@ -479,7 +473,6 @@
6AD0B3220B154EA2936C634A /* WatermarkElement.swift in Sources */, 6AD0B3220B154EA2936C634A /* WatermarkElement.swift in Sources */,
713E0129656649FC8630FDC6 /* LivePreviewView.swift in Sources */, 713E0129656649FC8630FDC6 /* LivePreviewView.swift in Sources */,
451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */, 451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */,
5F9B2C3D4E5A4B72C8D9E0F1 /* AdaptiveBitrateController.swift in Sources */,
E5E21D088782469D8EB23543 /* AccountScreen.swift in Sources */, E5E21D088782469D8EB23543 /* AccountScreen.swift in Sources */,
5B51805239FA4D4D90919D86 /* BroadcastControlsOverlay.swift in Sources */, 5B51805239FA4D4D90919D86 /* BroadcastControlsOverlay.swift in Sources */,
EE568EA02C404102B4DFD651 /* BroadcastScreen.swift in Sources */, EE568EA02C404102B4DFD651 /* BroadcastScreen.swift in Sources */,
@@ -523,7 +516,6 @@
5D91C53D2E8A4952937F816B /* ScoreActionDecodeTests.swift in Sources */, 5D91C53D2E8A4952937F816B /* ScoreActionDecodeTests.swift in Sources */,
FB66DD0422E14C54AC5B3EDB /* ScoreControllerTests.swift in Sources */, FB66DD0422E14C54AC5B3EDB /* ScoreControllerTests.swift in Sources */,
AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */, AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */,
7B1D4E5F6A7C4D94E0F1A2B3 /* AdaptiveBitrateTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
+10 -45
View File
@@ -118,13 +118,7 @@ enum L10n {
"broadcast.error.rtmp.session.create": "Impossibile creare sessione RTMP", "broadcast.error.rtmp.session.create": "Impossibile creare sessione RTMP",
"broadcast.error.rtmp.url.invalid": "URL RTMP non valido", "broadcast.error.rtmp.url.invalid": "URL RTMP non valido",
"broadcast.error.session.unavailable": "Sessione non disponibile", "broadcast.error.session.unavailable": "Sessione non disponibile",
"broadcast.hide.controls.cd": "Nascondi controlli", "broadcast.hide.controls.cd": "Nascondi controlli",
"broadcast.min.quality.applied": "Qualità minima: %1$@",
"broadcast.min.quality.auto": "Automatica",
"broadcast.min.quality.cd": "Qualità minima di trasmissione",
"broadcast.min.quality.error": "Impossibile impostare la qualità minima",
"broadcast.min.quality.hint": "L'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.",
"broadcast.min.quality.title": "Qualità minima",
"broadcast.next.period.cd": "Periodo successivo", "broadcast.next.period.cd": "Periodo successivo",
"broadcast.pause.cd": "Pausa diretta", "broadcast.pause.cd": "Pausa diretta",
"broadcast.period.label.timed": "%1$d° tempo", "broadcast.period.label.timed": "%1$d° tempo",
@@ -145,8 +139,7 @@ enum L10n {
"broadcast.show.controls.cd": "Mostra controlli", "broadcast.show.controls.cd": "Mostra controlli",
"broadcast.side.away": "ospite", "broadcast.side.away": "ospite",
"broadcast.side.home": "casa", "broadcast.side.home": "casa",
"broadcast.snackbar.closed.remote": "Diretta chiusa dalla regia", "broadcast.snackbar.closed.remote": "Diretta chiusa dalla regia",
"broadcast.snackbar.min.quality.remote": "Qualità minima aggiornata dalla regia",
"broadcast.snackbar.pause.error": "Pausa: %1$@", "broadcast.snackbar.pause.error": "Pausa: %1$@",
"broadcast.snackbar.paused": "Diretta in pausa", "broadcast.snackbar.paused": "Diretta in pausa",
"broadcast.snackbar.paused.remote": "Pausa dalla regia", "broadcast.snackbar.paused.remote": "Pausa dalla regia",
@@ -415,13 +408,7 @@ enum L10n {
"broadcast.error.rtmp.session.create": "Unable to create RTMP session", "broadcast.error.rtmp.session.create": "Unable to create RTMP session",
"broadcast.error.rtmp.url.invalid": "Invalid RTMP URL", "broadcast.error.rtmp.url.invalid": "Invalid RTMP URL",
"broadcast.error.session.unavailable": "Session not available", "broadcast.error.session.unavailable": "Session not available",
"broadcast.hide.controls.cd": "Hide controls", "broadcast.hide.controls.cd": "Hide controls",
"broadcast.min.quality.applied": "Minimum quality: %1$@",
"broadcast.min.quality.auto": "Automatic",
"broadcast.min.quality.cd": "Minimum streaming quality",
"broadcast.min.quality.error": "Unable to set minimum quality",
"broadcast.min.quality.hint": "The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.",
"broadcast.min.quality.title": "Minimum quality",
"broadcast.next.period.cd": "Next period", "broadcast.next.period.cd": "Next period",
"broadcast.pause.cd": "Pause live", "broadcast.pause.cd": "Pause live",
"broadcast.period.label.timed": "Period %1$d", "broadcast.period.label.timed": "Period %1$d",
@@ -442,8 +429,7 @@ enum L10n {
"broadcast.show.controls.cd": "Show controls", "broadcast.show.controls.cd": "Show controls",
"broadcast.side.away": "away", "broadcast.side.away": "away",
"broadcast.side.home": "home", "broadcast.side.home": "home",
"broadcast.snackbar.closed.remote": "Live closed from control room", "broadcast.snackbar.closed.remote": "Live closed from control room",
"broadcast.snackbar.min.quality.remote": "Minimum quality updated from control room",
"broadcast.snackbar.pause.error": "Pause: %1$@", "broadcast.snackbar.pause.error": "Pause: %1$@",
"broadcast.snackbar.paused": "Live paused", "broadcast.snackbar.paused": "Live paused",
"broadcast.snackbar.paused.remote": "Paused from control room", "broadcast.snackbar.paused.remote": "Paused from control room",
@@ -712,13 +698,7 @@ enum L10n {
"broadcast.error.rtmp.session.create": "Impossible de créer la session RTMP", "broadcast.error.rtmp.session.create": "Impossible de créer la session RTMP",
"broadcast.error.rtmp.url.invalid": "URL RTMP non valide", "broadcast.error.rtmp.url.invalid": "URL RTMP non valide",
"broadcast.error.session.unavailable": "Session non disponible", "broadcast.error.session.unavailable": "Session non disponible",
"broadcast.hide.controls.cd": "Masquer les commandes", "broadcast.hide.controls.cd": "Masquer les commandes",
"broadcast.min.quality.applied": "Qualité minimale : %1$@",
"broadcast.min.quality.auto": "Automatique",
"broadcast.min.quality.cd": "Qualité minimale de transmission",
"broadcast.min.quality.error": "Impossible de définir la qualité minimale",
"broadcast.min.quality.hint": "L'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.",
"broadcast.min.quality.title": "Qualité minimale",
"broadcast.next.period.cd": "Période suivante", "broadcast.next.period.cd": "Période suivante",
"broadcast.pause.cd": "Mettre le direct en pause", "broadcast.pause.cd": "Mettre le direct en pause",
"broadcast.period.label.timed": "Période %1$d", "broadcast.period.label.timed": "Période %1$d",
@@ -739,8 +719,7 @@ enum L10n {
"broadcast.show.controls.cd": "Afficher les commandes", "broadcast.show.controls.cd": "Afficher les commandes",
"broadcast.side.away": "extérieur", "broadcast.side.away": "extérieur",
"broadcast.side.home": "domicile", "broadcast.side.home": "domicile",
"broadcast.snackbar.closed.remote": "Direct fermé depuis la régie", "broadcast.snackbar.closed.remote": "Direct fermé depuis la régie",
"broadcast.snackbar.min.quality.remote": "Qualité minimale mise à jour depuis la régie",
"broadcast.snackbar.pause.error": "Pause : %1$@", "broadcast.snackbar.pause.error": "Pause : %1$@",
"broadcast.snackbar.paused": "Direct en pause", "broadcast.snackbar.paused": "Direct en pause",
"broadcast.snackbar.paused.remote": "Mis en pause depuis la régie", "broadcast.snackbar.paused.remote": "Mis en pause depuis la régie",
@@ -1009,13 +988,7 @@ enum L10n {
"broadcast.error.rtmp.session.create": "RTMP-Sitzung konnte nicht erstellt werden", "broadcast.error.rtmp.session.create": "RTMP-Sitzung konnte nicht erstellt werden",
"broadcast.error.rtmp.url.invalid": "Ungültige RTMP-URL", "broadcast.error.rtmp.url.invalid": "Ungültige RTMP-URL",
"broadcast.error.session.unavailable": "Sitzung nicht verfügbar", "broadcast.error.session.unavailable": "Sitzung nicht verfügbar",
"broadcast.hide.controls.cd": "Steuerung ausblenden", "broadcast.hide.controls.cd": "Steuerung ausblenden",
"broadcast.min.quality.applied": "Mindestqualität: %1$@",
"broadcast.min.quality.auto": "Automatisch",
"broadcast.min.quality.cd": "Minimale Übertragungsqualität",
"broadcast.min.quality.error": "Mindestqualität konnte nicht gesetzt werden",
"broadcast.min.quality.hint": "Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.",
"broadcast.min.quality.title": "Mindestqualität",
"broadcast.next.period.cd": "Nächste Periode", "broadcast.next.period.cd": "Nächste Periode",
"broadcast.pause.cd": "Livestream pausieren", "broadcast.pause.cd": "Livestream pausieren",
"broadcast.period.label.timed": "Halbzeit %1$d", "broadcast.period.label.timed": "Halbzeit %1$d",
@@ -1036,8 +1009,7 @@ enum L10n {
"broadcast.show.controls.cd": "Steuerung einblenden", "broadcast.show.controls.cd": "Steuerung einblenden",
"broadcast.side.away": "Gast", "broadcast.side.away": "Gast",
"broadcast.side.home": "Heim", "broadcast.side.home": "Heim",
"broadcast.snackbar.closed.remote": "Livestream von der Regie beendet", "broadcast.snackbar.closed.remote": "Livestream von der Regie beendet",
"broadcast.snackbar.min.quality.remote": "Mindestqualität von der Regie aktualisiert",
"broadcast.snackbar.pause.error": "Pause: %1$@", "broadcast.snackbar.pause.error": "Pause: %1$@",
"broadcast.snackbar.paused": "Livestream pausiert", "broadcast.snackbar.paused": "Livestream pausiert",
"broadcast.snackbar.paused.remote": "Von der Regie pausiert", "broadcast.snackbar.paused.remote": "Von der Regie pausiert",
@@ -1306,13 +1278,7 @@ enum L10n {
"broadcast.error.rtmp.session.create": "No se pudo crear la sesión RTMP", "broadcast.error.rtmp.session.create": "No se pudo crear la sesión RTMP",
"broadcast.error.rtmp.url.invalid": "URL RTMP no válida", "broadcast.error.rtmp.url.invalid": "URL RTMP no válida",
"broadcast.error.session.unavailable": "Sesión no disponible", "broadcast.error.session.unavailable": "Sesión no disponible",
"broadcast.hide.controls.cd": "Ocultar controles", "broadcast.hide.controls.cd": "Ocultar controles",
"broadcast.min.quality.applied": "Calidad mínima: %1$@",
"broadcast.min.quality.auto": "Automática",
"broadcast.min.quality.cd": "Calidad mínima de transmisión",
"broadcast.min.quality.error": "No se pudo fijar la calidad mínima",
"broadcast.min.quality.hint": "El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.",
"broadcast.min.quality.title": "Calidad mínima",
"broadcast.next.period.cd": "Periodo siguiente", "broadcast.next.period.cd": "Periodo siguiente",
"broadcast.pause.cd": "Pausar directo", "broadcast.pause.cd": "Pausar directo",
"broadcast.period.label.timed": "Tiempo %1$d", "broadcast.period.label.timed": "Tiempo %1$d",
@@ -1333,8 +1299,7 @@ enum L10n {
"broadcast.show.controls.cd": "Mostrar controles", "broadcast.show.controls.cd": "Mostrar controles",
"broadcast.side.away": "visitante", "broadcast.side.away": "visitante",
"broadcast.side.home": "local", "broadcast.side.home": "local",
"broadcast.snackbar.closed.remote": "Directo cerrado desde regie", "broadcast.snackbar.closed.remote": "Directo cerrado desde regie",
"broadcast.snackbar.min.quality.remote": "Calidad mínima actualizada desde la regie",
"broadcast.snackbar.pause.error": "Pausa: %1$@", "broadcast.snackbar.pause.error": "Pausa: %1$@",
"broadcast.snackbar.paused": "Directo en pausa", "broadcast.snackbar.paused": "Directo en pausa",
"broadcast.snackbar.paused.remote": "Pausado desde regie", "broadcast.snackbar.paused.remote": "Pausado desde regie",
@@ -39,13 +39,4 @@ enum ThermalState: Int, Sendable, Equatable, Comparable {
case .critical: return "🔴" case .critical: return "🔴"
} }
} }
var apiValue: String {
switch self {
case .nominal: return "nominal"
case .fair: return "fair"
case .serious: return "serious"
case .critical: return "critical"
}
}
} }
@@ -202,7 +202,6 @@ struct StreamSessionDto: Decodable {
let targetBitrate: Int? let targetBitrate: Int?
let targetFps: Int? let targetFps: Int?
let qualityPreset: String? let qualityPreset: String?
let minQualityPreset: String?
let startedAt: String? let startedAt: String?
let score: ScoreStateDto? let score: ScoreStateDto?
@@ -222,7 +221,6 @@ struct StreamSessionDto: Decodable {
targetBitrate: targetBitrate ?? 2_500_000, targetBitrate: targetBitrate ?? 2_500_000,
targetFps: targetFps ?? 30, targetFps: targetFps ?? 30,
qualityPreset: qualityPreset ?? "720p_30_2.5mbps", qualityPreset: qualityPreset ?? "720p_30_2.5mbps",
minQualityPreset: minQualityPreset ?? "auto",
startedAt: startedAt, startedAt: startedAt,
score: score?.toDomain() score: score?.toDomain()
) )
@@ -416,10 +414,6 @@ struct CreateSessionRequest: Encodable {
let youtubeChannel: String? let youtubeChannel: String?
} }
struct MinQualityRequest: Encodable {
let minQualityPreset: String
}
struct ScoringRulesBody: Codable { struct ScoringRulesBody: Codable {
let setsToWin: Int? let setsToWin: Int?
let pointsPerSet: Int? let pointsPerSet: Int?
@@ -505,7 +499,6 @@ struct TelemetryRequest: Encodable {
let currentBitrate: Int? let currentBitrate: Int?
let targetBitrate: Int? let targetBitrate: Int?
let fps: Int? let fps: Int?
let thermalState: String?
} }
extension ScoringRules { extension ScoringRules {
@@ -186,10 +186,6 @@ final class MatchLiveAPI: @unchecked Sendable {
try await patch("sessions/\(id)/resume", body: EmptyBody()) try await patch("sessions/\(id)/resume", body: EmptyBody())
} }
func setMinQuality(id: String, preset: String) async throws -> StreamSessionDto {
try await patch("sessions/\(id)/min_quality", body: MinQualityRequest(minQualityPreset: preset))
}
func networkTest(sessionId: String, body: NetworkTestRequest) async throws -> NetworkTestResponse { func networkTest(sessionId: String, body: NetworkTestRequest) async throws -> NetworkTestResponse {
try await post("sessions/\(sessionId)/network_test", body: body) try await post("sessions/\(sessionId)/network_test", body: body)
} }
@@ -8,7 +8,6 @@ final class SessionCableService: ObservableObject {
var onPauseStream: (() -> Void)? var onPauseStream: (() -> Void)?
var onResumeStream: (() -> Void)? var onResumeStream: (() -> Void)?
var onStopStream: (() -> Void)? var onStopStream: (() -> Void)?
var onMinQuality: ((String) -> Void)?
private var client: ActionCableClient? private var client: ActionCableClient?
private var intentionalDisconnect = false private var intentionalDisconnect = false
@@ -94,10 +93,6 @@ extension SessionCableService: ActionCableClient.Listener {
case "pause_stream": onPauseStream?() case "pause_stream": onPauseStream?()
case "resume_stream": onResumeStream?() case "resume_stream": onResumeStream?()
case "stop_stream": onStopStream?() case "stop_stream": onStopStream?()
case "set_min_quality":
if let preset = payload["min_quality_preset"] as? String {
onMinQuality?(preset)
}
default: break default: break
} }
case "stream_event": case "stream_event":
@@ -50,10 +50,6 @@ final class SessionRepository {
try await api.resumeSession(id: id).toDomain() try await api.resumeSession(id: id).toDomain()
} }
func setMinQuality(id: String, preset: String) async throws -> StreamSession {
try await api.setMinQuality(id: id, preset: preset).toDomain()
}
func submitNetworkTest( func submitNetworkTest(
sessionId: String, sessionId: String,
downloadMbps: Double, downloadMbps: Double,
@@ -92,8 +88,7 @@ final class SessionRepository {
signalStrength: nil, signalStrength: nil,
currentBitrate: currentBitrate, currentBitrate: currentBitrate,
targetBitrate: targetBitrate, targetBitrate: targetBitrate,
fps: fps, fps: fps
thermalState: health.thermalState.apiValue
) )
) )
} }
@@ -119,35 +119,11 @@ struct StreamSession: Identifiable, Equatable, Sendable {
let targetBitrate: Int let targetBitrate: Int
let targetFps: Int let targetFps: Int
let qualityPreset: String let qualityPreset: String
let minQualityPreset: String
let startedAt: String? let startedAt: String?
let score: ScoreState? let score: ScoreState?
var isLive: Bool { status == "live" || status == "reconnecting" } var isLive: Bool { status == "live" || status == "reconnecting" }
var isPaused: Bool { status == "paused" } var isPaused: Bool { status == "paused" }
var isEnded: Bool { status == "ended" || status == "error" }
func with(minQualityPreset: String) -> StreamSession {
StreamSession(
id: id,
matchId: matchId,
status: status,
platform: platform,
rtmpIngestUrl: rtmpIngestUrl,
hlsPlaybackUrl: hlsPlaybackUrl,
watchPageUrl: watchPageUrl,
shareUrl: shareUrl,
youtubeWatchUrl: youtubeWatchUrl,
youtubeReady: youtubeReady,
privacyStatus: privacyStatus,
targetBitrate: targetBitrate,
targetFps: targetFps,
qualityPreset: qualityPreset,
minQualityPreset: minQualityPreset,
startedAt: startedAt,
score: score
)
}
func watchShareUrl() -> String? { func watchShareUrl() -> String? {
if platform == "youtube", let url = youtubeWatchUrl, !url.isEmpty { return url } if platform == "youtube", let url = youtubeWatchUrl, !url.isEmpty { return url }
@@ -1,193 +0,0 @@
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,7 +7,6 @@ struct BroadcastConfig: Sendable {
var width: Int = 1280 var width: Int = 1280
var height: Int = 720 var height: Int = 720
var videoBitrate: Int = 2_500_000 var videoBitrate: Int = 2_500_000
var minVideoBitrate: Int = 800_000
var audioBitrate: Int = 128_000 var audioBitrate: Int = 128_000
var fps: Int = 30 var fps: Int = 30
var rotation: Int = 0 var rotation: Int = 0
@@ -5,37 +5,18 @@ import RTMPHaishinKit
import UIKit import UIKit
import VideoToolbox import VideoToolbox
private final class AdaptiveBroadcastBitRateStrategy: StreamBitRateStrategy, @unchecked Sendable { private struct BroadcastBitrateMonitor: StreamBitRateStrategy {
let controller: AdaptiveBitrateController let mamimumVideoBitRate: Int = 0
let onUpdate: @Sendable (Int) -> Void
var mamimumVideoBitRate: Int { controller.ceiling }
let mamimumAudioBitRate: Int = 0 let mamimumAudioBitRate: Int = 0
let onUpdate: @Sendable (Int) -> Void
init(controller: AdaptiveBitrateController, onUpdate: @escaping @Sendable (Int) -> Void) {
self.controller = controller
self.onUpdate = onUpdate
}
func adjustBitrate(_ event: NetworkMonitorEvent, stream: some StreamConvertible) async { func adjustBitrate(_ event: NetworkMonitorEvent, stream: some StreamConvertible) async {
let next: Int?
switch event { switch event {
case .status(let report): case .status(let report), .publishInsufficientBWOccured(let report):
let measuredBits = report.currentBytesOutPerSecond * 8
onUpdate(max(0, report.currentBytesOutPerSecond / 1024)) onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
next = controller.onMeasured(measuredBitsPerSecond: measuredBits, congested: false)
case .publishInsufficientBWOccured(let report):
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
next = controller.onCongestion(measuredBitsPerSecond: report.currentBytesOutPerSecond * 8)
case .reset: case .reset:
next = controller.resetToCeiling() break
@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)
} }
} }
@@ -66,7 +47,6 @@ final class LiveBroadcastEngine: ObservableObject {
private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation? private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
private var orientationDebounceTask: Task<Void, Never>? private var orientationDebounceTask: Task<Void, Never>?
private var rtmpSession: (any Session)? private var rtmpSession: (any Session)?
private let adaptiveBitrate = AdaptiveBitrateController()
private static var rtmpFactoryRegistered = false private static var rtmpFactoryRegistered = false
private static func ensureRTMPFactoryRegistered() async { private static func ensureRTMPFactoryRegistered() async {
@@ -121,7 +101,6 @@ final class LiveBroadcastEngine: ObservableObject {
func preparePreview(config: BroadcastConfig) async throws { func preparePreview(config: BroadcastConfig) async throws {
self.config = config self.config = config
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
publishPending = false publishPending = false
try await configurePipeline(config) try await configurePipeline(config)
setPhase(.preview) setPhase(.preview)
@@ -130,7 +109,6 @@ final class LiveBroadcastEngine: ObservableObject {
/// Configura camera/mixer e mette in coda il publish RTMP (eseguito quando l'anteprima è pronta). /// Configura camera/mixer e mette in coda il publish RTMP (eseguito quando l'anteprima è pronta).
func prepareForBroadcast(config: BroadcastConfig) async throws { func prepareForBroadcast(config: BroadcastConfig) async throws {
self.config = config self.config = config
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
publishPending = true publishPending = true
try await configurePipeline(config) try await configurePipeline(config)
setPhase(.connecting) setPhase(.connecting)
@@ -157,7 +135,6 @@ final class LiveBroadcastEngine: ObservableObject {
func resumeBroadcast(config: BroadcastConfig) async throws { func resumeBroadcast(config: BroadcastConfig) async throws {
self.config = config self.config = config
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
reconnectAttempts = 0 reconnectAttempts = 0
reconnectTask?.cancel() reconnectTask?.cancel()
reconnectTask = nil reconnectTask = nil
@@ -202,40 +179,18 @@ final class LiveBroadcastEngine: ObservableObject {
await stopBroadcast() await stopBroadcast()
} }
/// Adattamento termico: aggiorna il soffitto ABR e l'FPS senza interrompere la sessione RTMP. /// Adattamento termico: aggiorna profilo video senza interrompere la sessione RTMP attiva.
func applyThermalProfile(videoBitrate: Int, fps: Int) async { func applyThermalProfile(videoBitrate: Int, fps: Int) async {
guard var current = config else { return } guard var current = config else { return }
adaptiveBitrate.setCeiling(videoBitrate) guard current.videoBitrate != videoBitrate || current.fps != fps else { return }
let applied = adaptiveBitrate.setFloor(current.minVideoBitrate)
guard current.videoBitrate != videoBitrate || current.fps != fps else {
await applyEncoderBitrate(applied)
return
}
current.videoBitrate = videoBitrate current.videoBitrate = videoBitrate
current.fps = fps current.fps = fps
config = current config = current
if pipelineConfigured, let stream = await rtmpSession?.stream { if pipelineConfigured, let stream = await rtmpSession?.stream {
try? await applyCodecSettings(to: stream, config: current, videoBitrate: applied) try? await applyCodecSettings(to: stream, config: current)
} }
} }
func setMinQualityFloor(_ floorBitrate: Int, ceiling: Int? = nil) async {
if var current = config {
if let ceiling { current.videoBitrate = ceiling }
current.minVideoBitrate = floorBitrate
config = current
}
await applyEncoderBitrate(adaptiveBitrate.setFloor(floorBitrate))
}
private func applyEncoderBitrate(_ bitrate: Int) async {
guard pipelineConfigured, let stream = await rtmpSession?.stream else { return }
var videoSettings = await stream.videoSettings
guard videoSettings.bitRate != bitrate else { return }
videoSettings.bitRate = bitrate
try? await stream.setVideoSettings(videoSettings)
}
private func configurePipeline(_ config: BroadcastConfig) async throws { private func configurePipeline(_ config: BroadcastConfig) async throws {
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized, guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized,
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
@@ -361,7 +316,7 @@ final class LiveBroadcastEngine: ObservableObject {
let stream = await session.stream let stream = await session.stream
try await applyCodecSettings(to: stream, config: config) try await applyCodecSettings(to: stream, config: config)
await stream.setBitRateStrategy(AdaptiveBroadcastBitRateStrategy(controller: adaptiveBitrate) { [weak self] kbps in await stream.setBitRateStrategy(BroadcastBitrateMonitor { [weak self] kbps in
Task { @MainActor in Task { @MainActor in
guard let self else { return } guard let self else { return }
self.metrics = BroadcastMetrics( self.metrics = BroadcastMetrics(
@@ -394,10 +349,10 @@ final class LiveBroadcastEngine: ObservableObject {
setPhase(.live) setPhase(.live)
} }
private func applyCodecSettings(to stream: any StreamConvertible, config: BroadcastConfig, videoBitrate: Int? = nil) async throws { private func applyCodecSettings(to stream: any StreamConvertible, config: BroadcastConfig) async throws {
var videoSettings = await stream.videoSettings var videoSettings = await stream.videoSettings
videoSettings.videoSize = .init(width: config.width, height: config.height) videoSettings.videoSize = .init(width: config.width, height: config.height)
videoSettings.bitRate = videoBitrate ?? adaptiveBitrate.currentBitrate videoSettings.bitRate = config.videoBitrate
videoSettings.maxKeyFrameIntervalDuration = 2 videoSettings.maxKeyFrameIntervalDuration = 2
videoSettings.profileLevel = config.height > 720 videoSettings.profileLevel = config.height > 720
? (kVTProfileLevel_H264_Baseline_4_1 as String) ? (kVTProfileLevel_H264_Baseline_4_1 as String)
@@ -15,17 +15,11 @@ enum StreamVideoPreset {
static func broadcastConfig(for session: StreamSession, rtmpUrl: String) -> BroadcastConfig { static func broadcastConfig(for session: StreamSession, rtmpUrl: String) -> BroadcastConfig {
let dims = dimensions(for: session.qualityPreset) let dims = dimensions(for: session.qualityPreset)
let ceiling = StreamQualityLadder.encoderCeiling(
sessionTarget: session.targetBitrate,
minPresetId: session.minQualityPreset
)
let floor = StreamQualityLadder.encoderFloor(minPresetId: session.minQualityPreset, ceiling: ceiling)
return BroadcastConfig( return BroadcastConfig(
rtmpUrl: rtmpUrl, rtmpUrl: rtmpUrl,
width: dims.width, width: dims.width,
height: dims.height, height: dims.height,
videoBitrate: ceiling, videoBitrate: session.targetBitrate,
minVideoBitrate: floor,
audioBitrate: 128_000, audioBitrate: 128_000,
fps: session.targetFps fps: session.targetFps
) )
@@ -40,12 +40,8 @@ struct BroadcastControlsOverlay: View {
let bitrateKbps: Int let bitrateKbps: Int
let networkType: String let networkType: String
let deviceHealth: DeviceHealth let deviceHealth: DeviceHealth
var minQualityPreset: String = StreamQualityLadder.auto
var sessionQualityPreset: String = "720p_30_2.5mbps"
var onSelectMinQuality: (String) -> Void = { _ in }
@State private var showTerminateConfirm = false @State private var showTerminateConfirm = false
@State private var showMinQualityPicker = false
var body: some View { var body: some View {
Color.clear Color.clear
@@ -73,11 +69,7 @@ struct BroadcastControlsOverlay: View {
targetFps: targetFps, targetFps: targetFps,
bitrateKbps: bitrateKbps, bitrateKbps: bitrateKbps,
networkType: networkType, networkType: networkType,
deviceHealth: deviceHealth, deviceHealth: deviceHealth
minQualityLabel: StreamQualityLadder.displayName(
id: minQualityPreset,
autoLabel: L10n.t("broadcast.min.quality.auto")
)
) )
} }
.padding(.trailing, 8) .padding(.trailing, 8)
@@ -102,19 +94,6 @@ struct BroadcastControlsOverlay: View {
.padding(.bottom, 8) .padding(.bottom, 8)
} }
} }
.overlay {
if showMinQualityPicker {
MinQualityPickerOverlay(
selectedId: minQualityPreset,
sessionQualityPreset: sessionQualityPreset,
onSelect: { id in
showMinQualityPicker = false
onSelectMinQuality(id)
},
onDismiss: { showMinQualityPicker = false }
)
}
}
.alert(L10n.t("broadcast.terminate.title"), isPresented: $showTerminateConfirm) { .alert(L10n.t("broadcast.terminate.title"), isPresented: $showTerminateConfirm) {
Button(L10n.t("action.cancel"), role: .cancel) {} Button(L10n.t("action.cancel"), role: .cancel) {}
Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: onTerminate) Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: onTerminate)
@@ -136,11 +115,6 @@ struct BroadcastControlsOverlay: View {
accessibilityLabel: L10n.t("broadcast.share.regia.cd"), accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
action: onShareRegia action: onShareRegia
) )
SideIconButton(
systemName: "slider.horizontal.3",
accessibilityLabel: L10n.t("broadcast.min.quality.cd"),
action: { showMinQualityPicker = true }
)
if let onCloseSet { if let onCloseSet {
SideIconButton( SideIconButton(
systemName: "checkmark", systemName: "checkmark",
@@ -214,85 +188,6 @@ struct BroadcastControlsOverlay: View {
} }
} }
private struct MinQualityPickerOverlay: View {
let selectedId: String
let sessionQualityPreset: String
let onSelect: (String) -> Void
let onDismiss: () -> Void
private var options: [String] {
StreamQualityLadder.availableIds(sessionQualityPreset: sessionQualityPreset)
}
var body: some View {
ZStack {
Color.black.opacity(0.55)
.ignoresSafeArea()
.onTapGesture(perform: onDismiss)
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(L10n.t("broadcast.min.quality.title"))
.font(.headline)
.foregroundStyle(.white)
Spacer()
Button(L10n.t("action.cancel"), action: onDismiss)
.foregroundStyle(MatchColors.textSecondary)
}
Text(L10n.t("broadcast.min.quality.hint"))
.font(.footnote)
.foregroundStyle(MatchColors.textSecondary)
HStack(spacing: 8) {
ForEach(options, id: \.self) { id in
let selected = id == selectedId
Button {
onSelect(id)
} label: {
VStack(spacing: 4) {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(selected ? MatchColors.accentYellow : .clear)
Text(
StreamQualityLadder.displayName(
id: id,
autoLabel: L10n.t("broadcast.min.quality.auto")
)
)
.font(.system(size: 13, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? MatchColors.accentYellow : .white)
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 8)
.padding(.vertical, 12)
.background(
selected
? MatchColors.accentYellow.opacity(0.16)
: MatchColors.surfaceElevated,
in: RoundedRectangle(cornerRadius: 12)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(selected ? MatchColors.accentYellow : MatchColors.outline, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
}
.padding(16)
.frame(maxWidth: 720)
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(MatchColors.outline, lineWidth: 1)
)
.padding(.horizontal, 24)
}
}
}
private struct BroadcastTelemetryPanel: View { private struct BroadcastTelemetryPanel: View {
let cableConnected: Bool let cableConnected: Bool
let fps: Int let fps: Int
@@ -300,7 +195,6 @@ private struct BroadcastTelemetryPanel: View {
let bitrateKbps: Int let bitrateKbps: Int
let networkType: String let networkType: String
let deviceHealth: DeviceHealth let deviceHealth: DeviceHealth
let minQualityLabel: String
var body: some View { var body: some View {
VStack(alignment: .trailing, spacing: 2) { VStack(alignment: .trailing, spacing: 2) {
@@ -317,9 +211,6 @@ private struct BroadcastTelemetryPanel: View {
.font(.system(size: 11, weight: .medium)) .font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
} }
Text(minQualityLabel)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.accentYellow)
Text("\(networkType) · \(deviceHealth.batteryPercent)%") Text("\(networkType) · \(deviceHealth.batteryPercent)%")
.font(.system(size: 11, weight: .medium)) .font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)

Some files were not shown because too many files have changed in this diff Show More