Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f92ab3a813 |
@@ -55,6 +55,8 @@ class SessionChannel < ApplicationCable::Channel
|
|||||||
Sessions::Pause.new(session).call
|
Sessions::Pause.new(session).call
|
||||||
when "resume_stream"
|
when "resume_stream"
|
||||||
Sessions::Resume.new(session).call
|
Sessions::Resume.new(session).call
|
||||||
|
when "set_min_quality"
|
||||||
|
Sessions::SetMinQuality.new(session, preset: data["min_quality_preset"]).call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ module Api
|
|||||||
current_bitrate: params[:current_bitrate],
|
current_bitrate: params[:current_bitrate],
|
||||||
target_bitrate: params[:target_bitrate],
|
target_bitrate: params[:target_bitrate],
|
||||||
fps: params[:fps],
|
fps: params[:fps],
|
||||||
|
thermal_state: sanitized_thermal_state,
|
||||||
last_seen_at: Time.current
|
last_seen_at: Time.current
|
||||||
)
|
)
|
||||||
sync_publisher_when_streaming!(params[:fps].to_f)
|
sync_publisher_when_streaming!(params[:fps].to_f)
|
||||||
@@ -141,6 +142,13 @@ module Api
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def min_quality
|
||||||
|
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
|
||||||
|
render json: session_json(@session)
|
||||||
|
rescue ArgumentError => e
|
||||||
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
def youtube_stats
|
def youtube_stats
|
||||||
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
|
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
|
||||||
Youtube::BroadcastService.new(@session.match.team)
|
Youtube::BroadcastService.new(@session.match.team)
|
||||||
@@ -160,6 +168,11 @@ module Api
|
|||||||
.find(params[:id])
|
.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sanitized_thermal_state
|
||||||
|
value = params[:thermal_state].to_s.downcase.presence
|
||||||
|
value if value.in?(%w[nominal fair serious critical])
|
||||||
|
end
|
||||||
|
|
||||||
def session_params
|
def session_params
|
||||||
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
|
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
|
||||||
end
|
end
|
||||||
@@ -187,6 +200,7 @@ module Api
|
|||||||
youtube_ready: session.youtube_ready?,
|
youtube_ready: session.youtube_ready?,
|
||||||
privacy_status: session.privacy_status,
|
privacy_status: session.privacy_status,
|
||||||
quality_preset: session.quality_preset,
|
quality_preset: session.quality_preset,
|
||||||
|
min_quality_preset: session.min_quality_preset,
|
||||||
target_bitrate: session.target_bitrate,
|
target_bitrate: session.target_bitrate,
|
||||||
started_at: session.started_at,
|
started_at: session.started_at,
|
||||||
disconnection_count: session.disconnection_count
|
disconnection_count: session.disconnection_count
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ module Public
|
|||||||
layout "regia"
|
layout "regia"
|
||||||
|
|
||||||
protect_from_forgery with: :null_session
|
protect_from_forgery with: :null_session
|
||||||
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop]
|
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop min_quality]
|
||||||
|
|
||||||
before_action :set_session_from_token
|
before_action :set_session_from_token
|
||||||
|
|
||||||
@@ -61,8 +61,20 @@ module Public
|
|||||||
render json: status_payload
|
render json: status_payload
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def min_quality
|
||||||
|
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
|
||||||
|
render json: status_payload
|
||||||
|
rescue ArgumentError => e
|
||||||
|
render json: status_payload.merge(error: e.message), status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def camera_telemetry_payload(publisher_online)
|
||||||
|
cam = @session.device_states.find_by(device_role: "camera")
|
||||||
|
(cam&.as_regia_payload || {}).merge(publisher_online: publisher_online)
|
||||||
|
end
|
||||||
|
|
||||||
def set_session_from_token
|
def set_session_from_token
|
||||||
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
|
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
|
||||||
return if @session
|
return if @session
|
||||||
@@ -86,7 +98,10 @@ module Public
|
|||||||
home_name: @session.match.team.name,
|
home_name: @session.match.team.name,
|
||||||
away_name: @session.match.opponent_name,
|
away_name: @session.match.opponent_name,
|
||||||
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
|
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
|
||||||
score: score&.as_cable_payload
|
score: score&.as_cable_payload,
|
||||||
|
min_quality_preset: @session.min_quality_preset,
|
||||||
|
min_quality_options: Sessions::SelectQuality.allowed_min_ids(@session.quality_preset),
|
||||||
|
telemetry: camera_telemetry_payload(publisher_online)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -57,10 +57,6 @@ 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
|
||||||
|
|||||||
@@ -54,15 +54,34 @@ module Public
|
|||||||
copyPrompt: t("regia.js.copy_prompt"),
|
copyPrompt: t("regia.js.copy_prompt"),
|
||||||
shareRegiaText: t("regia.js.share_regia_text"),
|
shareRegiaText: t("regia.js.share_regia_text"),
|
||||||
shareLiveText: t("regia.js.share_live_text"),
|
shareLiveText: t("regia.js.share_live_text"),
|
||||||
|
minQualityUpdated: t("regia.js.min_quality_updated"),
|
||||||
|
minQualityError: t("regia.js.min_quality_error"),
|
||||||
|
minQualityAuto: t("regia.min_quality.auto"),
|
||||||
previewWaiting: t("regia.preview_waiting"),
|
previewWaiting: t("regia.preview_waiting"),
|
||||||
streamEnded: t("regia.preview_ended"),
|
streamEnded: t("regia.preview_ended"),
|
||||||
|
previewHide: t("regia.preview_hide"),
|
||||||
|
previewShow: t("regia.preview_show"),
|
||||||
|
previewOffHint: t("regia.preview_off_hint"),
|
||||||
resumeLabel: t("regia.resume"),
|
resumeLabel: t("regia.resume"),
|
||||||
pauseLabel: t("regia.pause"),
|
pauseLabel: t("regia.pause"),
|
||||||
endedBadge: t("regia.status.ended"),
|
endedBadge: t("regia.status.ended"),
|
||||||
pausedBadge: t("regia.status.paused"),
|
pausedBadge: t("regia.status.paused"),
|
||||||
liveBadge: t("regia.status.live"),
|
liveBadge: t("regia.status.live"),
|
||||||
waitingBadge: t("regia.status.waiting"),
|
waitingBadge: t("regia.status.waiting"),
|
||||||
subtitlePrefix: t("regia.subtitle_prefix")
|
subtitlePrefix: t("regia.subtitle_prefix"),
|
||||||
|
telemetryWaiting: t("regia.telemetry.waiting"),
|
||||||
|
telemetryStale: t("regia.telemetry.stale"),
|
||||||
|
telemetryJustNow: t("regia.telemetry.just_now"),
|
||||||
|
telemetrySecondsAgo: t("regia.telemetry.seconds_ago"),
|
||||||
|
telemetryPublisherOn: t("regia.telemetry.publisher_on"),
|
||||||
|
telemetryPublisherOff: t("regia.telemetry.publisher_off"),
|
||||||
|
telemetryThermalNominal: t("regia.telemetry.thermal_nominal"),
|
||||||
|
telemetryThermalFair: t("regia.telemetry.thermal_fair"),
|
||||||
|
telemetryThermalSerious: t("regia.telemetry.thermal_serious"),
|
||||||
|
telemetryThermalCritical: t("regia.telemetry.thermal_critical"),
|
||||||
|
telemetryBitrateMax: t("regia.telemetry.bitrate_max"),
|
||||||
|
telemetryBitrateTitle: t("regia.telemetry.bitrate_title"),
|
||||||
|
telemetryBitrateTap: t("regia.telemetry.bitrate_tap")
|
||||||
}.to_json
|
}.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -16,4 +16,17 @@ class DeviceState < ApplicationRecord
|
|||||||
status: stream_session.status
|
status: stream_session.status
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def as_regia_payload
|
||||||
|
{
|
||||||
|
battery: battery_level,
|
||||||
|
network: network_type,
|
||||||
|
signal: signal_strength,
|
||||||
|
bitrate: current_bitrate,
|
||||||
|
target_bitrate: target_bitrate,
|
||||||
|
fps: fps,
|
||||||
|
thermal_state: thermal_state,
|
||||||
|
last_seen_at: last_seen_at&.iso8601
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class StreamSession < ApplicationRecord
|
|||||||
validates :platform, inclusion: { in: PLATFORMS }
|
validates :platform, inclusion: { in: PLATFORMS }
|
||||||
validates :status, inclusion: { in: STATUSES }
|
validates :status, inclusion: { in: STATUSES }
|
||||||
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
|
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
|
||||||
|
validates :min_quality_preset, inclusion: { in: Sessions::SelectQuality::MIN_QUALITY_IDS }
|
||||||
|
|
||||||
before_validation :normalize_privacy_status
|
before_validation :normalize_privacy_status
|
||||||
before_validation :ensure_publish_token, on: :create
|
before_validation :ensure_publish_token, on: :create
|
||||||
|
|||||||
@@ -5,13 +5,9 @@ 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],
|
||||||
2490 => %w[premium_full monthly],
|
20_000 => %w[premium_full yearly]
|
||||||
20_000 => %w[premium_full yearly],
|
|
||||||
19_900 => %w[premium_full yearly]
|
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
|
|||||||
@@ -23,11 +23,10 @@ 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: name, price: price)
|
I18n.t("billing.messages.activate_button", plan: plan.name, price: price)
|
||||||
else
|
else
|
||||||
I18n.t("billing.messages.switch_button", plan: name, price: price)
|
I18n.t("billing.messages.switch_button", plan: plan.name, price: price)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,9 @@ module Billing
|
|||||||
INTERVALS = %w[monthly yearly].freeze
|
INTERVALS = %w[monthly yearly].freeze
|
||||||
DEFAULT_INTERVAL = "yearly"
|
DEFAULT_INTERVAL = "yearly"
|
||||||
|
|
||||||
# Importi in centesimi (EUR). Il yearly è il prezzo lancio addebitato.
|
PRICING = {
|
||||||
AMOUNTS = {
|
"premium_light" => { "yearly" => "€40/anno", "monthly" => "€5/mese" },
|
||||||
"premium_light" => {
|
"premium_full" => { "yearly" => "€200/anno", "monthly" => "€20/mese" }
|
||||||
"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
|
||||||
@@ -37,23 +30,7 @@ module Billing
|
|||||||
end
|
end
|
||||||
|
|
||||||
def label(plan_slug:, interval:)
|
def label(plan_slug:, interval:)
|
||||||
interval = normalize_interval(interval)
|
PRICING.dig(plan_slug.to_s, normalize_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:)
|
||||||
@@ -75,33 +52,8 @@ 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]
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ module Sessions
|
|||||||
@session.pause! if @session.may_pause?
|
@session.pause! if @session.may_pause?
|
||||||
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
||||||
begin
|
begin
|
||||||
Mediamtx::Client.new.set_path_recording(@session, enabled: false)
|
mtx = Mediamtx::Client.new
|
||||||
|
mtx.set_path_recording(@session, enabled: false)
|
||||||
|
mtx.set_always_available(@session, enabled: true)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}")
|
Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}")
|
||||||
end
|
end
|
||||||
Mediamtx::Client.new.set_always_available(@session, enabled: true)
|
|
||||||
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
|
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
|
||||||
log_event("paused")
|
log_event("paused")
|
||||||
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ module Sessions
|
|||||||
class SelectQuality
|
class SelectQuality
|
||||||
UPLOAD_HEADROOM = 0.8
|
UPLOAD_HEADROOM = 0.8
|
||||||
UPLOAD_HEADROOM_1080P = 1.0
|
UPLOAD_HEADROOM_1080P = 1.0
|
||||||
|
AUTO_ID = "auto"
|
||||||
|
AUTO_FLOOR_BITRATE = 800_000
|
||||||
|
|
||||||
PRESETS = [
|
PRESETS = [
|
||||||
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
|
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
|
||||||
@@ -11,6 +13,32 @@ module Sessions
|
|||||||
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
|
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
|
MIN_QUALITY_IDS = ([AUTO_ID] + PRESETS.map { |preset| preset[:id] }).freeze
|
||||||
|
|
||||||
|
def self.floor_bitrate(preset_id)
|
||||||
|
return AUTO_FLOOR_BITRATE if preset_id.blank? || preset_id == AUTO_ID
|
||||||
|
|
||||||
|
PRESETS.find { |preset| preset[:id] == preset_id }&.fetch(:target_bitrate) || AUTO_FLOOR_BITRATE
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.allowed_min_ids(session_preset)
|
||||||
|
ids = [AUTO_ID] + PRESETS.reject { |preset| preset[:id].start_with?("1080p") }.reverse.map { |preset| preset[:id] }
|
||||||
|
ids << "1080p_30_4.5mbps" if session_preset.to_s.start_with?("1080p")
|
||||||
|
ids
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.display_name(preset_id)
|
||||||
|
return AUTO_ID if preset_id.blank? || preset_id == AUTO_ID
|
||||||
|
|
||||||
|
preset = PRESETS.find { |item| item[:id] == preset_id }
|
||||||
|
return preset_id unless preset
|
||||||
|
|
||||||
|
resolution = preset[:id].start_with?("1080p") ? "1080p" : "720p"
|
||||||
|
mbps = preset[:target_bitrate] / 1_000_000.0
|
||||||
|
mbps_text = (mbps == mbps.to_i) ? mbps.to_i.to_s : format("%.1f", mbps)
|
||||||
|
"#{resolution} · #{mbps_text} Mbps"
|
||||||
|
end
|
||||||
|
|
||||||
def self.call(upload_mbps:)
|
def self.call(upload_mbps:)
|
||||||
new(upload_mbps: upload_mbps).call
|
new(upload_mbps: upload_mbps).call
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Sessions
|
||||||
|
class SetMinQuality
|
||||||
|
def initialize(session, preset:)
|
||||||
|
@session = session
|
||||||
|
@preset = preset.to_s.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
id = normalize
|
||||||
|
unless SelectQuality.allowed_min_ids(@session.quality_preset).include?(id)
|
||||||
|
raise ArgumentError, "Qualità minima non valida"
|
||||||
|
end
|
||||||
|
|
||||||
|
return @session if @session.min_quality_preset == id
|
||||||
|
|
||||||
|
@session.update!(min_quality_preset: id)
|
||||||
|
@session.stream_events.create!(
|
||||||
|
event_type: "quality_changed",
|
||||||
|
occurred_at: Time.current,
|
||||||
|
metadata: { "min_quality_preset" => id }
|
||||||
|
)
|
||||||
|
SessionChannel.broadcast_message(
|
||||||
|
@session,
|
||||||
|
{ type: "command", action: "set_min_quality", min_quality_preset: id }
|
||||||
|
)
|
||||||
|
@session
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalize
|
||||||
|
return SelectQuality::AUTO_ID if @preset.blank? || @preset == SelectQuality::AUTO_ID
|
||||||
|
|
||||||
|
@preset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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=63">
|
<link rel="stylesheet" href="/marketing.css?v=50">
|
||||||
</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=63">
|
<link rel="stylesheet" href="/marketing.css?v=50">
|
||||||
<link rel="stylesheet" href="/live.css?v=26">
|
<link rel="stylesheet" href="/live.css?v=26">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<meta name="theme-color" content="#0a0a0e">
|
<meta name="theme-color" content="#0a0a0e">
|
||||||
<title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title>
|
<title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title>
|
||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<link rel="stylesheet" href="/regia.css?v=1">
|
<link rel="stylesheet" href="/regia.css?v=11">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
<body class="regia-body">
|
<body class="regia-body">
|
||||||
|
|||||||
@@ -2,283 +2,63 @@
|
|||||||
<% 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) %>
|
||||||
|
|
||||||
<article class="features-page">
|
<div class="wrap">
|
||||||
<header class="features-hero">
|
<h1 style="margin-top:24px"><%= t("pages.features.title") %></h1>
|
||||||
<div class="wrap features-hero__grid">
|
<p style="color:#aaa;max-width:640px"><%= t("pages.features.lead") %></p>
|
||||||
<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>
|
|
||||||
|
|
||||||
<ol class="features-flow" aria-label="<%= t("pages.features.flow_aria") %>">
|
<section class="section">
|
||||||
<li class="features-flow__item">
|
<h2><%= t("pages.features.staff_section_title") %></h2>
|
||||||
<span class="features-flow__icon" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
|
<div class="features-grid">
|
||||||
<span class="features-flow__label"><%= t("pages.features.flow_phone") %></span>
|
|
||||||
</li>
|
|
||||||
<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 class="features-hero__visual">
|
|
||||||
<%= image_tag "/hero-devices.png",
|
|
||||||
alt: t("pages.features.hero_alt"),
|
|
||||||
class: "features-hero__img",
|
|
||||||
loading: "eager",
|
|
||||||
fetchpriority: "high" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section wrap features-replay" aria-labelledby="features-replay-title">
|
|
||||||
<div class="features-split features-split--reverse">
|
|
||||||
<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">
|
||||||
<span class="feature-card__icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
|
<h3><%= t("pages.features.staff_invite_title") %></h3>
|
||||||
<h3><%= t("pages.features.more_stable_title") %></h3>
|
<p><%= t("pages.features.staff_invite_body") %></p>
|
||||||
<p><%= t("pages.features.more_stable_body") %></p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<span class="feature-card__icon feature-card__icon--blue" aria-hidden="true"><i class="fa-solid fa-share-nodes"></i></span>
|
<h3><%= t("pages.features.staff_access_title") %></h3>
|
||||||
<h3><%= t("pages.features.more_link_title") %></h3>
|
<p><%= t("pages.features.staff_access_body") %></p>
|
||||||
<p><%= t("pages.features.more_link_body") %></p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<span class="feature-card__icon" aria-hidden="true"><i class="fa-brands fa-youtube"></i></span>
|
<h3><%= t("pages.features.staff_plans_title") %></h3>
|
||||||
<h3><%= t("pages.features.more_yt_mltv_title") %></h3>
|
<p><%= t("pages.features.staff_plans_body") %></p>
|
||||||
<p><%= t("pages.features.more_yt_mltv_body") %></p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section wrap features-cta" aria-labelledby="features-cta-title">
|
<section class="section">
|
||||||
<div class="features-cta__box">
|
<h2><%= t("pages.features.live_section_title") %></h2>
|
||||||
<h2 id="features-cta-title"><%= t("pages.features.cta_title") %></h2>
|
<div class="features-grid">
|
||||||
<p><%= t("pages.features.cta_body") %></p>
|
<div class="feature-card">
|
||||||
<div class="features-cta__actions">
|
<h3><%= t("pages.features.live_phone_title") %></h3>
|
||||||
<%= link_to t("pages.features.cta_primary"), public_signup_path, class: "btn btn-primary" %>
|
<p><%= t("pages.features.live_phone_body") %></p>
|
||||||
<%= link_to t("pages.features.cta_secondary"), public_prezzi_path, class: "btn btn-outline" %>
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<h3><%= t("pages.features.live_score_title") %></h3>
|
||||||
|
<p><%= t("pages.features.live_score_body") %></p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<h3><%= t("pages.features.live_link_title") %></h3>
|
||||||
|
<p><%= t("pages.features.live_link_body") %></p>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
</article>
|
|
||||||
|
<p style="text-align:center;margin:40px 0">
|
||||||
|
<%= link_to t("pages.features.cta_signup"), public_signup_path, class: "btn btn-primary" %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -10,8 +10,7 @@
|
|||||||
"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 %>
|
||||||
@@ -27,10 +26,6 @@
|
|||||||
<%= 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,60 +22,19 @@
|
|||||||
|
|
||||||
<%= render "shared/plan_cards" %>
|
<%= render "shared/plan_cards" %>
|
||||||
|
|
||||||
<div class="table-scroll compare-table-wrap">
|
<div class="table-scroll">
|
||||||
<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>
|
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
|
||||||
<th></th>
|
|
||||||
<th>Free</th>
|
|
||||||
<th>Premium Light</th>
|
|
||||||
<th>Premium Full</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr><td><%= t("pages.pricing.table_staff") %></td><td>1</td><td>5</td><td><%= t("pages.pricing.table_unlimited") %></td></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>
|
<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>
|
||||||
<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" style="margin-top:12px"><%= t("regia.board.close_set") %></button>
|
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set"><%= t("regia.board.close_set") %></button>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
||||||
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
|
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
|
||||||
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
|
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
|
||||||
|
data-min-quality-url="<%= j public_regia_min_quality_path(params[:token]) %>"
|
||||||
|
data-min-quality="<%= j @session.min_quality_preset %>"
|
||||||
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
||||||
data-hls-url="<%= j @session.hls_playback_url %>"
|
data-hls-url="<%= j @session.hls_playback_url %>"
|
||||||
data-home-name="<%= j @team.name %>"
|
data-home-name="<%= j @team.name %>"
|
||||||
@@ -29,40 +31,62 @@
|
|||||||
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>
|
||||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-live" style="margin:0">
|
<button type="button" class="regia-icon-btn" id="btn-copy-live" aria-label="<%= t("regia.copy_live") %>" title="<%= t("regia.copy_live") %>">
|
||||||
<%= t("regia.copy_link") %>
|
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.regia_share_label") %>">
|
||||||
<div class="regia-share-group">
|
<span class="regia-tool-group__label"><%= t("regia.regia_share_label") %></span>
|
||||||
<p class="regia-share-label"><%= t("regia.regia_share_label") %></p>
|
<div class="regia-tool-group__btns">
|
||||||
<div class="regia-share">
|
<button type="button" class="regia-icon-btn" id="btn-share-regia" aria-label="<%= t("regia.share_regia") %>" title="<%= t("regia.share_regia") %>">
|
||||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-regia" style="margin:0">
|
<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>
|
||||||
<%= t("regia.share_regia") %>
|
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-regia" style="margin:0">
|
<button type="button" class="regia-icon-btn" id="btn-copy-regia" aria-label="<%= t("regia.copy_regia") %>" title="<%= t("regia.copy_regia") %>">
|
||||||
<%= t("regia.copy_link") %>
|
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<% unless @stream_closed %>
|
||||||
|
<div class="regia-tool-group regia-tool-group--stream" role="group" aria-label="<%= t("regia.stream_controls_label") %>">
|
||||||
|
<span class="regia-tool-group__label" aria-hidden="true"> </span>
|
||||||
|
<div class="regia-tool-group__btns">
|
||||||
|
<button type="button" class="regia-icon-btn" id="btn-toggle-preview" aria-label="<%= t("regia.preview_hide") %>" title="<%= t("regia.preview_hide") %>" aria-pressed="true">
|
||||||
|
<svg class="icon-fill icon-eye" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
|
||||||
|
<svg class="icon-fill icon-eye-off" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78 3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="regia-icon-btn<%= " is-paused" if @session.paused? %>" id="btn-pause" aria-label="<%= @session.paused? ? t("regia.resume") : t("regia.pause") %>" title="<%= @session.paused? ? t("regia.resume") : t("regia.pause") %>">
|
||||||
|
<svg class="icon-fill icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||||
|
<svg class="icon-fill icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="regia-icon-btn regia-icon-btn--danger" id="btn-stop" aria-label="<%= t("regia.stop") %>" title="<%= t("regia.stop") %>">
|
||||||
|
<svg class="icon-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6h12v12H6z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="regia-preview">
|
<section class="regia-preview-wrap" id="regia-preview-wrap">
|
||||||
|
<div class="regia-preview" id="regia-preview-box">
|
||||||
<% unless @stream_closed %>
|
<% unless @stream_closed %>
|
||||||
<video id="regia-preview" playsinline muted autoplay></video>
|
<video id="regia-preview" playsinline muted autoplay></video>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -70,14 +94,55 @@
|
|||||||
<%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %>
|
<%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="regia-scoreboard">
|
<section class="regia-scoreboard">
|
||||||
<%= render regia_board_partial(@match) %>
|
<%= render regia_board_partial(@match) %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<% unless @stream_closed %>
|
<% unless @stream_closed %>
|
||||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause"><%= @session.paused? ? t("regia.resume") : t("regia.pause") %></button>
|
<section class="regia-telemetry" id="regia-telemetry" aria-label="<%= t("regia.telemetry.label") %>">
|
||||||
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop"><%= t("regia.stop") %></button>
|
<div class="regia-telemetry__row">
|
||||||
|
<span class="regia-chip" id="tel-battery" title="<%= t("regia.telemetry.battery") %>">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 4h-1V3h-6v1H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H8V6h8v14z"/></svg>
|
||||||
|
<span class="regia-chip__label"><%= t("regia.telemetry.battery") %></span>
|
||||||
|
<span class="regia-chip__value">—</span>
|
||||||
|
</span>
|
||||||
|
<span class="regia-chip" id="tel-network" title="<%= t("regia.telemetry.network") %>">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/></svg>
|
||||||
|
<span class="regia-chip__label"><%= t("regia.telemetry.network") %></span>
|
||||||
|
<span class="regia-chip__value">—</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" class="regia-chip regia-chip--action" id="tel-bitrate" aria-expanded="false" aria-controls="regia-quality" title="<%= t("regia.telemetry.bitrate_tap") %>">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 13h2v-2H3v2zm4 4h2v-6H7v6zm4 4h2V7h-2v14zm4-8h2V9h-2v4zm4-4h2V5h-2v4z"/></svg>
|
||||||
|
<span class="regia-chip__label"><%= t("regia.telemetry.bitrate") %></span>
|
||||||
|
<span class="regia-chip__value">—</span>
|
||||||
|
<span class="regia-chip__hint" hidden></span>
|
||||||
|
</button>
|
||||||
|
<span class="regia-chip" id="tel-fps" title="<%= t("regia.telemetry.fps") %>">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
|
||||||
|
<span class="regia-chip__label"><%= t("regia.telemetry.fps") %></span>
|
||||||
|
<span class="regia-chip__value">—</span>
|
||||||
|
</span>
|
||||||
|
<span class="regia-chip" id="tel-thermal" title="<%= t("regia.telemetry.thermal") %>">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 13V5c0-1.66-1.34-3-3-3S9 3.34 9 5v8c-1.21.91-2 2.37-2 4 0 2.76 2.24 5 5 5s5-2.24 5-5c0-1.63-.79-3.09-2-4zm-3 8c-1.66 0-3-1.34-3-3 0-1.19.7-2.23 1.71-2.72L11 14.73V5c0-.55.45-1 1-1s1 .45 1 1v9.73l.29.18C14.3 15.77 15 16.81 15 18c0 1.66-1.34 3-3 3z"/></svg>
|
||||||
|
<span class="regia-chip__label"><%= t("regia.telemetry.thermal") %></span>
|
||||||
|
<span class="regia-chip__value">—</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="regia-quality" id="regia-quality" hidden>
|
||||||
|
<p class="regia-quality__hint"><%= t("regia.min_quality.recommend") %></p>
|
||||||
|
<label for="min-quality"><%= t("regia.min_quality.label") %></label>
|
||||||
|
<select id="min-quality" title="<%= t("regia.min_quality.hint") %>">
|
||||||
|
<% Sessions::SelectQuality.allowed_min_ids(@session.quality_preset).each do |id| %>
|
||||||
|
<option value="<%= id %>" <%= "selected" if @session.min_quality_preset == id %>>
|
||||||
|
<%= id == Sessions::SelectQuality::AUTO_ID ? t("regia.min_quality.auto") : Sessions::SelectQuality.display_name(id) %>
|
||||||
|
</option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="regia-telemetry__meta" id="tel-meta"><%= t("regia.telemetry.waiting") %></p>
|
||||||
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -94,4 +159,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/regia.js?v=8"></script>
|
<script src="/regia.js?v=18"></script>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
<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 %> ·
|
||||||
|
|||||||
@@ -15,33 +15,23 @@
|
|||||||
current_interval: current_interval,
|
current_interval: current_interval,
|
||||||
subscription: subscription
|
subscription: subscription
|
||||||
) : nil %>
|
) : nil %>
|
||||||
<% featured = plan.slug == "premium_full" %>
|
<div class="plan-card">
|
||||||
<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 %>
|
||||||
<% yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
|
<% full_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
|
||||||
<% monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
|
<% full_monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
|
||||||
<% list = Billing::Stripe::PriceCatalog.list_label(plan_slug: plan.slug, interval: "yearly") %>
|
<div class="plan-price"><%= full_yearly %></div>
|
||||||
<% equiv = Billing::Stripe::PriceCatalog.equivalent_monthly_label(plan_slug: plan.slug) %>
|
<p class="plan-price-alt"><%= raw t("pages.plans.price_alt_html", price: full_monthly) %></p>
|
||||||
<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(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.staff_html", count: plan.max_staff_transmission || t("pages.plans.staff_unlimited")) %></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",
|
||||||
@@ -58,9 +48,6 @@
|
|||||||
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 %>
|
||||||
@@ -91,12 +78,8 @@
|
|||||||
<%= 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">
|
||||||
<% %w[yearly monthly].each do |interval| %>
|
<% plan_intervals_for_display(Billing::Stripe::PriceCatalog.available_intervals(plan_slug: plan.slug)).each do |interval| %>
|
||||||
<%= link_to t(
|
<%= link_to t("pages.plans.register_with_price", price: Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: interval)),
|
||||||
"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 %>
|
||||||
@@ -105,3 +88,4 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
<% 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>
|
|
||||||
@@ -62,8 +62,8 @@ Rails.application.configure do
|
|||||||
# Annotate rendered view with file names.
|
# Annotate rendered view with file names.
|
||||||
config.action_view.annotate_rendered_view_with_filenames = true
|
config.action_view.annotate_rendered_view_with_filenames = true
|
||||||
|
|
||||||
# Uncomment if you wish to allow Action Cable access from any origin.
|
# Permette Action Cable dall'emulatore Android (10.0.2.2) e da altri host locali.
|
||||||
# config.action_cable.disable_request_forgery_protection = true
|
config.action_cable.disable_request_forgery_protection = true
|
||||||
|
|
||||||
# Raise error when a before_action's only/except options reference missing actions.
|
# Raise error when a before_action's only/except options reference missing actions.
|
||||||
config.action_controller.raise_on_missing_callback_actions = true
|
config.action_controller.raise_on_missing_callback_actions = true
|
||||||
|
|||||||
@@ -180,13 +180,5 @@ 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
|
||||||
|
|||||||
@@ -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 Streaming-Konto pro Team, 1 Livestream"
|
plan_free: "Free — 1 Übertragungsverantwortlicher 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 — 59 €/Jahr (Light) oder 199 €/Jahr (Full)"
|
interval_yearly: "Jährlich — 40 €/Jahr (Light) oder 200 €/Jahr (Full)"
|
||||||
interval_monthly: "Monatlich — 7,90 €/Monat (Light) oder 24,90 €/Monat (Full)"
|
interval_monthly: "Monatlich — 5 €/Monat (Light) oder 20 €/Monat (Full)"
|
||||||
submit: Verein erstellen
|
submit: Verein erstellen
|
||||||
billing:
|
billing:
|
||||||
title: "Abonnement — %{name}"
|
title: "Abonnement — %{name}"
|
||||||
@@ -423,9 +423,6 @@ 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}"
|
||||||
@@ -435,16 +432,51 @@ de:
|
|||||||
paused: Pausiert
|
paused: Pausiert
|
||||||
live: Live
|
live: Live
|
||||||
waiting: Wartet
|
waiting: Wartet
|
||||||
live_share_label: "Live-Link (Zuschauer)"
|
live_share_label: Live
|
||||||
share_live: Livestream teilen
|
share_live: Livestream teilen
|
||||||
|
share_action: Teilen
|
||||||
copy_link: Link kopieren
|
copy_link: Link kopieren
|
||||||
regia_share_label: "Regie-Link (Punktestand)"
|
copy_action: Kopieren
|
||||||
|
copy_live: Live kopieren
|
||||||
|
copy_regia: Regie kopieren
|
||||||
|
links_nav: Schnellaktionen
|
||||||
|
regia_share_label: Regie
|
||||||
|
stream_controls_label: Live
|
||||||
share_regia: Regie-Link teilen
|
share_regia: Regie-Link teilen
|
||||||
preview_ended: Livestream beendet
|
preview_ended: Livestream beendet
|
||||||
preview_waiting: Vorschau wartet auf Signal…
|
preview_waiting: Vorschau wartet auf Signal…
|
||||||
|
preview_hide: Vorschau ausblenden
|
||||||
|
preview_show: Vorschau anzeigen
|
||||||
|
preview_off_hint: Vorschau aus · kein Video-Download
|
||||||
pause: Pausieren
|
pause: Pausieren
|
||||||
resume: Livestream fortsetzen
|
resume: Livestream fortsetzen
|
||||||
stop: Livestream beenden
|
stop: Livestream beenden
|
||||||
|
min_quality:
|
||||||
|
label: Minimale Übertragungsqualität
|
||||||
|
summary: Qualität
|
||||||
|
hint: Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.
|
||||||
|
recommend: "Automatisch wird empfohlen: passt sich an Netz und Telefon an, ohne das Maximum zu erzwingen."
|
||||||
|
auto: Automatisch
|
||||||
|
telemetry:
|
||||||
|
label: Telefon
|
||||||
|
waiting: Warte auf das Telefon…
|
||||||
|
stale: Telefon antwortet nicht
|
||||||
|
just_now: jetzt
|
||||||
|
seconds_ago: "vor %{n}s"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP fehlt
|
||||||
|
battery: Akku
|
||||||
|
network: Netz
|
||||||
|
bitrate: Bitrate
|
||||||
|
bitrate_max: "max %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps aktuell · max. %{target} Mbps"
|
||||||
|
bitrate_tap: Tippen, um die Mindestqualität festzulegen
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Warm
|
||||||
|
thermal_serious: Heiß
|
||||||
|
thermal_critical: Kritisch
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Satz gewonnen
|
set_won_title: Satz gewonnen
|
||||||
confirm: Satz beenden
|
confirm: Satz beenden
|
||||||
@@ -489,6 +521,8 @@ de:
|
|||||||
copy_prompt: "Link kopieren:"
|
copy_prompt: "Link kopieren:"
|
||||||
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
|
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
|
||||||
share_live_text: "Sieh dir die Übertragung auf Match Live TV an:"
|
share_live_text: "Sieh dir die Übertragung auf Match Live TV an:"
|
||||||
|
min_quality_updated: Mindestqualität aktualisiert
|
||||||
|
min_quality_error: Fehler beim Aktualisieren der Mindestqualität
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -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 transmitter account per team, 1 live stream"
|
plan_free: "Free — 1 broadcast manager 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 — €59/year (Light) or €199/year (Full)"
|
interval_yearly: "Yearly — €40/year (Light) or €200/year (Full)"
|
||||||
interval_monthly: "Monthly — €7.90/month (Light) or €24.90/month (Full)"
|
interval_monthly: "Monthly — €5/month (Light) or €20/month (Full)"
|
||||||
submit: Create club
|
submit: Create club
|
||||||
billing:
|
billing:
|
||||||
title: "Subscription — %{name}"
|
title: "Subscription — %{name}"
|
||||||
@@ -418,9 +418,6 @@ 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}"
|
||||||
@@ -430,16 +427,51 @@ en:
|
|||||||
paused: Paused
|
paused: Paused
|
||||||
live: Live
|
live: Live
|
||||||
waiting: Waiting
|
waiting: Waiting
|
||||||
live_share_label: "Live link (viewers)"
|
live_share_label: Live
|
||||||
share_live: Share live link
|
share_live: Share live link
|
||||||
|
share_action: Share
|
||||||
copy_link: Copy link
|
copy_link: Copy link
|
||||||
regia_share_label: "Control room link (score)"
|
copy_action: Copy
|
||||||
|
copy_live: Copy live
|
||||||
|
copy_regia: Copy room
|
||||||
|
links_nav: Quick actions
|
||||||
|
regia_share_label: Control
|
||||||
|
stream_controls_label: Live
|
||||||
share_regia: Share control room link
|
share_regia: Share control room link
|
||||||
preview_ended: Stream ended
|
preview_ended: Stream ended
|
||||||
preview_waiting: Waiting for signal…
|
preview_waiting: Waiting for signal…
|
||||||
|
preview_hide: Hide preview
|
||||||
|
preview_show: Show preview
|
||||||
|
preview_off_hint: Preview off · no video download
|
||||||
pause: Pause stream
|
pause: Pause stream
|
||||||
resume: Resume stream
|
resume: Resume stream
|
||||||
stop: End stream
|
stop: End stream
|
||||||
|
min_quality:
|
||||||
|
label: Minimum streaming quality
|
||||||
|
summary: Quality
|
||||||
|
hint: The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.
|
||||||
|
recommend: "Automatic is recommended: it adapts to the network and the phone, without forcing maximum quality."
|
||||||
|
auto: Automatic
|
||||||
|
telemetry:
|
||||||
|
label: Phone
|
||||||
|
waiting: Waiting for the phone…
|
||||||
|
stale: Phone not responding
|
||||||
|
just_now: now
|
||||||
|
seconds_ago: "%{n}s ago"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP down
|
||||||
|
battery: Battery
|
||||||
|
network: Network
|
||||||
|
bitrate: Bitrate
|
||||||
|
bitrate_max: "max %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps current · max %{target} Mbps"
|
||||||
|
bitrate_tap: Tap to set the minimum quality
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Warm
|
||||||
|
thermal_serious: Hot
|
||||||
|
thermal_critical: Critical
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set won
|
set_won_title: Set won
|
||||||
confirm: Close set
|
confirm: Close set
|
||||||
@@ -484,6 +516,8 @@ en:
|
|||||||
copy_prompt: "Copy the link:"
|
copy_prompt: "Copy the link:"
|
||||||
share_regia_text: "Open this link to manage the live score:"
|
share_regia_text: "Open this link to manage the live score:"
|
||||||
share_live_text: "Watch the live stream on Match Live TV:"
|
share_live_text: "Watch the live stream on Match Live TV:"
|
||||||
|
min_quality_updated: Minimum quality updated
|
||||||
|
min_quality_error: Error updating minimum quality
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -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 cuenta de emisión por equipo, 1 directo"
|
plan_free: "Free — 1 responsable de transmisió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 — 59 €/año (Light) o 199 €/año (Full)"
|
interval_yearly: "Anual — 40 €/año (Light) o 200 €/año (Full)"
|
||||||
interval_monthly: "Mensual — 7,90 €/mes (Light) o 24,90 €/mes (Full)"
|
interval_monthly: "Mensual — 5 €/mes (Light) o 20 €/mes (Full)"
|
||||||
submit: Crear club
|
submit: Crear club
|
||||||
billing:
|
billing:
|
||||||
title: "Suscripción — %{name}"
|
title: "Suscripción — %{name}"
|
||||||
@@ -423,9 +423,6 @@ 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}"
|
||||||
@@ -435,16 +432,51 @@ es:
|
|||||||
paused: En pausa
|
paused: En pausa
|
||||||
live: En directo
|
live: En directo
|
||||||
waiting: En espera
|
waiting: En espera
|
||||||
live_share_label: "Enlace del directo (espectadores)"
|
live_share_label: Directo
|
||||||
share_live: Compartir directo
|
share_live: Compartir directo
|
||||||
|
share_action: Compartir
|
||||||
copy_link: Copiar enlace
|
copy_link: Copiar enlace
|
||||||
regia_share_label: "Enlace de control (marcador)"
|
copy_action: Copiar
|
||||||
|
copy_live: Copiar directo
|
||||||
|
copy_regia: Copiar control
|
||||||
|
links_nav: Acciones rápidas
|
||||||
|
regia_share_label: Control
|
||||||
|
stream_controls_label: Live
|
||||||
share_regia: Compartir enlace de control
|
share_regia: Compartir enlace de control
|
||||||
preview_ended: Directo finalizado
|
preview_ended: Directo finalizado
|
||||||
preview_waiting: Vista previa a la espera de señal…
|
preview_waiting: Vista previa a la espera de señal…
|
||||||
|
preview_hide: Ocultar vista previa
|
||||||
|
preview_show: Mostrar vista previa
|
||||||
|
preview_off_hint: Vista previa apagada · sin descarga de vídeo
|
||||||
pause: Pausar directo
|
pause: Pausar directo
|
||||||
resume: Reanudar directo
|
resume: Reanudar directo
|
||||||
stop: Finalizar directo
|
stop: Finalizar directo
|
||||||
|
min_quality:
|
||||||
|
label: Calidad mínima de transmisión
|
||||||
|
summary: Calidad
|
||||||
|
hint: El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.
|
||||||
|
recommend: "Se recomienda Automática: se adapta a la red y al teléfono, sin forzar el máximo."
|
||||||
|
auto: Automática
|
||||||
|
telemetry:
|
||||||
|
label: Teléfono
|
||||||
|
waiting: Esperando al teléfono…
|
||||||
|
stale: El teléfono no responde
|
||||||
|
just_now: ahora
|
||||||
|
seconds_ago: "hace %{n}s"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP ausente
|
||||||
|
battery: Batería
|
||||||
|
network: Red
|
||||||
|
bitrate: Bitrate
|
||||||
|
bitrate_max: "máx %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps actuales · máximo %{target} Mbps"
|
||||||
|
bitrate_tap: Toca para definir la calidad mínima
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Caliente
|
||||||
|
thermal_serious: Muy caliente
|
||||||
|
thermal_critical: Crítica
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set ganado
|
set_won_title: Set ganado
|
||||||
confirm: Cerrar set
|
confirm: Cerrar set
|
||||||
@@ -489,6 +521,8 @@ es:
|
|||||||
copy_prompt: "Copia el enlace:"
|
copy_prompt: "Copia el enlace:"
|
||||||
share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
|
share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
|
||||||
share_live_text: "Mira la retransmisión en Match Live TV:"
|
share_live_text: "Mira la retransmisión en Match Live TV:"
|
||||||
|
min_quality_updated: Calidad mínima actualizada
|
||||||
|
min_quality_error: Error al actualizar la calidad mínima
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -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 compte diffuseur par équipe, 1 direct"
|
plan_free: "Gratuit — 1 responsable diffusion 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 — 59 €/an (Light) ou 199 €/an (Full)"
|
interval_yearly: "Annuel — 40 €/an (Light) ou 200 €/an (Full)"
|
||||||
interval_monthly: "Mensuel — 7,90 €/mois (Light) ou 24,90 €/mois (Full)"
|
interval_monthly: "Mensuel — 5 €/mois (Light) ou 20 €/mois (Full)"
|
||||||
submit: Créer le club
|
submit: Créer le club
|
||||||
billing:
|
billing:
|
||||||
title: "Abonnement — %{name}"
|
title: "Abonnement — %{name}"
|
||||||
@@ -423,9 +423,6 @@ 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}"
|
||||||
@@ -435,16 +432,51 @@ fr:
|
|||||||
paused: En pause
|
paused: En pause
|
||||||
live: En direct
|
live: En direct
|
||||||
waiting: En attente
|
waiting: En attente
|
||||||
live_share_label: "Lien direct (spectateurs)"
|
live_share_label: Direct
|
||||||
share_live: Partager le direct
|
share_live: Partager le direct
|
||||||
|
share_action: Partager
|
||||||
copy_link: Copier le lien
|
copy_link: Copier le lien
|
||||||
regia_share_label: "Lien régie (score)"
|
copy_action: Copier
|
||||||
|
copy_live: Copier live
|
||||||
|
copy_regia: Copier régie
|
||||||
|
links_nav: Actions rapides
|
||||||
|
regia_share_label: Régie
|
||||||
|
stream_controls_label: Live
|
||||||
share_regia: Partager le lien régie
|
share_regia: Partager le lien régie
|
||||||
preview_ended: Direct terminé
|
preview_ended: Direct terminé
|
||||||
preview_waiting: Aperçu en attente du signal…
|
preview_waiting: Aperçu en attente du signal…
|
||||||
|
preview_hide: Masquer l'aperçu
|
||||||
|
preview_show: Afficher l'aperçu
|
||||||
|
preview_off_hint: Aperçu désactivé · aucun téléchargement vidéo
|
||||||
pause: Mettre en pause
|
pause: Mettre en pause
|
||||||
resume: Reprendre le direct
|
resume: Reprendre le direct
|
||||||
stop: Terminer le direct
|
stop: Terminer le direct
|
||||||
|
min_quality:
|
||||||
|
label: Qualité minimale de transmission
|
||||||
|
summary: Qualité
|
||||||
|
hint: L'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.
|
||||||
|
recommend: "Automatique est conseillé : s'adapte au réseau et au téléphone, sans forcer le maximum."
|
||||||
|
auto: Automatique
|
||||||
|
telemetry:
|
||||||
|
label: Téléphone
|
||||||
|
waiting: En attente du téléphone…
|
||||||
|
stale: Téléphone sans réponse
|
||||||
|
just_now: maintenant
|
||||||
|
seconds_ago: "il y a %{n}s"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP absent
|
||||||
|
battery: Batterie
|
||||||
|
network: Réseau
|
||||||
|
bitrate: Débit
|
||||||
|
bitrate_max: "max %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps actuels · max %{target} Mbps"
|
||||||
|
bitrate_tap: Touchez pour définir la qualité minimale
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Chaude
|
||||||
|
thermal_serious: Très chaude
|
||||||
|
thermal_critical: Critique
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set gagné
|
set_won_title: Set gagné
|
||||||
confirm: Terminer le set
|
confirm: Terminer le set
|
||||||
@@ -489,6 +521,8 @@ fr:
|
|||||||
copy_prompt: "Copiez le lien :"
|
copy_prompt: "Copiez le lien :"
|
||||||
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
|
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
|
||||||
share_live_text: "Regardez le direct sur Match Live TV :"
|
share_live_text: "Regardez le direct sur Match Live TV :"
|
||||||
|
min_quality_updated: Qualité minimale mise à jour
|
||||||
|
min_quality_error: Erreur de mise à jour de la qualité minimale
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -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 account trasmettitore per squadra, 1 live"
|
plan_free: "Free — 1 responsabile trasmissione 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 — €59/anno (Light) o €199/anno (Full)"
|
interval_yearly: "Annuale — €40/anno (Light) o €200/anno (Full)"
|
||||||
interval_monthly: "Mensile — €7,90/mese (Light) o €24,90/mese (Full)"
|
interval_monthly: "Mensile — €5/mese (Light) o €20/mese (Full)"
|
||||||
submit: Crea società
|
submit: Crea società
|
||||||
billing:
|
billing:
|
||||||
title: "Abbonamento — %{name}"
|
title: "Abbonamento — %{name}"
|
||||||
@@ -418,9 +418,6 @@ 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}"
|
||||||
@@ -430,16 +427,51 @@ it:
|
|||||||
paused: In pausa
|
paused: In pausa
|
||||||
live: In onda
|
live: In onda
|
||||||
waiting: In attesa
|
waiting: In attesa
|
||||||
live_share_label: "Link diretta (spettatori)"
|
live_share_label: Diretta
|
||||||
share_live: Condividi diretta
|
share_live: Condividi diretta
|
||||||
|
share_action: Condividi
|
||||||
copy_link: Copia link
|
copy_link: Copia link
|
||||||
regia_share_label: "Link regia (punteggio)"
|
copy_action: Copia
|
||||||
|
copy_live: Copia live
|
||||||
|
copy_regia: Copia regia
|
||||||
|
links_nav: Azioni rapide
|
||||||
|
regia_share_label: Regia
|
||||||
|
stream_controls_label: Live
|
||||||
share_regia: Condividi link regia
|
share_regia: Condividi link regia
|
||||||
preview_ended: Diretta terminata
|
preview_ended: Diretta terminata
|
||||||
preview_waiting: Anteprima in attesa del segnale…
|
preview_waiting: Anteprima in attesa del segnale…
|
||||||
|
preview_hide: Nascondi anteprima
|
||||||
|
preview_show: Mostra anteprima
|
||||||
|
preview_off_hint: Anteprima spenta · nessun download video
|
||||||
pause: Metti in pausa
|
pause: Metti in pausa
|
||||||
resume: Riprendi diretta
|
resume: Riprendi diretta
|
||||||
stop: Chiudi diretta
|
stop: Chiudi diretta
|
||||||
|
min_quality:
|
||||||
|
label: Qualità minima di trasmissione
|
||||||
|
summary: Qualità
|
||||||
|
hint: L'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.
|
||||||
|
recommend: "Consigliata Automatica: si adatta alla rete e al telefono, senza forzare il massimo."
|
||||||
|
auto: Automatica
|
||||||
|
telemetry:
|
||||||
|
label: Telefono
|
||||||
|
waiting: In attesa del telefono…
|
||||||
|
stale: Telefono non risponde
|
||||||
|
just_now: ora
|
||||||
|
seconds_ago: "%{n}s fa"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP assente
|
||||||
|
battery: Batteria
|
||||||
|
network: Rete
|
||||||
|
bitrate: Bitrate
|
||||||
|
bitrate_max: "max %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps attuali · massimo %{target} Mbps"
|
||||||
|
bitrate_tap: Tocca per impostare la qualità minima
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Calda
|
||||||
|
thermal_serious: Molto calda
|
||||||
|
thermal_critical: Critica
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set vinto
|
set_won_title: Set vinto
|
||||||
confirm: Chiudi set
|
confirm: Chiudi set
|
||||||
@@ -484,6 +516,8 @@ it:
|
|||||||
copy_prompt: "Copia il link:"
|
copy_prompt: "Copia il link:"
|
||||||
share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
|
share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
|
||||||
share_live_text: "Guarda la diretta su Match Live TV:"
|
share_live_text: "Guarda la diretta su Match Live TV:"
|
||||||
|
min_quality_updated: Qualità minima aggiornata
|
||||||
|
min_quality_error: Errore aggiornamento qualità minima
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -2,80 +2,32 @@ 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: "Ü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."
|
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."
|
||||||
hero_title: "Die Livestreams Ihres Vereins. Ohne geteilte Passwörter, ohne komplizierte Technik."
|
title: Funktionen
|
||||||
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."
|
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_alt: Smartphone auf Stativ für den Livestream und zweites Handy für den Spielstand in der Halle
|
staff_section_title: Für Verein und Team
|
||||||
flow_aria: Ablauf der Übertragung
|
staff_invite_title: Einladungen mit eigener E-Mail
|
||||||
flow_phone: Ein Smartphone
|
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_account: Ein persönliches Konto
|
staff_access_title: Zugriffsverwaltung
|
||||||
flow_live: Sie sind live
|
staff_access_body: Sie können einem Helfer den Zugriff entziehen oder eine noch offene Einladung vom Team-Dashboard aus stornieren.
|
||||||
how_title: Von der Halle zu den Zuschauern in wenigen Minuten
|
staff_plans_title: Maßgeschneiderte Pläne
|
||||||
how_1_title: Spiel anlegen
|
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_body: Der Verein bereitet das Spiel mit Team, Gegner, Datum und Uhrzeit vor.
|
live_section_title: Auf dem Platz und live
|
||||||
how_2_title: App öffnen
|
live_phone_title: Vom Handy filmen
|
||||||
how_2_body: Die beauftragte Person meldet sich in der Match-Live-TV-App mit dem eigenen persönlichen Konto an.
|
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_3_title: Livestream starten
|
live_score_title: Spielstand vom zweiten Handy
|
||||||
how_3_body: Ein Smartphone reicht. Keine spezielle Ausrüstung und keine komplizierte Einrichtung.
|
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_4_title: Spielstand führen
|
live_link_title: Link für Familien und Fans
|
||||||
how_4_body: Eine andere Person kann Ergebnis und Spielstand über einen einfachen Link aktualisieren – ohne App und ohne Konto.
|
live_link_body: "Teilen Sie einen Link: Eltern, Großeltern und Freunde schauen im Browser von Handy oder Computer, ohne App-Installation."
|
||||||
how_5_title: Replay
|
live_archive_title: Spielarchiv
|
||||||
how_5_body: Nach dem Livestream kann das Spiel gespeichert und als Replay verfügbar gemacht werden – je nach Plan.
|
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."
|
||||||
accounts_title: Jeder mit dem eigenen Konto. Das Vereins-Passwort bleibt beim Verein.
|
live_youtube_mltv_title: Auch auf YouTube (Match Live TV)
|
||||||
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_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_invite_title: Einladen
|
live_youtube_club_title: YouTube-Kanal des Vereins
|
||||||
accounts_invite_body: Der Verein lädt Trainer, Verantwortliche oder Eltern ein, die die Übertragung übernehmen.
|
live_youtube_club_body: "Mit Premium Full können Sie denselben Livestream auch auf den offiziellen YouTube-Kanal des Vereins senden."
|
||||||
accounts_transmit_title: Übertragen
|
live_website_title: Auf der Website des Vereins
|
||||||
accounts_transmit_body: Jeder meldet sich in der App mit den eigenen Zugangsdaten an.
|
live_website_body: "Integration zum Einbetten des Players auf der Vereinswebsite — demnächst mit Premium Full."
|
||||||
accounts_revoke_title: Widerrufen
|
cta_signup: Team registrieren
|
||||||
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."
|
||||||
@@ -83,8 +35,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-Konten starten den Livestream aus der App; der Spielstand wird über einen teilbaren Link verwaltet."
|
team_lead: "Jahresabo für Vereine: die Streaming-Verantwortlichen starten den Livestream aus der App; der Spielstand wird über einen teilbaren Link verwaltet."
|
||||||
table_staff: Streaming-Konten
|
table_staff: Streaming-Verantwortliche / Jahr
|
||||||
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
|
||||||
@@ -92,7 +44,8 @@ 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_note: "Listenpreis %{list} — %{monthly}"
|
table_price_light_html: "€40/Jahr oder €5/Monat"
|
||||||
|
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
|
||||||
@@ -102,16 +55,8 @@ 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>"
|
||||||
price_equiv: "entspricht %{price}"
|
staff_html: "Streaming-Verantwortliche: <strong>%{count}</strong> / Jahr pro Team"
|
||||||
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}"
|
||||||
|
|||||||
@@ -2,80 +2,32 @@ 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: "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."
|
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."
|
||||||
hero_title: "Your club's live streams. No shared passwords, no complicated gear."
|
title: Features
|
||||||
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."
|
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_alt: Smartphone on a tripod for the live stream and a second phone for the gym scoreboard
|
staff_section_title: For the club and staff
|
||||||
flow_aria: Streaming flow
|
staff_invite_title: Invites with their own email
|
||||||
flow_phone: One smartphone
|
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_account: One personal account
|
staff_access_title: Access management
|
||||||
flow_live: You're live
|
staff_access_body: You can remove a volunteer's access or cancel a pending invite from the team dashboard.
|
||||||
how_title: From the gym to viewers in a few minutes
|
staff_plans_title: Plans to fit your needs
|
||||||
how_1_title: Create the match
|
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_body: The club sets up the match with team, opponent, date and time.
|
live_section_title: On the court and live
|
||||||
how_2_title: Open the app
|
live_phone_title: Film from your phone
|
||||||
how_2_body: The assigned person signs into the Match Live TV app with their own personal account.
|
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_3_title: Start the stream
|
live_score_title: Score from a second phone
|
||||||
how_3_body: A smartphone is enough. No dedicated equipment or complicated setup.
|
live_score_body: "Another volunteer updates sets and points from a second phone: just scan the code shown on the streamer's screen."
|
||||||
how_4_title: Manage the score
|
live_link_title: Link for families and fans
|
||||||
how_4_body: Someone else can update the result and score through a simple link, without installing an app or creating an account.
|
live_link_body: "Share a link: parents, grandparents and friends watch from their phone or computer browser, no app to install."
|
||||||
how_5_title: Replay
|
live_archive_title: Match archive
|
||||||
how_5_body: After the stream ends, the match can be kept and made available as a replay, depending on the plan.
|
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."
|
||||||
accounts_title: Everyone with their own account. The club password stays with the club.
|
live_youtube_mltv_title: Also on YouTube (Match Live TV)
|
||||||
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_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_invite_title: Invite
|
live_youtube_club_title: Your club's YouTube channel
|
||||||
accounts_invite_body: The club invites coaches, managers or parents assigned to stream.
|
live_youtube_club_body: With Premium Full you can send the same stream to your club's official YouTube channel.
|
||||||
accounts_transmit_title: Stream
|
live_website_title: On the club's website
|
||||||
accounts_transmit_body: Each person signs into the app with their own credentials.
|
live_website_body: Integration to embed the player on the club's website — coming with Premium Full.
|
||||||
accounts_revoke_title: Revoke
|
cta_signup: Register your team
|
||||||
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."
|
||||||
@@ -83,8 +35,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: transmitter accounts start the broadcast from the app; the score is managed with a shareable link."
|
team_lead: "Annual subscription for clubs: streaming staff start the broadcast from the app; the score is managed with a shareable link."
|
||||||
table_staff: Transmitter accounts
|
table_staff: Streaming staff / year
|
||||||
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
|
||||||
@@ -92,7 +44,8 @@ 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_note: "list %{list} — %{monthly}"
|
table_price_light_html: "€40/year or €5/month"
|
||||||
|
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
|
||||||
@@ -102,16 +55,8 @@ 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>"
|
||||||
price_equiv: "equivalent to %{price}"
|
staff_html: "Streaming staff: <strong>%{count}</strong> / year per team"
|
||||||
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}"
|
||||||
|
|||||||
@@ -2,80 +2,32 @@ 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: "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."
|
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."
|
||||||
hero_title: "Los directos de tu club. Sin contraseñas compartidas, sin equipo complicado."
|
title: Funciones
|
||||||
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."
|
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_alt: Smartphone en trípode para el directo y segundo móvil para el marcador en el pabellón
|
staff_section_title: Para el club y el staff
|
||||||
flow_aria: Flujo de la emisión
|
staff_invite_title: Invitaciones con su propio email
|
||||||
flow_phone: Un smartphone
|
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_account: Una cuenta personal
|
staff_access_title: Gestión de accesos
|
||||||
flow_live: Estás en directo
|
staff_access_body: Puedes retirar el acceso a un voluntario o cancelar una invitación pendiente desde el panel del equipo.
|
||||||
how_title: Del pabellón a los espectadores en pocos minutos
|
staff_plans_title: Planes a medida
|
||||||
how_1_title: Crea el partido
|
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_body: El club prepara el partido indicando equipo, rival, fecha y hora.
|
live_section_title: En la cancha y en directo
|
||||||
how_2_title: Abre la app
|
live_phone_title: Graba desde el móvil
|
||||||
how_2_body: La persona encargada accede a la app Match Live TV con su propia cuenta personal.
|
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_3_title: Empieza el directo
|
live_score_title: Marcador desde un segundo móvil
|
||||||
how_3_body: Basta el smartphone. Sin equipo dedicado ni configuración complicada.
|
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_4_title: Gestiona el marcador
|
live_link_title: Enlace para familias y aficionados
|
||||||
how_4_body: Otra persona puede actualizar resultado y marcador con un enlace sencillo, sin instalar apps ni crear una cuenta.
|
live_link_body: "Comparte un enlace: padres, abuelos y amigos miran desde el navegador del móvil o del ordenador, sin instalar apps."
|
||||||
how_5_title: Repetición
|
live_archive_title: Archivo de partidos
|
||||||
how_5_body: Al terminar el directo, el partido puede conservarse y quedar disponible como repetición, según el plan elegido.
|
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."
|
||||||
accounts_title: Cada uno con su propia cuenta. La contraseña del club se queda en el club.
|
live_youtube_mltv_title: También en YouTube (Match Live TV)
|
||||||
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_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_invite_title: Invita
|
live_youtube_club_title: Canal de YouTube del club
|
||||||
accounts_invite_body: El club invita a entrenadores, directivos o padres encargados de la emisión.
|
live_youtube_club_body: "Con Premium Full puedes enviar el mismo directo también al canal oficial de YouTube del club."
|
||||||
accounts_transmit_title: Emite
|
live_website_title: En el sitio web del club
|
||||||
accounts_transmit_body: Cada uno accede a la app con sus propias credenciales.
|
live_website_body: "Integración para incrustar el reproductor en el sitio web del club — próximamente con Premium Full."
|
||||||
accounts_revoke_title: Revoca
|
cta_signup: Registra tu equipo
|
||||||
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."
|
||||||
@@ -83,8 +35,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: las cuentas 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: los responsables de emisión inician el directo desde la app; el marcador se gestiona con un enlace para compartir."
|
||||||
table_staff: Cuentas de emisión
|
table_staff: Responsables de emisión / año
|
||||||
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
|
||||||
@@ -92,7 +44,8 @@ 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_note: "tarifa %{list} — %{monthly}"
|
table_price_light_html: "€40/año o €5/mes"
|
||||||
|
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
|
||||||
@@ -102,16 +55,8 @@ 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>"
|
||||||
price_equiv: "equivale a %{price}"
|
staff_html: "Responsables de emisión: <strong>%{count}</strong> / año por equipo"
|
||||||
launch_price: Precio de lanzamiento
|
staff_unlimited: ilimitado
|
||||||
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}"
|
||||||
|
|||||||
@@ -2,80 +2,32 @@ 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: "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é."
|
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."
|
||||||
hero_title: "Les directs de votre club. Sans mots de passe partagés, sans matériel compliqué."
|
title: Fonctionnalités
|
||||||
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."
|
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_alt: Smartphone sur trépied pour le direct et second téléphone pour le score en salle
|
staff_section_title: Pour le club et le staff
|
||||||
flow_aria: Déroulement de la diffusion
|
staff_invite_title: Invitations avec leur propre email
|
||||||
flow_phone: Un smartphone
|
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_account: Un compte personnel
|
staff_access_title: Gestion des accès
|
||||||
flow_live: Vous êtes en direct
|
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.
|
||||||
how_title: De la salle aux spectateurs en quelques minutes
|
staff_plans_title: Des offres sur mesure
|
||||||
how_1_title: Créez le match
|
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_body: Le club prépare le match en indiquant l'équipe, l'adversaire, la date et l'heure.
|
live_section_title: Sur le terrain et en direct
|
||||||
how_2_title: Ouvrez l'application
|
live_phone_title: Filmez depuis votre téléphone
|
||||||
how_2_body: La personne désignée se connecte à l'application Match Live TV avec son propre compte personnel.
|
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_3_title: Lancez le direct
|
live_score_title: Score depuis un second téléphone
|
||||||
how_3_body: Un smartphone suffit. Pas de matériel dédié ni de configuration compliquée.
|
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_4_title: Gérez le score
|
live_link_title: Lien pour les familles et supporters
|
||||||
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_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_5_title: Replay
|
live_archive_title: Archive des matchs
|
||||||
how_5_body: À la fin du direct, le match peut être conservé et rendu disponible en replay, selon l'offre choisie.
|
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."
|
||||||
accounts_title: Chacun avec son propre compte. Le mot de passe du club reste au club.
|
live_youtube_mltv_title: Aussi sur YouTube (Match Live TV)
|
||||||
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_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_invite_title: Invitez
|
live_youtube_club_title: Chaîne YouTube du club
|
||||||
accounts_invite_body: Le club invite les entraîneurs, dirigeants ou parents chargés de la diffusion.
|
live_youtube_club_body: "Avec Premium Full, vous pouvez diffuser le même direct sur la chaîne YouTube officielle du club."
|
||||||
accounts_transmit_title: Diffusez
|
live_website_title: Sur le site du club
|
||||||
accounts_transmit_body: Chacun se connecte à l'application avec ses propres identifiants.
|
live_website_body: "Intégration pour intégrer le lecteur sur le site du club — bientôt disponible avec Premium Full."
|
||||||
accounts_revoke_title: Révoquez
|
cta_signup: Inscrire l'équipe
|
||||||
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."
|
||||||
@@ -83,8 +35,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 comptes diffuseurs démarrent le direct depuis l'application ; le score se gère avec un lien à partager."
|
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."
|
||||||
table_staff: Comptes diffuseurs
|
table_staff: Responsables de diffusion / an
|
||||||
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
|
||||||
@@ -92,7 +44,8 @@ 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_note: "tarif %{list} — %{monthly}"
|
table_price_light_html: "€40/an ou €5/mois"
|
||||||
|
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é
|
||||||
@@ -102,16 +55,8 @@ 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>"
|
||||||
price_equiv: "soit %{price}"
|
staff_html: "Responsables de diffusion : <strong>%{count}</strong> / an par équipe"
|
||||||
launch_price: Prix de lancement
|
staff_unlimited: illimité
|
||||||
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}"
|
||||||
|
|||||||
@@ -2,80 +2,32 @@ 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: "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."
|
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."
|
||||||
hero_title: "Le dirette della tua società. Senza password condivise, senza attrezzatura complicata."
|
title: Funzionalità
|
||||||
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."
|
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_alt: Smartphone su treppiede per la diretta e secondo telefono per il punteggio in palestra
|
staff_section_title: Per la società e lo staff
|
||||||
flow_aria: Flusso della trasmissione
|
staff_invite_title: Inviti con la propria email
|
||||||
flow_phone: Uno smartphone
|
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_account: Un account personale
|
staff_access_title: Gestione accessi
|
||||||
flow_live: Sei in diretta
|
staff_access_body: Puoi togliere l'accesso a un volontario o annullare un invito ancora in sospeso dalla dashboard della squadra.
|
||||||
how_title: Dalla palestra agli spettatori in pochi minuti
|
staff_plans_title: Piani su misura
|
||||||
how_1_title: Crea la partita
|
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_body: La società prepara la partita indicando squadra, avversario, data e ora.
|
live_section_title: In campo e in diretta
|
||||||
how_2_title: Apri l'app
|
live_phone_title: Filma dal telefono
|
||||||
how_2_body: L'incaricato accede dall'app Match Live TV con il proprio account personale.
|
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_3_title: Inizia la diretta
|
live_score_title: Punteggio da un secondo telefono
|
||||||
how_3_body: Basta lo smartphone. Nessuna attrezzatura dedicata o configurazione complicata.
|
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_4_title: Gestisci il punteggio
|
live_link_title: Link per famiglie e tifosi
|
||||||
how_4_body: Un'altra persona può aggiornare risultato e punteggio tramite un semplice link, senza installare app e senza creare un account.
|
live_link_body: "Condividi un link: genitori, nonni e amici guardano dal browser del telefono o del computer, senza installare app."
|
||||||
how_5_title: Replay
|
live_archive_title: Archivio partite
|
||||||
how_5_body: Al termine della diretta la partita può essere conservata e resa disponibile come replay, in base al piano scelto.
|
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."
|
||||||
accounts_title: Ognuno con il proprio account. La password della società resta alla società.
|
live_youtube_mltv_title: Anche su YouTube (Match Live TV)
|
||||||
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_mltv_body: Con Premium Light la diretta è visibile anche sul canale YouTube Match Live TV, oltre al link sul sito.
|
||||||
accounts_invite_title: Invita
|
live_youtube_club_title: Canale YouTube della società
|
||||||
accounts_invite_body: La società invita allenatori, dirigenti o genitori incaricati della trasmissione.
|
live_youtube_club_body: Con Premium Full puoi mandare la stessa diretta anche sul canale YouTube ufficiale del club.
|
||||||
accounts_transmit_title: Trasmetti
|
live_website_title: Sul sito della società
|
||||||
accounts_transmit_body: Ognuno accede all'app con le proprie credenziali personali.
|
live_website_body: Integrazione per incorporare il player sul sito del club — in arrivo con Premium Full.
|
||||||
accounts_revoke_title: Revoca
|
cta_signup: Registra la squadra
|
||||||
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."
|
||||||
@@ -83,8 +35,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à: gli account trasmettitori avviano la diretta dall'app; il punteggio si gestisce con un link condivisibile."
|
team_lead: "Abbonamento annuale per società: i responsabili trasmissione avviano la diretta dall'app; il punteggio si gestisce con un link condivisibile."
|
||||||
table_staff: Account trasmettitori
|
table_staff: Responsabili trasmissione / anno
|
||||||
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
|
||||||
@@ -92,7 +44,8 @@ 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_note: "listino %{list} — %{monthly}"
|
table_price_light_html: "€40/anno o €5/mese"
|
||||||
|
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
|
||||||
@@ -102,16 +55,8 @@ it:
|
|||||||
contact_button: Contattaci
|
contact_button: Contattaci
|
||||||
plans:
|
plans:
|
||||||
price_alt_html: "oppure <strong>%{price}</strong>"
|
price_alt_html: "oppure <strong>%{price}</strong>"
|
||||||
price_equiv: "equivale a %{price}"
|
staff_html: "Responsabili trasmissione: <strong>%{count}</strong> / anno per squadra"
|
||||||
launch_price: Prezzo lancio
|
staff_unlimited: illimitato
|
||||||
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}"
|
||||||
|
|||||||
@@ -43,13 +43,6 @@ 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
|
||||||
|
|||||||
@@ -43,13 +43,6 @@ 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
|
||||||
|
|||||||
@@ -43,13 +43,6 @@ 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
|
||||||
|
|||||||
@@ -43,13 +43,6 @@ 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
|
||||||
|
|||||||
@@ -43,13 +43,6 @@ 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
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ Rails.application.routes.draw do
|
|||||||
post :claim_pairing
|
post :claim_pairing
|
||||||
post :regia_link
|
post :regia_link
|
||||||
post :network_test
|
post :network_test
|
||||||
|
patch :min_quality
|
||||||
get :youtube_stats
|
get :youtube_stats
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -143,6 +144,7 @@ Rails.application.routes.draw do
|
|||||||
post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause
|
post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause
|
||||||
post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume
|
post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume
|
||||||
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
|
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
|
||||||
|
patch "regia/:token/min_quality", to: "public/regia#min_quality", as: :public_regia_min_quality
|
||||||
|
|
||||||
root to: "public/pages#home"
|
root to: "public/pages#home"
|
||||||
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
|
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddMinQualityPresetToStreamSessions < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :stream_sessions, :min_quality_preset, :string, default: "auto", null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddThermalStateToDeviceStates < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :device_states, :thermal_state, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
Generated
+29
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
@@ -138,6 +138,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
t.datetime "last_seen_at"
|
t.datetime "last_seen_at"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "thermal_state"
|
||||||
t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true
|
t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true
|
||||||
t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id"
|
t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id"
|
||||||
end
|
end
|
||||||
@@ -255,6 +256,29 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id"
|
t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "stream_nodes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.string "slug", null: false
|
||||||
|
t.string "hostname", null: false
|
||||||
|
t.string "role", default: "home", null: false
|
||||||
|
t.string "status", default: "ready", null: false
|
||||||
|
t.string "rtmp_base_url", null: false
|
||||||
|
t.string "hls_base_url", null: false
|
||||||
|
t.string "api_base_url", null: false
|
||||||
|
t.string "internal_rtmp_url"
|
||||||
|
t.string "internal_hls_url"
|
||||||
|
t.integer "max_publishers", default: 6, null: false
|
||||||
|
t.integer "max_relays", default: 6, null: false
|
||||||
|
t.string "provider", default: "local", null: false
|
||||||
|
t.string "provider_instance_id"
|
||||||
|
t.datetime "last_health_at"
|
||||||
|
t.jsonb "metadata", default: {}, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["role"], name: "index_stream_nodes_on_role"
|
||||||
|
t.index ["slug"], name: "index_stream_nodes_on_slug", unique: true
|
||||||
|
t.index ["status"], name: "index_stream_nodes_on_status"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "match_id", null: false
|
t.uuid "match_id", null: false
|
||||||
t.uuid "user_id", null: false
|
t.uuid "user_id", null: false
|
||||||
@@ -280,10 +304,13 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.string "regia_token_digest"
|
t.string "regia_token_digest"
|
||||||
t.datetime "regia_token_expires_at"
|
t.datetime "regia_token_expires_at"
|
||||||
|
t.uuid "stream_node_id"
|
||||||
|
t.string "min_quality_preset", default: "auto", null: false
|
||||||
t.index ["match_id"], name: "index_stream_sessions_on_match_id"
|
t.index ["match_id"], name: "index_stream_sessions_on_match_id"
|
||||||
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
||||||
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
||||||
t.index ["status"], name: "index_stream_sessions_on_status"
|
t.index ["status"], name: "index_stream_sessions_on_status"
|
||||||
|
t.index ["stream_node_id"], name: "index_stream_sessions_on_stream_node_id"
|
||||||
t.index ["user_id"], name: "index_stream_sessions_on_user_id"
|
t.index ["user_id"], name: "index_stream_sessions_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -411,6 +438,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
add_foreign_key "score_states", "stream_sessions"
|
add_foreign_key "score_states", "stream_sessions"
|
||||||
add_foreign_key "stream_events", "stream_sessions"
|
add_foreign_key "stream_events", "stream_sessions"
|
||||||
add_foreign_key "stream_sessions", "matches"
|
add_foreign_key "stream_sessions", "matches"
|
||||||
|
add_foreign_key "stream_sessions", "stream_nodes"
|
||||||
add_foreign_key "stream_sessions", "users"
|
add_foreign_key "stream_sessions", "users"
|
||||||
add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
|
add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
|
||||||
add_foreign_key "subscriptions", "clubs"
|
add_foreign_key "subscriptions", "clubs"
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 569 B |
@@ -1,6 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 663 B |
+8
-1033
File diff suppressed because it is too large
Load Diff
+277
-41
@@ -4,6 +4,8 @@
|
|||||||
--regia-surface: #14141c;
|
--regia-surface: #14141c;
|
||||||
--regia-red: #e53935;
|
--regia-red: #e53935;
|
||||||
--regia-yellow: #f9a825;
|
--regia-yellow: #f9a825;
|
||||||
|
--regia-green: #43a047;
|
||||||
|
--regia-orange: #fb8c00;
|
||||||
--regia-text: #f5f5f5;
|
--regia-text: #f5f5f5;
|
||||||
--regia-muted: #9ca3af;
|
--regia-muted: #9ca3af;
|
||||||
--regia-safe-bottom: env(safe-area-inset-bottom, 0px);
|
--regia-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
@@ -22,37 +24,166 @@
|
|||||||
.regia-page {
|
.regia-page {
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 12px 16px calc(24px + var(--regia-safe-bottom));
|
padding: 8px 12px calc(12px + var(--regia-safe-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-header__text {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-header h1 {
|
.regia-header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-header p {
|
.regia-header p {
|
||||||
margin: 4px 0 0;
|
margin: 2px 0 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.78rem;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-badge {
|
.regia-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 8px;
|
margin-top: 0;
|
||||||
padding: 4px 10px;
|
padding: 3px 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 0.7rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-badge--live { background: #2e7d32; color: #fff; }
|
.regia-badge--live { background: #2e7d32; color: #fff; }
|
||||||
.regia-badge--wait { background: #444; color: #ddd; }
|
.regia-badge--wait { background: #444; color: #ddd; }
|
||||||
.regia-badge--ended { background: #374151; color: #ddd; }
|
.regia-badge--ended { background: #374151; color: #ddd; }
|
||||||
|
|
||||||
|
.regia-toolbar {
|
||||||
|
margin: 8px 0 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-tool-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-tool-group__label {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-tool-group__btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-tool-group--stream {
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #16161f;
|
||||||
|
color: var(--regia-text);
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn svg.icon-fill {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn svg.icon-outline {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn .icon-eye-off,
|
||||||
|
.regia-icon-btn .icon-play {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-off .icon-eye {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-off .icon-eye-off {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-paused .icon-pause {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-paused .icon-play {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn:active { transform: scale(0.96); }
|
||||||
|
|
||||||
|
.regia-icon-btn.is-off {
|
||||||
|
color: var(--regia-yellow);
|
||||||
|
border-color: #5a4a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn--danger {
|
||||||
|
color: var(--regia-red);
|
||||||
|
border-color: #7a2a28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-preview-wrap {
|
||||||
|
margin: 6px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-preview-wrap.is-off {
|
||||||
|
margin: 0;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.regia-preview {
|
.regia-preview {
|
||||||
margin: 14px 0;
|
margin: 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
@@ -90,29 +221,29 @@
|
|||||||
.regia-scoreboard {
|
.regia-scoreboard {
|
||||||
background: var(--regia-surface);
|
background: var(--regia-surface);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 16px;
|
padding: 10px 12px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-sets {
|
.regia-sets {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.82rem;
|
font-size: 0.78rem;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
margin: 0 0 8px;
|
margin: 0 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-score-line {
|
.regia-score-line {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1.5rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-partials {
|
.regia-partials {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.78rem;
|
font-size: 0.75rem;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
margin: 8px 0 0;
|
margin: 4px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-team-row {
|
.regia-team-row {
|
||||||
@@ -120,7 +251,7 @@
|
|||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 16px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-team-name {
|
.regia-team-name {
|
||||||
@@ -131,7 +262,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.regia-points {
|
.regia-points {
|
||||||
font-size: 2rem;
|
font-size: 1.55rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -146,7 +277,7 @@
|
|||||||
.regia-btn {
|
.regia-btn {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 16px 12px;
|
padding: 10px 8px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -156,8 +287,8 @@
|
|||||||
|
|
||||||
.regia-btn:active { transform: scale(0.98); }
|
.regia-btn:active { transform: scale(0.98); }
|
||||||
.regia-btn--point { background: var(--regia-red); color: #fff; }
|
.regia-btn--point { background: var(--regia-red); color: #fff; }
|
||||||
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 12px; }
|
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 8px; }
|
||||||
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 10px; }
|
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 8px; padding: 12px; }
|
||||||
.regia-btn--outline {
|
.regia-btn--outline {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid #444;
|
border: 1px solid #444;
|
||||||
@@ -173,26 +304,6 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-share-group {
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-share-label {
|
|
||||||
margin: 0 0 6px;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--regia-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-share {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-share .regia-btn { flex: 1; font-size: 0.9rem; padding: 12px; }
|
|
||||||
|
|
||||||
.regia-toast {
|
.regia-toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: calc(16px + var(--regia-safe-bottom));
|
bottom: calc(16px + var(--regia-safe-bottom));
|
||||||
@@ -240,7 +351,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
padding-top: 24px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-clock-line {
|
.regia-clock-line {
|
||||||
@@ -255,3 +366,128 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.regia-quality {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding: 8px 10px 10px;
|
||||||
|
background: var(--regia-surface);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-quality[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
.regia-quality__hint {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-quality label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-quality select {
|
||||||
|
width: 100%;
|
||||||
|
background: #0a0a0e;
|
||||||
|
color: var(--regia-text);
|
||||||
|
border: 1px solid #2a2a35;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-telemetry {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-telemetry__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 2px 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--regia-surface);
|
||||||
|
color: var(--regia-text);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip--action {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip--action:focus-visible {
|
||||||
|
outline: 2px solid var(--regia-yellow);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip--action.is-open {
|
||||||
|
border-color: #3a3a48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip__label {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip__value {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.15;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip__hint {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip__hint[hidden] { display: none; }
|
||||||
|
|
||||||
|
.regia-chip.is-ok { color: var(--regia-green); }
|
||||||
|
.regia-chip.is-warn { color: var(--regia-orange); }
|
||||||
|
.regia-chip.is-bad { color: var(--regia-red); }
|
||||||
|
|
||||||
|
.regia-telemetry__meta {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-telemetry.is-stale .regia-telemetry__meta {
|
||||||
|
color: var(--regia-yellow);
|
||||||
|
}
|
||||||
|
|||||||
+262
-5
@@ -37,13 +37,32 @@
|
|||||||
shareLiveText: "Guarda la diretta su Match Live TV:",
|
shareLiveText: "Guarda la diretta su Match Live TV:",
|
||||||
previewWaiting: "Anteprima in attesa del segnale…",
|
previewWaiting: "Anteprima in attesa del segnale…",
|
||||||
streamEnded: "Diretta terminata",
|
streamEnded: "Diretta terminata",
|
||||||
|
previewHide: "Nascondi anteprima",
|
||||||
|
previewShow: "Mostra anteprima",
|
||||||
|
previewOffHint: "Anteprima spenta · nessun download video",
|
||||||
resumeLabel: "Riprendi diretta",
|
resumeLabel: "Riprendi diretta",
|
||||||
pauseLabel: "Metti in pausa",
|
pauseLabel: "Metti in pausa",
|
||||||
endedBadge: "Terminata",
|
endedBadge: "Terminata",
|
||||||
pausedBadge: "In pausa",
|
pausedBadge: "In pausa",
|
||||||
liveBadge: "In onda",
|
liveBadge: "In onda",
|
||||||
waitingBadge: "In attesa",
|
waitingBadge: "In attesa",
|
||||||
subtitlePrefix: "Regia ·"
|
subtitlePrefix: "Regia ·",
|
||||||
|
minQualityUpdated: "Qualità minima aggiornata",
|
||||||
|
minQualityError: "Errore aggiornamento qualità minima",
|
||||||
|
minQualityAuto: "Automatica",
|
||||||
|
telemetryWaiting: "In attesa del telefono…",
|
||||||
|
telemetryStale: "Telefono non risponde",
|
||||||
|
telemetryJustNow: "ora",
|
||||||
|
telemetrySecondsAgo: "%{n}s fa",
|
||||||
|
telemetryPublisherOn: "RTMP ok",
|
||||||
|
telemetryPublisherOff: "RTMP assente",
|
||||||
|
telemetryThermalNominal: "OK",
|
||||||
|
telemetryThermalFair: "Calda",
|
||||||
|
telemetryThermalSerious: "Calda",
|
||||||
|
telemetryThermalCritical: "Critica",
|
||||||
|
telemetryBitrateMax: "max %{n}",
|
||||||
|
telemetryBitrateTitle: "%{current} Mbps attuali · massimo %{target} Mbps",
|
||||||
|
telemetryBitrateTap: "Tocca per impostare la qualità minima"
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadI18n() {
|
function loadI18n() {
|
||||||
@@ -77,6 +96,7 @@
|
|||||||
pauseUrl: root.dataset.pauseUrl,
|
pauseUrl: root.dataset.pauseUrl,
|
||||||
resumeUrl: root.dataset.resumeUrl,
|
resumeUrl: root.dataset.resumeUrl,
|
||||||
stopUrl: root.dataset.stopUrl,
|
stopUrl: root.dataset.stopUrl,
|
||||||
|
minQualityUrl: root.dataset.minQualityUrl,
|
||||||
cableUrl: root.dataset.cableUrl,
|
cableUrl: root.dataset.cableUrl,
|
||||||
hlsUrl: root.dataset.hlsUrl,
|
hlsUrl: root.dataset.hlsUrl,
|
||||||
homeName: root.dataset.homeName,
|
homeName: root.dataset.homeName,
|
||||||
@@ -102,15 +122,28 @@
|
|||||||
modalCancel: document.getElementById("modal-cancel"),
|
modalCancel: document.getElementById("modal-cancel"),
|
||||||
preview: document.getElementById("regia-preview"),
|
preview: document.getElementById("regia-preview"),
|
||||||
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
||||||
|
previewWrap: document.getElementById("regia-preview-wrap"),
|
||||||
|
btnTogglePreview: document.getElementById("btn-toggle-preview"),
|
||||||
btnPause: document.getElementById("btn-pause"),
|
btnPause: document.getElementById("btn-pause"),
|
||||||
subtitle: document.getElementById("regia-subtitle")
|
minQuality: document.getElementById("min-quality"),
|
||||||
|
subtitle: document.getElementById("regia-subtitle"),
|
||||||
|
telemetry: document.getElementById("regia-telemetry"),
|
||||||
|
telBattery: document.getElementById("tel-battery"),
|
||||||
|
telNetwork: document.getElementById("tel-network"),
|
||||||
|
telBitrate: document.getElementById("tel-bitrate"),
|
||||||
|
telFps: document.getElementById("tel-fps"),
|
||||||
|
telThermal: document.getElementById("tel-thermal"),
|
||||||
|
telMeta: document.getElementById("tel-meta"),
|
||||||
|
qualityPanel: document.getElementById("regia-quality")
|
||||||
};
|
};
|
||||||
|
|
||||||
let pendingAction = null;
|
let pendingAction = null;
|
||||||
let hls = null;
|
let hls = null;
|
||||||
let previewStarted = false;
|
let previewStarted = false;
|
||||||
|
let previewWanted = true;
|
||||||
let wasPausedPreview = false;
|
let wasPausedPreview = false;
|
||||||
let streamPaused = root.dataset.initialPaused === "true";
|
let streamPaused = root.dataset.initialPaused === "true";
|
||||||
|
const PREVIEW_PREF_KEY = "mltv-regia-preview";
|
||||||
|
|
||||||
function hlsUrlFresh() {
|
function hlsUrlFresh() {
|
||||||
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
|
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
|
||||||
@@ -139,8 +172,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readPreviewPref() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(PREVIEW_PREF_KEY) !== "0";
|
||||||
|
} catch (_) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePreviewPref(on) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PREVIEW_PREF_KEY, on ? "1" : "0");
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPreviewToggleUi() {
|
||||||
|
if (!els.previewWrap || !els.btnTogglePreview) return;
|
||||||
|
const label = previewWanted ? I18N.previewHide : I18N.previewShow;
|
||||||
|
els.previewWrap.classList.toggle("is-off", !previewWanted);
|
||||||
|
els.btnTogglePreview.classList.toggle("is-off", !previewWanted);
|
||||||
|
els.btnTogglePreview.setAttribute("aria-pressed", previewWanted ? "true" : "false");
|
||||||
|
els.btnTogglePreview.setAttribute("aria-label", label);
|
||||||
|
els.btnTogglePreview.title = previewWanted ? I18N.previewHide : I18N.previewOffHint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPreviewWanted(on) {
|
||||||
|
previewWanted = !!on;
|
||||||
|
writePreviewPref(previewWanted);
|
||||||
|
syncPreviewToggleUi();
|
||||||
|
if (previewWanted) {
|
||||||
|
startPreview();
|
||||||
|
} else {
|
||||||
|
destroyPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function refreshPreviewForCover() {
|
function refreshPreviewForCover() {
|
||||||
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed) return;
|
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed || !previewWanted) return;
|
||||||
destroyPreview();
|
destroyPreview();
|
||||||
previewStarted = false;
|
previewStarted = false;
|
||||||
startPreview();
|
startPreview();
|
||||||
@@ -243,7 +311,10 @@
|
|||||||
if (!els.btnPause) return;
|
if (!els.btnPause) return;
|
||||||
const paused = !!(data.paused || data.status === "paused");
|
const paused = !!(data.paused || data.status === "paused");
|
||||||
streamPaused = paused;
|
streamPaused = paused;
|
||||||
els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel;
|
const label = paused ? I18N.resumeLabel : I18N.pauseLabel;
|
||||||
|
els.btnPause.classList.toggle("is-paused", paused);
|
||||||
|
els.btnPause.setAttribute("aria-label", label);
|
||||||
|
els.btnPause.title = label;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBadge(data) {
|
function setBadge(data) {
|
||||||
@@ -364,6 +435,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const paused = data.paused || data.status === "paused";
|
const paused = data.paused || data.status === "paused";
|
||||||
|
if (!previewWanted) {
|
||||||
|
destroyPreview();
|
||||||
|
wasPausedPreview = paused;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.on_air) {
|
if (data.on_air) {
|
||||||
if (!previewStarted) {
|
if (!previewStarted) {
|
||||||
startPreview();
|
startPreview();
|
||||||
@@ -382,6 +458,119 @@
|
|||||||
wasPausedPreview = paused;
|
wasPausedPreview = paused;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mbpsNumber(bps) {
|
||||||
|
const n = Number(bps);
|
||||||
|
if (!n || n <= 0) return null;
|
||||||
|
const mbps = n >= 10_000 ? n / 1e6 : n / 1e3;
|
||||||
|
return mbps >= 10 ? mbps.toFixed(0) : mbps.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAge(iso) {
|
||||||
|
if (!iso) return null;
|
||||||
|
const then = Date.parse(iso);
|
||||||
|
if (!then) return null;
|
||||||
|
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
|
||||||
|
if (secs < 8) return I18N.telemetryJustNow;
|
||||||
|
return fillTemplate(I18N.telemetrySecondsAgo, { n: secs });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setChip(el, text, level, hint) {
|
||||||
|
if (!el) return;
|
||||||
|
const valueEl = el.querySelector(".regia-chip__value") || el;
|
||||||
|
valueEl.textContent = text;
|
||||||
|
el.classList.toggle("is-ok", level === "ok");
|
||||||
|
el.classList.toggle("is-warn", level === "warn");
|
||||||
|
el.classList.toggle("is-bad", level === "bad");
|
||||||
|
const hintEl = el.querySelector(".regia-chip__hint");
|
||||||
|
if (hintEl) {
|
||||||
|
hintEl.textContent = hint || "";
|
||||||
|
hintEl.hidden = !hint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatThermal(state) {
|
||||||
|
const key = String(state || "").toLowerCase();
|
||||||
|
if (key === "nominal") return { text: I18N.telemetryThermalNominal, level: "ok" };
|
||||||
|
if (key === "fair") return { text: I18N.telemetryThermalFair, level: "warn" };
|
||||||
|
if (key === "serious") return { text: I18N.telemetryThermalSerious, level: "bad" };
|
||||||
|
if (key === "critical") return { text: I18N.telemetryThermalCritical, level: "bad" };
|
||||||
|
return { text: "—", level: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bitrateLevel(current, target, paused) {
|
||||||
|
if (paused || !current || current <= 0) return null;
|
||||||
|
if (target && target > 0) {
|
||||||
|
const ratio = current / target;
|
||||||
|
if (ratio >= 0.7) return "ok";
|
||||||
|
if (ratio >= 0.4) return "warn";
|
||||||
|
return "bad";
|
||||||
|
}
|
||||||
|
const mbps = current >= 10_000 ? current / 1e6 : current / 1e3;
|
||||||
|
if (mbps >= 2.5) return "ok";
|
||||||
|
if (mbps >= 1.5) return "warn";
|
||||||
|
return "bad";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bitrateTitle(currentMbps, targetMbps) {
|
||||||
|
const tap = I18N.telemetryBitrateTap;
|
||||||
|
if (currentMbps && targetMbps) {
|
||||||
|
return `${fillTemplate(I18N.telemetryBitrateTitle, { current: currentMbps, target: targetMbps })} · ${tap}`;
|
||||||
|
}
|
||||||
|
if (currentMbps) return `${currentMbps} Mbps · ${tap}`;
|
||||||
|
return tap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTelemetry(data) {
|
||||||
|
if (!els.telemetry) return;
|
||||||
|
const tel = data.telemetry || {};
|
||||||
|
const battery = tel.battery;
|
||||||
|
const fps = tel.fps;
|
||||||
|
const bitrate = tel.bitrate;
|
||||||
|
const target = tel.target_bitrate || tel.targetBitrate;
|
||||||
|
const lastSeen = tel.last_seen_at || tel.lastSeenAt;
|
||||||
|
const ageSecs = lastSeen ? Math.max(0, Math.round((Date.now() - Date.parse(lastSeen)) / 1000)) : null;
|
||||||
|
const stale = ageSecs == null || ageSecs > 25;
|
||||||
|
const publishing = !!tel.publisher_online || !!tel.publisherOnline;
|
||||||
|
|
||||||
|
let battLevel = null;
|
||||||
|
let battText = "—";
|
||||||
|
if (battery != null && battery !== "") {
|
||||||
|
battText = `${battery}%`;
|
||||||
|
if (battery <= 15) battLevel = "bad";
|
||||||
|
else if (battery <= 25) battLevel = "warn";
|
||||||
|
}
|
||||||
|
setChip(els.telBattery, battText, battLevel);
|
||||||
|
setChip(els.telNetwork, tel.network || "—");
|
||||||
|
|
||||||
|
const currentMbps = mbpsNumber(bitrate);
|
||||||
|
const targetMbps = mbpsNumber(target);
|
||||||
|
const rateText = currentMbps || "—";
|
||||||
|
const rateHint = currentMbps && targetMbps
|
||||||
|
? fillTemplate(I18N.telemetryBitrateMax, { n: targetMbps })
|
||||||
|
: null;
|
||||||
|
if (els.telBitrate) els.telBitrate.title = bitrateTitle(currentMbps, targetMbps);
|
||||||
|
setChip(els.telBitrate, rateText, bitrateLevel(Number(bitrate), Number(target), data.paused), rateHint);
|
||||||
|
|
||||||
|
let fpsLevel = null;
|
||||||
|
if (fps != null && fps !== "" && Number(fps) > 0 && Number(fps) < 20) fpsLevel = "warn";
|
||||||
|
setChip(els.telFps, fps && Number(fps) > 0 ? String(fps) : "—", fpsLevel);
|
||||||
|
|
||||||
|
const thermal = formatThermal(tel.thermal_state || tel.thermalState);
|
||||||
|
setChip(els.telThermal, thermal.text, thermal.level);
|
||||||
|
|
||||||
|
els.telemetry.classList.toggle("is-stale", stale && !data.paused && !data.stream_closed);
|
||||||
|
if (!els.telMeta) return;
|
||||||
|
if (stale && !lastSeen) {
|
||||||
|
els.telMeta.textContent = I18N.telemetryWaiting;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parts = [publishing ? I18N.telemetryPublisherOn : I18N.telemetryPublisherOff];
|
||||||
|
const age = formatAge(lastSeen);
|
||||||
|
if (age) parts.push(age);
|
||||||
|
if (stale && !data.paused && !data.stream_closed) parts.push(I18N.telemetryStale);
|
||||||
|
els.telMeta.textContent = parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
async function pollStatus() {
|
async function pollStatus() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
|
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
|
||||||
@@ -389,6 +578,8 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
applyScorePayload(data);
|
applyScorePayload(data);
|
||||||
setBadge(data);
|
setBadge(data);
|
||||||
|
syncMinQuality(data);
|
||||||
|
syncTelemetry(data);
|
||||||
syncPreviewFromStatus(data);
|
syncPreviewFromStatus(data);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -401,7 +592,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startPreview() {
|
function startPreview() {
|
||||||
if (cfg.streamClosed || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
if (cfg.streamClosed || !previewWanted || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
||||||
|
|
||||||
previewStarted = true;
|
previewStarted = true;
|
||||||
const url = hlsUrlFresh();
|
const url = hlsUrlFresh();
|
||||||
@@ -454,6 +645,8 @@
|
|||||||
const wasPaused = streamPaused;
|
const wasPaused = streamPaused;
|
||||||
applyScorePayload(data);
|
applyScorePayload(data);
|
||||||
setBadge(data);
|
setBadge(data);
|
||||||
|
syncMinQuality(data);
|
||||||
|
syncTelemetry(data);
|
||||||
syncPreviewFromStatus(data);
|
syncPreviewFromStatus(data);
|
||||||
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
|
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
|
||||||
}
|
}
|
||||||
@@ -473,6 +666,70 @@
|
|||||||
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
|
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
|
||||||
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
|
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
|
||||||
|
|
||||||
|
let minQualityBusy = false;
|
||||||
|
|
||||||
|
function setQualityPanelOpen(open) {
|
||||||
|
if (!els.qualityPanel || !els.telBitrate) return;
|
||||||
|
els.qualityPanel.hidden = !open;
|
||||||
|
els.telBitrate.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
els.telBitrate.classList.toggle("is-open", open);
|
||||||
|
if (open && els.minQuality) {
|
||||||
|
window.requestAnimationFrame(() => els.minQuality.focus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMinQuality(data) {
|
||||||
|
if (!els.minQuality || minQualityBusy) return;
|
||||||
|
const value = data.min_quality_preset || "auto";
|
||||||
|
if (els.minQuality.value !== value) els.minQuality.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMinQuality(preset) {
|
||||||
|
if (!cfg.minQualityUrl) return;
|
||||||
|
minQualityBusy = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(cfg.minQualityUrl, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ min_quality_preset: preset })
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || I18N.minQualityError);
|
||||||
|
minQualityBusy = false;
|
||||||
|
syncMinQuality(data);
|
||||||
|
toast(I18N.minQualityUpdated);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || I18N.minQualityError);
|
||||||
|
} finally {
|
||||||
|
minQualityBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
els.minQuality?.addEventListener("change", () => {
|
||||||
|
setMinQuality(els.minQuality.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.telBitrate?.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const opening = !!(els.qualityPanel && els.qualityPanel.hidden);
|
||||||
|
setQualityPanelOpen(opening);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
if (!els.qualityPanel || els.qualityPanel.hidden) return;
|
||||||
|
if (els.qualityPanel.contains(e.target) || els.telBitrate?.contains(e.target)) return;
|
||||||
|
setQualityPanelOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") setQualityPanelOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.btnTogglePreview?.addEventListener("click", () => setPreviewWanted(!previewWanted));
|
||||||
|
previewWanted = readPreviewPref();
|
||||||
|
syncPreviewToggleUi();
|
||||||
|
if (!previewWanted) destroyPreview();
|
||||||
|
|
||||||
async function shareLink(url, title, text) {
|
async function shareLink(url, title, text) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
toast(I18N.linkUnavailable);
|
toast(I18N.linkUnavailable);
|
||||||
|
|||||||
@@ -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("€59/anno")
|
expect(action[:label]).to include("€40/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 Light — €59/anno")
|
expect(response.body).not_to include("Attiva Premium Light — €40/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("Attiva Light — €59/anno")
|
expect(response.body).to include("Premium Light — €40/anno")
|
||||||
expect(response.body).to include("Attiva Light — €7,90/mese")
|
expect(response.body).to include("Premium Light — €5/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")
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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,7 +1,7 @@
|
|||||||
require "rails_helper"
|
require "rails_helper"
|
||||||
|
|
||||||
RSpec.describe "Public regia", type: :request do
|
RSpec.describe "Public regia", type: :request do
|
||||||
let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "password123", role: "coach") }
|
let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "Password123", role: "coach") }
|
||||||
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||||
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
||||||
@@ -12,7 +12,18 @@ RSpec.describe "Public regia", type: :request do
|
|||||||
get public_regia_path(token)
|
get public_regia_path(token)
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.body).to include("Condividi diretta")
|
expect(response.body).to include("Condividi diretta")
|
||||||
|
expect(response.body).to include("Copia live")
|
||||||
expect(response.body).to include("Condividi link regia")
|
expect(response.body).to include("Condividi link regia")
|
||||||
|
expect(response.body).to include("Copia regia")
|
||||||
|
expect(response.body).to include(">Diretta<")
|
||||||
|
expect(response.body).to include(">Regia<")
|
||||||
|
expect(response.body).to include("regia-tool-group")
|
||||||
|
expect(response.body).to include("Nascondi anteprima")
|
||||||
|
expect(response.body).to include('id="btn-pause"')
|
||||||
|
expect(response.body).to include('id="btn-stop"')
|
||||||
|
expect(response.body).to include("Qualità minima di trasmissione")
|
||||||
|
expect(response.body).to include('id="tel-bitrate"')
|
||||||
|
expect(response.body).to include('aria-controls="regia-quality"')
|
||||||
expect(response.body).to include("/live/#{session.id}")
|
expect(response.body).to include("/live/#{session.id}")
|
||||||
expect(response.body).not_to include("Aruba")
|
expect(response.body).not_to include("Aruba")
|
||||||
end
|
end
|
||||||
@@ -156,4 +167,39 @@ RSpec.describe "Public regia", type: :request do
|
|||||||
get public_regia_path(token)
|
get public_regia_path(token)
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "aggiorna la qualità minima di trasmissione" do
|
||||||
|
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||||
|
patch public_regia_min_quality_path(token), params: { min_quality_preset: "720p_30_1.5mbps" }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(session.reload.min_quality_preset).to eq("720p_30_1.5mbps")
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["min_quality_preset"]).to eq("720p_30_1.5mbps")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "espone la telemetria della camera nello status" do
|
||||||
|
session.device_states.create!(
|
||||||
|
device_role: "camera",
|
||||||
|
battery_level: 81,
|
||||||
|
network_type: "WiFi",
|
||||||
|
current_bitrate: 2_400_000,
|
||||||
|
target_bitrate: 4_500_000,
|
||||||
|
fps: 30,
|
||||||
|
thermal_state: "nominal",
|
||||||
|
last_seen_at: Time.current
|
||||||
|
)
|
||||||
|
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||||
|
get public_regia_path(token)
|
||||||
|
expect(response.body).to include('id="regia-telemetry"')
|
||||||
|
|
||||||
|
get public_regia_status_path(token)
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
tel = response.parsed_body["telemetry"]
|
||||||
|
expect(tel["battery"]).to eq(81)
|
||||||
|
expect(tel["network"]).to eq("WiFi")
|
||||||
|
expect(tel["fps"]).to eq(30)
|
||||||
|
expect(tel["bitrate"]).to eq(2_400_000)
|
||||||
|
expect(tel["thermal_state"]).to eq("nominal")
|
||||||
|
expect(tel).to have_key("publisher_online")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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 — €7,90/mese")
|
expect(described_class.for_stripe_invoice(invoice)).to eq("Premium Light — €5/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 — €59/anno")
|
expect(payment.display_description).to eq("Premium Light — €40/anno")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -23,15 +23,6 @@ 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 — €59/anno")
|
expect(payment.description).to eq("Premium Light — €40/anno")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,4 +11,11 @@ RSpec.describe Sessions::SelectQuality do
|
|||||||
|
|
||||||
expect(preset[:id]).to eq("1080p_30_4.5mbps")
|
expect(preset[:id]).to eq("1080p_30_4.5mbps")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "offre 1080p come qualità minima solo se la sessione è 1080p" do
|
||||||
|
expect(described_class.allowed_min_ids("720p_30_2.5mbps")).to eq(
|
||||||
|
%w[auto 720p_30_1.5mbps 720p_30_2.5mbps 720p_30_4mbps]
|
||||||
|
)
|
||||||
|
expect(described_class.allowed_min_ids("1080p_30_4.5mbps")).to include("1080p_30_4.5mbps")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Sessions::SetMinQuality do
|
||||||
|
let!(:user) { User.create!(email: "abr@test.it", name: "U", password: "Password123", role: "coach") }
|
||||||
|
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
|
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||||
|
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
||||||
|
let!(:session) do
|
||||||
|
StreamSession.create!(
|
||||||
|
match: match,
|
||||||
|
user: user,
|
||||||
|
platform: "matchlivetv",
|
||||||
|
status: "live",
|
||||||
|
quality_preset: "720p_30_2.5mbps"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "salva il pavimento e notifica i client" do
|
||||||
|
expect(SessionChannel).to receive(:broadcast_message).with(
|
||||||
|
session,
|
||||||
|
hash_including(action: "set_min_quality", min_quality_preset: "720p_30_1.5mbps")
|
||||||
|
)
|
||||||
|
|
||||||
|
described_class.new(session, preset: "720p_30_1.5mbps").call
|
||||||
|
|
||||||
|
expect(session.reload.min_quality_preset).to eq("720p_30_1.5mbps")
|
||||||
|
expect(session.stream_events.where(event_type: "quality_changed")).to exist
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rifiuta 1080p se la sessione è 720p" do
|
||||||
|
expect {
|
||||||
|
described_class.new(session, preset: "1080p_30_4.5mbps").call
|
||||||
|
}.to raise_error(ArgumentError)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "accetta auto come qualità minima" do
|
||||||
|
session.update!(min_quality_preset: "720p_30_1.5mbps")
|
||||||
|
described_class.new(session, preset: "auto").call
|
||||||
|
expect(session.reload.min_quality_preset).to eq("auto")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,14 +4,13 @@
|
|||||||
|
|
||||||
| | Free | Premium Light | Premium Full |
|
| | Free | Premium Light | Premium Full |
|
||||||
|---|------|---------------|----------------|
|
|---|------|---------------|----------------|
|
||||||
| Account trasmettitori | 1 | 5 | Illimitati |
|
| Responsabili trasmissione / anno | 1 | 5 | Illimitato |
|
||||||
| 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 lancio | €0 | €59/anno o €7,90/mese | €199/anno o €24,90/mese |
|
| Prezzo | €0 | €40/anno o €5/mese | €200/anno o €20/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.
|
||||||
|
|||||||
@@ -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 (lancio) | Variabile `.env` |
|
| Piano app | Prodotto Stripe (Dashboard) | Mensile | Annuale | Variabile `.env` |
|
||||||
|-----------|------------------------------|---------|---------|------------------|
|
|-----------|------------------------------|---------|---------|------------------|
|
||||||
| Premium Light | **Match Live TV** | €7,90 | €59 | `STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID` / `..._YEARLY_...` |
|
| Premium Light | **Match Live TV** | €5 | €40 | `STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID` / `..._YEARLY_...` |
|
||||||
| Premium Full | **Match Live TV PRO** | €24,90 | €199 | `STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID` / `..._YEARLY_...` |
|
| Premium Full | **Match Live TV PRO** | €20 | €200 | `STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID` / `..._YEARLY_...` |
|
||||||
|
|
||||||
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.
|
Gli importi in Stripe Dashboard devono coincidere con il sito (€5/€40 e €20/€200).
|
||||||
|
|
||||||
In Stripe apri ogni prezzo e copia l’**ID prezzo** (`price_...`), non l’ID prodotto.
|
In Stripe apri ogni prezzo e copia l’**ID prezzo** (`price_...`), non l’ID prodotto.
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ 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=
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ services:
|
|||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
MEDIAMTX_API_URL: http://mediamtx:9997
|
MEDIAMTX_API_URL: http://mediamtx:9997
|
||||||
MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://127.0.0.1:1935}
|
MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://127.0.0.1:1935}
|
||||||
|
MEDIAMTX_HLS_URL: ${MEDIAMTX_HLS_URL:-http://mediamtx:8888}
|
||||||
|
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:3000/hls}
|
||||||
MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}
|
MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}
|
||||||
RAILS_WEBHOOK_URL: http://rails:3000
|
RAILS_WEBHOOK_URL: http://rails:3000
|
||||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min}
|
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min}
|
||||||
|
|||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package com.matchlivetv.match_live_tv
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.matchlivetv.match_live_tv.core.AppConfig
|
||||||
|
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import retrofit2.HttpException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contratto API qualità minima contro il backend locale (emulatore → 10.0.2.2).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class LocalAdaptiveBitrateApiTest {
|
||||||
|
private lateinit var container: AppContainer
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
container = AppContainer(context)
|
||||||
|
assertTrue(
|
||||||
|
"Questo test richiede API locale, trovato ${AppConfig.apiBaseUrl}",
|
||||||
|
AppConfig.apiBaseUrl.contains("10.0.2.2") || AppConfig.apiBaseUrl.contains("localhost"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun setMinQuality_persistsAndRejects1080On720Session() = runBlocking {
|
||||||
|
container.authRepository.login(
|
||||||
|
email = "coach@matchlivetv.test",
|
||||||
|
password = "Password123",
|
||||||
|
)
|
||||||
|
val teams = container.matchRepository.fetchTeams()
|
||||||
|
assertTrue(teams.isNotEmpty())
|
||||||
|
val match = container.matchRepository.createQuickMatch(teams.first().id)
|
||||||
|
val created = container.sessionRepository.createSession(match.id)
|
||||||
|
assertEquals("auto", created.minQualityPreset)
|
||||||
|
|
||||||
|
val updated = container.sessionRepository.setMinQuality(created.id, "720p_30_1.5mbps")
|
||||||
|
assertEquals("720p_30_1.5mbps", updated.minQualityPreset)
|
||||||
|
|
||||||
|
val fetched = container.sessionRepository.fetchSession(created.id)
|
||||||
|
assertEquals("720p_30_1.5mbps", fetched.minQualityPreset)
|
||||||
|
|
||||||
|
try {
|
||||||
|
container.sessionRepository.setMinQuality(created.id, "1080p_30_4.5mbps")
|
||||||
|
fail("1080p avrebbe dovuto essere rifiutato su sessione 720p")
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
assertEquals(422, e.code())
|
||||||
|
}
|
||||||
|
|
||||||
|
val forced = container.sessionRepository.setMinQuality(created.id, "720p_30_4mbps")
|
||||||
|
assertEquals("720p_30_4mbps", forced.minQualityPreset)
|
||||||
|
|
||||||
|
val auto = container.sessionRepository.setMinQuality(created.id, "auto")
|
||||||
|
assertEquals("auto", auto.minQualityPreset)
|
||||||
|
|
||||||
|
val started = container.sessionRepository.startSession(created.id)
|
||||||
|
assertFalse(started.rtmpIngestUrl.isNullOrBlank())
|
||||||
|
container.sessionRepository.stopSession(created.id)
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
package com.matchlivetv.match_live_tv
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.uiautomator.By
|
||||||
|
import androidx.test.uiautomator.UiDevice
|
||||||
|
import androidx.test.uiautomator.UiObject2
|
||||||
|
import androidx.test.uiautomator.Until
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E locale: login seed → wizard → schermata ripresa → qualità minima.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class LocalAdaptiveBitrateUiTest {
|
||||||
|
private lateinit var device: UiDevice
|
||||||
|
private val pkg = "com.matchlivetv.match_live_tv"
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
||||||
|
grantRuntimePermissions()
|
||||||
|
launchApp()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun broadcastScreen_canForceMinimumQuality() {
|
||||||
|
waitForAny(45_000, "Email", "ACCEDI", "LOG IN", "NUOVA PARTITA", "NEW MATCH")
|
||||||
|
if (!hasAny("NUOVA PARTITA", "NEW MATCH")) {
|
||||||
|
fillLogin()
|
||||||
|
if (!waitQuietly(8_000, "NUOVA PARTITA", "NEW MATCH")) {
|
||||||
|
tapClickableLogin()
|
||||||
|
}
|
||||||
|
waitForAny(45_000, "NUOVA PARTITA", "NEW MATCH")
|
||||||
|
}
|
||||||
|
tapAny("NUOVA PARTITA", "NEW MATCH")
|
||||||
|
waitForAny(15_000, "Avvia subito", "Start now")
|
||||||
|
tapAny("Avvia subito", "Start now")
|
||||||
|
waitForAny(45_000, "01 · Partita", "01 · Match")
|
||||||
|
scrollDown()
|
||||||
|
tapAny("AVANTI >", "NEXT >")
|
||||||
|
waitForAny(45_000, "02 · Trasmissione", "02 · Broadcast")
|
||||||
|
waitForAny(30_000, "Piattaforma", "Platform")
|
||||||
|
scrollDown()
|
||||||
|
tapAny("AVANTI >", "NEXT >")
|
||||||
|
waitForAny(45_000, "03 · Test rete", "03 · Network test")
|
||||||
|
waitForAny(30_000, "AVVIA TEST RETE", "START NETWORK TEST")
|
||||||
|
tapAny("AVVIA TEST RETE", "START NETWORK TEST")
|
||||||
|
waitForAny(30_000, "INIZIA >", "START >")
|
||||||
|
waitUntilEnabledAny("INIZIA >", "START >", timeoutMs = 25_000)
|
||||||
|
scrollDown()
|
||||||
|
tapAny("INIZIA >", "START >")
|
||||||
|
|
||||||
|
waitForAnyDesc(
|
||||||
|
60_000,
|
||||||
|
"Qualità minima di trasmissione",
|
||||||
|
"Minimum streaming quality",
|
||||||
|
)
|
||||||
|
tapAnyDesc("Qualità minima di trasmissione", "Minimum streaming quality")
|
||||||
|
waitForAny(10_000, "Qualità minima", "Minimum quality")
|
||||||
|
waitForAny(5_000, "Automatica", "Automatic")
|
||||||
|
tapClickableText("720p · 1.5 Mbps")
|
||||||
|
assertNotNull(
|
||||||
|
waitForAny(
|
||||||
|
20_000,
|
||||||
|
"Qualità minima: 720p · 1.5 Mbps",
|
||||||
|
"Minimum quality: 720p · 1.5 Mbps",
|
||||||
|
"720p · 1.5 Mbps",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun grantRuntimePermissions() {
|
||||||
|
listOf(
|
||||||
|
"android.permission.CAMERA",
|
||||||
|
"android.permission.RECORD_AUDIO",
|
||||||
|
"android.permission.POST_NOTIFICATIONS",
|
||||||
|
).forEach { permission ->
|
||||||
|
device.executeShellCommand("pm grant $pkg $permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchApp() {
|
||||||
|
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
val intent = context.packageManager.getLaunchIntentForPackage(pkg)?.apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
} ?: error("Launch intent mancante per $pkg")
|
||||||
|
context.startActivity(intent)
|
||||||
|
device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 15_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fillLogin() {
|
||||||
|
val email = waitForRes("login_email")
|
||||||
|
val password = waitForRes("login_password")
|
||||||
|
typeInto(email, "coach@matchlivetv.test")
|
||||||
|
typeInto(password, "Password123")
|
||||||
|
device.pressKeyCode(KeyEvent.KEYCODE_ENTER)
|
||||||
|
device.waitForIdle()
|
||||||
|
SystemClock.sleep(800)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasAny(vararg texts: String): Boolean =
|
||||||
|
texts.any { device.hasObject(By.text(it)) || device.hasObject(By.desc(it)) }
|
||||||
|
|
||||||
|
private fun waitQuietly(timeoutMs: Long, vararg texts: String): Boolean {
|
||||||
|
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||||
|
while (SystemClock.elapsedRealtime() < deadline) {
|
||||||
|
if (hasAny(*texts)) return true
|
||||||
|
SystemClock.sleep(250)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForRes(tag: String): UiObject2 {
|
||||||
|
val obj = device.wait(Until.findObject(By.res(tag)), 10_000)
|
||||||
|
?: device.wait(Until.findObject(By.res("$pkg:id/$tag")), 2_000)
|
||||||
|
?: error("Nodo non trovato: $tag | visibili: ${visibleTexts()}")
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun typeInto(field: UiObject2, value: String) {
|
||||||
|
field.click()
|
||||||
|
device.waitForIdle()
|
||||||
|
field.text = value
|
||||||
|
device.waitForIdle()
|
||||||
|
SystemClock.sleep(300)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tapClickableLogin() {
|
||||||
|
waitForResOrNull("login_submit")?.let { btn ->
|
||||||
|
val bounds = btn.visibleBounds
|
||||||
|
device.click(bounds.centerX(), bounds.centerY())
|
||||||
|
device.waitForIdle()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val deadline = SystemClock.elapsedRealtime() + 10_000
|
||||||
|
while (SystemClock.elapsedRealtime() < deadline) {
|
||||||
|
val candidates = device.findObjects(By.text("ACCEDI")) +
|
||||||
|
device.findObjects(By.text("LOG IN"))
|
||||||
|
for (node in candidates) {
|
||||||
|
if (clickEnabled(node)) return
|
||||||
|
}
|
||||||
|
SystemClock.sleep(250)
|
||||||
|
}
|
||||||
|
error("Login non cliccabile | visibili: ${visibleTexts()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForResOrNull(tag: String): UiObject2? {
|
||||||
|
return device.wait(Until.findObject(By.res(tag)), 3_000)
|
||||||
|
?: device.wait(Until.findObject(By.res("$pkg:id/$tag")), 1_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clickEnabled(start: UiObject2): Boolean {
|
||||||
|
var node: UiObject2? = start
|
||||||
|
for (i in 0 until 6) {
|
||||||
|
val current = node ?: break
|
||||||
|
if (current.isClickable && current.isEnabled) {
|
||||||
|
current.click()
|
||||||
|
device.waitForIdle()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
node = current.parent
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun visibleTexts(): String {
|
||||||
|
val interesting = listOf("ACCEDI", "LOG IN", "Email", "Password", "NUOVA", "NEW MATCH", "error", "Error", "Server")
|
||||||
|
return device.findObjects(By.pkg(pkg)).mapNotNull { it.text?.takeIf { t -> t.isNotBlank() } }
|
||||||
|
.distinct()
|
||||||
|
.filter { text -> interesting.any { key -> text.contains(key, ignoreCase = true) } || text.length < 40 }
|
||||||
|
.take(30)
|
||||||
|
.joinToString(" | ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForAny(timeoutMs: Long, vararg texts: String): UiObject2 {
|
||||||
|
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||||
|
while (SystemClock.elapsedRealtime() < deadline) {
|
||||||
|
for (text in texts) {
|
||||||
|
device.findObject(By.text(text))?.let { return it }
|
||||||
|
device.findObject(By.desc(text))?.let { return it }
|
||||||
|
}
|
||||||
|
SystemClock.sleep(250)
|
||||||
|
}
|
||||||
|
error("Nessuno dei testi trovato: ${texts.joinToString()} | visibili: ${visibleTexts()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForAnyDesc(timeoutMs: Long, vararg descs: String): UiObject2 {
|
||||||
|
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||||
|
while (SystemClock.elapsedRealtime() < deadline) {
|
||||||
|
for (desc in descs) {
|
||||||
|
device.findObject(By.desc(desc))?.let { return it }
|
||||||
|
}
|
||||||
|
SystemClock.sleep(250)
|
||||||
|
}
|
||||||
|
error("Nessuna contentDescription trovata: ${descs.joinToString()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tapAny(vararg texts: String) {
|
||||||
|
val label = waitForAny(15_000, *texts)
|
||||||
|
tapNode(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tapClickableText(text: String) {
|
||||||
|
tapAny(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tapAnyDesc(vararg descs: String) {
|
||||||
|
tapNode(waitForAnyDesc(10_000, *descs))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tapNode(label: UiObject2) {
|
||||||
|
if (label.isClickable) {
|
||||||
|
label.click()
|
||||||
|
device.waitForIdle()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var node: UiObject2? = label
|
||||||
|
for (i in 0 until 6) {
|
||||||
|
val current = node ?: break
|
||||||
|
if (current.isClickable) {
|
||||||
|
current.click()
|
||||||
|
device.waitForIdle()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node = current.parent
|
||||||
|
}
|
||||||
|
label.click()
|
||||||
|
device.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitUntilEnabledAny(vararg texts: String, timeoutMs: Long) {
|
||||||
|
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||||
|
while (SystemClock.elapsedRealtime() < deadline) {
|
||||||
|
for (text in texts) {
|
||||||
|
val label = device.findObject(By.text(text)) ?: continue
|
||||||
|
var node: UiObject2? = label
|
||||||
|
for (i in 0 until 6) {
|
||||||
|
val current = node ?: break
|
||||||
|
if (current.isClickable && current.isEnabled) return
|
||||||
|
node = current.parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SystemClock.sleep(500)
|
||||||
|
}
|
||||||
|
error("Pulsante non abilitato entro timeout: ${texts.joinToString()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scrollDown(steps: Int = 2) {
|
||||||
|
val centerX = device.displayWidth / 2
|
||||||
|
val startY = (device.displayHeight * 0.75).toInt()
|
||||||
|
val endY = (device.displayHeight * 0.25).toInt()
|
||||||
|
repeat(steps) {
|
||||||
|
device.swipe(centerX, startY, centerX, endY, 24)
|
||||||
|
device.waitForIdle()
|
||||||
|
SystemClock.sleep(300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,17 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
import com.matchlivetv.match_live_tv.core.AppLocale
|
import com.matchlivetv.match_live_tv.core.AppLocale
|
||||||
import com.matchlivetv.match_live_tv.core.ProvideAppLocale
|
import com.matchlivetv.match_live_tv.core.ProvideAppLocale
|
||||||
import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost
|
import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost
|
||||||
import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
|
import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
private val container by lazy { (application as MatchLiveTvApplication).container }
|
private val container by lazy { (application as MatchLiveTvApplication).container }
|
||||||
@@ -33,9 +39,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
setContent {
|
setContent {
|
||||||
ProvideAppLocale {
|
ProvideAppLocale {
|
||||||
MatchLiveTheme {
|
MatchLiveTheme {
|
||||||
|
Box(Modifier.semantics { testTagsAsResourceId = true }) {
|
||||||
AppNavHost(container)
|
AppNavHost(container)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ data class StreamSessionDto(
|
|||||||
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
|
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
|
||||||
@Json(name = "target_fps") val targetFps: Int? = null,
|
@Json(name = "target_fps") val targetFps: Int? = null,
|
||||||
@Json(name = "quality_preset") val qualityPreset: String? = null,
|
@Json(name = "quality_preset") val qualityPreset: String? = null,
|
||||||
|
@Json(name = "min_quality_preset") val minQualityPreset: String? = null,
|
||||||
@Json(name = "started_at") val startedAt: String? = null,
|
@Json(name = "started_at") val startedAt: String? = null,
|
||||||
val score: ScoreStateDto? = null,
|
val score: ScoreStateDto? = null,
|
||||||
) {
|
) {
|
||||||
@@ -201,6 +202,7 @@ data class StreamSessionDto(
|
|||||||
targetBitrate = targetBitrate ?: 2_500_000,
|
targetBitrate = targetBitrate ?: 2_500_000,
|
||||||
targetFps = targetFps ?: 30,
|
targetFps = targetFps ?: 30,
|
||||||
qualityPreset = qualityPreset ?: "720p_30_2.5mbps",
|
qualityPreset = qualityPreset ?: "720p_30_2.5mbps",
|
||||||
|
minQualityPreset = minQualityPreset ?: "auto",
|
||||||
startedAt = startedAt,
|
startedAt = startedAt,
|
||||||
score = score?.toDomain(),
|
score = score?.toDomain(),
|
||||||
)
|
)
|
||||||
@@ -285,6 +287,10 @@ data class CreateSessionRequest(
|
|||||||
@Json(name = "youtube_channel") val youtubeChannel: String? = null,
|
@Json(name = "youtube_channel") val youtubeChannel: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class MinQualityRequest(
|
||||||
|
@Json(name = "min_quality_preset") val minQualityPreset: String,
|
||||||
|
)
|
||||||
|
|
||||||
data class UpdateMatchBody(
|
data class UpdateMatchBody(
|
||||||
@Json(name = "opponent_name") val opponentName: String? = null,
|
@Json(name = "opponent_name") val opponentName: String? = null,
|
||||||
val location: String? = null,
|
val location: String? = null,
|
||||||
@@ -392,4 +398,5 @@ data class TelemetryRequest(
|
|||||||
@Json(name = "current_bitrate") val currentBitrate: Int? = null,
|
@Json(name = "current_bitrate") val currentBitrate: Int? = null,
|
||||||
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
|
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
|
||||||
val fps: Int? = null,
|
val fps: Int? = null,
|
||||||
|
@Json(name = "thermal_state") val thermalState: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
+6
@@ -117,6 +117,12 @@ interface MatchLiveApi {
|
|||||||
@PATCH("sessions/{sessionId}/resume")
|
@PATCH("sessions/{sessionId}/resume")
|
||||||
suspend fun resumeSession(@Path("sessionId") sessionId: String): StreamSessionDto
|
suspend fun resumeSession(@Path("sessionId") sessionId: String): StreamSessionDto
|
||||||
|
|
||||||
|
@PATCH("sessions/{sessionId}/min_quality")
|
||||||
|
suspend fun setMinQuality(
|
||||||
|
@Path("sessionId") sessionId: String,
|
||||||
|
@Body body: MinQualityRequest,
|
||||||
|
): StreamSessionDto
|
||||||
|
|
||||||
@POST("sessions/{sessionId}/network_test")
|
@POST("sessions/{sessionId}/network_test")
|
||||||
suspend fun networkTest(
|
suspend fun networkTest(
|
||||||
@Path("sessionId") sessionId: String,
|
@Path("sessionId") sessionId: String,
|
||||||
|
|||||||
+2
@@ -33,6 +33,7 @@ class SessionCableService(
|
|||||||
var onPauseStream: (() -> Unit)? = null
|
var onPauseStream: (() -> Unit)? = null
|
||||||
var onResumeStream: (() -> Unit)? = null
|
var onResumeStream: (() -> Unit)? = null
|
||||||
var onEndStream: (() -> Unit)? = null
|
var onEndStream: (() -> Unit)? = null
|
||||||
|
var onMinQuality: ((String) -> Unit)? = null
|
||||||
|
|
||||||
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
|
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
|
||||||
intentionalDisconnect = false
|
intentionalDisconnect = false
|
||||||
@@ -109,6 +110,7 @@ class SessionCableService(
|
|||||||
"pause_stream" -> onPauseStream?.invoke()
|
"pause_stream" -> onPauseStream?.invoke()
|
||||||
"resume_stream" -> onResumeStream?.invoke()
|
"resume_stream" -> onResumeStream?.invoke()
|
||||||
"stop_stream" -> onEndStream?.invoke()
|
"stop_stream" -> onEndStream?.invoke()
|
||||||
|
"set_min_quality" -> (payload["min_quality_preset"] as? String)?.let { onMinQuality?.invoke(it) }
|
||||||
}
|
}
|
||||||
"stream_event" -> when (payload["event"] as? String) {
|
"stream_event" -> when (payload["event"] as? String) {
|
||||||
"paused" -> onPauseStream?.invoke()
|
"paused" -> onPauseStream?.invoke()
|
||||||
|
|||||||
+6
@@ -2,6 +2,7 @@ package com.matchlivetv.match_live_tv.data.repository
|
|||||||
|
|
||||||
import com.matchlivetv.match_live_tv.data.api.CreateSessionRequest
|
import com.matchlivetv.match_live_tv.data.api.CreateSessionRequest
|
||||||
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
||||||
|
import com.matchlivetv.match_live_tv.data.api.MinQualityRequest
|
||||||
import com.matchlivetv.match_live_tv.data.api.NetworkTestRequest
|
import com.matchlivetv.match_live_tv.data.api.NetworkTestRequest
|
||||||
import com.matchlivetv.match_live_tv.data.api.NetworkTestResponse
|
import com.matchlivetv.match_live_tv.data.api.NetworkTestResponse
|
||||||
import com.matchlivetv.match_live_tv.domain.StreamSession
|
import com.matchlivetv.match_live_tv.domain.StreamSession
|
||||||
@@ -38,6 +39,9 @@ class SessionRepository(
|
|||||||
suspend fun resumeSession(sessionId: String): StreamSession =
|
suspend fun resumeSession(sessionId: String): StreamSession =
|
||||||
api.resumeSession(sessionId).toDomain()
|
api.resumeSession(sessionId).toDomain()
|
||||||
|
|
||||||
|
suspend fun setMinQuality(sessionId: String, preset: String): StreamSession =
|
||||||
|
api.setMinQuality(sessionId, MinQualityRequest(minQualityPreset = preset)).toDomain()
|
||||||
|
|
||||||
suspend fun submitNetworkTest(
|
suspend fun submitNetworkTest(
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
downloadMbps: Double,
|
downloadMbps: Double,
|
||||||
@@ -61,6 +65,7 @@ class SessionRepository(
|
|||||||
currentBitrate: Int? = null,
|
currentBitrate: Int? = null,
|
||||||
targetBitrate: Int? = null,
|
targetBitrate: Int? = null,
|
||||||
fps: Int? = null,
|
fps: Int? = null,
|
||||||
|
thermalState: String? = null,
|
||||||
) {
|
) {
|
||||||
api.postTelemetry(
|
api.postTelemetry(
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -72,6 +77,7 @@ class SessionRepository(
|
|||||||
currentBitrate = currentBitrate,
|
currentBitrate = currentBitrate,
|
||||||
targetBitrate = targetBitrate,
|
targetBitrate = targetBitrate,
|
||||||
fps = fps,
|
fps = fps,
|
||||||
|
thermalState = thermalState,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ data class StreamSession(
|
|||||||
val targetBitrate: Int = 2_500_000,
|
val targetBitrate: Int = 2_500_000,
|
||||||
val targetFps: Int = 30,
|
val targetFps: Int = 30,
|
||||||
val qualityPreset: String = "720p_30_2.5mbps",
|
val qualityPreset: String = "720p_30_2.5mbps",
|
||||||
|
val minQualityPreset: String = "auto",
|
||||||
val startedAt: String? = null,
|
val startedAt: String? = null,
|
||||||
val score: ScoreState? = null,
|
val score: ScoreState? = null,
|
||||||
) {
|
) {
|
||||||
@@ -133,6 +134,8 @@ data class StreamSession(
|
|||||||
|
|
||||||
val isPaused: Boolean get() = status == "paused"
|
val isPaused: Boolean get() = status == "paused"
|
||||||
|
|
||||||
|
val isEnded: Boolean get() = status == "ended" || status == "error"
|
||||||
|
|
||||||
fun watchShareUrl(): String? = when {
|
fun watchShareUrl(): String? = when {
|
||||||
platform == "youtube" && !youtubeWatchUrl.isNullOrBlank() -> youtubeWatchUrl
|
platform == "youtube" && !youtubeWatchUrl.isNullOrBlank() -> youtubeWatchUrl
|
||||||
!shareUrl.isNullOrBlank() -> shareUrl
|
!shareUrl.isNullOrBlank() -> shareUrl
|
||||||
|
|||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.streaming
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ABR conservativo per RTMP: scende in fretta in congestione, risale piano.
|
||||||
|
* Il pavimento è la qualità minima scelta dall'utente; il soffitto è il target
|
||||||
|
* di sessione (eventualmente ridotto dal termico).
|
||||||
|
*/
|
||||||
|
class AdaptiveBitrateController(
|
||||||
|
private val audioBitrate: Int = 128_000,
|
||||||
|
private val healthySamplesBeforeUp: Int = 8,
|
||||||
|
private val minDelta: Int = 50_000,
|
||||||
|
) {
|
||||||
|
@Volatile
|
||||||
|
var ceiling: Int = DEFAULT_CEILING
|
||||||
|
private set
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var floor: Int = AUTO_FLOOR
|
||||||
|
private set
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var currentBitrate: Int = DEFAULT_CEILING
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var healthyCount = 0
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun configure(ceiling: Int, floor: Int) {
|
||||||
|
this.ceiling = ceiling.coerceAtLeast(MIN_ABSOLUTE)
|
||||||
|
this.floor = floor.coerceIn(MIN_ABSOLUTE, this.ceiling)
|
||||||
|
currentBitrate = this.ceiling
|
||||||
|
healthyCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun setCeiling(value: Int): Int {
|
||||||
|
ceiling = value.coerceAtLeast(MIN_ABSOLUTE)
|
||||||
|
if (floor > ceiling) floor = ceiling
|
||||||
|
if (currentBitrate > ceiling) {
|
||||||
|
currentBitrate = ceiling
|
||||||
|
healthyCount = 0
|
||||||
|
}
|
||||||
|
return currentBitrate
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun setFloor(value: Int): Int {
|
||||||
|
floor = value.coerceIn(MIN_ABSOLUTE, ceiling)
|
||||||
|
if (currentBitrate < floor) {
|
||||||
|
currentBitrate = floor
|
||||||
|
healthyCount = 0
|
||||||
|
}
|
||||||
|
return currentBitrate
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun onCongestion(measuredBitsPerSecond: Int): Int? {
|
||||||
|
healthyCount = 0
|
||||||
|
val next = if (measuredBitsPerSecond <= 0) {
|
||||||
|
(currentBitrate * 0.8).toInt()
|
||||||
|
} else {
|
||||||
|
min(currentBitrate, measuredBitsPerSecond - audioBitrate)
|
||||||
|
}
|
||||||
|
return maybeApply(next.coerceIn(floor, ceiling))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun onHealthy(): Int? {
|
||||||
|
if (currentBitrate >= ceiling) {
|
||||||
|
healthyCount = 0
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
healthyCount += 1
|
||||||
|
if (healthyCount < healthySamplesBeforeUp) return null
|
||||||
|
healthyCount = 0
|
||||||
|
val step = (ceiling / 10).coerceAtLeast(minDelta)
|
||||||
|
return maybeApply(min(currentBitrate + step, ceiling))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun onMeasured(measuredBitsPerSecond: Long, congested: Boolean): Int? {
|
||||||
|
if (congested) return onCongestion(measuredBitsPerSecond.toInt())
|
||||||
|
if (measuredBitsPerSecond <= 0L) return null
|
||||||
|
val dropThreshold = (currentBitrate * 0.80).toLong()
|
||||||
|
return if (measuredBitsPerSecond < dropThreshold) {
|
||||||
|
onCongestion(measuredBitsPerSecond.toInt())
|
||||||
|
} else {
|
||||||
|
onHealthy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun resetToCeiling(): Int {
|
||||||
|
healthyCount = 0
|
||||||
|
currentBitrate = ceiling
|
||||||
|
return currentBitrate
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maybeApply(next: Int): Int? {
|
||||||
|
if (next == currentBitrate) return null
|
||||||
|
val atBound = next == floor || next == ceiling
|
||||||
|
if (!atBound && abs(next - currentBitrate) < minDelta) return null
|
||||||
|
currentBitrate = next
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val AUTO_FLOOR = 800_000
|
||||||
|
const val MIN_ABSOLUTE = 500_000
|
||||||
|
const val DEFAULT_CEILING = 2_500_000
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -9,6 +9,7 @@ data class BroadcastConfig(
|
|||||||
val width: Int = 1280,
|
val width: Int = 1280,
|
||||||
val height: Int = 720,
|
val height: Int = 720,
|
||||||
val videoBitrate: Int = 2_500_000,
|
val videoBitrate: Int = 2_500_000,
|
||||||
|
val minVideoBitrate: Int = 800_000,
|
||||||
val audioBitrate: Int = 128_000,
|
val audioBitrate: Int = 128_000,
|
||||||
val fps: Int = 30,
|
val fps: Int = 30,
|
||||||
val rotation: Int = 0,
|
val rotation: Int = 0,
|
||||||
|
|||||||
+37
-4
@@ -39,6 +39,7 @@ class LiveBroadcastEngine(
|
|||||||
private var publishInProgress = false
|
private var publishInProgress = false
|
||||||
private var currentBitrateKbps: Long = 0
|
private var currentBitrateKbps: Long = 0
|
||||||
private var currentFps: Int = 0
|
private var currentFps: Int = 0
|
||||||
|
private val adaptiveBitrate = AdaptiveBitrateController()
|
||||||
private val pausingIntentionally = AtomicBoolean(false)
|
private val pausingIntentionally = AtomicBoolean(false)
|
||||||
private val reconnectAttempts = AtomicInteger(0)
|
private val reconnectAttempts = AtomicInteger(0)
|
||||||
private val overlayRenderer = OverlayRenderer(appContext)
|
private val overlayRenderer = OverlayRenderer(appContext)
|
||||||
@@ -133,6 +134,7 @@ class LiveBroadcastEngine(
|
|||||||
fun startBroadcast(broadcastConfig: BroadcastConfig) {
|
fun startBroadcast(broadcastConfig: BroadcastConfig) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
config = broadcastConfig
|
config = broadcastConfig
|
||||||
|
adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
|
||||||
reconnectAttempts.set(0)
|
reconnectAttempts.set(0)
|
||||||
streamStartPending = true
|
streamStartPending = true
|
||||||
publishInProgress = false
|
publishInProgress = false
|
||||||
@@ -250,6 +252,7 @@ class LiveBroadcastEngine(
|
|||||||
fun resumeBroadcast(broadcastConfig: BroadcastConfig) {
|
fun resumeBroadcast(broadcastConfig: BroadcastConfig) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
config = broadcastConfig
|
config = broadcastConfig
|
||||||
|
adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
|
||||||
reconnectAttempts.set(0)
|
reconnectAttempts.set(0)
|
||||||
streamStartPending = true
|
streamStartPending = true
|
||||||
publishInProgress = false
|
publishInProgress = false
|
||||||
@@ -264,21 +267,45 @@ class LiveBroadcastEngine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adattamento termico: riduce bitrate/FPS senza fermare lo stream. */
|
/** Adattamento termico: aggiorna il soffitto ABR e l'FPS senza fermare lo stream. */
|
||||||
fun applyThermalProfile(videoBitrate: Int, fps: Int) {
|
fun applyThermalProfile(videoBitrate: Int, fps: Int) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
val current = config ?: return@post
|
val current = config ?: return@post
|
||||||
if (current.videoBitrate == videoBitrate && current.fps == fps) return@post
|
adaptiveBitrate.setCeiling(videoBitrate)
|
||||||
|
val applied = adaptiveBitrate.setFloor(current.minVideoBitrate)
|
||||||
|
if (current.videoBitrate == videoBitrate && current.fps == fps) {
|
||||||
|
applyAdaptiveBitrate(applied)
|
||||||
|
return@post
|
||||||
|
}
|
||||||
config = current.copy(videoBitrate = videoBitrate, fps = fps)
|
config = current.copy(videoBitrate = videoBitrate, fps = fps)
|
||||||
genericStream?.let { stream ->
|
genericStream?.let { stream ->
|
||||||
runCatching { stream.setVideoBitrateOnFly(videoBitrate) }
|
applyAdaptiveBitrate(applied)
|
||||||
.onFailure { Log.w(TAG, "applyThermalProfile bitrate: ${it.message}") }
|
|
||||||
runCatching { stream.getGlInterface().setForceRender(true, fps) }
|
runCatching { stream.getGlInterface().setForceRender(true, fps) }
|
||||||
.onFailure { Log.w(TAG, "applyThermalProfile fps: ${it.message}") }
|
.onFailure { Log.w(TAG, "applyThermalProfile fps: ${it.message}") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setMinQualityFloor(floorBitrate: Int, ceilingBitrate: Int? = null) {
|
||||||
|
mainHandler.post {
|
||||||
|
val current = config
|
||||||
|
if (current != null) {
|
||||||
|
config = current.copy(
|
||||||
|
videoBitrate = ceilingBitrate ?: current.videoBitrate,
|
||||||
|
minVideoBitrate = floorBitrate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
applyAdaptiveBitrate(adaptiveBitrate.setFloor(floorBitrate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyAdaptiveBitrate(bitrate: Int) {
|
||||||
|
genericStream?.let { stream ->
|
||||||
|
runCatching { stream.setVideoBitrateOnFly(bitrate) }
|
||||||
|
.onFailure { Log.w(TAG, "ABR bitrate: ${it.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun obtainStream(cfg: BroadcastConfig): GenericStream {
|
private fun obtainStream(cfg: BroadcastConfig): GenericStream {
|
||||||
val existing = genericStream
|
val existing = genericStream
|
||||||
if (existing != null) return existing
|
if (existing != null) return existing
|
||||||
@@ -552,6 +579,12 @@ class LiveBroadcastEngine(
|
|||||||
override fun onNewBitrate(bitrate: Long) {
|
override fun onNewBitrate(bitrate: Long) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
currentBitrateKbps = bitrate / 1000
|
currentBitrateKbps = bitrate / 1000
|
||||||
|
val congestion = runCatching {
|
||||||
|
genericStream?.getStreamClient()?.hasCongestion() == true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
adaptiveBitrate.onMeasured(bitrate, congestion)?.let { next ->
|
||||||
|
applyAdaptiveBitrate(next)
|
||||||
|
}
|
||||||
emitMetrics()
|
emitMetrics()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.streaming
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scala qualità allineata a `Sessions::SelectQuality`.
|
||||||
|
* In diretta si adatta solo il bitrate (niente cambio risoluzione a caldo).
|
||||||
|
*/
|
||||||
|
object StreamQualityLadder {
|
||||||
|
const val AUTO = "auto"
|
||||||
|
|
||||||
|
data class Preset(
|
||||||
|
val id: String,
|
||||||
|
val bitrate: Int,
|
||||||
|
val fps: Int = 30,
|
||||||
|
) {
|
||||||
|
val is1080: Boolean get() = id.startsWith("1080p")
|
||||||
|
val resolutionLabel: String get() = if (is1080) "1080p" else "720p"
|
||||||
|
}
|
||||||
|
|
||||||
|
val PRESETS = listOf(
|
||||||
|
Preset("1080p_30_4.5mbps", 4_500_000),
|
||||||
|
Preset("720p_30_4mbps", 4_000_000),
|
||||||
|
Preset("720p_30_2.5mbps", 2_500_000),
|
||||||
|
Preset("720p_30_1.5mbps", 1_500_000),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun normalize(presetId: String?): String {
|
||||||
|
val id = presetId?.ifBlank { AUTO } ?: AUTO
|
||||||
|
if (id == AUTO) return AUTO
|
||||||
|
return if (PRESETS.any { it.id == id }) id else AUTO
|
||||||
|
}
|
||||||
|
|
||||||
|
fun floorBitrate(presetId: String?): Int {
|
||||||
|
val id = normalize(presetId)
|
||||||
|
if (id == AUTO) return AdaptiveBitrateController.AUTO_FLOOR
|
||||||
|
return PRESETS.find { it.id == id }?.bitrate ?: AdaptiveBitrateController.AUTO_FLOOR
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ceilingContribution(presetId: String?): Int {
|
||||||
|
val id = normalize(presetId)
|
||||||
|
if (id == AUTO) return 0
|
||||||
|
return PRESETS.find { it.id == id }?.bitrate ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encoderCeiling(sessionTarget: Int, minPresetId: String?): Int =
|
||||||
|
maxOf(sessionTarget, ceilingContribution(minPresetId))
|
||||||
|
|
||||||
|
fun encoderFloor(minPresetId: String?, ceiling: Int): Int =
|
||||||
|
minOf(floorBitrate(minPresetId), ceiling)
|
||||||
|
|
||||||
|
fun availableIds(sessionQualityPreset: String?): List<String> {
|
||||||
|
val ids = mutableListOf(AUTO)
|
||||||
|
ids += PRESETS.filter { !it.is1080 }.asReversed().map { it.id }
|
||||||
|
if (sessionQualityPreset?.startsWith("1080p") == true) {
|
||||||
|
PRESETS.find { it.is1080 }?.let { ids += it.id }
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
fun displayName(id: String, autoLabel: String): String {
|
||||||
|
if (normalize(id) == AUTO) return autoLabel
|
||||||
|
val preset = PRESETS.find { it.id == id } ?: return id
|
||||||
|
val mbps = preset.bitrate / 1_000_000.0
|
||||||
|
val mbpsText = if (mbps == mbps.toInt().toDouble()) {
|
||||||
|
mbps.toInt().toString()
|
||||||
|
} else {
|
||||||
|
"%.1f".format(Locale.US, mbps)
|
||||||
|
}
|
||||||
|
return "${preset.resolutionLabel} · $mbpsText Mbps"
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -15,11 +15,14 @@ object StreamVideoPreset {
|
|||||||
|
|
||||||
fun broadcastConfig(session: StreamSession, rtmpUrl: String): BroadcastConfig {
|
fun broadcastConfig(session: StreamSession, rtmpUrl: String): BroadcastConfig {
|
||||||
val dims = dimensions(session.qualityPreset)
|
val dims = dimensions(session.qualityPreset)
|
||||||
|
val ceiling = StreamQualityLadder.encoderCeiling(session.targetBitrate, session.minQualityPreset)
|
||||||
|
val floor = StreamQualityLadder.encoderFloor(session.minQualityPreset, ceiling)
|
||||||
return BroadcastConfig(
|
return BroadcastConfig(
|
||||||
rtmpUrl = rtmpUrl,
|
rtmpUrl = rtmpUrl,
|
||||||
width = dims.width,
|
width = dims.width,
|
||||||
height = dims.height,
|
height = dims.height,
|
||||||
videoBitrate = session.targetBitrate,
|
videoBitrate = ceiling,
|
||||||
|
minVideoBitrate = floor,
|
||||||
fps = session.targetFps,
|
fps = session.targetFps,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+142
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -29,6 +30,7 @@ import androidx.compose.material.icons.filled.PlayArrow
|
|||||||
import androidx.compose.material.icons.filled.SkipNext
|
import androidx.compose.material.icons.filled.SkipNext
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material.icons.filled.Videocam
|
import androidx.compose.material.icons.filled.Videocam
|
||||||
@@ -58,6 +60,8 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.matchlivetv.match_live_tv.R
|
import com.matchlivetv.match_live_tv.R
|
||||||
@@ -65,6 +69,7 @@ import com.matchlivetv.match_live_tv.core.resolveMediaUrl
|
|||||||
import com.matchlivetv.match_live_tv.core.DeviceHealthSnapshot
|
import com.matchlivetv.match_live_tv.core.DeviceHealthSnapshot
|
||||||
import com.matchlivetv.match_live_tv.core.ThermalState
|
import com.matchlivetv.match_live_tv.core.ThermalState
|
||||||
import com.matchlivetv.match_live_tv.domain.ScoreState
|
import com.matchlivetv.match_live_tv.domain.ScoreState
|
||||||
|
import com.matchlivetv.match_live_tv.streaming.StreamQualityLadder
|
||||||
import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge
|
import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge
|
||||||
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||||
|
|
||||||
@@ -109,9 +114,27 @@ fun BroadcastControlsOverlay(
|
|||||||
bitrateKbps: Long,
|
bitrateKbps: Long,
|
||||||
networkType: String,
|
networkType: String,
|
||||||
deviceHealth: DeviceHealthSnapshot,
|
deviceHealth: DeviceHealthSnapshot,
|
||||||
|
minQualityPreset: String = StreamQualityLadder.AUTO,
|
||||||
|
sessionQualityPreset: String = "720p_30_2.5mbps",
|
||||||
|
onSelectMinQuality: (String) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var showTerminateConfirm by remember { mutableStateOf(false) }
|
var showTerminateConfirm by remember { mutableStateOf(false) }
|
||||||
|
var showMinQualityPicker by remember { mutableStateOf(false) }
|
||||||
|
val autoLabel = stringResource(R.string.broadcast_min_quality_auto)
|
||||||
|
|
||||||
|
if (showMinQualityPicker) {
|
||||||
|
MinQualityPickerDialog(
|
||||||
|
selectedId = minQualityPreset,
|
||||||
|
sessionQualityPreset = sessionQualityPreset,
|
||||||
|
autoLabel = autoLabel,
|
||||||
|
onSelect = { id ->
|
||||||
|
showMinQualityPicker = false
|
||||||
|
onSelectMinQuality(id)
|
||||||
|
},
|
||||||
|
onDismiss = { showMinQualityPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showTerminateConfirm) {
|
if (showTerminateConfirm) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
@@ -174,6 +197,7 @@ fun BroadcastControlsOverlay(
|
|||||||
bitrateKbps = bitrateKbps,
|
bitrateKbps = bitrateKbps,
|
||||||
networkType = networkType,
|
networkType = networkType,
|
||||||
deviceHealth = deviceHealth,
|
deviceHealth = deviceHealth,
|
||||||
|
minQualityLabel = StreamQualityLadder.displayName(minQualityPreset, autoLabel),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +219,11 @@ fun BroadcastControlsOverlay(
|
|||||||
contentDescription = stringResource(R.string.broadcast_share_regia_cd),
|
contentDescription = stringResource(R.string.broadcast_share_regia_cd),
|
||||||
onClick = onShareRegia,
|
onClick = onShareRegia,
|
||||||
)
|
)
|
||||||
|
SideIconButton(
|
||||||
|
icon = Icons.Default.Tune,
|
||||||
|
contentDescription = stringResource(R.string.broadcast_min_quality_cd),
|
||||||
|
onClick = { showMinQualityPicker = true },
|
||||||
|
)
|
||||||
if (onCloseSet != null) {
|
if (onCloseSet != null) {
|
||||||
SideIconButton(
|
SideIconButton(
|
||||||
icon = Icons.Default.Check,
|
icon = Icons.Default.Check,
|
||||||
@@ -287,6 +316,7 @@ private fun BroadcastTelemetryPanel(
|
|||||||
bitrateKbps: Long,
|
bitrateKbps: Long,
|
||||||
networkType: String,
|
networkType: String,
|
||||||
deviceHealth: DeviceHealthSnapshot,
|
deviceHealth: DeviceHealthSnapshot,
|
||||||
|
minQualityLabel: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val labelStyle = MaterialTheme.typography.labelSmall
|
val labelStyle = MaterialTheme.typography.labelSmall
|
||||||
@@ -321,6 +351,11 @@ private fun BroadcastTelemetryPanel(
|
|||||||
color = MatchColors.TextSecondary,
|
color = MatchColors.TextSecondary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Text(
|
||||||
|
minQualityLabel,
|
||||||
|
style = labelStyle,
|
||||||
|
color = MatchColors.AccentYellow,
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
"${networkType} · ${deviceHealth.batteryPercent}%",
|
"${networkType} · ${deviceHealth.batteryPercent}%",
|
||||||
style = labelStyle,
|
style = labelStyle,
|
||||||
@@ -604,6 +639,113 @@ private fun TeamLogoThumbnail(url: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MinQualityPickerDialog(
|
||||||
|
selectedId: String,
|
||||||
|
sessionQualityPreset: String,
|
||||||
|
autoLabel: String,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val options = StreamQualityLadder.availableIds(sessionQualityPreset)
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.widthIn(max = 720.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(MatchColors.Surface)
|
||||||
|
.border(1.dp, MatchColors.Outline, RoundedCornerShape(16.dp))
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.broadcast_min_quality_title),
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.action_cancel), color = MatchColors.TextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.broadcast_min_quality_hint),
|
||||||
|
color = MatchColors.TextSecondary,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
options.forEach { id ->
|
||||||
|
MinQualityOptionChip(
|
||||||
|
label = StreamQualityLadder.displayName(id, autoLabel),
|
||||||
|
selected = id == selectedId,
|
||||||
|
onClick = { onSelect(id) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MinQualityOptionChip(
|
||||||
|
label: String,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(12.dp)
|
||||||
|
val border = if (selected) MatchColors.AccentYellow else MatchColors.Outline
|
||||||
|
val background = if (selected) {
|
||||||
|
MatchColors.AccentYellow.copy(alpha = 0.16f)
|
||||||
|
} else {
|
||||||
|
MatchColors.SurfaceElevated
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(shape)
|
||||||
|
.background(background)
|
||||||
|
.border(1.dp, border, shape)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
if (selected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MatchColors.AccentYellow,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
color = if (selected) MatchColors.AccentYellow else Color.White,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SideIconButton(
|
private fun SideIconButton(
|
||||||
|
|||||||
+77
-6
@@ -44,6 +44,7 @@ import com.matchlivetv.match_live_tv.domain.Match
|
|||||||
import com.matchlivetv.match_live_tv.domain.MatchScoringRules
|
import com.matchlivetv.match_live_tv.domain.MatchScoringRules
|
||||||
import com.matchlivetv.match_live_tv.domain.StreamSession
|
import com.matchlivetv.match_live_tv.domain.StreamSession
|
||||||
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
|
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
|
||||||
|
import com.matchlivetv.match_live_tv.streaming.StreamQualityLadder
|
||||||
import com.matchlivetv.match_live_tv.streaming.StreamVideoPreset
|
import com.matchlivetv.match_live_tv.streaming.StreamVideoPreset
|
||||||
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
|
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
|
||||||
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
|
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
|
||||||
@@ -78,6 +79,7 @@ fun BroadcastScreen(
|
|||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var pauseInFlight by remember { mutableStateOf(false) }
|
var pauseInFlight by remember { mutableStateOf(false) }
|
||||||
var resumeInFlight by remember { mutableStateOf(false) }
|
var resumeInFlight by remember { mutableStateOf(false) }
|
||||||
|
var minQualityInFlight by remember { mutableStateOf(false) }
|
||||||
var controlsVisible by remember { mutableStateOf(true) }
|
var controlsVisible by remember { mutableStateOf(true) }
|
||||||
var logoReady by remember { mutableStateOf(0) }
|
var logoReady by remember { mutableStateOf(0) }
|
||||||
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) }
|
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) }
|
||||||
@@ -156,10 +158,16 @@ fun BroadcastScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun applyRemotePause() {
|
suspend fun applyRemotePause() {
|
||||||
|
if (pauseInFlight) return
|
||||||
|
pauseInFlight = true
|
||||||
|
try {
|
||||||
container.broadcastCoordinator.engine.pauseBroadcast()
|
container.broadcastCoordinator.engine.pauseBroadcast()
|
||||||
session = runCatching { container.sessionRepository.fetchSession(sessionId) }
|
session = runCatching { container.sessionRepository.fetchSession(sessionId) }
|
||||||
.getOrNull() ?: session?.copy(status = "paused")
|
.getOrNull() ?: session?.copy(status = "paused")
|
||||||
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_paused_remote))
|
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_paused_remote))
|
||||||
|
} finally {
|
||||||
|
pauseInFlight = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun applyRemoteResume() {
|
suspend fun applyRemoteResume() {
|
||||||
@@ -191,6 +199,38 @@ fun BroadcastScreen(
|
|||||||
onFinished()
|
onFinished()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun applyMinQualityLocally(preset: String, loaded: StreamSession) {
|
||||||
|
val updated = loaded.copy(minQualityPreset = preset)
|
||||||
|
session = updated
|
||||||
|
val cfg = broadcastConfig(updated)
|
||||||
|
container.broadcastCoordinator.engine.setMinQualityFloor(cfg.minVideoBitrate, cfg.videoBitrate)
|
||||||
|
thermalManager.updateBaseline(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun persistMinQuality(preset: String) {
|
||||||
|
val current = session ?: return
|
||||||
|
minQualityInFlight = true
|
||||||
|
runCatching { container.sessionRepository.setMinQuality(sessionId, preset) }
|
||||||
|
.onSuccess { updated ->
|
||||||
|
applyMinQualityLocally(updated.minQualityPreset, updated)
|
||||||
|
snackbar.showSnackbar(
|
||||||
|
context.getString(
|
||||||
|
R.string.broadcast_min_quality_applied,
|
||||||
|
StreamQualityLadder.displayName(
|
||||||
|
updated.minQualityPreset,
|
||||||
|
context.getString(R.string.broadcast_min_quality_auto),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
snackbar.showSnackbar(
|
||||||
|
it.message ?: context.getString(R.string.broadcast_min_quality_error),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
minQualityInFlight = false
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(sessionId, permissions.granted) {
|
LaunchedEffect(sessionId, permissions.granted) {
|
||||||
if (!permissions.granted) return@LaunchedEffect
|
if (!permissions.granted) return@LaunchedEffect
|
||||||
loading = true
|
loading = true
|
||||||
@@ -215,6 +255,15 @@ fun BroadcastScreen(
|
|||||||
container.sessionCable.onEndStream = {
|
container.sessionCable.onEndStream = {
|
||||||
scope.launch { applyRemoteStop() }
|
scope.launch { applyRemoteStop() }
|
||||||
}
|
}
|
||||||
|
container.sessionCable.onMinQuality = { preset ->
|
||||||
|
scope.launch {
|
||||||
|
val current = session
|
||||||
|
if (current != null && current.minQualityPreset != preset) {
|
||||||
|
applyMinQualityLocally(preset, current)
|
||||||
|
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_min_quality_remote))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
container.sessionCable.connect(sessionId, token, deviceRole = "camera")
|
container.sessionCable.connect(sessionId, token, deviceRole = "camera")
|
||||||
}
|
}
|
||||||
container.broadcastCoordinator.startService()
|
container.broadcastCoordinator.startService()
|
||||||
@@ -329,11 +378,27 @@ fun BroadcastScreen(
|
|||||||
|
|
||||||
LaunchedEffect(sessionId) {
|
LaunchedEffect(sessionId) {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(4_000)
|
delay(2_000)
|
||||||
runCatching {
|
if (minQualityInFlight || pauseInFlight || resumeInFlight) continue
|
||||||
container.sessionRepository.fetchSession(sessionId).score
|
runCatching { container.sessionRepository.fetchSession(sessionId) }
|
||||||
}.getOrNull()?.let { remote ->
|
.getOrNull()
|
||||||
container.scoreController.applyRemote(remote)
|
?.let { fresh ->
|
||||||
|
fresh.score?.let { container.scoreController.applyRemote(it) }
|
||||||
|
val current = session ?: return@let
|
||||||
|
if (fresh.minQualityPreset != current.minQualityPreset) {
|
||||||
|
applyMinQualityLocally(fresh.minQualityPreset, current)
|
||||||
|
}
|
||||||
|
val phase = container.broadcastCoordinator.metrics.value.phase
|
||||||
|
val publishing = phase == BroadcastPhase.LIVE ||
|
||||||
|
phase == BroadcastPhase.CONNECTING ||
|
||||||
|
phase == BroadcastPhase.RECONNECTING
|
||||||
|
when {
|
||||||
|
fresh.isEnded && phase != BroadcastPhase.IDLE -> applyRemoteStop()
|
||||||
|
fresh.isPaused && publishing -> applyRemotePause()
|
||||||
|
!fresh.isPaused && !fresh.isEnded && phase == BroadcastPhase.PAUSED ->
|
||||||
|
applyRemoteResume()
|
||||||
|
else -> session = fresh
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,7 +407,6 @@ fun BroadcastScreen(
|
|||||||
while (true) {
|
while (true) {
|
||||||
delay(10_000)
|
delay(10_000)
|
||||||
val current = session ?: continue
|
val current = session ?: continue
|
||||||
if (current.isPaused) continue
|
|
||||||
runCatching {
|
runCatching {
|
||||||
container.sessionRepository.postTelemetry(
|
container.sessionRepository.postTelemetry(
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
@@ -352,6 +416,7 @@ fun BroadcastScreen(
|
|||||||
currentBitrate = (metrics.bitrateKbps * 1000).toInt().takeIf { it > 0 },
|
currentBitrate = (metrics.bitrateKbps * 1000).toInt().takeIf { it > 0 },
|
||||||
targetBitrate = current.targetBitrate,
|
targetBitrate = current.targetBitrate,
|
||||||
fps = metrics.fps.takeIf { it > 0 },
|
fps = metrics.fps.takeIf { it > 0 },
|
||||||
|
thermalState = thermalManager.state.value.name.lowercase(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,6 +429,7 @@ fun BroadcastScreen(
|
|||||||
container.sessionCable.onPauseStream = null
|
container.sessionCable.onPauseStream = null
|
||||||
container.sessionCable.onResumeStream = null
|
container.sessionCable.onResumeStream = null
|
||||||
container.sessionCable.onEndStream = null
|
container.sessionCable.onEndStream = null
|
||||||
|
container.sessionCable.onMinQuality = null
|
||||||
container.sessionCable.disconnect()
|
container.sessionCable.disconnect()
|
||||||
container.broadcastCoordinator.stopService()
|
container.broadcastCoordinator.stopService()
|
||||||
container.broadcastCoordinator.engine.stopBroadcast()
|
container.broadcastCoordinator.engine.stopBroadcast()
|
||||||
@@ -578,6 +644,11 @@ fun BroadcastScreen(
|
|||||||
bitrateKbps = metrics.bitrateKbps,
|
bitrateKbps = metrics.bitrateKbps,
|
||||||
networkType = DeviceTelemetry.networkType(context),
|
networkType = DeviceTelemetry.networkType(context),
|
||||||
deviceHealth = DeviceTelemetry.snapshot(context, thermalState),
|
deviceHealth = DeviceTelemetry.snapshot(context, thermalState),
|
||||||
|
minQualityPreset = currentSession?.minQualityPreset ?: StreamQualityLadder.AUTO,
|
||||||
|
sessionQualityPreset = currentSession?.qualityPreset ?: "720p_30_2.5mbps",
|
||||||
|
onSelectMinQuality = { preset ->
|
||||||
|
scope.launch { persistMinQuality(preset) }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-3
@@ -29,11 +29,15 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
@@ -50,7 +54,7 @@ import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
|
|||||||
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
container: AppContainer,
|
container: AppContainer,
|
||||||
@@ -130,7 +134,10 @@ fun LoginScreen(
|
|||||||
onValueChange = { email = it },
|
onValueChange = { email = it },
|
||||||
label = { Text(stringResource(R.string.login_email)) },
|
label = { Text(stringResource(R.string.login_email)) },
|
||||||
placeholder = { Text(stringResource(R.string.login_email_placeholder)) },
|
placeholder = { Text(stringResource(R.string.login_email_placeholder)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("login_email")
|
||||||
|
.semantics { testTagsAsResourceId = true },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = KeyboardType.Email,
|
keyboardType = KeyboardType.Email,
|
||||||
@@ -146,7 +153,10 @@ fun LoginScreen(
|
|||||||
value = password,
|
value = password,
|
||||||
onValueChange = { password = it },
|
onValueChange = { password = it },
|
||||||
label = { Text(stringResource(R.string.login_password)) },
|
label = { Text(stringResource(R.string.login_password)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("login_password")
|
||||||
|
.semantics { testTagsAsResourceId = true },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation = if (passwordVisible) {
|
visualTransformation = if (passwordVisible) {
|
||||||
VisualTransformation.None
|
VisualTransformation.None
|
||||||
@@ -198,6 +208,9 @@ fun LoginScreen(
|
|||||||
loading = loading,
|
loading = loading,
|
||||||
enabled = email.isNotBlank() && password.isNotBlank(),
|
enabled = email.isNotBlank() && password.isNotBlank(),
|
||||||
onClick = { submitLogin() },
|
onClick = { submitLogin() },
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag("login_submit")
|
||||||
|
.semantics { testTagsAsResourceId = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,13 @@
|
|||||||
<string name="broadcast_resume_cd">Livestream fortsetzen</string>
|
<string name="broadcast_resume_cd">Livestream fortsetzen</string>
|
||||||
<string name="broadcast_pause_cd">Livestream pausieren</string>
|
<string name="broadcast_pause_cd">Livestream pausieren</string>
|
||||||
<string name="broadcast_terminate_cd">Livestream beenden</string>
|
<string name="broadcast_terminate_cd">Livestream beenden</string>
|
||||||
|
<string name="broadcast_min_quality_title">Mindestqualität</string>
|
||||||
|
<string name="broadcast_min_quality_cd">Minimale Übertragungsqualität</string>
|
||||||
|
<string name="broadcast_min_quality_hint">Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.</string>
|
||||||
|
<string name="broadcast_min_quality_auto">Automatisch</string>
|
||||||
|
<string name="broadcast_min_quality_applied">Mindestqualität: %1$s</string>
|
||||||
|
<string name="broadcast_min_quality_error">Mindestqualität konnte nicht gesetzt werden</string>
|
||||||
|
<string name="broadcast_snackbar_min_quality_remote">Mindestqualität von der Regie aktualisiert</string>
|
||||||
<string name="broadcast_team_home_label">HEIM</string>
|
<string name="broadcast_team_home_label">HEIM</string>
|
||||||
<string name="broadcast_team_away_label">GAST</string>
|
<string name="broadcast_team_away_label">GAST</string>
|
||||||
<string name="broadcast_scoreboard_connected">Anzeigetafel OK</string>
|
<string name="broadcast_scoreboard_connected">Anzeigetafel OK</string>
|
||||||
|
|||||||
@@ -164,6 +164,13 @@
|
|||||||
<string name="broadcast_resume_cd">Resume live</string>
|
<string name="broadcast_resume_cd">Resume live</string>
|
||||||
<string name="broadcast_pause_cd">Pause live</string>
|
<string name="broadcast_pause_cd">Pause live</string>
|
||||||
<string name="broadcast_terminate_cd">End live</string>
|
<string name="broadcast_terminate_cd">End live</string>
|
||||||
|
<string name="broadcast_min_quality_title">Minimum quality</string>
|
||||||
|
<string name="broadcast_min_quality_cd">Minimum streaming quality</string>
|
||||||
|
<string name="broadcast_min_quality_hint">The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.</string>
|
||||||
|
<string name="broadcast_min_quality_auto">Automatic</string>
|
||||||
|
<string name="broadcast_min_quality_applied">Minimum quality: %1$s</string>
|
||||||
|
<string name="broadcast_min_quality_error">Unable to set minimum quality</string>
|
||||||
|
<string name="broadcast_snackbar_min_quality_remote">Minimum quality updated from control room</string>
|
||||||
<string name="broadcast_team_home_label">HOME</string>
|
<string name="broadcast_team_home_label">HOME</string>
|
||||||
<string name="broadcast_team_away_label">AWAY</string>
|
<string name="broadcast_team_away_label">AWAY</string>
|
||||||
<string name="broadcast_scoreboard_connected">Scoreboard OK</string>
|
<string name="broadcast_scoreboard_connected">Scoreboard OK</string>
|
||||||
|
|||||||
@@ -164,6 +164,13 @@
|
|||||||
<string name="broadcast_resume_cd">Reanudar directo</string>
|
<string name="broadcast_resume_cd">Reanudar directo</string>
|
||||||
<string name="broadcast_pause_cd">Pausar directo</string>
|
<string name="broadcast_pause_cd">Pausar directo</string>
|
||||||
<string name="broadcast_terminate_cd">Terminar directo</string>
|
<string name="broadcast_terminate_cd">Terminar directo</string>
|
||||||
|
<string name="broadcast_min_quality_title">Calidad mínima</string>
|
||||||
|
<string name="broadcast_min_quality_cd">Calidad mínima de transmisión</string>
|
||||||
|
<string name="broadcast_min_quality_hint">El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.</string>
|
||||||
|
<string name="broadcast_min_quality_auto">Automática</string>
|
||||||
|
<string name="broadcast_min_quality_applied">Calidad mínima: %1$s</string>
|
||||||
|
<string name="broadcast_min_quality_error">No se pudo fijar la calidad mínima</string>
|
||||||
|
<string name="broadcast_snackbar_min_quality_remote">Calidad mínima actualizada desde la regie</string>
|
||||||
<string name="broadcast_team_home_label">LOCAL</string>
|
<string name="broadcast_team_home_label">LOCAL</string>
|
||||||
<string name="broadcast_team_away_label">VISITANTE</string>
|
<string name="broadcast_team_away_label">VISITANTE</string>
|
||||||
<string name="broadcast_scoreboard_connected">Marcador OK</string>
|
<string name="broadcast_scoreboard_connected">Marcador OK</string>
|
||||||
|
|||||||
@@ -164,6 +164,13 @@
|
|||||||
<string name="broadcast_resume_cd">Reprendre le direct</string>
|
<string name="broadcast_resume_cd">Reprendre le direct</string>
|
||||||
<string name="broadcast_pause_cd">Mettre le direct en pause</string>
|
<string name="broadcast_pause_cd">Mettre le direct en pause</string>
|
||||||
<string name="broadcast_terminate_cd">Terminer le direct</string>
|
<string name="broadcast_terminate_cd">Terminer le direct</string>
|
||||||
|
<string name="broadcast_min_quality_title">Qualité minimale</string>
|
||||||
|
<string name="broadcast_min_quality_cd">Qualité minimale de transmission</string>
|
||||||
|
<string name="broadcast_min_quality_hint">L\'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.</string>
|
||||||
|
<string name="broadcast_min_quality_auto">Automatique</string>
|
||||||
|
<string name="broadcast_min_quality_applied">Qualité minimale : %1$s</string>
|
||||||
|
<string name="broadcast_min_quality_error">Impossible de définir la qualité minimale</string>
|
||||||
|
<string name="broadcast_snackbar_min_quality_remote">Qualité minimale mise à jour depuis la régie</string>
|
||||||
<string name="broadcast_team_home_label">DOMICILE</string>
|
<string name="broadcast_team_home_label">DOMICILE</string>
|
||||||
<string name="broadcast_team_away_label">EXTÉRIEUR</string>
|
<string name="broadcast_team_away_label">EXTÉRIEUR</string>
|
||||||
<string name="broadcast_scoreboard_connected">Tableau OK</string>
|
<string name="broadcast_scoreboard_connected">Tableau OK</string>
|
||||||
|
|||||||
@@ -164,6 +164,13 @@
|
|||||||
<string name="broadcast_resume_cd">Riprendi diretta</string>
|
<string name="broadcast_resume_cd">Riprendi diretta</string>
|
||||||
<string name="broadcast_pause_cd">Pausa diretta</string>
|
<string name="broadcast_pause_cd">Pausa diretta</string>
|
||||||
<string name="broadcast_terminate_cd">Termina diretta</string>
|
<string name="broadcast_terminate_cd">Termina diretta</string>
|
||||||
|
<string name="broadcast_min_quality_title">Qualità minima</string>
|
||||||
|
<string name="broadcast_min_quality_cd">Qualità minima di trasmissione</string>
|
||||||
|
<string name="broadcast_min_quality_hint">L\'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.</string>
|
||||||
|
<string name="broadcast_min_quality_auto">Automatica</string>
|
||||||
|
<string name="broadcast_min_quality_applied">Qualità minima: %1$s</string>
|
||||||
|
<string name="broadcast_min_quality_error">Impossibile impostare la qualità minima</string>
|
||||||
|
<string name="broadcast_snackbar_min_quality_remote">Qualità minima aggiornata dalla regia</string>
|
||||||
<string name="broadcast_team_home_label">CASA</string>
|
<string name="broadcast_team_home_label">CASA</string>
|
||||||
<string name="broadcast_team_away_label">OSPITE</string>
|
<string name="broadcast_team_away_label">OSPITE</string>
|
||||||
<string name="broadcast_scoreboard_connected">Tabellone OK</string>
|
<string name="broadcast_scoreboard_connected">Tabellone OK</string>
|
||||||
|
|||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.streaming
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AdaptiveBitrateControllerTest {
|
||||||
|
private fun controller() = AdaptiveBitrateController().also {
|
||||||
|
it.configure(ceiling = 2_500_000, floor = AdaptiveBitrateController.AUTO_FLOOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun congestionDropsTowardMeasuredAndRespectsFloor() {
|
||||||
|
val abr = controller()
|
||||||
|
val next = abr.onCongestion(1_200_000)
|
||||||
|
assertEquals(1_200_000 - 128_000, next)
|
||||||
|
assertEquals(1_072_000, abr.currentBitrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun congestionDoesNotGoBelowFloor() {
|
||||||
|
val abr = controller()
|
||||||
|
abr.setFloor(1_500_000)
|
||||||
|
val next = abr.onCongestion(400_000)
|
||||||
|
assertEquals(1_500_000, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun healthyRampsUpAfterEnoughSamples() {
|
||||||
|
val abr = controller()
|
||||||
|
abr.onCongestion(1_200_000)
|
||||||
|
repeat(7) { assertNull(abr.onHealthy()) }
|
||||||
|
val raised = abr.onHealthy()
|
||||||
|
assertEquals(1_072_000 + 250_000, raised)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thermalCeilingClampsCurrent() {
|
||||||
|
val abr = controller()
|
||||||
|
val applied = abr.setCeiling(1_250_000)
|
||||||
|
assertEquals(1_250_000, applied)
|
||||||
|
assertEquals(1_250_000, abr.ceiling)
|
||||||
|
assertTrue(abr.floor <= abr.ceiling)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun minFloorBumpsCurrentUp() {
|
||||||
|
val abr = controller()
|
||||||
|
abr.onCongestion(1_200_000)
|
||||||
|
val applied = abr.setFloor(2_000_000)
|
||||||
|
assertEquals(2_000_000, applied)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun autoOptionsExclude1080On720Session() {
|
||||||
|
val ids = StreamQualityLadder.availableIds("720p_30_2.5mbps")
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
StreamQualityLadder.AUTO,
|
||||||
|
"720p_30_1.5mbps",
|
||||||
|
"720p_30_2.5mbps",
|
||||||
|
"720p_30_4mbps",
|
||||||
|
),
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun measuredZeroWithoutCongestionDoesNotDrop() {
|
||||||
|
val abr = controller()
|
||||||
|
assertNull(abr.onMeasured(0L, congested = false))
|
||||||
|
assertEquals(2_500_000, abr.currentBitrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun measuredLowThroughputDropsWithoutCongestionFlag() {
|
||||||
|
val abr = controller()
|
||||||
|
val next = abr.onMeasured(1_200_000L, congested = false)
|
||||||
|
assertEquals(1_200_000 - 128_000, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thermalDropThenRecoverRestoresUserFloor() {
|
||||||
|
val abr = controller()
|
||||||
|
abr.setFloor(1_500_000)
|
||||||
|
abr.setCeiling(1_000_000)
|
||||||
|
assertEquals(1_000_000, abr.floor)
|
||||||
|
abr.setCeiling(2_500_000)
|
||||||
|
assertEquals(1_000_000, abr.floor)
|
||||||
|
assertEquals(1_500_000, abr.setFloor(1_500_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encoderCeilingRisesWhenMinExceedsSessionTarget() {
|
||||||
|
val ceiling = StreamQualityLadder.encoderCeiling(
|
||||||
|
sessionTarget = 2_500_000,
|
||||||
|
minPresetId = "720p_30_4mbps",
|
||||||
|
)
|
||||||
|
assertEquals(4_000_000, ceiling)
|
||||||
|
assertEquals(
|
||||||
|
4_000_000,
|
||||||
|
StreamQualityLadder.encoderFloor("720p_30_4mbps", ceiling),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
39335ED5DE5847E5904222AB /* MatchSessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813463F3E17D453EAA801739 /* MatchSessionLauncher.swift */; };
|
39335ED5DE5847E5904222AB /* MatchSessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813463F3E17D453EAA801739 /* MatchSessionLauncher.swift */; };
|
||||||
39F7D49D44244F51907E803D /* TeamColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7001BB5D65B34280B306ECCF /* TeamColorPicker.swift */; };
|
39F7D49D44244F51907E803D /* TeamColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7001BB5D65B34280B306ECCF /* TeamColorPicker.swift */; };
|
||||||
451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */; };
|
451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */; };
|
||||||
|
5F9B2C3D4E5A4B72C8D9E0F1 /* AdaptiveBitrateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8A1B2C3D4F4A61B7C8D9E0 /* AdaptiveBitrateController.swift */; };
|
||||||
454725D14B224276B06BCC67 /* MatchHubFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */; };
|
454725D14B224276B06BCC67 /* MatchHubFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */; };
|
||||||
45776A6A76D04EF5A0C0D187 /* TeamBrandingEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBBE0F1399840FFA0E7EF2E /* TeamBrandingEditor.swift */; };
|
45776A6A76D04EF5A0C0D187 /* TeamBrandingEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBBE0F1399840FFA0E7EF2E /* TeamBrandingEditor.swift */; };
|
||||||
470957DE79A84D9889F0663C /* ScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC8ECFD24A14054BDD984C3 /* ScoreRepository.swift */; };
|
470957DE79A84D9889F0663C /* ScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC8ECFD24A14054BDD984C3 /* ScoreRepository.swift */; };
|
||||||
@@ -68,6 +69,7 @@
|
|||||||
AA18DECE8D08461B9197EB71 /* MatchScoringRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */; };
|
AA18DECE8D08461B9197EB71 /* MatchScoringRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */; };
|
||||||
AA3A68B2C1784294ADB89605 /* MatchLiveTv/Resources/it.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 857C036B5B5C43368506DD8B /* MatchLiveTv/Resources/it.lproj */; };
|
AA3A68B2C1784294ADB89605 /* MatchLiveTv/Resources/it.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 857C036B5B5C43368506DD8B /* MatchLiveTv/Resources/it.lproj */; };
|
||||||
AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */; };
|
AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */; };
|
||||||
|
7B1D4E5F6A7C4D94E0F1A2B3 /* AdaptiveBitrateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A0C3D4E5F6B4C83D9E0F1A2 /* AdaptiveBitrateTests.swift */; };
|
||||||
ABC5853FEC554FA4AFBDCD2E /* OverlayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DDC7305F3B549D0B7646B14 /* OverlayState.swift */; };
|
ABC5853FEC554FA4AFBDCD2E /* OverlayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DDC7305F3B549D0B7646B14 /* OverlayState.swift */; };
|
||||||
ACC54AA19C5747CFA118C70B /* ApiInstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FEB862C3A34BF8AC24052E /* ApiInstantTests.swift */; };
|
ACC54AA19C5747CFA118C70B /* ApiInstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FEB862C3A34BF8AC24052E /* ApiInstantTests.swift */; };
|
||||||
AECC36D80A0C411AB47D581A /* MatchRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537FC2C063D341DF97A391B9 /* MatchRepository.swift */; };
|
AECC36D80A0C411AB47D581A /* MatchRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537FC2C063D341DF97A391B9 /* MatchRepository.swift */; };
|
||||||
@@ -127,6 +129,7 @@
|
|||||||
31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = "<group>"; };
|
31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = "<group>"; };
|
||||||
34BB2251A67E401A9F1E789E /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; };
|
34BB2251A67E401A9F1E789E /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; };
|
||||||
377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThermalAdaptationTests.swift; path = MatchLiveTvTests/ThermalAdaptationTests.swift; sourceTree = "<group>"; };
|
377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThermalAdaptationTests.swift; path = MatchLiveTvTests/ThermalAdaptationTests.swift; sourceTree = "<group>"; };
|
||||||
|
6A0C3D4E5F6B4C83D9E0F1A2 /* AdaptiveBitrateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AdaptiveBitrateTests.swift; path = MatchLiveTvTests/AdaptiveBitrateTests.swift; sourceTree = "<group>"; };
|
||||||
39F3C00B63534E6AA6D8A90A /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; };
|
39F3C00B63534E6AA6D8A90A /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; };
|
||||||
3A6F9AAB5A1249BBB4FC02E6 /* MatchLiveTv/Resources/en.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/en.lproj; sourceTree = "<group>"; };
|
3A6F9AAB5A1249BBB4FC02E6 /* MatchLiveTv/Resources/en.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/en.lproj; sourceTree = "<group>"; };
|
||||||
3BFB2F18615747B1A1C9E9B2 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = "<group>"; };
|
3BFB2F18615747B1A1C9E9B2 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = "<group>"; };
|
||||||
@@ -160,6 +163,7 @@
|
|||||||
87C01CC031C34343B3EBFF08 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = "<group>"; };
|
87C01CC031C34343B3EBFF08 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = "<group>"; };
|
||||||
8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = "<group>"; };
|
8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = "<group>"; };
|
||||||
8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamVideoPreset.swift; path = MatchLiveTv/Streaming/StreamVideoPreset.swift; sourceTree = "<group>"; };
|
8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamVideoPreset.swift; path = MatchLiveTv/Streaming/StreamVideoPreset.swift; sourceTree = "<group>"; };
|
||||||
|
4E8A1B2C3D4F4A61B7C8D9E0 /* AdaptiveBitrateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AdaptiveBitrateController.swift; path = MatchLiveTv/Streaming/AdaptiveBitrateController.swift; sourceTree = "<group>"; };
|
||||||
902831E3D87C46359F0D0A21 /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = "<group>"; };
|
902831E3D87C46359F0D0A21 /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = "<group>"; };
|
||||||
951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = "<group>"; };
|
951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = "<group>"; };
|
||||||
98526C26B2584F55AADF0FFF /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = "<group>"; };
|
98526C26B2584F55AADF0FFF /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = "<group>"; };
|
||||||
@@ -237,6 +241,7 @@
|
|||||||
10AD0F18190542818572CAEF /* ScoreActionDecodeTests.swift */,
|
10AD0F18190542818572CAEF /* ScoreActionDecodeTests.swift */,
|
||||||
6DF142EBF33B45739BA8E42C /* ScoreControllerTests.swift */,
|
6DF142EBF33B45739BA8E42C /* ScoreControllerTests.swift */,
|
||||||
377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */,
|
377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */,
|
||||||
|
6A0C3D4E5F6B4C83D9E0F1A2 /* AdaptiveBitrateTests.swift */,
|
||||||
);
|
);
|
||||||
name = MatchLiveTvTests;
|
name = MatchLiveTvTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -296,6 +301,7 @@
|
|||||||
31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */,
|
31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */,
|
||||||
46CE696CAB994E2C8E399776 /* LivePreviewView.swift */,
|
46CE696CAB994E2C8E399776 /* LivePreviewView.swift */,
|
||||||
8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */,
|
8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */,
|
||||||
|
4E8A1B2C3D4F4A61B7C8D9E0 /* AdaptiveBitrateController.swift */,
|
||||||
4419E10415414556B7C58458 /* AccountScreen.swift */,
|
4419E10415414556B7C58458 /* AccountScreen.swift */,
|
||||||
E860A0E35362427DA84F8972 /* BroadcastControlsOverlay.swift */,
|
E860A0E35362427DA84F8972 /* BroadcastControlsOverlay.swift */,
|
||||||
71816F0D99E44551839474CE /* BroadcastScreen.swift */,
|
71816F0D99E44551839474CE /* BroadcastScreen.swift */,
|
||||||
@@ -473,6 +479,7 @@
|
|||||||
6AD0B3220B154EA2936C634A /* WatermarkElement.swift in Sources */,
|
6AD0B3220B154EA2936C634A /* WatermarkElement.swift in Sources */,
|
||||||
713E0129656649FC8630FDC6 /* LivePreviewView.swift in Sources */,
|
713E0129656649FC8630FDC6 /* LivePreviewView.swift in Sources */,
|
||||||
451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */,
|
451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */,
|
||||||
|
5F9B2C3D4E5A4B72C8D9E0F1 /* AdaptiveBitrateController.swift in Sources */,
|
||||||
E5E21D088782469D8EB23543 /* AccountScreen.swift in Sources */,
|
E5E21D088782469D8EB23543 /* AccountScreen.swift in Sources */,
|
||||||
5B51805239FA4D4D90919D86 /* BroadcastControlsOverlay.swift in Sources */,
|
5B51805239FA4D4D90919D86 /* BroadcastControlsOverlay.swift in Sources */,
|
||||||
EE568EA02C404102B4DFD651 /* BroadcastScreen.swift in Sources */,
|
EE568EA02C404102B4DFD651 /* BroadcastScreen.swift in Sources */,
|
||||||
@@ -516,6 +523,7 @@
|
|||||||
5D91C53D2E8A4952937F816B /* ScoreActionDecodeTests.swift in Sources */,
|
5D91C53D2E8A4952937F816B /* ScoreActionDecodeTests.swift in Sources */,
|
||||||
FB66DD0422E14C54AC5B3EDB /* ScoreControllerTests.swift in Sources */,
|
FB66DD0422E14C54AC5B3EDB /* ScoreControllerTests.swift in Sources */,
|
||||||
AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */,
|
AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */,
|
||||||
|
7B1D4E5F6A7C4D94E0F1A2B3 /* AdaptiveBitrateTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -119,6 +119,12 @@ enum L10n {
|
|||||||
"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",
|
||||||
@@ -140,6 +146,7 @@ enum L10n {
|
|||||||
"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",
|
||||||
@@ -409,6 +416,12 @@ enum L10n {
|
|||||||
"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",
|
||||||
@@ -430,6 +443,7 @@ enum L10n {
|
|||||||
"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",
|
||||||
@@ -699,6 +713,12 @@ enum L10n {
|
|||||||
"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",
|
||||||
@@ -720,6 +740,7 @@ enum L10n {
|
|||||||
"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",
|
||||||
@@ -989,6 +1010,12 @@ enum L10n {
|
|||||||
"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",
|
||||||
@@ -1010,6 +1037,7 @@ enum L10n {
|
|||||||
"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",
|
||||||
@@ -1279,6 +1307,12 @@ enum L10n {
|
|||||||
"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",
|
||||||
@@ -1300,6 +1334,7 @@ enum L10n {
|
|||||||
"broadcast.side.away": "visitante",
|
"broadcast.side.away": "visitante",
|
||||||
"broadcast.side.home": "local",
|
"broadcast.side.home": "local",
|
||||||
"broadcast.snackbar.closed.remote": "Directo cerrado desde regie",
|
"broadcast.snackbar.closed.remote": "Directo cerrado desde regie",
|
||||||
|
"broadcast.snackbar.min.quality.remote": "Calidad mínima actualizada desde la regie",
|
||||||
"broadcast.snackbar.pause.error": "Pausa: %1$@",
|
"broadcast.snackbar.pause.error": "Pausa: %1$@",
|
||||||
"broadcast.snackbar.paused": "Directo en pausa",
|
"broadcast.snackbar.paused": "Directo en pausa",
|
||||||
"broadcast.snackbar.paused.remote": "Pausado desde regie",
|
"broadcast.snackbar.paused.remote": "Pausado desde regie",
|
||||||
|
|||||||
@@ -39,4 +39,13 @@ enum ThermalState: Int, Sendable, Equatable, Comparable {
|
|||||||
case .critical: return "🔴"
|
case .critical: return "🔴"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var apiValue: String {
|
||||||
|
switch self {
|
||||||
|
case .nominal: return "nominal"
|
||||||
|
case .fair: return "fair"
|
||||||
|
case .serious: return "serious"
|
||||||
|
case .critical: return "critical"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ struct StreamSessionDto: Decodable {
|
|||||||
let targetBitrate: Int?
|
let targetBitrate: Int?
|
||||||
let targetFps: Int?
|
let targetFps: Int?
|
||||||
let qualityPreset: String?
|
let qualityPreset: String?
|
||||||
|
let minQualityPreset: String?
|
||||||
let startedAt: String?
|
let startedAt: String?
|
||||||
let score: ScoreStateDto?
|
let score: ScoreStateDto?
|
||||||
|
|
||||||
@@ -221,6 +222,7 @@ struct StreamSessionDto: Decodable {
|
|||||||
targetBitrate: targetBitrate ?? 2_500_000,
|
targetBitrate: targetBitrate ?? 2_500_000,
|
||||||
targetFps: targetFps ?? 30,
|
targetFps: targetFps ?? 30,
|
||||||
qualityPreset: qualityPreset ?? "720p_30_2.5mbps",
|
qualityPreset: qualityPreset ?? "720p_30_2.5mbps",
|
||||||
|
minQualityPreset: minQualityPreset ?? "auto",
|
||||||
startedAt: startedAt,
|
startedAt: startedAt,
|
||||||
score: score?.toDomain()
|
score: score?.toDomain()
|
||||||
)
|
)
|
||||||
@@ -414,6 +416,10 @@ struct CreateSessionRequest: Encodable {
|
|||||||
let youtubeChannel: String?
|
let youtubeChannel: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct MinQualityRequest: Encodable {
|
||||||
|
let minQualityPreset: String
|
||||||
|
}
|
||||||
|
|
||||||
struct ScoringRulesBody: Codable {
|
struct ScoringRulesBody: Codable {
|
||||||
let setsToWin: Int?
|
let setsToWin: Int?
|
||||||
let pointsPerSet: Int?
|
let pointsPerSet: Int?
|
||||||
@@ -499,6 +505,7 @@ struct TelemetryRequest: Encodable {
|
|||||||
let currentBitrate: Int?
|
let currentBitrate: Int?
|
||||||
let targetBitrate: Int?
|
let targetBitrate: Int?
|
||||||
let fps: Int?
|
let fps: Int?
|
||||||
|
let thermalState: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ScoringRules {
|
extension ScoringRules {
|
||||||
|
|||||||
@@ -186,6 +186,10 @@ final class MatchLiveAPI: @unchecked Sendable {
|
|||||||
try await patch("sessions/\(id)/resume", body: EmptyBody())
|
try await patch("sessions/\(id)/resume", body: EmptyBody())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setMinQuality(id: String, preset: String) async throws -> StreamSessionDto {
|
||||||
|
try await patch("sessions/\(id)/min_quality", body: MinQualityRequest(minQualityPreset: preset))
|
||||||
|
}
|
||||||
|
|
||||||
func networkTest(sessionId: String, body: NetworkTestRequest) async throws -> NetworkTestResponse {
|
func networkTest(sessionId: String, body: NetworkTestRequest) async throws -> NetworkTestResponse {
|
||||||
try await post("sessions/\(sessionId)/network_test", body: body)
|
try await post("sessions/\(sessionId)/network_test", body: body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ final class SessionCableService: ObservableObject {
|
|||||||
var onPauseStream: (() -> Void)?
|
var onPauseStream: (() -> Void)?
|
||||||
var onResumeStream: (() -> Void)?
|
var onResumeStream: (() -> Void)?
|
||||||
var onStopStream: (() -> Void)?
|
var onStopStream: (() -> Void)?
|
||||||
|
var onMinQuality: ((String) -> Void)?
|
||||||
|
|
||||||
private var client: ActionCableClient?
|
private var client: ActionCableClient?
|
||||||
private var intentionalDisconnect = false
|
private var intentionalDisconnect = false
|
||||||
@@ -93,6 +94,10 @@ extension SessionCableService: ActionCableClient.Listener {
|
|||||||
case "pause_stream": onPauseStream?()
|
case "pause_stream": onPauseStream?()
|
||||||
case "resume_stream": onResumeStream?()
|
case "resume_stream": onResumeStream?()
|
||||||
case "stop_stream": onStopStream?()
|
case "stop_stream": onStopStream?()
|
||||||
|
case "set_min_quality":
|
||||||
|
if let preset = payload["min_quality_preset"] as? String {
|
||||||
|
onMinQuality?(preset)
|
||||||
|
}
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
case "stream_event":
|
case "stream_event":
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ final class SessionRepository {
|
|||||||
try await api.resumeSession(id: id).toDomain()
|
try await api.resumeSession(id: id).toDomain()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setMinQuality(id: String, preset: String) async throws -> StreamSession {
|
||||||
|
try await api.setMinQuality(id: id, preset: preset).toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
func submitNetworkTest(
|
func submitNetworkTest(
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
downloadMbps: Double,
|
downloadMbps: Double,
|
||||||
@@ -88,7 +92,8 @@ final class SessionRepository {
|
|||||||
signalStrength: nil,
|
signalStrength: nil,
|
||||||
currentBitrate: currentBitrate,
|
currentBitrate: currentBitrate,
|
||||||
targetBitrate: targetBitrate,
|
targetBitrate: targetBitrate,
|
||||||
fps: fps
|
fps: fps,
|
||||||
|
thermalState: health.thermalState.apiValue
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,11 +119,35 @@ struct StreamSession: Identifiable, Equatable, Sendable {
|
|||||||
let targetBitrate: Int
|
let targetBitrate: Int
|
||||||
let targetFps: Int
|
let targetFps: Int
|
||||||
let qualityPreset: String
|
let qualityPreset: String
|
||||||
|
let minQualityPreset: String
|
||||||
let startedAt: String?
|
let startedAt: String?
|
||||||
let score: ScoreState?
|
let score: ScoreState?
|
||||||
|
|
||||||
var isLive: Bool { status == "live" || status == "reconnecting" }
|
var isLive: Bool { status == "live" || status == "reconnecting" }
|
||||||
var isPaused: Bool { status == "paused" }
|
var isPaused: Bool { status == "paused" }
|
||||||
|
var isEnded: Bool { status == "ended" || status == "error" }
|
||||||
|
|
||||||
|
func with(minQualityPreset: String) -> StreamSession {
|
||||||
|
StreamSession(
|
||||||
|
id: id,
|
||||||
|
matchId: matchId,
|
||||||
|
status: status,
|
||||||
|
platform: platform,
|
||||||
|
rtmpIngestUrl: rtmpIngestUrl,
|
||||||
|
hlsPlaybackUrl: hlsPlaybackUrl,
|
||||||
|
watchPageUrl: watchPageUrl,
|
||||||
|
shareUrl: shareUrl,
|
||||||
|
youtubeWatchUrl: youtubeWatchUrl,
|
||||||
|
youtubeReady: youtubeReady,
|
||||||
|
privacyStatus: privacyStatus,
|
||||||
|
targetBitrate: targetBitrate,
|
||||||
|
targetFps: targetFps,
|
||||||
|
qualityPreset: qualityPreset,
|
||||||
|
minQualityPreset: minQualityPreset,
|
||||||
|
startedAt: startedAt,
|
||||||
|
score: score
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func watchShareUrl() -> String? {
|
func watchShareUrl() -> String? {
|
||||||
if platform == "youtube", let url = youtubeWatchUrl, !url.isEmpty { return url }
|
if platform == "youtube", let url = youtubeWatchUrl, !url.isEmpty { return url }
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum StreamQualityLadder {
|
||||||
|
static let auto = "auto"
|
||||||
|
|
||||||
|
struct Preset: Equatable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let bitrate: Int
|
||||||
|
let fps: Int
|
||||||
|
|
||||||
|
var is1080: Bool { id.hasPrefix("1080p") }
|
||||||
|
var resolutionLabel: String { is1080 ? "1080p" : "720p" }
|
||||||
|
}
|
||||||
|
|
||||||
|
static let presets: [Preset] = [
|
||||||
|
Preset(id: "1080p_30_4.5mbps", bitrate: 4_500_000, fps: 30),
|
||||||
|
Preset(id: "720p_30_4mbps", bitrate: 4_000_000, fps: 30),
|
||||||
|
Preset(id: "720p_30_2.5mbps", bitrate: 2_500_000, fps: 30),
|
||||||
|
Preset(id: "720p_30_1.5mbps", bitrate: 1_500_000, fps: 30)
|
||||||
|
]
|
||||||
|
|
||||||
|
static func normalize(_ presetId: String?) -> String {
|
||||||
|
let id = presetId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if id.isEmpty || id == auto { return auto }
|
||||||
|
return presets.contains(where: { $0.id == id }) ? id : auto
|
||||||
|
}
|
||||||
|
|
||||||
|
static func floorBitrate(_ presetId: String?) -> Int {
|
||||||
|
let id = normalize(presetId)
|
||||||
|
if id == auto { return AdaptiveBitrateController.autoFloor }
|
||||||
|
return presets.first(where: { $0.id == id })?.bitrate ?? AdaptiveBitrateController.autoFloor
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ceilingContribution(_ presetId: String?) -> Int {
|
||||||
|
let id = normalize(presetId)
|
||||||
|
if id == auto { return 0 }
|
||||||
|
return presets.first(where: { $0.id == id })?.bitrate ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
static func encoderCeiling(sessionTarget: Int, minPresetId: String?) -> Int {
|
||||||
|
max(sessionTarget, ceilingContribution(minPresetId))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func encoderFloor(minPresetId: String?, ceiling: Int) -> Int {
|
||||||
|
min(floorBitrate(minPresetId), ceiling)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func availableIds(sessionQualityPreset: String?) -> [String] {
|
||||||
|
var ids = [auto]
|
||||||
|
ids += presets.filter { !$0.is1080 }.reversed().map(\.id)
|
||||||
|
if sessionQualityPreset?.hasPrefix("1080p") == true,
|
||||||
|
let p1080 = presets.first(where: { $0.is1080 }) {
|
||||||
|
ids.append(p1080.id)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
static func displayName(id: String, autoLabel: String) -> String {
|
||||||
|
if normalize(id) == auto { return autoLabel }
|
||||||
|
guard let preset = presets.first(where: { $0.id == id }) else { return id }
|
||||||
|
let mbps = Double(preset.bitrate) / 1_000_000.0
|
||||||
|
let mbpsText = mbps == Double(Int(mbps))
|
||||||
|
? String(Int(mbps))
|
||||||
|
: String(format: "%.1f", mbps)
|
||||||
|
return "\(preset.resolutionLabel) · \(mbpsText) Mbps"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ABR conservativo per RTMP: scende in fretta in congestione, risale piano.
|
||||||
|
final class AdaptiveBitrateController: @unchecked Sendable {
|
||||||
|
static let autoFloor = 800_000
|
||||||
|
static let minAbsolute = 500_000
|
||||||
|
static let defaultCeiling = 2_500_000
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private let audioBitrate: Int
|
||||||
|
private let healthySamplesBeforeUp: Int
|
||||||
|
private let minDelta: Int
|
||||||
|
private var healthyCount = 0
|
||||||
|
|
||||||
|
private(set) var ceiling: Int = AdaptiveBitrateController.defaultCeiling
|
||||||
|
private(set) var floor: Int = AdaptiveBitrateController.autoFloor
|
||||||
|
private(set) var currentBitrate: Int = AdaptiveBitrateController.defaultCeiling
|
||||||
|
|
||||||
|
init(
|
||||||
|
audioBitrate: Int = 128_000,
|
||||||
|
healthySamplesBeforeUp: Int = 8,
|
||||||
|
minDelta: Int = 50_000
|
||||||
|
) {
|
||||||
|
self.audioBitrate = audioBitrate
|
||||||
|
self.healthySamplesBeforeUp = healthySamplesBeforeUp
|
||||||
|
self.minDelta = minDelta
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(ceiling: Int, floor: Int) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
self.ceiling = max(ceiling, Self.minAbsolute)
|
||||||
|
self.floor = min(max(floor, Self.minAbsolute), self.ceiling)
|
||||||
|
currentBitrate = self.ceiling
|
||||||
|
healthyCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setCeiling(_ value: Int) -> Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
ceiling = max(value, Self.minAbsolute)
|
||||||
|
if floor > ceiling { floor = ceiling }
|
||||||
|
if currentBitrate > ceiling {
|
||||||
|
currentBitrate = ceiling
|
||||||
|
healthyCount = 0
|
||||||
|
}
|
||||||
|
return currentBitrate
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setFloor(_ value: Int) -> Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
floor = min(max(value, Self.minAbsolute), ceiling)
|
||||||
|
if currentBitrate < floor {
|
||||||
|
currentBitrate = floor
|
||||||
|
healthyCount = 0
|
||||||
|
}
|
||||||
|
return currentBitrate
|
||||||
|
}
|
||||||
|
|
||||||
|
func onCongestion(measuredBitsPerSecond: Int) -> Int? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return applyCongestionLocked(measuredBitsPerSecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func onHealthy() -> Int? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return applyHealthyLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onMeasured(measuredBitsPerSecond: Int, congested: Bool) -> Int? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
if congested {
|
||||||
|
return applyCongestionLocked(measuredBitsPerSecond)
|
||||||
|
}
|
||||||
|
if measuredBitsPerSecond <= 0 { return nil }
|
||||||
|
let dropThreshold = Int(Double(currentBitrate) * 0.80)
|
||||||
|
if measuredBitsPerSecond < dropThreshold {
|
||||||
|
return applyCongestionLocked(measuredBitsPerSecond)
|
||||||
|
}
|
||||||
|
return applyHealthyLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetToCeiling() -> Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
healthyCount = 0
|
||||||
|
currentBitrate = ceiling
|
||||||
|
return currentBitrate
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyCongestionLocked(_ measuredBitsPerSecond: Int) -> Int? {
|
||||||
|
healthyCount = 0
|
||||||
|
let next: Int
|
||||||
|
if measuredBitsPerSecond <= 0 {
|
||||||
|
next = Int(Double(currentBitrate) * 0.8)
|
||||||
|
} else {
|
||||||
|
next = min(currentBitrate, measuredBitsPerSecond - audioBitrate)
|
||||||
|
}
|
||||||
|
return maybeApplyLocked(min(max(next, floor), ceiling))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyHealthyLocked() -> Int? {
|
||||||
|
if currentBitrate >= ceiling {
|
||||||
|
healthyCount = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
healthyCount += 1
|
||||||
|
if healthyCount < healthySamplesBeforeUp { return nil }
|
||||||
|
healthyCount = 0
|
||||||
|
let step = max(ceiling / 10, minDelta)
|
||||||
|
return maybeApplyLocked(min(currentBitrate + step, ceiling))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func maybeApplyLocked(_ next: Int) -> Int? {
|
||||||
|
if next == currentBitrate { return nil }
|
||||||
|
let atBound = next == floor || next == ceiling
|
||||||
|
if !atBound && abs(next - currentBitrate) < minDelta { return nil }
|
||||||
|
currentBitrate = next
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ struct BroadcastConfig: Sendable {
|
|||||||
var width: Int = 1280
|
var width: Int = 1280
|
||||||
var height: Int = 720
|
var height: Int = 720
|
||||||
var videoBitrate: Int = 2_500_000
|
var videoBitrate: Int = 2_500_000
|
||||||
|
var minVideoBitrate: Int = 800_000
|
||||||
var audioBitrate: Int = 128_000
|
var audioBitrate: Int = 128_000
|
||||||
var fps: Int = 30
|
var fps: Int = 30
|
||||||
var rotation: Int = 0
|
var rotation: Int = 0
|
||||||
|
|||||||
@@ -5,18 +5,37 @@ import RTMPHaishinKit
|
|||||||
import UIKit
|
import UIKit
|
||||||
import VideoToolbox
|
import VideoToolbox
|
||||||
|
|
||||||
private struct BroadcastBitrateMonitor: StreamBitRateStrategy {
|
private final class AdaptiveBroadcastBitRateStrategy: StreamBitRateStrategy, @unchecked Sendable {
|
||||||
let mamimumVideoBitRate: Int = 0
|
let controller: AdaptiveBitrateController
|
||||||
let mamimumAudioBitRate: Int = 0
|
|
||||||
let onUpdate: @Sendable (Int) -> Void
|
let onUpdate: @Sendable (Int) -> Void
|
||||||
|
var mamimumVideoBitRate: Int { controller.ceiling }
|
||||||
|
let mamimumAudioBitRate: Int = 0
|
||||||
|
|
||||||
|
init(controller: AdaptiveBitrateController, onUpdate: @escaping @Sendable (Int) -> Void) {
|
||||||
|
self.controller = controller
|
||||||
|
self.onUpdate = onUpdate
|
||||||
|
}
|
||||||
|
|
||||||
func adjustBitrate(_ event: NetworkMonitorEvent, stream: some StreamConvertible) async {
|
func adjustBitrate(_ event: NetworkMonitorEvent, stream: some StreamConvertible) async {
|
||||||
|
let next: Int?
|
||||||
switch event {
|
switch event {
|
||||||
case .status(let report), .publishInsufficientBWOccured(let report):
|
case .status(let report):
|
||||||
|
let measuredBits = report.currentBytesOutPerSecond * 8
|
||||||
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
|
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
|
||||||
|
next = controller.onMeasured(measuredBitsPerSecond: measuredBits, congested: false)
|
||||||
|
case .publishInsufficientBWOccured(let report):
|
||||||
|
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
|
||||||
|
next = controller.onCongestion(measuredBitsPerSecond: report.currentBytesOutPerSecond * 8)
|
||||||
case .reset:
|
case .reset:
|
||||||
break
|
next = controller.resetToCeiling()
|
||||||
|
@unknown default:
|
||||||
|
next = nil
|
||||||
}
|
}
|
||||||
|
guard let bitrate = next else { return }
|
||||||
|
var videoSettings = await stream.videoSettings
|
||||||
|
guard videoSettings.bitRate != bitrate else { return }
|
||||||
|
videoSettings.bitRate = bitrate
|
||||||
|
try? await stream.setVideoSettings(videoSettings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +66,7 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
|
private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
|
||||||
private var orientationDebounceTask: Task<Void, Never>?
|
private var orientationDebounceTask: Task<Void, Never>?
|
||||||
private var rtmpSession: (any Session)?
|
private var rtmpSession: (any Session)?
|
||||||
|
private let adaptiveBitrate = AdaptiveBitrateController()
|
||||||
private static var rtmpFactoryRegistered = false
|
private static var rtmpFactoryRegistered = false
|
||||||
|
|
||||||
private static func ensureRTMPFactoryRegistered() async {
|
private static func ensureRTMPFactoryRegistered() async {
|
||||||
@@ -101,6 +121,7 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
|
|
||||||
func preparePreview(config: BroadcastConfig) async throws {
|
func preparePreview(config: BroadcastConfig) async throws {
|
||||||
self.config = config
|
self.config = config
|
||||||
|
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
|
||||||
publishPending = false
|
publishPending = false
|
||||||
try await configurePipeline(config)
|
try await configurePipeline(config)
|
||||||
setPhase(.preview)
|
setPhase(.preview)
|
||||||
@@ -109,6 +130,7 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
/// Configura camera/mixer e mette in coda il publish RTMP (eseguito quando l'anteprima è pronta).
|
/// Configura camera/mixer e mette in coda il publish RTMP (eseguito quando l'anteprima è pronta).
|
||||||
func prepareForBroadcast(config: BroadcastConfig) async throws {
|
func prepareForBroadcast(config: BroadcastConfig) async throws {
|
||||||
self.config = config
|
self.config = config
|
||||||
|
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
|
||||||
publishPending = true
|
publishPending = true
|
||||||
try await configurePipeline(config)
|
try await configurePipeline(config)
|
||||||
setPhase(.connecting)
|
setPhase(.connecting)
|
||||||
@@ -135,6 +157,7 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
|
|
||||||
func resumeBroadcast(config: BroadcastConfig) async throws {
|
func resumeBroadcast(config: BroadcastConfig) async throws {
|
||||||
self.config = config
|
self.config = config
|
||||||
|
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
|
||||||
reconnectAttempts = 0
|
reconnectAttempts = 0
|
||||||
reconnectTask?.cancel()
|
reconnectTask?.cancel()
|
||||||
reconnectTask = nil
|
reconnectTask = nil
|
||||||
@@ -179,18 +202,40 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
await stopBroadcast()
|
await stopBroadcast()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adattamento termico: aggiorna profilo video senza interrompere la sessione RTMP attiva.
|
/// Adattamento termico: aggiorna il soffitto ABR e l'FPS senza interrompere la sessione RTMP.
|
||||||
func applyThermalProfile(videoBitrate: Int, fps: Int) async {
|
func applyThermalProfile(videoBitrate: Int, fps: Int) async {
|
||||||
guard var current = config else { return }
|
guard var current = config else { return }
|
||||||
guard current.videoBitrate != videoBitrate || current.fps != fps else { return }
|
adaptiveBitrate.setCeiling(videoBitrate)
|
||||||
|
let applied = adaptiveBitrate.setFloor(current.minVideoBitrate)
|
||||||
|
guard current.videoBitrate != videoBitrate || current.fps != fps else {
|
||||||
|
await applyEncoderBitrate(applied)
|
||||||
|
return
|
||||||
|
}
|
||||||
current.videoBitrate = videoBitrate
|
current.videoBitrate = videoBitrate
|
||||||
current.fps = fps
|
current.fps = fps
|
||||||
config = current
|
config = current
|
||||||
if pipelineConfigured, let stream = await rtmpSession?.stream {
|
if pipelineConfigured, let stream = await rtmpSession?.stream {
|
||||||
try? await applyCodecSettings(to: stream, config: current)
|
try? await applyCodecSettings(to: stream, config: current, videoBitrate: applied)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setMinQualityFloor(_ floorBitrate: Int, ceiling: Int? = nil) async {
|
||||||
|
if var current = config {
|
||||||
|
if let ceiling { current.videoBitrate = ceiling }
|
||||||
|
current.minVideoBitrate = floorBitrate
|
||||||
|
config = current
|
||||||
|
}
|
||||||
|
await applyEncoderBitrate(adaptiveBitrate.setFloor(floorBitrate))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyEncoderBitrate(_ bitrate: Int) async {
|
||||||
|
guard pipelineConfigured, let stream = await rtmpSession?.stream else { return }
|
||||||
|
var videoSettings = await stream.videoSettings
|
||||||
|
guard videoSettings.bitRate != bitrate else { return }
|
||||||
|
videoSettings.bitRate = bitrate
|
||||||
|
try? await stream.setVideoSettings(videoSettings)
|
||||||
|
}
|
||||||
|
|
||||||
private func configurePipeline(_ config: BroadcastConfig) async throws {
|
private func configurePipeline(_ config: BroadcastConfig) async throws {
|
||||||
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized,
|
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized,
|
||||||
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||||
@@ -316,7 +361,7 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
|
|
||||||
let stream = await session.stream
|
let stream = await session.stream
|
||||||
try await applyCodecSettings(to: stream, config: config)
|
try await applyCodecSettings(to: stream, config: config)
|
||||||
await stream.setBitRateStrategy(BroadcastBitrateMonitor { [weak self] kbps in
|
await stream.setBitRateStrategy(AdaptiveBroadcastBitRateStrategy(controller: adaptiveBitrate) { [weak self] kbps in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.metrics = BroadcastMetrics(
|
self.metrics = BroadcastMetrics(
|
||||||
@@ -349,10 +394,10 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
setPhase(.live)
|
setPhase(.live)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyCodecSettings(to stream: any StreamConvertible, config: BroadcastConfig) async throws {
|
private func applyCodecSettings(to stream: any StreamConvertible, config: BroadcastConfig, videoBitrate: Int? = nil) async throws {
|
||||||
var videoSettings = await stream.videoSettings
|
var videoSettings = await stream.videoSettings
|
||||||
videoSettings.videoSize = .init(width: config.width, height: config.height)
|
videoSettings.videoSize = .init(width: config.width, height: config.height)
|
||||||
videoSettings.bitRate = config.videoBitrate
|
videoSettings.bitRate = videoBitrate ?? adaptiveBitrate.currentBitrate
|
||||||
videoSettings.maxKeyFrameIntervalDuration = 2
|
videoSettings.maxKeyFrameIntervalDuration = 2
|
||||||
videoSettings.profileLevel = config.height > 720
|
videoSettings.profileLevel = config.height > 720
|
||||||
? (kVTProfileLevel_H264_Baseline_4_1 as String)
|
? (kVTProfileLevel_H264_Baseline_4_1 as String)
|
||||||
|
|||||||
@@ -15,11 +15,17 @@ enum StreamVideoPreset {
|
|||||||
|
|
||||||
static func broadcastConfig(for session: StreamSession, rtmpUrl: String) -> BroadcastConfig {
|
static func broadcastConfig(for session: StreamSession, rtmpUrl: String) -> BroadcastConfig {
|
||||||
let dims = dimensions(for: session.qualityPreset)
|
let dims = dimensions(for: session.qualityPreset)
|
||||||
|
let ceiling = StreamQualityLadder.encoderCeiling(
|
||||||
|
sessionTarget: session.targetBitrate,
|
||||||
|
minPresetId: session.minQualityPreset
|
||||||
|
)
|
||||||
|
let floor = StreamQualityLadder.encoderFloor(minPresetId: session.minQualityPreset, ceiling: ceiling)
|
||||||
return BroadcastConfig(
|
return BroadcastConfig(
|
||||||
rtmpUrl: rtmpUrl,
|
rtmpUrl: rtmpUrl,
|
||||||
width: dims.width,
|
width: dims.width,
|
||||||
height: dims.height,
|
height: dims.height,
|
||||||
videoBitrate: session.targetBitrate,
|
videoBitrate: ceiling,
|
||||||
|
minVideoBitrate: floor,
|
||||||
audioBitrate: 128_000,
|
audioBitrate: 128_000,
|
||||||
fps: session.targetFps
|
fps: session.targetFps
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,8 +40,12 @@ struct BroadcastControlsOverlay: View {
|
|||||||
let bitrateKbps: Int
|
let bitrateKbps: Int
|
||||||
let networkType: String
|
let networkType: String
|
||||||
let deviceHealth: DeviceHealth
|
let deviceHealth: DeviceHealth
|
||||||
|
var minQualityPreset: String = StreamQualityLadder.auto
|
||||||
|
var sessionQualityPreset: String = "720p_30_2.5mbps"
|
||||||
|
var onSelectMinQuality: (String) -> Void = { _ in }
|
||||||
|
|
||||||
@State private var showTerminateConfirm = false
|
@State private var showTerminateConfirm = false
|
||||||
|
@State private var showMinQualityPicker = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Color.clear
|
Color.clear
|
||||||
@@ -69,7 +73,11 @@ struct BroadcastControlsOverlay: View {
|
|||||||
targetFps: targetFps,
|
targetFps: targetFps,
|
||||||
bitrateKbps: bitrateKbps,
|
bitrateKbps: bitrateKbps,
|
||||||
networkType: networkType,
|
networkType: networkType,
|
||||||
deviceHealth: deviceHealth
|
deviceHealth: deviceHealth,
|
||||||
|
minQualityLabel: StreamQualityLadder.displayName(
|
||||||
|
id: minQualityPreset,
|
||||||
|
autoLabel: L10n.t("broadcast.min.quality.auto")
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(.trailing, 8)
|
.padding(.trailing, 8)
|
||||||
@@ -94,6 +102,19 @@ struct BroadcastControlsOverlay: View {
|
|||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.overlay {
|
||||||
|
if showMinQualityPicker {
|
||||||
|
MinQualityPickerOverlay(
|
||||||
|
selectedId: minQualityPreset,
|
||||||
|
sessionQualityPreset: sessionQualityPreset,
|
||||||
|
onSelect: { id in
|
||||||
|
showMinQualityPicker = false
|
||||||
|
onSelectMinQuality(id)
|
||||||
|
},
|
||||||
|
onDismiss: { showMinQualityPicker = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
.alert(L10n.t("broadcast.terminate.title"), isPresented: $showTerminateConfirm) {
|
.alert(L10n.t("broadcast.terminate.title"), isPresented: $showTerminateConfirm) {
|
||||||
Button(L10n.t("action.cancel"), role: .cancel) {}
|
Button(L10n.t("action.cancel"), role: .cancel) {}
|
||||||
Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: onTerminate)
|
Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: onTerminate)
|
||||||
@@ -115,6 +136,11 @@ struct BroadcastControlsOverlay: View {
|
|||||||
accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
|
accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
|
||||||
action: onShareRegia
|
action: onShareRegia
|
||||||
)
|
)
|
||||||
|
SideIconButton(
|
||||||
|
systemName: "slider.horizontal.3",
|
||||||
|
accessibilityLabel: L10n.t("broadcast.min.quality.cd"),
|
||||||
|
action: { showMinQualityPicker = true }
|
||||||
|
)
|
||||||
if let onCloseSet {
|
if let onCloseSet {
|
||||||
SideIconButton(
|
SideIconButton(
|
||||||
systemName: "checkmark",
|
systemName: "checkmark",
|
||||||
@@ -188,6 +214,85 @@ struct BroadcastControlsOverlay: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct MinQualityPickerOverlay: View {
|
||||||
|
let selectedId: String
|
||||||
|
let sessionQualityPreset: String
|
||||||
|
let onSelect: (String) -> Void
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
private var options: [String] {
|
||||||
|
StreamQualityLadder.availableIds(sessionQualityPreset: sessionQualityPreset)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.55)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onTapGesture(perform: onDismiss)
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text(L10n.t("broadcast.min.quality.title"))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Spacer()
|
||||||
|
Button(L10n.t("action.cancel"), action: onDismiss)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
Text(L10n.t("broadcast.min.quality.hint"))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(options, id: \.self) { id in
|
||||||
|
let selected = id == selectedId
|
||||||
|
Button {
|
||||||
|
onSelect(id)
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.foregroundStyle(selected ? MatchColors.accentYellow : .clear)
|
||||||
|
Text(
|
||||||
|
StreamQualityLadder.displayName(
|
||||||
|
id: id,
|
||||||
|
autoLabel: L10n.t("broadcast.min.quality.auto")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.font(.system(size: 13, weight: selected ? .semibold : .medium))
|
||||||
|
.foregroundStyle(selected ? MatchColors.accentYellow : .white)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineLimit(2)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
selected
|
||||||
|
? MatchColors.accentYellow.opacity(0.16)
|
||||||
|
: MatchColors.surfaceElevated,
|
||||||
|
in: RoundedRectangle(cornerRadius: 12)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(selected ? MatchColors.accentYellow : MatchColors.outline, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: 720)
|
||||||
|
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.stroke(MatchColors.outline, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct BroadcastTelemetryPanel: View {
|
private struct BroadcastTelemetryPanel: View {
|
||||||
let cableConnected: Bool
|
let cableConnected: Bool
|
||||||
let fps: Int
|
let fps: Int
|
||||||
@@ -195,6 +300,7 @@ private struct BroadcastTelemetryPanel: View {
|
|||||||
let bitrateKbps: Int
|
let bitrateKbps: Int
|
||||||
let networkType: String
|
let networkType: String
|
||||||
let deviceHealth: DeviceHealth
|
let deviceHealth: DeviceHealth
|
||||||
|
let minQualityLabel: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
@@ -211,6 +317,9 @@ private struct BroadcastTelemetryPanel: View {
|
|||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 11, weight: .medium))
|
||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
}
|
}
|
||||||
|
Text(minQualityLabel)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(MatchColors.accentYellow)
|
||||||
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
|
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 11, weight: .medium))
|
||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user