Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f92ab3a813 | ||
|
|
bc2257efd2 | ||
|
|
4af4fa68ac | ||
|
|
45bdda7c95 | ||
|
|
b926531447 | ||
|
|
da6fc1d523 |
@@ -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
|
||||||
|
|||||||
@@ -11,17 +11,8 @@ module Public
|
|||||||
load_club_billing_context_for_pricing
|
load_club_billing_context_for_pricing
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
def support
|
||||||
|
@app_store_review_chrome = true
|
||||||
def load_club_billing_context_for_pricing
|
|
||||||
return unless logged_in?
|
|
||||||
|
|
||||||
@club = current_user.primary_club
|
|
||||||
return unless @club
|
|
||||||
|
|
||||||
@subscription = @club.subscription
|
|
||||||
@team = @club.teams.order(:name).first
|
|
||||||
@entitlements = @team&.entitlements
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def privacy
|
def privacy
|
||||||
@@ -38,5 +29,18 @@ module Public
|
|||||||
|
|
||||||
def pallavolo
|
def pallavolo
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_club_billing_context_for_pricing
|
||||||
|
return unless logged_in?
|
||||||
|
|
||||||
|
@club = current_user.primary_club
|
||||||
|
return unless @club
|
||||||
|
|
||||||
|
@subscription = @club.subscription
|
||||||
|
@team = @club.teams.order(:name).first
|
||||||
|
@entitlements = @team&.entitlements
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ module Public
|
|||||||
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
||||||
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
||||||
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
||||||
|
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
|
||||||
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
|
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
|
||||||
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
|
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -54,15 +54,34 @@ module Public
|
|||||||
copyPrompt: t("regia.js.copy_prompt"),
|
copyPrompt: t("regia.js.copy_prompt"),
|
||||||
shareRegiaText: t("regia.js.share_regia_text"),
|
shareRegiaText: t("regia.js.share_regia_text"),
|
||||||
shareLiveText: t("regia.js.share_live_text"),
|
shareLiveText: t("regia.js.share_live_text"),
|
||||||
|
minQualityUpdated: t("regia.js.min_quality_updated"),
|
||||||
|
minQualityError: t("regia.js.min_quality_error"),
|
||||||
|
minQualityAuto: t("regia.min_quality.auto"),
|
||||||
previewWaiting: t("regia.preview_waiting"),
|
previewWaiting: t("regia.preview_waiting"),
|
||||||
streamEnded: t("regia.preview_ended"),
|
streamEnded: t("regia.preview_ended"),
|
||||||
|
previewHide: t("regia.preview_hide"),
|
||||||
|
previewShow: t("regia.preview_show"),
|
||||||
|
previewOffHint: t("regia.preview_off_hint"),
|
||||||
resumeLabel: t("regia.resume"),
|
resumeLabel: t("regia.resume"),
|
||||||
pauseLabel: t("regia.pause"),
|
pauseLabel: t("regia.pause"),
|
||||||
endedBadge: t("regia.status.ended"),
|
endedBadge: t("regia.status.ended"),
|
||||||
pausedBadge: t("regia.status.paused"),
|
pausedBadge: t("regia.status.paused"),
|
||||||
liveBadge: t("regia.status.live"),
|
liveBadge: t("regia.status.live"),
|
||||||
waitingBadge: t("regia.status.waiting"),
|
waitingBadge: t("regia.status.waiting"),
|
||||||
subtitlePrefix: t("regia.subtitle_prefix")
|
subtitlePrefix: t("regia.subtitle_prefix"),
|
||||||
|
telemetryWaiting: t("regia.telemetry.waiting"),
|
||||||
|
telemetryStale: t("regia.telemetry.stale"),
|
||||||
|
telemetryJustNow: t("regia.telemetry.just_now"),
|
||||||
|
telemetrySecondsAgo: t("regia.telemetry.seconds_ago"),
|
||||||
|
telemetryPublisherOn: t("regia.telemetry.publisher_on"),
|
||||||
|
telemetryPublisherOff: t("regia.telemetry.publisher_off"),
|
||||||
|
telemetryThermalNominal: t("regia.telemetry.thermal_nominal"),
|
||||||
|
telemetryThermalFair: t("regia.telemetry.thermal_fair"),
|
||||||
|
telemetryThermalSerious: t("regia.telemetry.thermal_serious"),
|
||||||
|
telemetryThermalCritical: t("regia.telemetry.thermal_critical"),
|
||||||
|
telemetryBitrateMax: t("regia.telemetry.bitrate_max"),
|
||||||
|
telemetryBitrateTitle: t("regia.telemetry.bitrate_title"),
|
||||||
|
telemetryBitrateTap: t("regia.telemetry.bitrate_tap")
|
||||||
}.to_json
|
}.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -16,4 +16,17 @@ class DeviceState < ApplicationRecord
|
|||||||
status: stream_session.status
|
status: stream_session.status
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def as_regia_payload
|
||||||
|
{
|
||||||
|
battery: battery_level,
|
||||||
|
network: network_type,
|
||||||
|
signal: signal_strength,
|
||||||
|
bitrate: current_bitrate,
|
||||||
|
target_bitrate: target_bitrate,
|
||||||
|
fps: fps,
|
||||||
|
thermal_state: thermal_state,
|
||||||
|
last_seen_at: last_seen_at&.iso8601
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class StreamSession < ApplicationRecord
|
|||||||
validates :platform, inclusion: { in: PLATFORMS }
|
validates :platform, inclusion: { in: PLATFORMS }
|
||||||
validates :status, inclusion: { in: STATUSES }
|
validates :status, inclusion: { in: STATUSES }
|
||||||
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
|
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
|
||||||
|
validates :min_quality_preset, inclusion: { in: Sessions::SelectQuality::MIN_QUALITY_IDS }
|
||||||
|
|
||||||
before_validation :normalize_privacy_status
|
before_validation :normalize_privacy_status
|
||||||
before_validation :ensure_publish_token, on: :create
|
before_validation :ensure_publish_token, on: :create
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ module Sessions
|
|||||||
@session.pause! if @session.may_pause?
|
@session.pause! if @session.may_pause?
|
||||||
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
||||||
begin
|
begin
|
||||||
Mediamtx::Client.new.set_path_recording(@session, enabled: false)
|
mtx = Mediamtx::Client.new
|
||||||
|
mtx.set_path_recording(@session, enabled: false)
|
||||||
|
mtx.set_always_available(@session, enabled: true)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}")
|
Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}")
|
||||||
end
|
end
|
||||||
Mediamtx::Client.new.set_always_available(@session, enabled: true)
|
|
||||||
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
|
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
|
||||||
log_event("paused")
|
log_event("paused")
|
||||||
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ module Sessions
|
|||||||
class SelectQuality
|
class SelectQuality
|
||||||
UPLOAD_HEADROOM = 0.8
|
UPLOAD_HEADROOM = 0.8
|
||||||
UPLOAD_HEADROOM_1080P = 1.0
|
UPLOAD_HEADROOM_1080P = 1.0
|
||||||
|
AUTO_ID = "auto"
|
||||||
|
AUTO_FLOOR_BITRATE = 800_000
|
||||||
|
|
||||||
PRESETS = [
|
PRESETS = [
|
||||||
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
|
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
|
||||||
@@ -11,6 +13,32 @@ module Sessions
|
|||||||
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
|
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
|
MIN_QUALITY_IDS = ([AUTO_ID] + PRESETS.map { |preset| preset[:id] }).freeze
|
||||||
|
|
||||||
|
def self.floor_bitrate(preset_id)
|
||||||
|
return AUTO_FLOOR_BITRATE if preset_id.blank? || preset_id == AUTO_ID
|
||||||
|
|
||||||
|
PRESETS.find { |preset| preset[:id] == preset_id }&.fetch(:target_bitrate) || AUTO_FLOOR_BITRATE
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.allowed_min_ids(session_preset)
|
||||||
|
ids = [AUTO_ID] + PRESETS.reject { |preset| preset[:id].start_with?("1080p") }.reverse.map { |preset| preset[:id] }
|
||||||
|
ids << "1080p_30_4.5mbps" if session_preset.to_s.start_with?("1080p")
|
||||||
|
ids
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.display_name(preset_id)
|
||||||
|
return AUTO_ID if preset_id.blank? || preset_id == AUTO_ID
|
||||||
|
|
||||||
|
preset = PRESETS.find { |item| item[:id] == preset_id }
|
||||||
|
return preset_id unless preset
|
||||||
|
|
||||||
|
resolution = preset[:id].start_with?("1080p") ? "1080p" : "720p"
|
||||||
|
mbps = preset[:target_bitrate] / 1_000_000.0
|
||||||
|
mbps_text = (mbps == mbps.to_i) ? mbps.to_i.to_s : format("%.1f", mbps)
|
||||||
|
"#{resolution} · #{mbps_text} Mbps"
|
||||||
|
end
|
||||||
|
|
||||||
def self.call(upload_mbps:)
|
def self.call(upload_mbps:)
|
||||||
new(upload_mbps: upload_mbps).call
|
new(upload_mbps: upload_mbps).call
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Sessions
|
||||||
|
class SetMinQuality
|
||||||
|
def initialize(session, preset:)
|
||||||
|
@session = session
|
||||||
|
@preset = preset.to_s.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
id = normalize
|
||||||
|
unless SelectQuality.allowed_min_ids(@session.quality_preset).include?(id)
|
||||||
|
raise ArgumentError, "Qualità minima non valida"
|
||||||
|
end
|
||||||
|
|
||||||
|
return @session if @session.min_quality_preset == id
|
||||||
|
|
||||||
|
@session.update!(min_quality_preset: id)
|
||||||
|
@session.stream_events.create!(
|
||||||
|
event_type: "quality_changed",
|
||||||
|
occurred_at: Time.current,
|
||||||
|
metadata: { "min_quality_preset" => id }
|
||||||
|
)
|
||||||
|
SessionChannel.broadcast_message(
|
||||||
|
@session,
|
||||||
|
{ type: "command", action: "set_min_quality", min_quality_preset: id }
|
||||||
|
)
|
||||||
|
@session
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalize
|
||||||
|
return SelectQuality::AUTO_ID if @preset.blank? || @preset == SelectQuality::AUTO_ID
|
||||||
|
|
||||||
|
@preset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -12,13 +12,13 @@
|
|||||||
</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" %>
|
||||||
<%= render "shared/marketing_nav" %>
|
<%= render(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %>
|
||||||
<main>
|
<main>
|
||||||
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
||||||
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
</main>
|
</main>
|
||||||
<%= render "shared/marketing_footer" %>
|
<%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %>
|
||||||
<script src="/branding-form.js?v=1" defer></script>
|
<script src="/branding-form.js?v=1" defer></script>
|
||||||
<script src="/roster-form.js?v=1" defer></script>
|
<script src="/roster-form.js?v=1" defer></script>
|
||||||
<script src="/password-toggle.js?v=2" defer></script>
|
<script src="/password-toggle.js?v=2" defer></script>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<% content_for :title, t("legal.support.title") %>
|
||||||
|
<% content_for :meta_description, t("legal.support.meta_description") %>
|
||||||
|
<% content_for :canonical_url, seo_absolute_url(public_support_path) %>
|
||||||
|
|
||||||
|
<div class="wrap legal-doc">
|
||||||
|
<h1><%= t("legal.support.h1") %></h1>
|
||||||
|
<p><%= t("legal.support.intro") %></p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<%= t("legal.support.email_label") %>
|
||||||
|
<a href="mailto:<%= MatchLiveTv.support_email %>"><%= MatchLiveTv.support_email %></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><%= t("legal.support.access_title") %></h2>
|
||||||
|
<p><%= t("legal.support.access_body") %></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><%= t("legal.support.live_title") %></h2>
|
||||||
|
<p><%= t("legal.support.live_body") %></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><%= t("legal.support.team_title") %></h2>
|
||||||
|
<p><%= t("legal.support.team_body") %></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><%= t("legal.support.privacy_title") %></h2>
|
||||||
|
<p>
|
||||||
|
<%= raw t(
|
||||||
|
"legal.support.privacy_body_html",
|
||||||
|
privacy_link: link_to(t("legal.support.privacy_link_text"), public_privacy_path)
|
||||||
|
) %>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -21,4 +21,4 @@
|
|||||||
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
<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>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<%= link_to t("common.support"), public_support_path %> ·
|
||||||
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
||||||
<%= link_to t("footer.live"), public_live_index_path %> ·
|
<%= link_to t("footer.live"), public_live_index_path %> ·
|
||||||
<%= link_to t("common.faq"), public_faq_path %> ·
|
<%= link_to t("common.faq"), public_faq_path %> ·
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %>
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= link_to t("common.support"), public_support_path %> ·
|
||||||
|
<%= link_to t("common.privacy"), public_privacy_path %> ·
|
||||||
|
<%= link_to t("common.cookies"), public_cookies_path %> ·
|
||||||
|
<%= link_to t("common.terms"), public_termini_path %>
|
||||||
|
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
||||||
|
</div>
|
||||||
|
<div class="site-footer__legal">
|
||||||
|
<p><%= t("footer.copyright") %></p>
|
||||||
|
<p><%= t("footer.responsibility") %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<%# Chrome minimale per App Store Review: branding, lingua, Privacy e Supporto — senza CTA commerciali. %>
|
||||||
|
<div class="site-chrome">
|
||||||
|
<div class="site-masthead">
|
||||||
|
<div class="wrap mast-inner">
|
||||||
|
<%= link_to public_support_path, class: "mast-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
|
||||||
|
<img class="mast-brand-logo" src="/logo.png?v=3" alt="Match Live TV" width="40" height="40" decoding="async">
|
||||||
|
<span class="brand" aria-hidden="true">Match <span>Live TV</span></span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="mast-tools">
|
||||||
|
<button type="button" class="nav-toggle" aria-label="<%= t('nav.open_menu') %>" aria-expanded="false" aria-controls="site-nav">
|
||||||
|
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
|
||||||
|
<div class="nav-panel">
|
||||||
|
<div class="nav-mobile-head">
|
||||||
|
<%= link_to public_support_path, class: "nav-mobile-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
|
||||||
|
<img class="nav-mobile-brand-logo" src="/logo.png?v=3" alt="" width="40" height="40" decoding="async">
|
||||||
|
<span class="brand">Match <span>Live TV</span></span>
|
||||||
|
<% end %>
|
||||||
|
<div class="nav-lang">
|
||||||
|
<%= render "shared/language_switcher" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%= link_to t("common.support"), public_support_path, class: "nav-active" %>
|
||||||
|
<%= link_to t("common.privacy"), public_privacy_path, class: (request.path == "/privacy" ? "nav-active" : nil) %>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-backdrop" id="nav-backdrop" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var chrome = document.querySelector(".site-chrome");
|
||||||
|
var toggle = document.querySelector(".nav-toggle");
|
||||||
|
var backdrop = document.getElementById("nav-backdrop");
|
||||||
|
var nav = document.getElementById("site-nav");
|
||||||
|
if (!chrome || !toggle || !nav) return;
|
||||||
|
var openLabel = <%= raw t("nav.open_menu").to_json %>;
|
||||||
|
var closeLabel = <%= raw t("nav.close_menu").to_json %>;
|
||||||
|
|
||||||
|
function setOpen(open) {
|
||||||
|
chrome.classList.toggle("nav-open", open);
|
||||||
|
document.body.classList.toggle("nav-menu-open", open);
|
||||||
|
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
toggle.setAttribute("aria-label", open ? closeLabel : openLabel);
|
||||||
|
nav.setAttribute("aria-hidden", open ? "false" : "true");
|
||||||
|
if (backdrop) backdrop.setAttribute("aria-hidden", open ? "false" : "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() { setOpen(false); }
|
||||||
|
|
||||||
|
toggle.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
document.body.classList.contains("nav-menu-open") ? closeMenu() : setOpen(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (backdrop) backdrop.addEventListener("click", closeMenu);
|
||||||
|
|
||||||
|
function shouldCloseNavOnControl(el) {
|
||||||
|
if (!el.closest("[data-lang-switcher]")) return true;
|
||||||
|
return el.classList.contains("lang-switcher__option");
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.querySelectorAll("a, button").forEach(function (el) {
|
||||||
|
el.addEventListener("click", function () {
|
||||||
|
if (shouldCloseNavOnControl(el)) closeMenu();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("resize", function () {
|
||||||
|
if (window.matchMedia("(min-width: 900px)").matches) closeMenu();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Escape") closeMenu();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ module MatchLiveTv
|
|||||||
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
|
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Email di supporto (App Store Support URL e contatti assistenza).
|
||||||
|
# Preferisce SUPPORT_CONTACT_EMAIL; default = contatto commerciale già usato nel sito.
|
||||||
|
def support_email
|
||||||
|
ENV["SUPPORT_CONTACT_EMAIL"].presence || "info@matchlivetv.it"
|
||||||
|
end
|
||||||
|
|
||||||
def privacy_controller_address
|
def privacy_controller_address
|
||||||
ENV.fetch("PRIVACY_CONTROLLER_ADDRESS", "Via Guido De Ruggiero, 89 - 20142 - Milano (MI)")
|
ENV.fetch("PRIVACY_CONTROLLER_ADDRESS", "Via Guido De Ruggiero, 89 - 20142 - Milano (MI)")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -432,16 +432,51 @@ de:
|
|||||||
paused: Pausiert
|
paused: Pausiert
|
||||||
live: Live
|
live: Live
|
||||||
waiting: Wartet
|
waiting: Wartet
|
||||||
live_share_label: "Live-Link (Zuschauer)"
|
live_share_label: Live
|
||||||
share_live: Livestream teilen
|
share_live: Livestream teilen
|
||||||
|
share_action: Teilen
|
||||||
copy_link: Link kopieren
|
copy_link: Link kopieren
|
||||||
regia_share_label: "Regie-Link (Punktestand)"
|
copy_action: Kopieren
|
||||||
|
copy_live: Live kopieren
|
||||||
|
copy_regia: Regie kopieren
|
||||||
|
links_nav: Schnellaktionen
|
||||||
|
regia_share_label: Regie
|
||||||
|
stream_controls_label: Live
|
||||||
share_regia: Regie-Link teilen
|
share_regia: Regie-Link teilen
|
||||||
preview_ended: Livestream beendet
|
preview_ended: Livestream beendet
|
||||||
preview_waiting: Vorschau wartet auf Signal…
|
preview_waiting: Vorschau wartet auf Signal…
|
||||||
|
preview_hide: Vorschau ausblenden
|
||||||
|
preview_show: Vorschau anzeigen
|
||||||
|
preview_off_hint: Vorschau aus · kein Video-Download
|
||||||
pause: Pausieren
|
pause: Pausieren
|
||||||
resume: Livestream fortsetzen
|
resume: Livestream fortsetzen
|
||||||
stop: Livestream beenden
|
stop: Livestream beenden
|
||||||
|
min_quality:
|
||||||
|
label: Minimale Übertragungsqualität
|
||||||
|
summary: Qualität
|
||||||
|
hint: Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.
|
||||||
|
recommend: "Automatisch wird empfohlen: passt sich an Netz und Telefon an, ohne das Maximum zu erzwingen."
|
||||||
|
auto: Automatisch
|
||||||
|
telemetry:
|
||||||
|
label: Telefon
|
||||||
|
waiting: Warte auf das Telefon…
|
||||||
|
stale: Telefon antwortet nicht
|
||||||
|
just_now: jetzt
|
||||||
|
seconds_ago: "vor %{n}s"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP fehlt
|
||||||
|
battery: Akku
|
||||||
|
network: Netz
|
||||||
|
bitrate: Bitrate
|
||||||
|
bitrate_max: "max %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps aktuell · max. %{target} Mbps"
|
||||||
|
bitrate_tap: Tippen, um die Mindestqualität festzulegen
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Warm
|
||||||
|
thermal_serious: Heiß
|
||||||
|
thermal_critical: Kritisch
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Satz gewonnen
|
set_won_title: Satz gewonnen
|
||||||
confirm: Satz beenden
|
confirm: Satz beenden
|
||||||
@@ -486,6 +521,8 @@ de:
|
|||||||
copy_prompt: "Link kopieren:"
|
copy_prompt: "Link kopieren:"
|
||||||
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
|
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
|
||||||
share_live_text: "Sieh dir die Übertragung auf Match Live TV an:"
|
share_live_text: "Sieh dir die Übertragung auf Match Live TV an:"
|
||||||
|
min_quality_updated: Mindestqualität aktualisiert
|
||||||
|
min_quality_error: Fehler beim Aktualisieren der Mindestqualität
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -427,16 +427,51 @@ en:
|
|||||||
paused: Paused
|
paused: Paused
|
||||||
live: Live
|
live: Live
|
||||||
waiting: Waiting
|
waiting: Waiting
|
||||||
live_share_label: "Live link (viewers)"
|
live_share_label: Live
|
||||||
share_live: Share live link
|
share_live: Share live link
|
||||||
|
share_action: Share
|
||||||
copy_link: Copy link
|
copy_link: Copy link
|
||||||
regia_share_label: "Control room link (score)"
|
copy_action: Copy
|
||||||
|
copy_live: Copy live
|
||||||
|
copy_regia: Copy room
|
||||||
|
links_nav: Quick actions
|
||||||
|
regia_share_label: Control
|
||||||
|
stream_controls_label: Live
|
||||||
share_regia: Share control room link
|
share_regia: Share control room link
|
||||||
preview_ended: Stream ended
|
preview_ended: Stream ended
|
||||||
preview_waiting: Waiting for signal…
|
preview_waiting: Waiting for signal…
|
||||||
|
preview_hide: Hide preview
|
||||||
|
preview_show: Show preview
|
||||||
|
preview_off_hint: Preview off · no video download
|
||||||
pause: Pause stream
|
pause: Pause stream
|
||||||
resume: Resume stream
|
resume: Resume stream
|
||||||
stop: End stream
|
stop: End stream
|
||||||
|
min_quality:
|
||||||
|
label: Minimum streaming quality
|
||||||
|
summary: Quality
|
||||||
|
hint: The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.
|
||||||
|
recommend: "Automatic is recommended: it adapts to the network and the phone, without forcing maximum quality."
|
||||||
|
auto: Automatic
|
||||||
|
telemetry:
|
||||||
|
label: Phone
|
||||||
|
waiting: Waiting for the phone…
|
||||||
|
stale: Phone not responding
|
||||||
|
just_now: now
|
||||||
|
seconds_ago: "%{n}s ago"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP down
|
||||||
|
battery: Battery
|
||||||
|
network: Network
|
||||||
|
bitrate: Bitrate
|
||||||
|
bitrate_max: "max %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps current · max %{target} Mbps"
|
||||||
|
bitrate_tap: Tap to set the minimum quality
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Warm
|
||||||
|
thermal_serious: Hot
|
||||||
|
thermal_critical: Critical
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set won
|
set_won_title: Set won
|
||||||
confirm: Close set
|
confirm: Close set
|
||||||
@@ -481,6 +516,8 @@ en:
|
|||||||
copy_prompt: "Copy the link:"
|
copy_prompt: "Copy the link:"
|
||||||
share_regia_text: "Open this link to manage the live score:"
|
share_regia_text: "Open this link to manage the live score:"
|
||||||
share_live_text: "Watch the live stream on Match Live TV:"
|
share_live_text: "Watch the live stream on Match Live TV:"
|
||||||
|
min_quality_updated: Minimum quality updated
|
||||||
|
min_quality_error: Error updating minimum quality
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -432,16 +432,51 @@ es:
|
|||||||
paused: En pausa
|
paused: En pausa
|
||||||
live: En directo
|
live: En directo
|
||||||
waiting: En espera
|
waiting: En espera
|
||||||
live_share_label: "Enlace del directo (espectadores)"
|
live_share_label: Directo
|
||||||
share_live: Compartir directo
|
share_live: Compartir directo
|
||||||
|
share_action: Compartir
|
||||||
copy_link: Copiar enlace
|
copy_link: Copiar enlace
|
||||||
regia_share_label: "Enlace de control (marcador)"
|
copy_action: Copiar
|
||||||
|
copy_live: Copiar directo
|
||||||
|
copy_regia: Copiar control
|
||||||
|
links_nav: Acciones rápidas
|
||||||
|
regia_share_label: Control
|
||||||
|
stream_controls_label: Live
|
||||||
share_regia: Compartir enlace de control
|
share_regia: Compartir enlace de control
|
||||||
preview_ended: Directo finalizado
|
preview_ended: Directo finalizado
|
||||||
preview_waiting: Vista previa a la espera de señal…
|
preview_waiting: Vista previa a la espera de señal…
|
||||||
|
preview_hide: Ocultar vista previa
|
||||||
|
preview_show: Mostrar vista previa
|
||||||
|
preview_off_hint: Vista previa apagada · sin descarga de vídeo
|
||||||
pause: Pausar directo
|
pause: Pausar directo
|
||||||
resume: Reanudar directo
|
resume: Reanudar directo
|
||||||
stop: Finalizar directo
|
stop: Finalizar directo
|
||||||
|
min_quality:
|
||||||
|
label: Calidad mínima de transmisión
|
||||||
|
summary: Calidad
|
||||||
|
hint: El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.
|
||||||
|
recommend: "Se recomienda Automática: se adapta a la red y al teléfono, sin forzar el máximo."
|
||||||
|
auto: Automática
|
||||||
|
telemetry:
|
||||||
|
label: Teléfono
|
||||||
|
waiting: Esperando al teléfono…
|
||||||
|
stale: El teléfono no responde
|
||||||
|
just_now: ahora
|
||||||
|
seconds_ago: "hace %{n}s"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP ausente
|
||||||
|
battery: Batería
|
||||||
|
network: Red
|
||||||
|
bitrate: Bitrate
|
||||||
|
bitrate_max: "máx %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps actuales · máximo %{target} Mbps"
|
||||||
|
bitrate_tap: Toca para definir la calidad mínima
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Caliente
|
||||||
|
thermal_serious: Muy caliente
|
||||||
|
thermal_critical: Crítica
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set ganado
|
set_won_title: Set ganado
|
||||||
confirm: Cerrar set
|
confirm: Cerrar set
|
||||||
@@ -486,6 +521,8 @@ es:
|
|||||||
copy_prompt: "Copia el enlace:"
|
copy_prompt: "Copia el enlace:"
|
||||||
share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
|
share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
|
||||||
share_live_text: "Mira la retransmisión en Match Live TV:"
|
share_live_text: "Mira la retransmisión en Match Live TV:"
|
||||||
|
min_quality_updated: Calidad mínima actualizada
|
||||||
|
min_quality_error: Error al actualizar la calidad mínima
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -432,16 +432,51 @@ fr:
|
|||||||
paused: En pause
|
paused: En pause
|
||||||
live: En direct
|
live: En direct
|
||||||
waiting: En attente
|
waiting: En attente
|
||||||
live_share_label: "Lien direct (spectateurs)"
|
live_share_label: Direct
|
||||||
share_live: Partager le direct
|
share_live: Partager le direct
|
||||||
|
share_action: Partager
|
||||||
copy_link: Copier le lien
|
copy_link: Copier le lien
|
||||||
regia_share_label: "Lien régie (score)"
|
copy_action: Copier
|
||||||
|
copy_live: Copier live
|
||||||
|
copy_regia: Copier régie
|
||||||
|
links_nav: Actions rapides
|
||||||
|
regia_share_label: Régie
|
||||||
|
stream_controls_label: Live
|
||||||
share_regia: Partager le lien régie
|
share_regia: Partager le lien régie
|
||||||
preview_ended: Direct terminé
|
preview_ended: Direct terminé
|
||||||
preview_waiting: Aperçu en attente du signal…
|
preview_waiting: Aperçu en attente du signal…
|
||||||
|
preview_hide: Masquer l'aperçu
|
||||||
|
preview_show: Afficher l'aperçu
|
||||||
|
preview_off_hint: Aperçu désactivé · aucun téléchargement vidéo
|
||||||
pause: Mettre en pause
|
pause: Mettre en pause
|
||||||
resume: Reprendre le direct
|
resume: Reprendre le direct
|
||||||
stop: Terminer le direct
|
stop: Terminer le direct
|
||||||
|
min_quality:
|
||||||
|
label: Qualité minimale de transmission
|
||||||
|
summary: Qualité
|
||||||
|
hint: L'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.
|
||||||
|
recommend: "Automatique est conseillé : s'adapte au réseau et au téléphone, sans forcer le maximum."
|
||||||
|
auto: Automatique
|
||||||
|
telemetry:
|
||||||
|
label: Téléphone
|
||||||
|
waiting: En attente du téléphone…
|
||||||
|
stale: Téléphone sans réponse
|
||||||
|
just_now: maintenant
|
||||||
|
seconds_ago: "il y a %{n}s"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP absent
|
||||||
|
battery: Batterie
|
||||||
|
network: Réseau
|
||||||
|
bitrate: Débit
|
||||||
|
bitrate_max: "max %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps actuels · max %{target} Mbps"
|
||||||
|
bitrate_tap: Touchez pour définir la qualité minimale
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Chaude
|
||||||
|
thermal_serious: Très chaude
|
||||||
|
thermal_critical: Critique
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set gagné
|
set_won_title: Set gagné
|
||||||
confirm: Terminer le set
|
confirm: Terminer le set
|
||||||
@@ -486,6 +521,8 @@ fr:
|
|||||||
copy_prompt: "Copiez le lien :"
|
copy_prompt: "Copiez le lien :"
|
||||||
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
|
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
|
||||||
share_live_text: "Regardez le direct sur Match Live TV :"
|
share_live_text: "Regardez le direct sur Match Live TV :"
|
||||||
|
min_quality_updated: Qualité minimale mise à jour
|
||||||
|
min_quality_error: Erreur de mise à jour de la qualité minimale
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -427,16 +427,51 @@ it:
|
|||||||
paused: In pausa
|
paused: In pausa
|
||||||
live: In onda
|
live: In onda
|
||||||
waiting: In attesa
|
waiting: In attesa
|
||||||
live_share_label: "Link diretta (spettatori)"
|
live_share_label: Diretta
|
||||||
share_live: Condividi diretta
|
share_live: Condividi diretta
|
||||||
|
share_action: Condividi
|
||||||
copy_link: Copia link
|
copy_link: Copia link
|
||||||
regia_share_label: "Link regia (punteggio)"
|
copy_action: Copia
|
||||||
|
copy_live: Copia live
|
||||||
|
copy_regia: Copia regia
|
||||||
|
links_nav: Azioni rapide
|
||||||
|
regia_share_label: Regia
|
||||||
|
stream_controls_label: Live
|
||||||
share_regia: Condividi link regia
|
share_regia: Condividi link regia
|
||||||
preview_ended: Diretta terminata
|
preview_ended: Diretta terminata
|
||||||
preview_waiting: Anteprima in attesa del segnale…
|
preview_waiting: Anteprima in attesa del segnale…
|
||||||
|
preview_hide: Nascondi anteprima
|
||||||
|
preview_show: Mostra anteprima
|
||||||
|
preview_off_hint: Anteprima spenta · nessun download video
|
||||||
pause: Metti in pausa
|
pause: Metti in pausa
|
||||||
resume: Riprendi diretta
|
resume: Riprendi diretta
|
||||||
stop: Chiudi diretta
|
stop: Chiudi diretta
|
||||||
|
min_quality:
|
||||||
|
label: Qualità minima di trasmissione
|
||||||
|
summary: Qualità
|
||||||
|
hint: L'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.
|
||||||
|
recommend: "Consigliata Automatica: si adatta alla rete e al telefono, senza forzare il massimo."
|
||||||
|
auto: Automatica
|
||||||
|
telemetry:
|
||||||
|
label: Telefono
|
||||||
|
waiting: In attesa del telefono…
|
||||||
|
stale: Telefono non risponde
|
||||||
|
just_now: ora
|
||||||
|
seconds_ago: "%{n}s fa"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP assente
|
||||||
|
battery: Batteria
|
||||||
|
network: Rete
|
||||||
|
bitrate: Bitrate
|
||||||
|
bitrate_max: "max %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps attuali · massimo %{target} Mbps"
|
||||||
|
bitrate_tap: Tocca per impostare la qualità minima
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Calda
|
||||||
|
thermal_serious: Molto calda
|
||||||
|
thermal_critical: Critica
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set vinto
|
set_won_title: Set vinto
|
||||||
confirm: Chiudi set
|
confirm: Chiudi set
|
||||||
@@ -481,6 +516,8 @@ it:
|
|||||||
copy_prompt: "Copia il link:"
|
copy_prompt: "Copia il link:"
|
||||||
share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
|
share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
|
||||||
share_live_text: "Guarda la diretta su Match Live TV:"
|
share_live_text: "Guarda la diretta su Match Live TV:"
|
||||||
|
min_quality_updated: Qualità minima aggiornata
|
||||||
|
min_quality_error: Errore aggiornamento qualità minima
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -195,3 +195,18 @@ de:
|
|||||||
s6_body_html: "Um die von der DSGVO vorgesehenen Rechte auszuüben (Zugang, Löschung, Widerspruch, Widerruf der Einwilligung), schreiben Sie an %{email_link}. Details finden Sie im %{privacy_doc_link}."
|
s6_body_html: "Um die von der DSGVO vorgesehenen Rechte auszuüben (Zugang, Löschung, Widerspruch, Widerruf der Einwilligung), schreiben Sie an %{email_link}. Details finden Sie im %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: Datenschutzdokument
|
s6_privacy_doc_link_text: Datenschutzdokument
|
||||||
manage_button: Cookie-Einstellungen verwalten
|
manage_button: Cookie-Einstellungen verwalten
|
||||||
|
support:
|
||||||
|
title: Match Live TV Support
|
||||||
|
meta_description: "Match Live TV Hilfe: Zugangsprobleme, Livestreams, Teamverwaltung und Support-Kontaktdaten."
|
||||||
|
h1: Match Live TV Support
|
||||||
|
intro: "Brauchen Sie Hilfe mit Match Live TV? Bei Problemen mit dem Zugang, der Einrichtung von Livestreams, der Spielverwaltung oder der Nutzung der App können Sie unseren Support kontaktieren."
|
||||||
|
email_label: "Support-E-Mail:"
|
||||||
|
access_title: Zugangsprobleme
|
||||||
|
access_body: "Wenn Sie sich nicht anmelden können, prüfen Sie die Zugangsdaten, die Sie von Ihrem Sportverein erhalten haben. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support."
|
||||||
|
live_title: Probleme während eines Livestreams
|
||||||
|
live_body: "Prüfen Sie Ihre Internetverbindung und versuchen Sie erneut, die Übertragung in der App zu starten. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support und geben Sie Gerät, App-Version und eine kurze Beschreibung des Problems an."
|
||||||
|
team_title: Teamverwaltung
|
||||||
|
team_body: "Konten und Übertragungsberechtigungen werden vom Sportverein verwaltet."
|
||||||
|
privacy_title: Datenschutz
|
||||||
|
privacy_body_html: "Informationen zur Verarbeitung personenbezogener Daten finden Sie auf der Seite %{privacy_link}."
|
||||||
|
privacy_link_text: Datenschutz
|
||||||
|
|||||||
@@ -195,3 +195,18 @@ en:
|
|||||||
s6_body_html: "To exercise the rights provided by the GDPR (access, erasure, objection, withdrawal of consent) write to %{email_link}. Details are in the %{privacy_doc_link}."
|
s6_body_html: "To exercise the rights provided by the GDPR (access, erasure, objection, withdrawal of consent) write to %{email_link}. Details are in the %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: privacy document
|
s6_privacy_doc_link_text: privacy document
|
||||||
manage_button: Manage cookie preferences
|
manage_button: Manage cookie preferences
|
||||||
|
support:
|
||||||
|
title: Match Live TV Support
|
||||||
|
meta_description: "Match Live TV help: access issues, live streaming, team management and support contact details."
|
||||||
|
h1: Match Live TV Support
|
||||||
|
intro: "Need help with Match Live TV? For access issues, live stream setup, match management or app usage, you can contact our support team."
|
||||||
|
email_label: "Support email:"
|
||||||
|
access_title: Access issues
|
||||||
|
access_body: "If you cannot sign in, check the credentials provided by your sports club. If the problem persists, contact support."
|
||||||
|
live_title: Issues during a live stream
|
||||||
|
live_body: "Check your Internet connection and try starting the broadcast from the app again. If the problem persists, contact support and include your device, app version and a short description of the issue."
|
||||||
|
team_title: Team management
|
||||||
|
team_body: "Accounts and broadcasting permissions are managed by the sports club."
|
||||||
|
privacy_title: Privacy
|
||||||
|
privacy_body_html: "For information on personal data processing, see the %{privacy_link} page."
|
||||||
|
privacy_link_text: Privacy
|
||||||
|
|||||||
@@ -195,3 +195,18 @@ es:
|
|||||||
s6_body_html: "Para ejercer los derechos previstos por el RGPD (acceso, supresión, oposición, revocación del consentimiento) escribe a %{email_link}. Más detalles en el %{privacy_doc_link}."
|
s6_body_html: "Para ejercer los derechos previstos por el RGPD (acceso, supresión, oposición, revocación del consentimiento) escribe a %{email_link}. Más detalles en el %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: documento de privacidad
|
s6_privacy_doc_link_text: documento de privacidad
|
||||||
manage_button: Gestionar preferencias de cookies
|
manage_button: Gestionar preferencias de cookies
|
||||||
|
support:
|
||||||
|
title: Soporte Match Live TV
|
||||||
|
meta_description: "Ayuda de Match Live TV: problemas de acceso, directos, gestión del equipo y datos de contacto del soporte."
|
||||||
|
h1: Soporte Match Live TV
|
||||||
|
intro: "¿Necesitas ayuda con Match Live TV? Para problemas de acceso, configuración de directos, gestión de partidos o uso de la app puedes contactar con nuestro soporte."
|
||||||
|
email_label: "Correo de soporte:"
|
||||||
|
access_title: Problemas de acceso
|
||||||
|
access_body: "Si no puedes acceder, verifica las credenciales recibidas de tu club deportivo. Si el problema continúa, contacta con el soporte."
|
||||||
|
live_title: Problemas durante un directo
|
||||||
|
live_body: "Verifica la conexión a Internet e intenta de nuevo iniciar la transmisión desde la app. Si el problema continúa, contacta con el soporte indicando el dispositivo, la versión de la app y una breve descripción del problema."
|
||||||
|
team_title: Gestión del equipo
|
||||||
|
team_body: "Las cuentas y los permisos de transmisión los gestiona el club deportivo."
|
||||||
|
privacy_title: Privacidad
|
||||||
|
privacy_body_html: "Para información sobre el tratamiento de datos personales, consulta la página %{privacy_link}."
|
||||||
|
privacy_link_text: Privacidad
|
||||||
|
|||||||
@@ -195,3 +195,18 @@ fr:
|
|||||||
s6_body_html: "Pour exercer les droits prévus par le RGPD (accès, effacement, opposition, retrait du consentement), écrivez à %{email_link}. Détails dans le %{privacy_doc_link}."
|
s6_body_html: "Pour exercer les droits prévus par le RGPD (accès, effacement, opposition, retrait du consentement), écrivez à %{email_link}. Détails dans le %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: document de confidentialité
|
s6_privacy_doc_link_text: document de confidentialité
|
||||||
manage_button: Gérer les préférences de cookies
|
manage_button: Gérer les préférences de cookies
|
||||||
|
support:
|
||||||
|
title: Support Match Live TV
|
||||||
|
meta_description: "Assistance Match Live TV : problèmes d'accès, directs, gestion d'équipe et coordonnées du support."
|
||||||
|
h1: Support Match Live TV
|
||||||
|
intro: "Besoin d'aide avec Match Live TV ? Pour les problèmes d'accès, la configuration des directs, la gestion des matchs ou l'utilisation de l'application, vous pouvez contacter notre support."
|
||||||
|
email_label: "E-mail du support :"
|
||||||
|
access_title: Problèmes d'accès
|
||||||
|
access_body: "Si vous ne parvenez pas à vous connecter, vérifiez les identifiants fournis par votre club sportif. Si le problème persiste, contactez le support."
|
||||||
|
live_title: Problèmes pendant un direct
|
||||||
|
live_body: "Vérifiez votre connexion Internet et réessayez de démarrer la diffusion depuis l'application. Si le problème persiste, contactez le support en indiquant l'appareil, la version de l'application et une brève description du problème."
|
||||||
|
team_title: Gestion de l'équipe
|
||||||
|
team_body: "Les comptes et les autorisations de diffusion sont gérés par le club sportif."
|
||||||
|
privacy_title: Confidentialité
|
||||||
|
privacy_body_html: "Pour les informations sur le traitement des données personnelles, consultez la page %{privacy_link}."
|
||||||
|
privacy_link_text: Confidentialité
|
||||||
|
|||||||
@@ -195,3 +195,18 @@ it:
|
|||||||
s6_body_html: "Per esercitare i diritti previsti dal GDPR (accesso, cancellazione, opposizione, revoca consenso) scrivi a %{email_link}. Dettagli nel %{privacy_doc_link}."
|
s6_body_html: "Per esercitare i diritti previsti dal GDPR (accesso, cancellazione, opposizione, revoca consenso) scrivi a %{email_link}. Dettagli nel %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: documento privacy
|
s6_privacy_doc_link_text: documento privacy
|
||||||
manage_button: Gestisci preferenze cookie
|
manage_button: Gestisci preferenze cookie
|
||||||
|
support:
|
||||||
|
title: Supporto Match Live TV
|
||||||
|
meta_description: "Assistenza Match Live TV: problemi di accesso, dirette live, gestione squadra e contatti del supporto."
|
||||||
|
h1: Supporto Match Live TV
|
||||||
|
intro: "Hai bisogno di assistenza con Match Live TV? Per problemi di accesso, configurazione delle dirette, gestione delle partite o utilizzo dell’app puoi contattare il nostro supporto."
|
||||||
|
email_label: "Email di supporto:"
|
||||||
|
access_title: Problemi di accesso
|
||||||
|
access_body: "Se non riesci ad accedere, verifica le credenziali ricevute dalla tua società sportiva. Se il problema persiste, contatta il supporto."
|
||||||
|
live_title: Problemi durante una diretta
|
||||||
|
live_body: "Verifica la connessione Internet e riprova ad avviare la trasmissione dall’app. Se il problema persiste, contatta il supporto indicando dispositivo, versione dell’app e una breve descrizione del problema."
|
||||||
|
team_title: Gestione della squadra
|
||||||
|
team_body: "Gli account e le autorizzazioni per la trasmissione sono gestiti dalla società sportiva."
|
||||||
|
privacy_title: Privacy
|
||||||
|
privacy_body_html: "Per informazioni sul trattamento dei dati personali consulta la pagina %{privacy_link}."
|
||||||
|
privacy_link_text: Privacy
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ de:
|
|||||||
save_password: Passwort aktualisieren
|
save_password: Passwort aktualisieren
|
||||||
common:
|
common:
|
||||||
privacy: Datenschutz
|
privacy: Datenschutz
|
||||||
|
support: Support
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: AGB
|
terms: AGB
|
||||||
pricing: Preise
|
pricing: Preise
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ en:
|
|||||||
save_password: Update password
|
save_password: Update password
|
||||||
common:
|
common:
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
|
support: Support
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Terms
|
terms: Terms
|
||||||
pricing: Pricing
|
pricing: Pricing
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ es:
|
|||||||
save_password: Actualizar contraseña
|
save_password: Actualizar contraseña
|
||||||
common:
|
common:
|
||||||
privacy: Privacidad
|
privacy: Privacidad
|
||||||
|
support: Soporte
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Términos
|
terms: Términos
|
||||||
pricing: Precios
|
pricing: Precios
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ fr:
|
|||||||
save_password: Mettre à jour le mot de passe
|
save_password: Mettre à jour le mot de passe
|
||||||
common:
|
common:
|
||||||
privacy: Confidentialité
|
privacy: Confidentialité
|
||||||
|
support: Support
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Conditions
|
terms: Conditions
|
||||||
pricing: Tarifs
|
pricing: Tarifs
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ it:
|
|||||||
save_password: Aggiorna password
|
save_password: Aggiorna password
|
||||||
common:
|
common:
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
|
support: Supporto
|
||||||
cookies: Cookie
|
cookies: Cookie
|
||||||
terms: Termini
|
terms: Termini
|
||||||
pricing: Prezzi
|
pricing: Prezzi
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -154,6 +156,7 @@ Rails.application.routes.draw do
|
|||||||
get "prezzi", to: "pages#pricing", as: :prezzi
|
get "prezzi", to: "pages#pricing", as: :prezzi
|
||||||
get "pricing", to: redirect("/prezzi")
|
get "pricing", to: redirect("/prezzi")
|
||||||
get "privacy", to: "pages#privacy", as: :privacy
|
get "privacy", to: "pages#privacy", as: :privacy
|
||||||
|
get "support", to: "pages#support", as: :support
|
||||||
get "cookie", to: "pages#cookies", as: :cookies
|
get "cookie", to: "pages#cookies", as: :cookies
|
||||||
get "cookies", to: redirect("/cookie")
|
get "cookies", to: redirect("/cookie")
|
||||||
get "termini", to: "pages#terms", as: :termini
|
get "termini", to: "pages#terms", as: :termini
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddMinQualityPresetToStreamSessions < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :stream_sessions, :min_quality_preset, :string, default: "auto", null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddThermalStateToDeviceStates < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :device_states, :thermal_state, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
Generated
+29
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
@@ -138,6 +138,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
t.datetime "last_seen_at"
|
t.datetime "last_seen_at"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "thermal_state"
|
||||||
t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true
|
t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true
|
||||||
t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id"
|
t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id"
|
||||||
end
|
end
|
||||||
@@ -255,6 +256,29 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id"
|
t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "stream_nodes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.string "slug", null: false
|
||||||
|
t.string "hostname", null: false
|
||||||
|
t.string "role", default: "home", null: false
|
||||||
|
t.string "status", default: "ready", null: false
|
||||||
|
t.string "rtmp_base_url", null: false
|
||||||
|
t.string "hls_base_url", null: false
|
||||||
|
t.string "api_base_url", null: false
|
||||||
|
t.string "internal_rtmp_url"
|
||||||
|
t.string "internal_hls_url"
|
||||||
|
t.integer "max_publishers", default: 6, null: false
|
||||||
|
t.integer "max_relays", default: 6, null: false
|
||||||
|
t.string "provider", default: "local", null: false
|
||||||
|
t.string "provider_instance_id"
|
||||||
|
t.datetime "last_health_at"
|
||||||
|
t.jsonb "metadata", default: {}, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["role"], name: "index_stream_nodes_on_role"
|
||||||
|
t.index ["slug"], name: "index_stream_nodes_on_slug", unique: true
|
||||||
|
t.index ["status"], name: "index_stream_nodes_on_status"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "match_id", null: false
|
t.uuid "match_id", null: false
|
||||||
t.uuid "user_id", null: false
|
t.uuid "user_id", null: false
|
||||||
@@ -280,10 +304,13 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.string "regia_token_digest"
|
t.string "regia_token_digest"
|
||||||
t.datetime "regia_token_expires_at"
|
t.datetime "regia_token_expires_at"
|
||||||
|
t.uuid "stream_node_id"
|
||||||
|
t.string "min_quality_preset", default: "auto", null: false
|
||||||
t.index ["match_id"], name: "index_stream_sessions_on_match_id"
|
t.index ["match_id"], name: "index_stream_sessions_on_match_id"
|
||||||
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
||||||
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
||||||
t.index ["status"], name: "index_stream_sessions_on_status"
|
t.index ["status"], name: "index_stream_sessions_on_status"
|
||||||
|
t.index ["stream_node_id"], name: "index_stream_sessions_on_stream_node_id"
|
||||||
t.index ["user_id"], name: "index_stream_sessions_on_user_id"
|
t.index ["user_id"], name: "index_stream_sessions_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -411,6 +438,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
add_foreign_key "score_states", "stream_sessions"
|
add_foreign_key "score_states", "stream_sessions"
|
||||||
add_foreign_key "stream_events", "stream_sessions"
|
add_foreign_key "stream_events", "stream_sessions"
|
||||||
add_foreign_key "stream_sessions", "matches"
|
add_foreign_key "stream_sessions", "matches"
|
||||||
|
add_foreign_key "stream_sessions", "stream_nodes"
|
||||||
add_foreign_key "stream_sessions", "users"
|
add_foreign_key "stream_sessions", "users"
|
||||||
add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
|
add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
|
||||||
add_foreign_key "subscriptions", "clubs"
|
add_foreign_key "subscriptions", "clubs"
|
||||||
|
|||||||
+277
-41
@@ -4,6 +4,8 @@
|
|||||||
--regia-surface: #14141c;
|
--regia-surface: #14141c;
|
||||||
--regia-red: #e53935;
|
--regia-red: #e53935;
|
||||||
--regia-yellow: #f9a825;
|
--regia-yellow: #f9a825;
|
||||||
|
--regia-green: #43a047;
|
||||||
|
--regia-orange: #fb8c00;
|
||||||
--regia-text: #f5f5f5;
|
--regia-text: #f5f5f5;
|
||||||
--regia-muted: #9ca3af;
|
--regia-muted: #9ca3af;
|
||||||
--regia-safe-bottom: env(safe-area-inset-bottom, 0px);
|
--regia-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
@@ -22,37 +24,166 @@
|
|||||||
.regia-page {
|
.regia-page {
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 12px 16px calc(24px + var(--regia-safe-bottom));
|
padding: 8px 12px calc(12px + var(--regia-safe-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-header__text {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-header h1 {
|
.regia-header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-header p {
|
.regia-header p {
|
||||||
margin: 4px 0 0;
|
margin: 2px 0 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.78rem;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-badge {
|
.regia-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 8px;
|
margin-top: 0;
|
||||||
padding: 4px 10px;
|
padding: 3px 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 0.7rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-badge--live { background: #2e7d32; color: #fff; }
|
.regia-badge--live { background: #2e7d32; color: #fff; }
|
||||||
.regia-badge--wait { background: #444; color: #ddd; }
|
.regia-badge--wait { background: #444; color: #ddd; }
|
||||||
.regia-badge--ended { background: #374151; color: #ddd; }
|
.regia-badge--ended { background: #374151; color: #ddd; }
|
||||||
|
|
||||||
|
.regia-toolbar {
|
||||||
|
margin: 8px 0 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-tool-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-tool-group__label {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-tool-group__btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-tool-group--stream {
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #16161f;
|
||||||
|
color: var(--regia-text);
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn svg.icon-fill {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn svg.icon-outline {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn .icon-eye-off,
|
||||||
|
.regia-icon-btn .icon-play {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-off .icon-eye {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-off .icon-eye-off {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-paused .icon-pause {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-paused .icon-play {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn:active { transform: scale(0.96); }
|
||||||
|
|
||||||
|
.regia-icon-btn.is-off {
|
||||||
|
color: var(--regia-yellow);
|
||||||
|
border-color: #5a4a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn--danger {
|
||||||
|
color: var(--regia-red);
|
||||||
|
border-color: #7a2a28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-preview-wrap {
|
||||||
|
margin: 6px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-preview-wrap.is-off {
|
||||||
|
margin: 0;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.regia-preview {
|
.regia-preview {
|
||||||
margin: 14px 0;
|
margin: 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
@@ -90,29 +221,29 @@
|
|||||||
.regia-scoreboard {
|
.regia-scoreboard {
|
||||||
background: var(--regia-surface);
|
background: var(--regia-surface);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 16px;
|
padding: 10px 12px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-sets {
|
.regia-sets {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.82rem;
|
font-size: 0.78rem;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
margin: 0 0 8px;
|
margin: 0 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-score-line {
|
.regia-score-line {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1.5rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-partials {
|
.regia-partials {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.78rem;
|
font-size: 0.75rem;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
margin: 8px 0 0;
|
margin: 4px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-team-row {
|
.regia-team-row {
|
||||||
@@ -120,7 +251,7 @@
|
|||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 16px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-team-name {
|
.regia-team-name {
|
||||||
@@ -131,7 +262,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.regia-points {
|
.regia-points {
|
||||||
font-size: 2rem;
|
font-size: 1.55rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -146,7 +277,7 @@
|
|||||||
.regia-btn {
|
.regia-btn {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 16px 12px;
|
padding: 10px 8px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -156,8 +287,8 @@
|
|||||||
|
|
||||||
.regia-btn:active { transform: scale(0.98); }
|
.regia-btn:active { transform: scale(0.98); }
|
||||||
.regia-btn--point { background: var(--regia-red); color: #fff; }
|
.regia-btn--point { background: var(--regia-red); color: #fff; }
|
||||||
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 12px; }
|
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 8px; }
|
||||||
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 10px; }
|
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 8px; padding: 12px; }
|
||||||
.regia-btn--outline {
|
.regia-btn--outline {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid #444;
|
border: 1px solid #444;
|
||||||
@@ -173,26 +304,6 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-share-group {
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-share-label {
|
|
||||||
margin: 0 0 6px;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--regia-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-share {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-share .regia-btn { flex: 1; font-size: 0.9rem; padding: 12px; }
|
|
||||||
|
|
||||||
.regia-toast {
|
.regia-toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: calc(16px + var(--regia-safe-bottom));
|
bottom: calc(16px + var(--regia-safe-bottom));
|
||||||
@@ -240,7 +351,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
padding-top: 24px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-clock-line {
|
.regia-clock-line {
|
||||||
@@ -255,3 +366,128 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.regia-quality {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding: 8px 10px 10px;
|
||||||
|
background: var(--regia-surface);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-quality[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
.regia-quality__hint {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-quality label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-quality select {
|
||||||
|
width: 100%;
|
||||||
|
background: #0a0a0e;
|
||||||
|
color: var(--regia-text);
|
||||||
|
border: 1px solid #2a2a35;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-telemetry {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-telemetry__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 2px 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--regia-surface);
|
||||||
|
color: var(--regia-text);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip--action {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip--action:focus-visible {
|
||||||
|
outline: 2px solid var(--regia-yellow);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip--action.is-open {
|
||||||
|
border-color: #3a3a48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip__label {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip__value {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.15;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip__hint {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip__hint[hidden] { display: none; }
|
||||||
|
|
||||||
|
.regia-chip.is-ok { color: var(--regia-green); }
|
||||||
|
.regia-chip.is-warn { color: var(--regia-orange); }
|
||||||
|
.regia-chip.is-bad { color: var(--regia-red); }
|
||||||
|
|
||||||
|
.regia-telemetry__meta {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-telemetry.is-stale .regia-telemetry__meta {
|
||||||
|
color: var(--regia-yellow);
|
||||||
|
}
|
||||||
|
|||||||
+262
-5
@@ -37,13 +37,32 @@
|
|||||||
shareLiveText: "Guarda la diretta su Match Live TV:",
|
shareLiveText: "Guarda la diretta su Match Live TV:",
|
||||||
previewWaiting: "Anteprima in attesa del segnale…",
|
previewWaiting: "Anteprima in attesa del segnale…",
|
||||||
streamEnded: "Diretta terminata",
|
streamEnded: "Diretta terminata",
|
||||||
|
previewHide: "Nascondi anteprima",
|
||||||
|
previewShow: "Mostra anteprima",
|
||||||
|
previewOffHint: "Anteprima spenta · nessun download video",
|
||||||
resumeLabel: "Riprendi diretta",
|
resumeLabel: "Riprendi diretta",
|
||||||
pauseLabel: "Metti in pausa",
|
pauseLabel: "Metti in pausa",
|
||||||
endedBadge: "Terminata",
|
endedBadge: "Terminata",
|
||||||
pausedBadge: "In pausa",
|
pausedBadge: "In pausa",
|
||||||
liveBadge: "In onda",
|
liveBadge: "In onda",
|
||||||
waitingBadge: "In attesa",
|
waitingBadge: "In attesa",
|
||||||
subtitlePrefix: "Regia ·"
|
subtitlePrefix: "Regia ·",
|
||||||
|
minQualityUpdated: "Qualità minima aggiornata",
|
||||||
|
minQualityError: "Errore aggiornamento qualità minima",
|
||||||
|
minQualityAuto: "Automatica",
|
||||||
|
telemetryWaiting: "In attesa del telefono…",
|
||||||
|
telemetryStale: "Telefono non risponde",
|
||||||
|
telemetryJustNow: "ora",
|
||||||
|
telemetrySecondsAgo: "%{n}s fa",
|
||||||
|
telemetryPublisherOn: "RTMP ok",
|
||||||
|
telemetryPublisherOff: "RTMP assente",
|
||||||
|
telemetryThermalNominal: "OK",
|
||||||
|
telemetryThermalFair: "Calda",
|
||||||
|
telemetryThermalSerious: "Calda",
|
||||||
|
telemetryThermalCritical: "Critica",
|
||||||
|
telemetryBitrateMax: "max %{n}",
|
||||||
|
telemetryBitrateTitle: "%{current} Mbps attuali · massimo %{target} Mbps",
|
||||||
|
telemetryBitrateTap: "Tocca per impostare la qualità minima"
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadI18n() {
|
function loadI18n() {
|
||||||
@@ -77,6 +96,7 @@
|
|||||||
pauseUrl: root.dataset.pauseUrl,
|
pauseUrl: root.dataset.pauseUrl,
|
||||||
resumeUrl: root.dataset.resumeUrl,
|
resumeUrl: root.dataset.resumeUrl,
|
||||||
stopUrl: root.dataset.stopUrl,
|
stopUrl: root.dataset.stopUrl,
|
||||||
|
minQualityUrl: root.dataset.minQualityUrl,
|
||||||
cableUrl: root.dataset.cableUrl,
|
cableUrl: root.dataset.cableUrl,
|
||||||
hlsUrl: root.dataset.hlsUrl,
|
hlsUrl: root.dataset.hlsUrl,
|
||||||
homeName: root.dataset.homeName,
|
homeName: root.dataset.homeName,
|
||||||
@@ -102,15 +122,28 @@
|
|||||||
modalCancel: document.getElementById("modal-cancel"),
|
modalCancel: document.getElementById("modal-cancel"),
|
||||||
preview: document.getElementById("regia-preview"),
|
preview: document.getElementById("regia-preview"),
|
||||||
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
||||||
|
previewWrap: document.getElementById("regia-preview-wrap"),
|
||||||
|
btnTogglePreview: document.getElementById("btn-toggle-preview"),
|
||||||
btnPause: document.getElementById("btn-pause"),
|
btnPause: document.getElementById("btn-pause"),
|
||||||
subtitle: document.getElementById("regia-subtitle")
|
minQuality: document.getElementById("min-quality"),
|
||||||
|
subtitle: document.getElementById("regia-subtitle"),
|
||||||
|
telemetry: document.getElementById("regia-telemetry"),
|
||||||
|
telBattery: document.getElementById("tel-battery"),
|
||||||
|
telNetwork: document.getElementById("tel-network"),
|
||||||
|
telBitrate: document.getElementById("tel-bitrate"),
|
||||||
|
telFps: document.getElementById("tel-fps"),
|
||||||
|
telThermal: document.getElementById("tel-thermal"),
|
||||||
|
telMeta: document.getElementById("tel-meta"),
|
||||||
|
qualityPanel: document.getElementById("regia-quality")
|
||||||
};
|
};
|
||||||
|
|
||||||
let pendingAction = null;
|
let pendingAction = null;
|
||||||
let hls = null;
|
let hls = null;
|
||||||
let previewStarted = false;
|
let previewStarted = false;
|
||||||
|
let previewWanted = true;
|
||||||
let wasPausedPreview = false;
|
let wasPausedPreview = false;
|
||||||
let streamPaused = root.dataset.initialPaused === "true";
|
let streamPaused = root.dataset.initialPaused === "true";
|
||||||
|
const PREVIEW_PREF_KEY = "mltv-regia-preview";
|
||||||
|
|
||||||
function hlsUrlFresh() {
|
function hlsUrlFresh() {
|
||||||
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
|
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
|
||||||
@@ -139,8 +172,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readPreviewPref() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(PREVIEW_PREF_KEY) !== "0";
|
||||||
|
} catch (_) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePreviewPref(on) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PREVIEW_PREF_KEY, on ? "1" : "0");
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPreviewToggleUi() {
|
||||||
|
if (!els.previewWrap || !els.btnTogglePreview) return;
|
||||||
|
const label = previewWanted ? I18N.previewHide : I18N.previewShow;
|
||||||
|
els.previewWrap.classList.toggle("is-off", !previewWanted);
|
||||||
|
els.btnTogglePreview.classList.toggle("is-off", !previewWanted);
|
||||||
|
els.btnTogglePreview.setAttribute("aria-pressed", previewWanted ? "true" : "false");
|
||||||
|
els.btnTogglePreview.setAttribute("aria-label", label);
|
||||||
|
els.btnTogglePreview.title = previewWanted ? I18N.previewHide : I18N.previewOffHint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPreviewWanted(on) {
|
||||||
|
previewWanted = !!on;
|
||||||
|
writePreviewPref(previewWanted);
|
||||||
|
syncPreviewToggleUi();
|
||||||
|
if (previewWanted) {
|
||||||
|
startPreview();
|
||||||
|
} else {
|
||||||
|
destroyPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function refreshPreviewForCover() {
|
function refreshPreviewForCover() {
|
||||||
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed) return;
|
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed || !previewWanted) return;
|
||||||
destroyPreview();
|
destroyPreview();
|
||||||
previewStarted = false;
|
previewStarted = false;
|
||||||
startPreview();
|
startPreview();
|
||||||
@@ -243,7 +311,10 @@
|
|||||||
if (!els.btnPause) return;
|
if (!els.btnPause) return;
|
||||||
const paused = !!(data.paused || data.status === "paused");
|
const paused = !!(data.paused || data.status === "paused");
|
||||||
streamPaused = paused;
|
streamPaused = paused;
|
||||||
els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel;
|
const label = paused ? I18N.resumeLabel : I18N.pauseLabel;
|
||||||
|
els.btnPause.classList.toggle("is-paused", paused);
|
||||||
|
els.btnPause.setAttribute("aria-label", label);
|
||||||
|
els.btnPause.title = label;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBadge(data) {
|
function setBadge(data) {
|
||||||
@@ -364,6 +435,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const paused = data.paused || data.status === "paused";
|
const paused = data.paused || data.status === "paused";
|
||||||
|
if (!previewWanted) {
|
||||||
|
destroyPreview();
|
||||||
|
wasPausedPreview = paused;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.on_air) {
|
if (data.on_air) {
|
||||||
if (!previewStarted) {
|
if (!previewStarted) {
|
||||||
startPreview();
|
startPreview();
|
||||||
@@ -382,6 +458,119 @@
|
|||||||
wasPausedPreview = paused;
|
wasPausedPreview = paused;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mbpsNumber(bps) {
|
||||||
|
const n = Number(bps);
|
||||||
|
if (!n || n <= 0) return null;
|
||||||
|
const mbps = n >= 10_000 ? n / 1e6 : n / 1e3;
|
||||||
|
return mbps >= 10 ? mbps.toFixed(0) : mbps.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAge(iso) {
|
||||||
|
if (!iso) return null;
|
||||||
|
const then = Date.parse(iso);
|
||||||
|
if (!then) return null;
|
||||||
|
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
|
||||||
|
if (secs < 8) return I18N.telemetryJustNow;
|
||||||
|
return fillTemplate(I18N.telemetrySecondsAgo, { n: secs });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setChip(el, text, level, hint) {
|
||||||
|
if (!el) return;
|
||||||
|
const valueEl = el.querySelector(".regia-chip__value") || el;
|
||||||
|
valueEl.textContent = text;
|
||||||
|
el.classList.toggle("is-ok", level === "ok");
|
||||||
|
el.classList.toggle("is-warn", level === "warn");
|
||||||
|
el.classList.toggle("is-bad", level === "bad");
|
||||||
|
const hintEl = el.querySelector(".regia-chip__hint");
|
||||||
|
if (hintEl) {
|
||||||
|
hintEl.textContent = hint || "";
|
||||||
|
hintEl.hidden = !hint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatThermal(state) {
|
||||||
|
const key = String(state || "").toLowerCase();
|
||||||
|
if (key === "nominal") return { text: I18N.telemetryThermalNominal, level: "ok" };
|
||||||
|
if (key === "fair") return { text: I18N.telemetryThermalFair, level: "warn" };
|
||||||
|
if (key === "serious") return { text: I18N.telemetryThermalSerious, level: "bad" };
|
||||||
|
if (key === "critical") return { text: I18N.telemetryThermalCritical, level: "bad" };
|
||||||
|
return { text: "—", level: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bitrateLevel(current, target, paused) {
|
||||||
|
if (paused || !current || current <= 0) return null;
|
||||||
|
if (target && target > 0) {
|
||||||
|
const ratio = current / target;
|
||||||
|
if (ratio >= 0.7) return "ok";
|
||||||
|
if (ratio >= 0.4) return "warn";
|
||||||
|
return "bad";
|
||||||
|
}
|
||||||
|
const mbps = current >= 10_000 ? current / 1e6 : current / 1e3;
|
||||||
|
if (mbps >= 2.5) return "ok";
|
||||||
|
if (mbps >= 1.5) return "warn";
|
||||||
|
return "bad";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bitrateTitle(currentMbps, targetMbps) {
|
||||||
|
const tap = I18N.telemetryBitrateTap;
|
||||||
|
if (currentMbps && targetMbps) {
|
||||||
|
return `${fillTemplate(I18N.telemetryBitrateTitle, { current: currentMbps, target: targetMbps })} · ${tap}`;
|
||||||
|
}
|
||||||
|
if (currentMbps) return `${currentMbps} Mbps · ${tap}`;
|
||||||
|
return tap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTelemetry(data) {
|
||||||
|
if (!els.telemetry) return;
|
||||||
|
const tel = data.telemetry || {};
|
||||||
|
const battery = tel.battery;
|
||||||
|
const fps = tel.fps;
|
||||||
|
const bitrate = tel.bitrate;
|
||||||
|
const target = tel.target_bitrate || tel.targetBitrate;
|
||||||
|
const lastSeen = tel.last_seen_at || tel.lastSeenAt;
|
||||||
|
const ageSecs = lastSeen ? Math.max(0, Math.round((Date.now() - Date.parse(lastSeen)) / 1000)) : null;
|
||||||
|
const stale = ageSecs == null || ageSecs > 25;
|
||||||
|
const publishing = !!tel.publisher_online || !!tel.publisherOnline;
|
||||||
|
|
||||||
|
let battLevel = null;
|
||||||
|
let battText = "—";
|
||||||
|
if (battery != null && battery !== "") {
|
||||||
|
battText = `${battery}%`;
|
||||||
|
if (battery <= 15) battLevel = "bad";
|
||||||
|
else if (battery <= 25) battLevel = "warn";
|
||||||
|
}
|
||||||
|
setChip(els.telBattery, battText, battLevel);
|
||||||
|
setChip(els.telNetwork, tel.network || "—");
|
||||||
|
|
||||||
|
const currentMbps = mbpsNumber(bitrate);
|
||||||
|
const targetMbps = mbpsNumber(target);
|
||||||
|
const rateText = currentMbps || "—";
|
||||||
|
const rateHint = currentMbps && targetMbps
|
||||||
|
? fillTemplate(I18N.telemetryBitrateMax, { n: targetMbps })
|
||||||
|
: null;
|
||||||
|
if (els.telBitrate) els.telBitrate.title = bitrateTitle(currentMbps, targetMbps);
|
||||||
|
setChip(els.telBitrate, rateText, bitrateLevel(Number(bitrate), Number(target), data.paused), rateHint);
|
||||||
|
|
||||||
|
let fpsLevel = null;
|
||||||
|
if (fps != null && fps !== "" && Number(fps) > 0 && Number(fps) < 20) fpsLevel = "warn";
|
||||||
|
setChip(els.telFps, fps && Number(fps) > 0 ? String(fps) : "—", fpsLevel);
|
||||||
|
|
||||||
|
const thermal = formatThermal(tel.thermal_state || tel.thermalState);
|
||||||
|
setChip(els.telThermal, thermal.text, thermal.level);
|
||||||
|
|
||||||
|
els.telemetry.classList.toggle("is-stale", stale && !data.paused && !data.stream_closed);
|
||||||
|
if (!els.telMeta) return;
|
||||||
|
if (stale && !lastSeen) {
|
||||||
|
els.telMeta.textContent = I18N.telemetryWaiting;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parts = [publishing ? I18N.telemetryPublisherOn : I18N.telemetryPublisherOff];
|
||||||
|
const age = formatAge(lastSeen);
|
||||||
|
if (age) parts.push(age);
|
||||||
|
if (stale && !data.paused && !data.stream_closed) parts.push(I18N.telemetryStale);
|
||||||
|
els.telMeta.textContent = parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
async function pollStatus() {
|
async function pollStatus() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
|
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
|
||||||
@@ -389,6 +578,8 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
applyScorePayload(data);
|
applyScorePayload(data);
|
||||||
setBadge(data);
|
setBadge(data);
|
||||||
|
syncMinQuality(data);
|
||||||
|
syncTelemetry(data);
|
||||||
syncPreviewFromStatus(data);
|
syncPreviewFromStatus(data);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -401,7 +592,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startPreview() {
|
function startPreview() {
|
||||||
if (cfg.streamClosed || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
if (cfg.streamClosed || !previewWanted || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
||||||
|
|
||||||
previewStarted = true;
|
previewStarted = true;
|
||||||
const url = hlsUrlFresh();
|
const url = hlsUrlFresh();
|
||||||
@@ -454,6 +645,8 @@
|
|||||||
const wasPaused = streamPaused;
|
const wasPaused = streamPaused;
|
||||||
applyScorePayload(data);
|
applyScorePayload(data);
|
||||||
setBadge(data);
|
setBadge(data);
|
||||||
|
syncMinQuality(data);
|
||||||
|
syncTelemetry(data);
|
||||||
syncPreviewFromStatus(data);
|
syncPreviewFromStatus(data);
|
||||||
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
|
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
|
||||||
}
|
}
|
||||||
@@ -473,6 +666,70 @@
|
|||||||
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
|
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
|
||||||
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
|
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
|
||||||
|
|
||||||
|
let minQualityBusy = false;
|
||||||
|
|
||||||
|
function setQualityPanelOpen(open) {
|
||||||
|
if (!els.qualityPanel || !els.telBitrate) return;
|
||||||
|
els.qualityPanel.hidden = !open;
|
||||||
|
els.telBitrate.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
els.telBitrate.classList.toggle("is-open", open);
|
||||||
|
if (open && els.minQuality) {
|
||||||
|
window.requestAnimationFrame(() => els.minQuality.focus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMinQuality(data) {
|
||||||
|
if (!els.minQuality || minQualityBusy) return;
|
||||||
|
const value = data.min_quality_preset || "auto";
|
||||||
|
if (els.minQuality.value !== value) els.minQuality.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMinQuality(preset) {
|
||||||
|
if (!cfg.minQualityUrl) return;
|
||||||
|
minQualityBusy = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(cfg.minQualityUrl, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ min_quality_preset: preset })
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || I18N.minQualityError);
|
||||||
|
minQualityBusy = false;
|
||||||
|
syncMinQuality(data);
|
||||||
|
toast(I18N.minQualityUpdated);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || I18N.minQualityError);
|
||||||
|
} finally {
|
||||||
|
minQualityBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
els.minQuality?.addEventListener("change", () => {
|
||||||
|
setMinQuality(els.minQuality.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.telBitrate?.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const opening = !!(els.qualityPanel && els.qualityPanel.hidden);
|
||||||
|
setQualityPanelOpen(opening);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
if (!els.qualityPanel || els.qualityPanel.hidden) return;
|
||||||
|
if (els.qualityPanel.contains(e.target) || els.telBitrate?.contains(e.target)) return;
|
||||||
|
setQualityPanelOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") setQualityPanelOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.btnTogglePreview?.addEventListener("click", () => setPreviewWanted(!previewWanted));
|
||||||
|
previewWanted = readPreviewPref();
|
||||||
|
syncPreviewToggleUi();
|
||||||
|
if (!previewWanted) destroyPreview();
|
||||||
|
|
||||||
async function shareLink(url, title, text) {
|
async function shareLink(url, title, text) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
toast(I18N.linkUnavailable);
|
toast(I18N.linkUnavailable);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
require "rails_helper"
|
require "rails_helper"
|
||||||
|
|
||||||
RSpec.describe "Public regia", type: :request do
|
RSpec.describe "Public regia", type: :request do
|
||||||
let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "password123", role: "coach") }
|
let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "Password123", role: "coach") }
|
||||||
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||||
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
||||||
@@ -12,7 +12,18 @@ RSpec.describe "Public regia", type: :request do
|
|||||||
get public_regia_path(token)
|
get public_regia_path(token)
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.body).to include("Condividi diretta")
|
expect(response.body).to include("Condividi diretta")
|
||||||
|
expect(response.body).to include("Copia live")
|
||||||
expect(response.body).to include("Condividi link regia")
|
expect(response.body).to include("Condividi link regia")
|
||||||
|
expect(response.body).to include("Copia regia")
|
||||||
|
expect(response.body).to include(">Diretta<")
|
||||||
|
expect(response.body).to include(">Regia<")
|
||||||
|
expect(response.body).to include("regia-tool-group")
|
||||||
|
expect(response.body).to include("Nascondi anteprima")
|
||||||
|
expect(response.body).to include('id="btn-pause"')
|
||||||
|
expect(response.body).to include('id="btn-stop"')
|
||||||
|
expect(response.body).to include("Qualità minima di trasmissione")
|
||||||
|
expect(response.body).to include('id="tel-bitrate"')
|
||||||
|
expect(response.body).to include('aria-controls="regia-quality"')
|
||||||
expect(response.body).to include("/live/#{session.id}")
|
expect(response.body).to include("/live/#{session.id}")
|
||||||
expect(response.body).not_to include("Aruba")
|
expect(response.body).not_to include("Aruba")
|
||||||
end
|
end
|
||||||
@@ -156,4 +167,39 @@ RSpec.describe "Public regia", type: :request do
|
|||||||
get public_regia_path(token)
|
get public_regia_path(token)
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "aggiorna la qualità minima di trasmissione" do
|
||||||
|
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||||
|
patch public_regia_min_quality_path(token), params: { min_quality_preset: "720p_30_1.5mbps" }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(session.reload.min_quality_preset).to eq("720p_30_1.5mbps")
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["min_quality_preset"]).to eq("720p_30_1.5mbps")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "espone la telemetria della camera nello status" do
|
||||||
|
session.device_states.create!(
|
||||||
|
device_role: "camera",
|
||||||
|
battery_level: 81,
|
||||||
|
network_type: "WiFi",
|
||||||
|
current_bitrate: 2_400_000,
|
||||||
|
target_bitrate: 4_500_000,
|
||||||
|
fps: 30,
|
||||||
|
thermal_state: "nominal",
|
||||||
|
last_seen_at: Time.current
|
||||||
|
)
|
||||||
|
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||||
|
get public_regia_path(token)
|
||||||
|
expect(response.body).to include('id="regia-telemetry"')
|
||||||
|
|
||||||
|
get public_regia_status_path(token)
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
tel = response.parsed_body["telemetry"]
|
||||||
|
expect(tel["battery"]).to eq(81)
|
||||||
|
expect(tel["network"]).to eq("WiFi")
|
||||||
|
expect(tel["fps"]).to eq(30)
|
||||||
|
expect(tel["bitrate"]).to eq(2_400_000)
|
||||||
|
expect(tel["thermal_state"]).to eq("nominal")
|
||||||
|
expect(tel).to have_key("publisher_online")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Public support page", type: :request do
|
||||||
|
it "è pubblica, indicizzabile e mostra l'email di supporto configurata" do
|
||||||
|
get public_support_path
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include(MatchLiveTv.support_email)
|
||||||
|
expect(response.body).to include("mailto:#{MatchLiveTv.support_email}")
|
||||||
|
expect(response.body).to include(I18n.t("legal.support.h1", locale: :it))
|
||||||
|
expect(response.body).to include('rel="canonical"')
|
||||||
|
expect(response.body).to include(public_support_path)
|
||||||
|
expect(response.body).to include(public_privacy_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "non espone CTA o link commerciali (App Store Review)" do
|
||||||
|
get public_support_path
|
||||||
|
body = response.body
|
||||||
|
|
||||||
|
expect(body).not_to include(public_prezzi_path)
|
||||||
|
expect(body).not_to include(public_signup_path)
|
||||||
|
expect(body).not_to include('href="/prezzi"')
|
||||||
|
expect(body).not_to include('href="/signup"')
|
||||||
|
expect(body).not_to include(I18n.t("nav.signup", locale: :it))
|
||||||
|
expect(body).not_to include(I18n.t("nav.pricing", locale: :it))
|
||||||
|
expect(body).not_to include(I18n.t("common.pricing", locale: :it))
|
||||||
|
expect(body).not_to match(/\bPremium Light\b/i)
|
||||||
|
expect(body).not_to match(/\bPremium Full\b/i)
|
||||||
|
expect(body).not_to match(/\bpiano Free\b/i)
|
||||||
|
expect(body).not_to match(/\bAbbonati\b/i)
|
||||||
|
expect(body).not_to match(/\bAcquista\b/i)
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
"it" => "Supporto Match Live TV",
|
||||||
|
"en" => "Match Live TV Support",
|
||||||
|
"fr" => "Support Match Live TV",
|
||||||
|
"de" => "Match Live TV Support",
|
||||||
|
"es" => "Soporte Match Live TV"
|
||||||
|
}.each do |locale, heading|
|
||||||
|
it "renderizza correttamente in #{locale} senza translation missing" do
|
||||||
|
cookies[:mltv_locale] = locale
|
||||||
|
get public_support_path
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include(heading)
|
||||||
|
expect(response.body).not_to include("translation missing")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "include /support nella sitemap" do
|
||||||
|
get "/sitemap.xml"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("#{MatchLiveTv.app_public_url.chomp('/')}/support")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -11,4 +11,11 @@ RSpec.describe Sessions::SelectQuality do
|
|||||||
|
|
||||||
expect(preset[:id]).to eq("1080p_30_4.5mbps")
|
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
|
||||||
@@ -38,10 +38,10 @@ Solo se la società vuole il **proprio** canale invece di Match Live TV:
|
|||||||
|
|
||||||
Nella home app (**Partite**):
|
Nella home app (**Partite**):
|
||||||
|
|
||||||
- **Partita programmata** — scegli dal calendario una gara già inserita (sito o app)
|
|
||||||
- **Nuova partita** — programma data/ora oppure **Avvia subito** senza orario
|
- **Nuova partita** — programma data/ora oppure **Avvia subito** senza orario
|
||||||
|
- Oppure tocca una gara già in calendario (sito o app)
|
||||||
|
|
||||||
Poi tocca la card o conferma dal foglio: si apre il wizard (Partita → Trasmissione → …).
|
Poi conferma dal foglio o tocca la card: si apre il wizard (Partita → Trasmissione → …).
|
||||||
|
|
||||||
## 4. Avvia diretta
|
## 4. Avvia diretta
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Gap Android → iOS: allineamento app nativa
|
# Gap Android → iOS: allineamento app nativa
|
||||||
|
|
||||||
Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare un’app **allineata ad Android `2.0.5-native`**.
|
Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare un’app **allineata ad Android**.
|
||||||
|
|
||||||
**Aggiornato:** 24 luglio 2026
|
**Aggiornato:** 8 agosto 2026
|
||||||
**Riferimento Android (produzione / telefono / Play):** `2.0.5-native` (`versionCode` **26**), API `https://www.matchlivetv.it`
|
**Riferimento Android (produzione / telefono / Play):** `2.0.10-native` (`versionCode` **31**), API `https://www.matchlivetv.it`
|
||||||
**Stato iOS attuale:** marketing `2.0.5`, build `26` — i18n **allineato** (Login, hub, sheet/dialog, wizard, broadcast)
|
**Stato iOS attuale:** marketing `2.0.10`, build `31` — i18n **allineato** (Login, hub, sheet/dialog, wizard, broadcast, account/forgot)
|
||||||
|
|
||||||
Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog, wizard, broadcast) + bump versione, **senza** i bug di sessione/crash già risolti su Android 2.0.5.
|
Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog, wizard, broadcast, account) + bump versione, **senza** i bug di sessione/crash già risolti su Android.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ Queste sono regressioni/bug già visti su Android; **non ripeterli** su iOS.
|
|||||||
| Broadcast + score dialog | Sì | `broadcast.*` / `score.*` | **Fatto** |
|
| Broadcast + score dialog | Sì | `broadcast.*` / `score.*` | **Fatto** |
|
||||||
| Catalogo stringhe | `values*` (~230) | `AppLanguage.swift` L10n (~225, no FGS) | **Fatto** |
|
| Catalogo stringhe | `values*` (~230) | `AppLanguage.swift` L10n (~225, no FGS) | **Fatto** |
|
||||||
| Logout UI | Icona | Icona SF Symbol | **Fatto** |
|
| Logout UI | Icona | Icona SF Symbol | **Fatto** |
|
||||||
| Versione | `2.0.5-native` / **26** | `2.0.5` / **26** | **Fatto** |
|
| Versione | `2.0.10-native` / **31** | `2.0.10` / **31** | **Fatto** |
|
||||||
| RTMP ingest | `RtmpIngestUrl.kt` | `MediaUrl.swift` | OK |
|
| RTMP ingest | `RtmpIngestUrl.kt` | `MediaUrl.swift` | OK |
|
||||||
|
|
||||||
### Residui fuori scope Android (opzionali)
|
### Residui fuori scope Android (opzionali)
|
||||||
@@ -141,7 +141,7 @@ xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \
|
|||||||
|
|
||||||
Nessuna modifica server richiesta.
|
Nessuna modifica server richiesta.
|
||||||
|
|
||||||
Versione store target: **`2.0.5` / build `26`** (parità con Android `2.0.5-native` / versionCode `26`).
|
Versione store target: **`2.0.10` / build `31`** (parità con Android `2.0.10-native` / versionCode `31`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
Documento operativo per **Cursor su Mac**: allineare l’app iOS a backend/Android dopo il ramo `feature/password-policy` (merge su `main`).
|
Documento operativo per **Cursor su Mac**: allineare l’app iOS a backend/Android dopo il ramo `feature/password-policy` (merge su `main`).
|
||||||
|
|
||||||
**Aggiornato:** 2026-08-08
|
**Aggiornato:** 2026-08-08
|
||||||
**Android di riferimento (testato su emulatore → API locale):** `2.0.10-native` (`versionCode` **31**)
|
**Android di riferimento:** `2.0.10-native` (`versionCode` **31**)
|
||||||
**iOS oggi:** marketing `2.0.5` / build `26`
|
**iOS (allineato):** marketing `2.0.10` / build `31`
|
||||||
**API produzione:** `https://www.matchlivetv.it`
|
**API produzione:** `https://www.matchlivetv.it`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -51,86 +51,38 @@ Chiavi rilevanti (`backend/config/locales/app.{it,en,fr,de,es}.yml`):
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stato iOS vs Android (dopo il merge di questo branch)
|
## Stato iOS vs Android
|
||||||
|
|
||||||
| Area | Android 2.0.10 | iOS (repo dopo merge) | Da fare su Mac |
|
| Area | Android 2.0.10 | iOS | Stato |
|
||||||
|------|----------------|------------------------|----------------|
|
|------|----------------|-----|-------|
|
||||||
| Header `Accept-Language` su tutte le request API | `AppContainer` OkHttp interceptor | `MatchLiveAPI.request` + `multipartPatch` | **Verificare** in debug che parta su login/account/password; se manca su altri helper HTTP, allineare |
|
| Header `Accept-Language` su tutte le request API | OkHttp interceptor | `MatchLiveAPI.request` + `multipartPatch` | **OK** |
|
||||||
| Hint UI nuova password | `account_new_password` in `values*` (5 lingue) | `account.new.password` in `AppLanguage.swift` (già allineato al testo policy) | OK — rivedi solo se cambi copy |
|
| Hint UI nuova password | `account_new_password` | `account.new.password` | **OK** |
|
||||||
| Schermata Account | Mostra `error` dal body HTTP | `AccountScreen` + `APIError` estrae `error`/`message` | **Test manuale** (vedi sotto) |
|
| Schermata Account | AccountScreen | AccountScreen | **OK** |
|
||||||
| Parsing errori API | Regex su `"error"` | `APIError.friendlyHttpMessage` | OK |
|
| Forgot password | ForgotPasswordScreen | ForgotPasswordScreen | **OK** |
|
||||||
| Client-side pre-check complessità | No (si affida al server) | No | Opzionale: stessa regola di `PasswordComplexity` prima della PATCH |
|
| Parsing errori API | body `error` | `APIError.friendlyHttpMessage` | **OK** |
|
||||||
| Versione App Store | `2.0.10-native` / 31 | `2.0.5` / 26 | **Bump** marketing + build prima del submit TestFlight/App Store |
|
| Client-side pre-check complessità | No | No | N/A (come Android) |
|
||||||
| Signup in-app | Non è il flusso principale | Nessuna UI register dedicata | N/A (signup resta web) |
|
| Versione store | `2.0.10-native` / **31** | `2.0.10` / **31** | **OK** |
|
||||||
|
| Signup in-app | Non principale | Nessuna UI register | N/A (signup web) |
|
||||||
|
|
||||||
### File già toccati / da tenere
|
### File iOS
|
||||||
|
|
||||||
```
|
```
|
||||||
native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift # Accept-Language
|
native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift
|
||||||
native/ios/MatchLiveTv/Core/AppLanguage.swift # hint account.new.password (5 lingue)
|
native/ios/MatchLiveTv/Core/AppLanguage.swift
|
||||||
native/ios/MatchLiveTv/UI/Account/AccountScreen.swift
|
native/ios/MatchLiveTv/UI/Account/AccountScreen.swift
|
||||||
native/ios/MatchLiveTv/Core/UserFacingError.swift
|
native/ios/MatchLiveTv/UI/Login/ForgotPasswordScreen.swift
|
||||||
native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift # enum APIError
|
native/ios/MatchLiveTv/Resources/Info.plist
|
||||||
|
native/ios/generate_xcodeproj.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Riferimento Android:
|
|
||||||
|
|
||||||
```
|
|
||||||
native/android/.../data/AppContainer.kt
|
|
||||||
native/android/.../ui/account/AccountScreen.kt
|
|
||||||
native/android/app/src/main/res/values*/strings.xml # account_new_password
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Checklist operativa su Mac
|
|
||||||
|
|
||||||
1. **Pull** `main` (dopo merge/push da Linux).
|
|
||||||
2. Apri `native/ios/MatchLiveTv.xcodeproj`.
|
|
||||||
3. Build debug con `API_BASE_URL=https://www.matchlivetv.it` (o locale se hai tunnel).
|
|
||||||
4. **Test Account** (credenziali demo se disponibili, altrimenti account reale di test):
|
|
||||||
- password debole (`password`) → messaggio *too_weak* nella lingua UI
|
|
||||||
- password = corrente → *same_as_current*
|
|
||||||
- conferma diversa → *mismatch*
|
|
||||||
- password valida nuova → successo; login successivo ok
|
|
||||||
5. Cambia lingua app (globo) e ripeti un errore: il testo API deve seguire `Accept-Language` / lingua scelta.
|
|
||||||
6. **Bump versione** allineata ad Android se rilasci store: es. marketing `2.0.10`, build `≥ 31`.
|
|
||||||
7. (Opzionale) Unit test Swift che replica `PasswordComplexity.strong_enough?` se aggiungi validazione client.
|
|
||||||
|
|
||||||
### Comandi utili
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone/pull
|
|
||||||
git checkout main && git pull
|
|
||||||
|
|
||||||
# Release iOS (script repo)
|
|
||||||
./scripts/build_ios_release.sh
|
|
||||||
# oppure API staging:
|
|
||||||
# API_BASE_URL=https://www.matchlivetv.it ./scripts/build_ios_release.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cosa non serve rifare su iOS
|
|
||||||
|
|
||||||
- Fix CSS/menu mobile web, tabelle scroll, logo nel drawer: **solo sito**.
|
|
||||||
- Redirect `GET /account/password` → `/account`: **solo web**.
|
|
||||||
- Seed password / concern Rails: già lato server.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Retrocompatibilità (per release iOS)
|
|
||||||
|
|
||||||
- App iOS **vecchie** (senza `Accept-Language`): continuano a funzionare; messaggi API nella locale default del server.
|
|
||||||
- Login con password già esistenti: invariato.
|
|
||||||
- Solo **impostazione/cambio** password debole viene rifiutato (stesso JSON `error`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Criterio “fatto”
|
## Criterio “fatto”
|
||||||
|
|
||||||
- [ ] `Accept-Language` presente su login, me, account, change password, forgot (Charles/Proxyman o log)
|
- [x] `Accept-Language` su login, me, account, change password, forgot (`MatchLiveAPI`)
|
||||||
- [ ] Errori password in it/en/fr/de/es coerenti col backend
|
- [x] Hint campo nuova password parla di “min. 8 / 3 tipi”
|
||||||
- [ ] Hint campo nuova password parla di “min. 8 / 3 tipi”
|
- [x] Catalogo stringhe account/forgot in it/en/fr/de/es
|
||||||
- [ ] Versione bumpata se lo store release è in scope
|
- [x] Versione bumpata a `2.0.10` / `31`
|
||||||
- [ ] Nessuna regressione login / lista partite / broadcast
|
- [ ] QA manuale errori password in lingua UI (weak / same / mismatch / success)
|
||||||
|
|
||||||
Quando completo, aggiorna questo file con data + versione iOS rilasciata.
|
Quando rilasci su TestFlight/App Store, conferma la build `31`.
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
|
|||||||
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||||
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||||
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||||
|
# Opzionale: email dedicata di supporto (default = info@matchlivetv.it)
|
||||||
|
# SUPPORT_CONTACT_EMAIL=info@matchlivetv.it
|
||||||
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
|
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
|
||||||
PASSWORD_RESET_EXPIRY_HOURS=2
|
PASSWORD_RESET_EXPIRY_HOURS=2
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ RAILS_LOG_LEVEL=info
|
|||||||
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||||
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||||
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||||
|
# Opzionale: email di supporto App Store (default = info@matchlivetv.it)
|
||||||
|
# SUPPORT_CONTACT_EMAIL=info@matchlivetv.it
|
||||||
PRIVACY_CONTROLLER_VAT=
|
PRIVACY_CONTROLLER_VAT=
|
||||||
|
|
||||||
# Email transazionali (reset password, inviti)
|
# Email transazionali (reset password, inviti)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ services:
|
|||||||
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
|
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
|
||||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||||
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
||||||
|
SUPPORT_CONTACT_EMAIL: ${SUPPORT_CONTACT_EMAIL:-}
|
||||||
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
||||||
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
||||||
PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-}
|
PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-}
|
||||||
|
|||||||
@@ -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,6 +73,7 @@ services:
|
|||||||
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
||||||
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
||||||
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
||||||
|
SUPPORT_CONTACT_EMAIL: ${SUPPORT_CONTACT_EMAIL:-}
|
||||||
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlivetv.it>}
|
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlivetv.it>}
|
||||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
||||||
|
|||||||
+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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-9
@@ -57,7 +57,6 @@ import com.matchlivetv.match_live_tv.ui.components.LanguagePickerDialog
|
|||||||
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
|
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
|
||||||
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
||||||
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
|
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
|
||||||
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
|
|
||||||
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
|
||||||
|
|
||||||
@@ -237,18 +236,11 @@ fun MatchesScreen(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
MatchSecondaryButton(
|
|
||||||
label = stringResource(R.string.matches_schedule).uppercase(),
|
|
||||||
onClick = { showScheduleSheet = true },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
MatchPrimaryButton(
|
MatchPrimaryButton(
|
||||||
label = stringResource(R.string.matches_new).uppercase(),
|
label = stringResource(R.string.matches_new).uppercase(),
|
||||||
onClick = { showNewMatchSheet = true },
|
onClick = { showNewMatchSheet = true },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
activeTeam?.let { team ->
|
activeTeam?.let { team ->
|
||||||
TeamPickerBar(
|
TeamPickerBar(
|
||||||
|
|||||||
@@ -21,13 +21,12 @@
|
|||||||
<string name="login_error_generic">Anmeldung fehlgeschlagen</string>
|
<string name="login_error_generic">Anmeldung fehlgeschlagen</string>
|
||||||
<string name="matches_title">Spiele</string>
|
<string name="matches_title">Spiele</string>
|
||||||
<string name="matches_hello">Hallo, %1$s</string>
|
<string name="matches_hello">Hallo, %1$s</string>
|
||||||
<string name="matches_subtitle">Nimm einen laufenden Livestream wieder auf oder starte ein geplantes Spiel.</string>
|
<string name="matches_subtitle">Nimm einen laufenden Livestream wieder auf oder starte ein neues Spiel.</string>
|
||||||
<string name="matches_schedule">Geplantes Spiel</string>
|
|
||||||
<string name="matches_new">Neues Spiel</string>
|
<string name="matches_new">Neues Spiel</string>
|
||||||
<string name="matches_empty_title">Keine Spiele im Kalender</string>
|
<string name="matches_empty_title">Keine Spiele im Kalender</string>
|
||||||
<string name="matches_scheduled_title">Geplante Spiele</string>
|
<string name="matches_scheduled_title">Geplante Spiele</string>
|
||||||
<string name="matches_ready_title">Bereit zum Start</string>
|
<string name="matches_ready_title">Bereit zum Start</string>
|
||||||
<string name="matches_empty_hint">Plane ein Spiel oder starte ein neues mit «Neues Spiel».</string>
|
<string name="matches_empty_hint">Tippe auf «Neues Spiel», um im Voraus zu planen oder sofort zu starten.</string>
|
||||||
<string name="matches_active_team">Aktives Team: %1$s.</string>
|
<string name="matches_active_team">Aktives Team: %1$s.</string>
|
||||||
<string name="matches_multi_team_hint">Du hast mehrere Teams: prüfe die oben ausgewählte.</string>
|
<string name="matches_multi_team_hint">Du hast mehrere Teams: prüfe die oben ausgewählte.</string>
|
||||||
<string name="matches_no_other">Keine weiteren Spiele im Kalender.</string>
|
<string name="matches_no_other">Keine weiteren Spiele im Kalender.</string>
|
||||||
@@ -165,6 +164,13 @@
|
|||||||
<string name="broadcast_resume_cd">Livestream fortsetzen</string>
|
<string name="broadcast_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>
|
||||||
|
|||||||
@@ -21,13 +21,12 @@
|
|||||||
<string name="login_error_generic">Login failed</string>
|
<string name="login_error_generic">Login failed</string>
|
||||||
<string name="matches_title">Matches</string>
|
<string name="matches_title">Matches</string>
|
||||||
<string name="matches_hello">Hi, %1$s</string>
|
<string name="matches_hello">Hi, %1$s</string>
|
||||||
<string name="matches_subtitle">Resume a live stream or start a scheduled match.</string>
|
<string name="matches_subtitle">Resume a live stream or start a new match.</string>
|
||||||
<string name="matches_schedule">Scheduled match</string>
|
|
||||||
<string name="matches_new">New match</string>
|
<string name="matches_new">New match</string>
|
||||||
<string name="matches_empty_title">No matches on the calendar</string>
|
<string name="matches_empty_title">No matches on the calendar</string>
|
||||||
<string name="matches_scheduled_title">Scheduled matches</string>
|
<string name="matches_scheduled_title">Scheduled matches</string>
|
||||||
<string name="matches_ready_title">Ready to start</string>
|
<string name="matches_ready_title">Ready to start</string>
|
||||||
<string name="matches_empty_hint">Schedule a match or start a new one with «New match».</string>
|
<string name="matches_empty_hint">Tap «New match» to schedule ahead or start right away.</string>
|
||||||
<string name="matches_active_team">Active team: %1$s.</string>
|
<string name="matches_active_team">Active team: %1$s.</string>
|
||||||
<string name="matches_multi_team_hint">You have multiple teams: check the one selected above.</string>
|
<string name="matches_multi_team_hint">You have multiple teams: check the one selected above.</string>
|
||||||
<string name="matches_no_other">No other matches on the calendar.</string>
|
<string name="matches_no_other">No other matches on the calendar.</string>
|
||||||
@@ -165,6 +164,13 @@
|
|||||||
<string name="broadcast_resume_cd">Resume live</string>
|
<string name="broadcast_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>
|
||||||
|
|||||||
@@ -21,13 +21,12 @@
|
|||||||
<string name="login_error_generic">Error de acceso</string>
|
<string name="login_error_generic">Error de acceso</string>
|
||||||
<string name="matches_title">Partidos</string>
|
<string name="matches_title">Partidos</string>
|
||||||
<string name="matches_hello">Hola, %1$s</string>
|
<string name="matches_hello">Hola, %1$s</string>
|
||||||
<string name="matches_subtitle">Reanuda un directo en curso o inicia un partido programado.</string>
|
<string name="matches_subtitle">Reanuda un directo en curso o inicia un nuevo partido.</string>
|
||||||
<string name="matches_schedule">Partido programado</string>
|
|
||||||
<string name="matches_new">Nuevo partido</string>
|
<string name="matches_new">Nuevo partido</string>
|
||||||
<string name="matches_empty_title">Ningún partido en el calendario</string>
|
<string name="matches_empty_title">Ningún partido en el calendario</string>
|
||||||
<string name="matches_scheduled_title">Partidos programados</string>
|
<string name="matches_scheduled_title">Partidos programados</string>
|
||||||
<string name="matches_ready_title">Listos para empezar</string>
|
<string name="matches_ready_title">Listos para empezar</string>
|
||||||
<string name="matches_empty_hint">Programa un partido o inicia uno nuevo con «Nuevo partido».</string>
|
<string name="matches_empty_hint">Toca «Nuevo partido» para programar con antelación o empezar ya.</string>
|
||||||
<string name="matches_active_team">Equipo activo: %1$s.</string>
|
<string name="matches_active_team">Equipo activo: %1$s.</string>
|
||||||
<string name="matches_multi_team_hint">Tienes varios equipos: comprueba el seleccionado arriba.</string>
|
<string name="matches_multi_team_hint">Tienes varios equipos: comprueba el seleccionado arriba.</string>
|
||||||
<string name="matches_no_other">Ningún otro partido en el calendario.</string>
|
<string name="matches_no_other">Ningún otro partido en el calendario.</string>
|
||||||
@@ -165,6 +164,13 @@
|
|||||||
<string name="broadcast_resume_cd">Reanudar directo</string>
|
<string name="broadcast_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>
|
||||||
|
|||||||
@@ -21,13 +21,12 @@
|
|||||||
<string name="login_error_generic">Échec de la connexion</string>
|
<string name="login_error_generic">Échec de la connexion</string>
|
||||||
<string name="matches_title">Matchs</string>
|
<string name="matches_title">Matchs</string>
|
||||||
<string name="matches_hello">Bonjour, %1$s</string>
|
<string name="matches_hello">Bonjour, %1$s</string>
|
||||||
<string name="matches_subtitle">Reprenez un direct en cours ou démarrez un match programmé.</string>
|
<string name="matches_subtitle">Reprenez un direct en cours ou démarrez un nouveau match.</string>
|
||||||
<string name="matches_schedule">Match programmé</string>
|
|
||||||
<string name="matches_new">Nouveau match</string>
|
<string name="matches_new">Nouveau match</string>
|
||||||
<string name="matches_empty_title">Aucun match au calendrier</string>
|
<string name="matches_empty_title">Aucun match au calendrier</string>
|
||||||
<string name="matches_scheduled_title">Matchs programmés</string>
|
<string name="matches_scheduled_title">Matchs programmés</string>
|
||||||
<string name="matches_ready_title">Prêts à démarrer</string>
|
<string name="matches_ready_title">Prêts à démarrer</string>
|
||||||
<string name="matches_empty_hint">Programmez un match ou démarrez-en un avec « Nouveau match ».</string>
|
<string name="matches_empty_hint">Touchez « Nouveau match » pour planifier à l\'avance ou démarrer tout de suite.</string>
|
||||||
<string name="matches_active_team">Équipe active : %1$s.</string>
|
<string name="matches_active_team">Équipe active : %1$s.</string>
|
||||||
<string name="matches_multi_team_hint">Vous avez plusieurs équipes : vérifiez celle sélectionnée ci-dessus.</string>
|
<string name="matches_multi_team_hint">Vous avez plusieurs équipes : vérifiez celle sélectionnée ci-dessus.</string>
|
||||||
<string name="matches_no_other">Aucun autre match au calendrier.</string>
|
<string name="matches_no_other">Aucun autre match au calendrier.</string>
|
||||||
@@ -165,6 +164,13 @@
|
|||||||
<string name="broadcast_resume_cd">Reprendre le direct</string>
|
<string name="broadcast_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>
|
||||||
|
|||||||
@@ -21,13 +21,12 @@
|
|||||||
<string name="login_error_generic">Login fallito</string>
|
<string name="login_error_generic">Login fallito</string>
|
||||||
<string name="matches_title">Partite</string>
|
<string name="matches_title">Partite</string>
|
||||||
<string name="matches_hello">Ciao, %1$s</string>
|
<string name="matches_hello">Ciao, %1$s</string>
|
||||||
<string name="matches_subtitle">Riprendi una diretta in corso o avvia una partita programmata.</string>
|
<string name="matches_subtitle">Riprendi una diretta in corso o avvia una nuova partita.</string>
|
||||||
<string name="matches_schedule">Partita programmata</string>
|
|
||||||
<string name="matches_new">Nuova partita</string>
|
<string name="matches_new">Nuova partita</string>
|
||||||
<string name="matches_empty_title">Nessuna partita in calendario</string>
|
<string name="matches_empty_title">Nessuna partita in calendario</string>
|
||||||
<string name="matches_scheduled_title">Partite programmate</string>
|
<string name="matches_scheduled_title">Partite programmate</string>
|
||||||
<string name="matches_ready_title">Pronte da avviare</string>
|
<string name="matches_ready_title">Pronte da avviare</string>
|
||||||
<string name="matches_empty_hint">Programma una partita o avviane una nuova con «Nuova partita».</string>
|
<string name="matches_empty_hint">Tocca «Nuova partita» per programmare in anticipo o avviare subito.</string>
|
||||||
<string name="matches_active_team">Squadra attiva: %1$s.</string>
|
<string name="matches_active_team">Squadra attiva: %1$s.</string>
|
||||||
<string name="matches_multi_team_hint">Hai più squadre: verifica quella selezionata sopra.</string>
|
<string name="matches_multi_team_hint">Hai più squadre: verifica quella selezionata sopra.</string>
|
||||||
<string name="matches_no_other">Nessuna altra partita in calendario.</string>
|
<string name="matches_no_other">Nessuna altra partita in calendario.</string>
|
||||||
@@ -165,6 +164,13 @@
|
|||||||
<string name="broadcast_resume_cd">Riprendi diretta</string>
|
<string name="broadcast_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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,102 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme LastUpgradeVersion="1600" version="1.7">
|
<Scheme
|
||||||
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
LastUpgradeVersion = "2660"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
<BuildActionEntry
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="768EC47B451F4731A05BE1F9" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "F54F6C97361C4E8A96AEDD11"
|
||||||
|
BuildableName = "MatchLiveTv.app"
|
||||||
|
BlueprintName = "MatchLiveTv"
|
||||||
|
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
|
<BuildActionEntry
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F6079500E62048DB89AE3866" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "NO"
|
||||||
|
buildForProfiling = "NO"
|
||||||
|
buildForArchiving = "NO"
|
||||||
|
buildForAnalyzing = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "20C695DBFB4042879308B0F5"
|
||||||
|
BuildableName = "MatchLiveTvTests.xctest"
|
||||||
|
BlueprintName = "MatchLiveTvTests"
|
||||||
|
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
</BuildAction>
|
</BuildAction>
|
||||||
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
<Testables>
|
<Testables>
|
||||||
<TestableReference skipped="NO">
|
<TestableReference
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F6079500E62048DB89AE3866" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "20C695DBFB4042879308B0F5"
|
||||||
|
BuildableName = "MatchLiveTvTests.xctest"
|
||||||
|
BlueprintName = "MatchLiveTvTests"
|
||||||
|
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
|
<LaunchAction
|
||||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
buildConfiguration = "Debug"
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="768EC47B451F4731A05BE1F9" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "F54F6C97361C4E8A96AEDD11"
|
||||||
|
BuildableName = "MatchLiveTv.app"
|
||||||
|
BlueprintName = "MatchLiveTv"
|
||||||
|
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
|
<ProfileAction
|
||||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
buildConfiguration = "Release"
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="768EC47B451F4731A05BE1F9" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "F54F6C97361C4E8A96AEDD11"
|
||||||
|
BuildableName = "MatchLiveTv.app"
|
||||||
|
BlueprintName = "MatchLiveTv"
|
||||||
|
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
<AnalyzeAction buildConfiguration="Debug"/>
|
<AnalyzeAction
|
||||||
<ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
</Scheme>
|
</Scheme>
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -194,7 +201,7 @@ enum L10n {
|
|||||||
"match.status.start": "AVVIA",
|
"match.status.start": "AVVIA",
|
||||||
"matches.active.team": "Squadra attiva: %1$@.",
|
"matches.active.team": "Squadra attiva: %1$@.",
|
||||||
"matches.change": "Cambia",
|
"matches.change": "Cambia",
|
||||||
"matches.empty.hint": "Programma una partita o avviane una nuova con «Nuova partita».",
|
"matches.empty.hint": "Tocca «Nuova partita» per programmare in anticipo o avviare subito.",
|
||||||
"matches.empty.title": "Nessuna partita in calendario",
|
"matches.empty.title": "Nessuna partita in calendario",
|
||||||
"matches.hello": "Ciao, %1$@",
|
"matches.hello": "Ciao, %1$@",
|
||||||
"matches.load.error": "Errore caricamento",
|
"matches.load.error": "Errore caricamento",
|
||||||
@@ -212,9 +219,8 @@ enum L10n {
|
|||||||
"matches.no.team.title": "Nessuna squadra assegnata",
|
"matches.no.team.title": "Nessuna squadra assegnata",
|
||||||
"matches.ready.title": "Pronte da avviare",
|
"matches.ready.title": "Pronte da avviare",
|
||||||
"matches.retry": "Riprova",
|
"matches.retry": "Riprova",
|
||||||
"matches.schedule": "Partita programmata",
|
|
||||||
"matches.scheduled.title": "Partite programmate",
|
"matches.scheduled.title": "Partite programmate",
|
||||||
"matches.subtitle": "Riprendi una diretta in corso o avvia una partita programmata.",
|
"matches.subtitle": "Riprendi una diretta in corso o avvia una nuova partita.",
|
||||||
"matches.tap.change.team": "Tocca per cambiare squadra",
|
"matches.tap.change.team": "Tocca per cambiare squadra",
|
||||||
"matches.team.for.live": "Squadra per la diretta",
|
"matches.team.for.live": "Squadra per la diretta",
|
||||||
"matches.title": "Partite",
|
"matches.title": "Partite",
|
||||||
@@ -381,7 +387,7 @@ enum L10n {
|
|||||||
"account.save.password": "Update password",
|
"account.save.password": "Update password",
|
||||||
"account.save.profile": "Save profile",
|
"account.save.profile": "Save profile",
|
||||||
"account.title": "Account",
|
"account.title": "Account",
|
||||||
"forgot.password.lead": "Enter your account email: we'll send you a link to reset your password.",
|
"forgot.password.lead": "Enter your account email: we will send you a link to reset your password.",
|
||||||
"forgot.password.submit": "Send reset link",
|
"forgot.password.submit": "Send reset link",
|
||||||
"forgot.password.success": "If the email is registered, you will receive a password reset link shortly.",
|
"forgot.password.success": "If the email is registered, you will receive a password reset link shortly.",
|
||||||
"forgot.password.title": "Forgot password",
|
"forgot.password.title": "Forgot password",
|
||||||
@@ -410,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",
|
||||||
@@ -431,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",
|
||||||
@@ -485,7 +498,7 @@ enum L10n {
|
|||||||
"match.status.start": "START",
|
"match.status.start": "START",
|
||||||
"matches.active.team": "Active team: %1$@.",
|
"matches.active.team": "Active team: %1$@.",
|
||||||
"matches.change": "Change",
|
"matches.change": "Change",
|
||||||
"matches.empty.hint": "Schedule a match or start a new one with «New match».",
|
"matches.empty.hint": "Tap «New match» to schedule ahead or start right away.",
|
||||||
"matches.empty.title": "No matches on the calendar",
|
"matches.empty.title": "No matches on the calendar",
|
||||||
"matches.hello": "Hi, %1$@",
|
"matches.hello": "Hi, %1$@",
|
||||||
"matches.load.error": "Loading error",
|
"matches.load.error": "Loading error",
|
||||||
@@ -503,9 +516,8 @@ enum L10n {
|
|||||||
"matches.no.team.title": "No team assigned",
|
"matches.no.team.title": "No team assigned",
|
||||||
"matches.ready.title": "Ready to start",
|
"matches.ready.title": "Ready to start",
|
||||||
"matches.retry": "Retry",
|
"matches.retry": "Retry",
|
||||||
"matches.schedule": "Scheduled match",
|
|
||||||
"matches.scheduled.title": "Scheduled matches",
|
"matches.scheduled.title": "Scheduled matches",
|
||||||
"matches.subtitle": "Resume a live stream or start a scheduled match.",
|
"matches.subtitle": "Resume a live stream or start a new match.",
|
||||||
"matches.tap.change.team": "Tap to change team",
|
"matches.tap.change.team": "Tap to change team",
|
||||||
"matches.team.for.live": "Team for the live stream",
|
"matches.team.for.live": "Team for the live stream",
|
||||||
"matches.title": "Matches",
|
"matches.title": "Matches",
|
||||||
@@ -701,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",
|
||||||
@@ -722,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",
|
||||||
@@ -776,7 +795,7 @@ enum L10n {
|
|||||||
"match.status.start": "DÉMARRER",
|
"match.status.start": "DÉMARRER",
|
||||||
"matches.active.team": "Équipe active : %1$@.",
|
"matches.active.team": "Équipe active : %1$@.",
|
||||||
"matches.change": "Changer",
|
"matches.change": "Changer",
|
||||||
"matches.empty.hint": "Programmez un match ou démarrez-en un avec « Nouveau match ».",
|
"matches.empty.hint": "Touchez « Nouveau match » pour planifier à l'avance ou démarrer tout de suite.",
|
||||||
"matches.empty.title": "Aucun match au calendrier",
|
"matches.empty.title": "Aucun match au calendrier",
|
||||||
"matches.hello": "Bonjour, %1$@",
|
"matches.hello": "Bonjour, %1$@",
|
||||||
"matches.load.error": "Erreur de chargement",
|
"matches.load.error": "Erreur de chargement",
|
||||||
@@ -794,9 +813,8 @@ enum L10n {
|
|||||||
"matches.no.team.title": "Aucune équipe assignée",
|
"matches.no.team.title": "Aucune équipe assignée",
|
||||||
"matches.ready.title": "Prêts à démarrer",
|
"matches.ready.title": "Prêts à démarrer",
|
||||||
"matches.retry": "Réessayer",
|
"matches.retry": "Réessayer",
|
||||||
"matches.schedule": "Match programmé",
|
|
||||||
"matches.scheduled.title": "Matchs programmés",
|
"matches.scheduled.title": "Matchs programmés",
|
||||||
"matches.subtitle": "Reprenez un direct en cours ou démarrez un match programmé.",
|
"matches.subtitle": "Reprenez un direct en cours ou démarrez un nouveau match.",
|
||||||
"matches.tap.change.team": "Appuyez pour changer d'équipe",
|
"matches.tap.change.team": "Appuyez pour changer d'équipe",
|
||||||
"matches.team.for.live": "Équipe pour le direct",
|
"matches.team.for.live": "Équipe pour le direct",
|
||||||
"matches.title": "Matchs",
|
"matches.title": "Matchs",
|
||||||
@@ -992,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",
|
||||||
@@ -1013,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",
|
||||||
@@ -1067,7 +1092,7 @@ enum L10n {
|
|||||||
"match.status.start": "STARTEN",
|
"match.status.start": "STARTEN",
|
||||||
"matches.active.team": "Aktives Team: %1$@.",
|
"matches.active.team": "Aktives Team: %1$@.",
|
||||||
"matches.change": "Wechseln",
|
"matches.change": "Wechseln",
|
||||||
"matches.empty.hint": "Plane ein Spiel oder starte ein neues mit «Neues Spiel».",
|
"matches.empty.hint": "Tippe auf «Neues Spiel», um im Voraus zu planen oder sofort zu starten.",
|
||||||
"matches.empty.title": "Keine Spiele im Kalender",
|
"matches.empty.title": "Keine Spiele im Kalender",
|
||||||
"matches.hello": "Hallo, %1$@",
|
"matches.hello": "Hallo, %1$@",
|
||||||
"matches.load.error": "Ladefehler",
|
"matches.load.error": "Ladefehler",
|
||||||
@@ -1085,9 +1110,8 @@ enum L10n {
|
|||||||
"matches.no.team.title": "Kein Team zugewiesen",
|
"matches.no.team.title": "Kein Team zugewiesen",
|
||||||
"matches.ready.title": "Bereit zum Start",
|
"matches.ready.title": "Bereit zum Start",
|
||||||
"matches.retry": "Erneut versuchen",
|
"matches.retry": "Erneut versuchen",
|
||||||
"matches.schedule": "Geplantes Spiel",
|
|
||||||
"matches.scheduled.title": "Geplante Spiele",
|
"matches.scheduled.title": "Geplante Spiele",
|
||||||
"matches.subtitle": "Nimm einen laufenden Livestream wieder auf oder starte ein geplantes Spiel.",
|
"matches.subtitle": "Nimm einen laufenden Livestream wieder auf oder starte ein neues Spiel.",
|
||||||
"matches.tap.change.team": "Tippen zum Teamwechsel",
|
"matches.tap.change.team": "Tippen zum Teamwechsel",
|
||||||
"matches.team.for.live": "Team für den Livestream",
|
"matches.team.for.live": "Team für den Livestream",
|
||||||
"matches.title": "Spiele",
|
"matches.title": "Spiele",
|
||||||
@@ -1283,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",
|
||||||
@@ -1304,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",
|
||||||
@@ -1358,7 +1389,7 @@ enum L10n {
|
|||||||
"match.status.start": "INICIAR",
|
"match.status.start": "INICIAR",
|
||||||
"matches.active.team": "Equipo activo: %1$@.",
|
"matches.active.team": "Equipo activo: %1$@.",
|
||||||
"matches.change": "Cambiar",
|
"matches.change": "Cambiar",
|
||||||
"matches.empty.hint": "Programa un partido o inicia uno nuevo con «Nuevo partido».",
|
"matches.empty.hint": "Toca «Nuevo partido» para programar con antelación o empezar ya.",
|
||||||
"matches.empty.title": "Ningún partido en el calendario",
|
"matches.empty.title": "Ningún partido en el calendario",
|
||||||
"matches.hello": "Hola, %1$@",
|
"matches.hello": "Hola, %1$@",
|
||||||
"matches.load.error": "Error de carga",
|
"matches.load.error": "Error de carga",
|
||||||
@@ -1376,9 +1407,8 @@ enum L10n {
|
|||||||
"matches.no.team.title": "Ningún equipo asignado",
|
"matches.no.team.title": "Ningún equipo asignado",
|
||||||
"matches.ready.title": "Listos para empezar",
|
"matches.ready.title": "Listos para empezar",
|
||||||
"matches.retry": "Reintentar",
|
"matches.retry": "Reintentar",
|
||||||
"matches.schedule": "Partido programado",
|
|
||||||
"matches.scheduled.title": "Partidos programados",
|
"matches.scheduled.title": "Partidos programados",
|
||||||
"matches.subtitle": "Reanuda un directo en curso o inicia un partido programado.",
|
"matches.subtitle": "Reanuda un directo en curso o inicia un nuevo partido.",
|
||||||
"matches.tap.change.team": "Toca para cambiar de equipo",
|
"matches.tap.change.team": "Toca para cambiar de equipo",
|
||||||
"matches.team.for.live": "Equipo para el directo",
|
"matches.team.for.live": "Equipo para el directo",
|
||||||
"matches.title": "Partidos",
|
"matches.title": "Partidos",
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 257 KiB After Width: | Height: | Size: 216 KiB |
@@ -19,7 +19,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2.0.5</string>
|
<string>2.0.10</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>26</string>
|
<string>32</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
@@ -55,6 +55,14 @@
|
|||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum StreamQualityLadder {
|
||||||
|
static let auto = "auto"
|
||||||
|
|
||||||
|
struct Preset: Equatable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let bitrate: Int
|
||||||
|
let fps: Int
|
||||||
|
|
||||||
|
var is1080: Bool { id.hasPrefix("1080p") }
|
||||||
|
var resolutionLabel: String { is1080 ? "1080p" : "720p" }
|
||||||
|
}
|
||||||
|
|
||||||
|
static let presets: [Preset] = [
|
||||||
|
Preset(id: "1080p_30_4.5mbps", bitrate: 4_500_000, fps: 30),
|
||||||
|
Preset(id: "720p_30_4mbps", bitrate: 4_000_000, fps: 30),
|
||||||
|
Preset(id: "720p_30_2.5mbps", bitrate: 2_500_000, fps: 30),
|
||||||
|
Preset(id: "720p_30_1.5mbps", bitrate: 1_500_000, fps: 30)
|
||||||
|
]
|
||||||
|
|
||||||
|
static func normalize(_ presetId: String?) -> String {
|
||||||
|
let id = presetId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if id.isEmpty || id == auto { return auto }
|
||||||
|
return presets.contains(where: { $0.id == id }) ? id : auto
|
||||||
|
}
|
||||||
|
|
||||||
|
static func floorBitrate(_ presetId: String?) -> Int {
|
||||||
|
let id = normalize(presetId)
|
||||||
|
if id == auto { return AdaptiveBitrateController.autoFloor }
|
||||||
|
return presets.first(where: { $0.id == id })?.bitrate ?? AdaptiveBitrateController.autoFloor
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ceilingContribution(_ presetId: String?) -> Int {
|
||||||
|
let id = normalize(presetId)
|
||||||
|
if id == auto { return 0 }
|
||||||
|
return presets.first(where: { $0.id == id })?.bitrate ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
static func encoderCeiling(sessionTarget: Int, minPresetId: String?) -> Int {
|
||||||
|
max(sessionTarget, ceilingContribution(minPresetId))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func encoderFloor(minPresetId: String?, ceiling: Int) -> Int {
|
||||||
|
min(floorBitrate(minPresetId), ceiling)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func availableIds(sessionQualityPreset: String?) -> [String] {
|
||||||
|
var ids = [auto]
|
||||||
|
ids += presets.filter { !$0.is1080 }.reversed().map(\.id)
|
||||||
|
if sessionQualityPreset?.hasPrefix("1080p") == true,
|
||||||
|
let p1080 = presets.first(where: { $0.is1080 }) {
|
||||||
|
ids.append(p1080.id)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
static func displayName(id: String, autoLabel: String) -> String {
|
||||||
|
if normalize(id) == auto { return autoLabel }
|
||||||
|
guard let preset = presets.first(where: { $0.id == id }) else { return id }
|
||||||
|
let mbps = Double(preset.bitrate) / 1_000_000.0
|
||||||
|
let mbpsText = mbps == Double(Int(mbps))
|
||||||
|
? String(Int(mbps))
|
||||||
|
: String(format: "%.1f", mbps)
|
||||||
|
return "\(preset.resolutionLabel) · \(mbpsText) Mbps"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ABR conservativo per RTMP: scende in fretta in congestione, risale piano.
|
||||||
|
final class AdaptiveBitrateController: @unchecked Sendable {
|
||||||
|
static let autoFloor = 800_000
|
||||||
|
static let minAbsolute = 500_000
|
||||||
|
static let defaultCeiling = 2_500_000
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private let audioBitrate: Int
|
||||||
|
private let healthySamplesBeforeUp: Int
|
||||||
|
private let minDelta: Int
|
||||||
|
private var healthyCount = 0
|
||||||
|
|
||||||
|
private(set) var ceiling: Int = AdaptiveBitrateController.defaultCeiling
|
||||||
|
private(set) var floor: Int = AdaptiveBitrateController.autoFloor
|
||||||
|
private(set) var currentBitrate: Int = AdaptiveBitrateController.defaultCeiling
|
||||||
|
|
||||||
|
init(
|
||||||
|
audioBitrate: Int = 128_000,
|
||||||
|
healthySamplesBeforeUp: Int = 8,
|
||||||
|
minDelta: Int = 50_000
|
||||||
|
) {
|
||||||
|
self.audioBitrate = audioBitrate
|
||||||
|
self.healthySamplesBeforeUp = healthySamplesBeforeUp
|
||||||
|
self.minDelta = minDelta
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(ceiling: Int, floor: Int) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
self.ceiling = max(ceiling, Self.minAbsolute)
|
||||||
|
self.floor = min(max(floor, Self.minAbsolute), self.ceiling)
|
||||||
|
currentBitrate = self.ceiling
|
||||||
|
healthyCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setCeiling(_ value: Int) -> Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
ceiling = max(value, Self.minAbsolute)
|
||||||
|
if floor > ceiling { floor = ceiling }
|
||||||
|
if currentBitrate > ceiling {
|
||||||
|
currentBitrate = ceiling
|
||||||
|
healthyCount = 0
|
||||||
|
}
|
||||||
|
return currentBitrate
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setFloor(_ value: Int) -> Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
floor = min(max(value, Self.minAbsolute), ceiling)
|
||||||
|
if currentBitrate < floor {
|
||||||
|
currentBitrate = floor
|
||||||
|
healthyCount = 0
|
||||||
|
}
|
||||||
|
return currentBitrate
|
||||||
|
}
|
||||||
|
|
||||||
|
func onCongestion(measuredBitsPerSecond: Int) -> Int? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return applyCongestionLocked(measuredBitsPerSecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func onHealthy() -> Int? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return applyHealthyLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onMeasured(measuredBitsPerSecond: Int, congested: Bool) -> Int? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
if congested {
|
||||||
|
return applyCongestionLocked(measuredBitsPerSecond)
|
||||||
|
}
|
||||||
|
if measuredBitsPerSecond <= 0 { return nil }
|
||||||
|
let dropThreshold = Int(Double(currentBitrate) * 0.80)
|
||||||
|
if measuredBitsPerSecond < dropThreshold {
|
||||||
|
return applyCongestionLocked(measuredBitsPerSecond)
|
||||||
|
}
|
||||||
|
return applyHealthyLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetToCeiling() -> Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
healthyCount = 0
|
||||||
|
currentBitrate = ceiling
|
||||||
|
return currentBitrate
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyCongestionLocked(_ measuredBitsPerSecond: Int) -> Int? {
|
||||||
|
healthyCount = 0
|
||||||
|
let next: Int
|
||||||
|
if measuredBitsPerSecond <= 0 {
|
||||||
|
next = Int(Double(currentBitrate) * 0.8)
|
||||||
|
} else {
|
||||||
|
next = min(currentBitrate, measuredBitsPerSecond - audioBitrate)
|
||||||
|
}
|
||||||
|
return maybeApplyLocked(min(max(next, floor), ceiling))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyHealthyLocked() -> Int? {
|
||||||
|
if currentBitrate >= ceiling {
|
||||||
|
healthyCount = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
healthyCount += 1
|
||||||
|
if healthyCount < healthySamplesBeforeUp { return nil }
|
||||||
|
healthyCount = 0
|
||||||
|
let step = max(ceiling / 10, minDelta)
|
||||||
|
return maybeApplyLocked(min(currentBitrate + step, ceiling))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func maybeApplyLocked(_ next: Int) -> Int? {
|
||||||
|
if next == currentBitrate { return nil }
|
||||||
|
let atBound = next == floor || next == ceiling
|
||||||
|
if !atBound && abs(next - currentBitrate) < minDelta { return nil }
|
||||||
|
currentBitrate = next
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ struct BroadcastConfig: Sendable {
|
|||||||
var width: Int = 1280
|
var width: Int = 1280
|
||||||
var height: Int = 720
|
var height: Int = 720
|
||||||
var videoBitrate: Int = 2_500_000
|
var videoBitrate: Int = 2_500_000
|
||||||
|
var minVideoBitrate: Int = 800_000
|
||||||
var audioBitrate: Int = 128_000
|
var audioBitrate: Int = 128_000
|
||||||
var fps: Int = 30
|
var fps: Int = 30
|
||||||
var rotation: Int = 0
|
var rotation: Int = 0
|
||||||
|
|||||||
@@ -5,18 +5,37 @@ import RTMPHaishinKit
|
|||||||
import UIKit
|
import UIKit
|
||||||
import VideoToolbox
|
import VideoToolbox
|
||||||
|
|
||||||
private struct BroadcastBitrateMonitor: StreamBitRateStrategy {
|
private final class AdaptiveBroadcastBitRateStrategy: StreamBitRateStrategy, @unchecked Sendable {
|
||||||
let mamimumVideoBitRate: Int = 0
|
let controller: AdaptiveBitrateController
|
||||||
let mamimumAudioBitRate: Int = 0
|
|
||||||
let onUpdate: @Sendable (Int) -> Void
|
let onUpdate: @Sendable (Int) -> Void
|
||||||
|
var mamimumVideoBitRate: Int { controller.ceiling }
|
||||||
|
let mamimumAudioBitRate: Int = 0
|
||||||
|
|
||||||
|
init(controller: AdaptiveBitrateController, onUpdate: @escaping @Sendable (Int) -> Void) {
|
||||||
|
self.controller = controller
|
||||||
|
self.onUpdate = onUpdate
|
||||||
|
}
|
||||||
|
|
||||||
func adjustBitrate(_ event: NetworkMonitorEvent, stream: some StreamConvertible) async {
|
func adjustBitrate(_ event: NetworkMonitorEvent, stream: some StreamConvertible) async {
|
||||||
|
let next: Int?
|
||||||
switch event {
|
switch event {
|
||||||
case .status(let report), .publishInsufficientBWOccured(let report):
|
case .status(let report):
|
||||||
|
let measuredBits = report.currentBytesOutPerSecond * 8
|
||||||
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
|
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
|
||||||
|
next = controller.onMeasured(measuredBitsPerSecond: measuredBits, congested: false)
|
||||||
|
case .publishInsufficientBWOccured(let report):
|
||||||
|
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
|
||||||
|
next = controller.onCongestion(measuredBitsPerSecond: report.currentBytesOutPerSecond * 8)
|
||||||
case .reset:
|
case .reset:
|
||||||
break
|
next = controller.resetToCeiling()
|
||||||
|
@unknown default:
|
||||||
|
next = nil
|
||||||
}
|
}
|
||||||
|
guard let bitrate = next else { return }
|
||||||
|
var videoSettings = await stream.videoSettings
|
||||||
|
guard videoSettings.bitRate != bitrate else { return }
|
||||||
|
videoSettings.bitRate = bitrate
|
||||||
|
try? await stream.setVideoSettings(videoSettings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +66,7 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
|
private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
|
||||||
private var orientationDebounceTask: Task<Void, Never>?
|
private var orientationDebounceTask: Task<Void, Never>?
|
||||||
private var rtmpSession: (any Session)?
|
private var rtmpSession: (any Session)?
|
||||||
|
private let adaptiveBitrate = AdaptiveBitrateController()
|
||||||
private static var rtmpFactoryRegistered = false
|
private static var rtmpFactoryRegistered = false
|
||||||
|
|
||||||
private static func ensureRTMPFactoryRegistered() async {
|
private static func ensureRTMPFactoryRegistered() async {
|
||||||
@@ -101,6 +121,7 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
|
|
||||||
func preparePreview(config: BroadcastConfig) async throws {
|
func preparePreview(config: BroadcastConfig) async throws {
|
||||||
self.config = config
|
self.config = config
|
||||||
|
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
|
||||||
publishPending = false
|
publishPending = false
|
||||||
try await configurePipeline(config)
|
try await configurePipeline(config)
|
||||||
setPhase(.preview)
|
setPhase(.preview)
|
||||||
@@ -109,6 +130,7 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
/// Configura camera/mixer e mette in coda il publish RTMP (eseguito quando l'anteprima è pronta).
|
/// Configura camera/mixer e mette in coda il publish RTMP (eseguito quando l'anteprima è pronta).
|
||||||
func prepareForBroadcast(config: BroadcastConfig) async throws {
|
func prepareForBroadcast(config: BroadcastConfig) async throws {
|
||||||
self.config = config
|
self.config = config
|
||||||
|
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
|
||||||
publishPending = true
|
publishPending = true
|
||||||
try await configurePipeline(config)
|
try await configurePipeline(config)
|
||||||
setPhase(.connecting)
|
setPhase(.connecting)
|
||||||
@@ -135,6 +157,7 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
|
|
||||||
func resumeBroadcast(config: BroadcastConfig) async throws {
|
func resumeBroadcast(config: BroadcastConfig) async throws {
|
||||||
self.config = config
|
self.config = config
|
||||||
|
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
|
||||||
reconnectAttempts = 0
|
reconnectAttempts = 0
|
||||||
reconnectTask?.cancel()
|
reconnectTask?.cancel()
|
||||||
reconnectTask = nil
|
reconnectTask = nil
|
||||||
@@ -179,18 +202,40 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
await stopBroadcast()
|
await stopBroadcast()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adattamento termico: aggiorna profilo video senza interrompere la sessione RTMP attiva.
|
/// Adattamento termico: aggiorna il soffitto ABR e l'FPS senza interrompere la sessione RTMP.
|
||||||
func applyThermalProfile(videoBitrate: Int, fps: Int) async {
|
func applyThermalProfile(videoBitrate: Int, fps: Int) async {
|
||||||
guard var current = config else { return }
|
guard var current = config else { return }
|
||||||
guard current.videoBitrate != videoBitrate || current.fps != fps else { return }
|
adaptiveBitrate.setCeiling(videoBitrate)
|
||||||
|
let applied = adaptiveBitrate.setFloor(current.minVideoBitrate)
|
||||||
|
guard current.videoBitrate != videoBitrate || current.fps != fps else {
|
||||||
|
await applyEncoderBitrate(applied)
|
||||||
|
return
|
||||||
|
}
|
||||||
current.videoBitrate = videoBitrate
|
current.videoBitrate = videoBitrate
|
||||||
current.fps = fps
|
current.fps = fps
|
||||||
config = current
|
config = current
|
||||||
if pipelineConfigured, let stream = await rtmpSession?.stream {
|
if pipelineConfigured, let stream = await rtmpSession?.stream {
|
||||||
try? await applyCodecSettings(to: stream, config: current)
|
try? await applyCodecSettings(to: stream, config: current, videoBitrate: applied)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setMinQualityFloor(_ floorBitrate: Int, ceiling: Int? = nil) async {
|
||||||
|
if var current = config {
|
||||||
|
if let ceiling { current.videoBitrate = ceiling }
|
||||||
|
current.minVideoBitrate = floorBitrate
|
||||||
|
config = current
|
||||||
|
}
|
||||||
|
await applyEncoderBitrate(adaptiveBitrate.setFloor(floorBitrate))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyEncoderBitrate(_ bitrate: Int) async {
|
||||||
|
guard pipelineConfigured, let stream = await rtmpSession?.stream else { return }
|
||||||
|
var videoSettings = await stream.videoSettings
|
||||||
|
guard videoSettings.bitRate != bitrate else { return }
|
||||||
|
videoSettings.bitRate = bitrate
|
||||||
|
try? await stream.setVideoSettings(videoSettings)
|
||||||
|
}
|
||||||
|
|
||||||
private func configurePipeline(_ config: BroadcastConfig) async throws {
|
private func configurePipeline(_ config: BroadcastConfig) async throws {
|
||||||
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized,
|
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized,
|
||||||
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||||
@@ -316,7 +361,7 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
|
|
||||||
let stream = await session.stream
|
let stream = await session.stream
|
||||||
try await applyCodecSettings(to: stream, config: config)
|
try await applyCodecSettings(to: stream, config: config)
|
||||||
await stream.setBitRateStrategy(BroadcastBitrateMonitor { [weak self] kbps in
|
await stream.setBitRateStrategy(AdaptiveBroadcastBitRateStrategy(controller: adaptiveBitrate) { [weak self] kbps in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.metrics = BroadcastMetrics(
|
self.metrics = BroadcastMetrics(
|
||||||
@@ -349,10 +394,10 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
setPhase(.live)
|
setPhase(.live)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyCodecSettings(to stream: any StreamConvertible, config: BroadcastConfig) async throws {
|
private func applyCodecSettings(to stream: any StreamConvertible, config: BroadcastConfig, videoBitrate: Int? = nil) async throws {
|
||||||
var videoSettings = await stream.videoSettings
|
var videoSettings = await stream.videoSettings
|
||||||
videoSettings.videoSize = .init(width: config.width, height: config.height)
|
videoSettings.videoSize = .init(width: config.width, height: config.height)
|
||||||
videoSettings.bitRate = config.videoBitrate
|
videoSettings.bitRate = videoBitrate ?? adaptiveBitrate.currentBitrate
|
||||||
videoSettings.maxKeyFrameIntervalDuration = 2
|
videoSettings.maxKeyFrameIntervalDuration = 2
|
||||||
videoSettings.profileLevel = config.height > 720
|
videoSettings.profileLevel = config.height > 720
|
||||||
? (kVTProfileLevel_H264_Baseline_4_1 as String)
|
? (kVTProfileLevel_H264_Baseline_4_1 as String)
|
||||||
|
|||||||
@@ -15,11 +15,17 @@ enum StreamVideoPreset {
|
|||||||
|
|
||||||
static func broadcastConfig(for session: StreamSession, rtmpUrl: String) -> BroadcastConfig {
|
static func broadcastConfig(for session: StreamSession, rtmpUrl: String) -> BroadcastConfig {
|
||||||
let dims = dimensions(for: session.qualityPreset)
|
let dims = dimensions(for: session.qualityPreset)
|
||||||
|
let ceiling = StreamQualityLadder.encoderCeiling(
|
||||||
|
sessionTarget: session.targetBitrate,
|
||||||
|
minPresetId: session.minQualityPreset
|
||||||
|
)
|
||||||
|
let floor = StreamQualityLadder.encoderFloor(minPresetId: session.minQualityPreset, ceiling: ceiling)
|
||||||
return BroadcastConfig(
|
return BroadcastConfig(
|
||||||
rtmpUrl: rtmpUrl,
|
rtmpUrl: rtmpUrl,
|
||||||
width: dims.width,
|
width: dims.width,
|
||||||
height: dims.height,
|
height: dims.height,
|
||||||
videoBitrate: session.targetBitrate,
|
videoBitrate: ceiling,
|
||||||
|
minVideoBitrate: floor,
|
||||||
audioBitrate: 128_000,
|
audioBitrate: 128_000,
|
||||||
fps: session.targetFps
|
fps: session.targetFps
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,8 +40,12 @@ struct BroadcastControlsOverlay: View {
|
|||||||
let bitrateKbps: Int
|
let bitrateKbps: Int
|
||||||
let networkType: String
|
let networkType: String
|
||||||
let deviceHealth: DeviceHealth
|
let deviceHealth: DeviceHealth
|
||||||
|
var minQualityPreset: String = StreamQualityLadder.auto
|
||||||
|
var sessionQualityPreset: String = "720p_30_2.5mbps"
|
||||||
|
var onSelectMinQuality: (String) -> Void = { _ in }
|
||||||
|
|
||||||
@State private var showTerminateConfirm = false
|
@State private var showTerminateConfirm = false
|
||||||
|
@State private var showMinQualityPicker = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Color.clear
|
Color.clear
|
||||||
@@ -69,7 +73,11 @@ struct BroadcastControlsOverlay: View {
|
|||||||
targetFps: targetFps,
|
targetFps: targetFps,
|
||||||
bitrateKbps: bitrateKbps,
|
bitrateKbps: bitrateKbps,
|
||||||
networkType: networkType,
|
networkType: networkType,
|
||||||
deviceHealth: deviceHealth
|
deviceHealth: deviceHealth,
|
||||||
|
minQualityLabel: StreamQualityLadder.displayName(
|
||||||
|
id: minQualityPreset,
|
||||||
|
autoLabel: L10n.t("broadcast.min.quality.auto")
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(.trailing, 8)
|
.padding(.trailing, 8)
|
||||||
@@ -94,6 +102,19 @@ struct BroadcastControlsOverlay: View {
|
|||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.overlay {
|
||||||
|
if showMinQualityPicker {
|
||||||
|
MinQualityPickerOverlay(
|
||||||
|
selectedId: minQualityPreset,
|
||||||
|
sessionQualityPreset: sessionQualityPreset,
|
||||||
|
onSelect: { id in
|
||||||
|
showMinQualityPicker = false
|
||||||
|
onSelectMinQuality(id)
|
||||||
|
},
|
||||||
|
onDismiss: { showMinQualityPicker = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
.alert(L10n.t("broadcast.terminate.title"), isPresented: $showTerminateConfirm) {
|
.alert(L10n.t("broadcast.terminate.title"), isPresented: $showTerminateConfirm) {
|
||||||
Button(L10n.t("action.cancel"), role: .cancel) {}
|
Button(L10n.t("action.cancel"), role: .cancel) {}
|
||||||
Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: onTerminate)
|
Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: onTerminate)
|
||||||
@@ -115,6 +136,11 @@ struct BroadcastControlsOverlay: View {
|
|||||||
accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
|
accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
|
||||||
action: onShareRegia
|
action: onShareRegia
|
||||||
)
|
)
|
||||||
|
SideIconButton(
|
||||||
|
systemName: "slider.horizontal.3",
|
||||||
|
accessibilityLabel: L10n.t("broadcast.min.quality.cd"),
|
||||||
|
action: { showMinQualityPicker = true }
|
||||||
|
)
|
||||||
if let onCloseSet {
|
if let onCloseSet {
|
||||||
SideIconButton(
|
SideIconButton(
|
||||||
systemName: "checkmark",
|
systemName: "checkmark",
|
||||||
@@ -188,6 +214,85 @@ struct BroadcastControlsOverlay: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct MinQualityPickerOverlay: View {
|
||||||
|
let selectedId: String
|
||||||
|
let sessionQualityPreset: String
|
||||||
|
let onSelect: (String) -> Void
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
private var options: [String] {
|
||||||
|
StreamQualityLadder.availableIds(sessionQualityPreset: sessionQualityPreset)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.55)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onTapGesture(perform: onDismiss)
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text(L10n.t("broadcast.min.quality.title"))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Spacer()
|
||||||
|
Button(L10n.t("action.cancel"), action: onDismiss)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
Text(L10n.t("broadcast.min.quality.hint"))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(options, id: \.self) { id in
|
||||||
|
let selected = id == selectedId
|
||||||
|
Button {
|
||||||
|
onSelect(id)
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.foregroundStyle(selected ? MatchColors.accentYellow : .clear)
|
||||||
|
Text(
|
||||||
|
StreamQualityLadder.displayName(
|
||||||
|
id: id,
|
||||||
|
autoLabel: L10n.t("broadcast.min.quality.auto")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.font(.system(size: 13, weight: selected ? .semibold : .medium))
|
||||||
|
.foregroundStyle(selected ? MatchColors.accentYellow : .white)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineLimit(2)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
selected
|
||||||
|
? MatchColors.accentYellow.opacity(0.16)
|
||||||
|
: MatchColors.surfaceElevated,
|
||||||
|
in: RoundedRectangle(cornerRadius: 12)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(selected ? MatchColors.accentYellow : MatchColors.outline, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: 720)
|
||||||
|
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.stroke(MatchColors.outline, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct BroadcastTelemetryPanel: View {
|
private struct BroadcastTelemetryPanel: View {
|
||||||
let cableConnected: Bool
|
let cableConnected: Bool
|
||||||
let fps: Int
|
let fps: Int
|
||||||
@@ -195,6 +300,7 @@ private struct BroadcastTelemetryPanel: View {
|
|||||||
let bitrateKbps: Int
|
let bitrateKbps: Int
|
||||||
let networkType: String
|
let networkType: String
|
||||||
let deviceHealth: DeviceHealth
|
let deviceHealth: DeviceHealth
|
||||||
|
let minQualityLabel: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
@@ -211,6 +317,9 @@ private struct BroadcastTelemetryPanel: View {
|
|||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 11, weight: .medium))
|
||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
}
|
}
|
||||||
|
Text(minQualityLabel)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(MatchColors.accentYellow)
|
||||||
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
|
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 11, weight: .medium))
|
||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ struct BroadcastScreen: View {
|
|||||||
@State private var logoReady = 0
|
@State private var logoReady = 0
|
||||||
@State private var pauseInFlight = false
|
@State private var pauseInFlight = false
|
||||||
@State private var resumeInFlight = false
|
@State private var resumeInFlight = false
|
||||||
|
@State private var minQualityInFlight = false
|
||||||
@State private var deviceHealth = DeviceTelemetry.snapshot()
|
@State private var deviceHealth = DeviceTelemetry.snapshot()
|
||||||
@State private var snackbarMessage: String?
|
@State private var snackbarMessage: String?
|
||||||
@State private var shareItem: ShareItem?
|
@State private var shareItem: ShareItem?
|
||||||
@@ -83,9 +84,15 @@ struct BroadcastScreen: View {
|
|||||||
.task(id: sessionId) {
|
.task(id: sessionId) {
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
if let remote = try? await container.sessionRepository.fetchSession(id: sessionId).score {
|
guard !minQualityInFlight, !pauseInFlight, !resumeInFlight else { continue }
|
||||||
container.scoreController.applyRemote(remote, source: .polling)
|
guard let remote = try? await container.sessionRepository.fetchSession(id: sessionId) else { continue }
|
||||||
|
if let score = remote.score {
|
||||||
|
container.scoreController.applyRemote(score, source: .polling)
|
||||||
}
|
}
|
||||||
|
if session?.minQualityPreset != remote.minQualityPreset {
|
||||||
|
applyMinQualityLocally(preset: remote.minQualityPreset, fromRemote: true, notify: false)
|
||||||
|
}
|
||||||
|
await syncBroadcastToRemoteSession(remote)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task(id: sessionId) {
|
.task(id: sessionId) {
|
||||||
@@ -290,7 +297,12 @@ struct BroadcastScreen: View {
|
|||||||
targetFps: session.targetFps,
|
targetFps: session.targetFps,
|
||||||
bitrateKbps: metrics.bitrateKbps,
|
bitrateKbps: metrics.bitrateKbps,
|
||||||
networkType: deviceHealth.networkType,
|
networkType: deviceHealth.networkType,
|
||||||
deviceHealth: DeviceTelemetry.snapshot(thermalState: thermalManager.state)
|
deviceHealth: DeviceTelemetry.snapshot(thermalState: thermalManager.state),
|
||||||
|
minQualityPreset: session.minQualityPreset,
|
||||||
|
sessionQualityPreset: session.qualityPreset,
|
||||||
|
onSelectMinQuality: { preset in
|
||||||
|
Task { await persistMinQuality(preset) }
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,6 +430,12 @@ struct BroadcastScreen: View {
|
|||||||
container.sessionCable.onStopStream = { [weak container] in
|
container.sessionCable.onStopStream = { [weak container] in
|
||||||
Task { @MainActor in await stopStream() }
|
Task { @MainActor in await stopStream() }
|
||||||
}
|
}
|
||||||
|
container.sessionCable.onMinQuality = { [weak container] preset in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let container else { return }
|
||||||
|
applyMinQualityLocally(preset: preset, fromRemote: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
container.sessionCable.connect(sessionId: sessionId, accessToken: token)
|
container.sessionCable.connect(sessionId: sessionId, accessToken: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,6 +522,33 @@ struct BroadcastScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Allinea encoder locale allo stato sessione se Cable non ha consegnato il comando.
|
||||||
|
private func syncBroadcastToRemoteSession(_ remote: StreamSession) async {
|
||||||
|
let phase = container.broadcastCoordinator.metrics.phase
|
||||||
|
let publishing = phase == .live || phase == .connecting || phase == .reconnecting
|
||||||
|
if remote.isEnded {
|
||||||
|
if phase != .idle && phase != .preview {
|
||||||
|
await stopStream()
|
||||||
|
} else {
|
||||||
|
session = remote
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if remote.isPaused {
|
||||||
|
if publishing {
|
||||||
|
await applyRemotePause(container: container)
|
||||||
|
} else {
|
||||||
|
session = remote
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if phase == .paused {
|
||||||
|
await applyRemoteResume(container: container)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session = remote
|
||||||
|
}
|
||||||
|
|
||||||
/// Pausa dalla regia / echo cable: solo RTMP locale, senza PATCH pause.
|
/// Pausa dalla regia / echo cable: solo RTMP locale, senza PATCH pause.
|
||||||
private func applyRemotePause(container: AppContainer) async {
|
private func applyRemotePause(container: AppContainer) async {
|
||||||
guard !pauseInFlight else { return }
|
guard !pauseInFlight else { return }
|
||||||
@@ -541,6 +586,43 @@ struct BroadcastScreen: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyMinQualityLocally(preset: String, fromRemote: Bool, notify: Bool = true) {
|
||||||
|
guard var current = session else { return }
|
||||||
|
if current.minQualityPreset == preset && fromRemote { return }
|
||||||
|
current = current.with(minQualityPreset: preset)
|
||||||
|
session = current
|
||||||
|
guard let url = current.rtmpIngestUrl, !url.isEmpty else { return }
|
||||||
|
let config = broadcastConfig(for: current, rtmpUrl: url)
|
||||||
|
Task {
|
||||||
|
await container.broadcastCoordinator.engine.setMinQualityFloor(
|
||||||
|
config.minVideoBitrate,
|
||||||
|
ceiling: config.videoBitrate
|
||||||
|
)
|
||||||
|
thermalManager.updateBaseline(config)
|
||||||
|
}
|
||||||
|
if fromRemote && notify {
|
||||||
|
snackbarMessage = L10n.t("broadcast.snackbar.min.quality.remote")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistMinQuality(_ preset: String) async {
|
||||||
|
minQualityInFlight = true
|
||||||
|
defer { minQualityInFlight = false }
|
||||||
|
do {
|
||||||
|
let updated = try await container.sessionRepository.setMinQuality(id: sessionId, preset: preset)
|
||||||
|
applyMinQualityLocally(preset: updated.minQualityPreset, fromRemote: false)
|
||||||
|
snackbarMessage = L10n.t(
|
||||||
|
"broadcast.min.quality.applied",
|
||||||
|
StreamQualityLadder.displayName(
|
||||||
|
id: updated.minQualityPreset,
|
||||||
|
autoLabel: L10n.t("broadcast.min.quality.auto")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
snackbarMessage = UserFacingError.message(for: error) ?? L10n.t("broadcast.min.quality.error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func retryBroadcast() async {
|
private func retryBroadcast() async {
|
||||||
error = nil
|
error = nil
|
||||||
guard let session, let url = session.rtmpIngestUrl, !url.isEmpty else { return }
|
guard let session, let url = session.rtmpIngestUrl, !url.isEmpty else { return }
|
||||||
@@ -567,6 +649,7 @@ struct BroadcastScreen: View {
|
|||||||
container.sessionCable.onPauseStream = nil
|
container.sessionCable.onPauseStream = nil
|
||||||
container.sessionCable.onResumeStream = nil
|
container.sessionCable.onResumeStream = nil
|
||||||
container.sessionCable.onStopStream = nil
|
container.sessionCable.onStopStream = nil
|
||||||
|
container.sessionCable.onMinQuality = nil
|
||||||
container.sessionCable.disconnect()
|
container.sessionCable.disconnect()
|
||||||
await container.broadcastCoordinator.stopBroadcast(sessionId: sessionId)
|
await container.broadcastCoordinator.stopBroadcast(sessionId: sessionId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ struct MatchPrimaryButton: View {
|
|||||||
.font(MatchTypography.labelLarge)
|
.font(MatchTypography.labelLarge)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.7)
|
.minimumScaleFactor(0.7)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|||||||
@@ -21,5 +21,8 @@ struct MatchScreenScaffold<Content: View, TopBar: View>: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.background(MatchColors.background)
|
.background(MatchColors.background)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
// Chrome custom: nasconde la navigation bar di sistema (evita chevron indietro spurî).
|
||||||
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
|
.navigationBarBackButtonHidden(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct MatchSecondaryButton: View {
|
|||||||
.font(MatchTypography.labelLarge)
|
.font(MatchTypography.labelLarge)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.7)
|
.minimumScaleFactor(0.7)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 52)
|
.frame(height: 52)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,10 +77,7 @@ struct MatchesScreen: View {
|
|||||||
Text(L10n.t("matches.subtitle"))
|
Text(L10n.t("matches.subtitle"))
|
||||||
.font(MatchTypography.bodyMedium)
|
.font(MatchTypography.bodyMedium)
|
||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
HStack(spacing: 12) {
|
|
||||||
MatchSecondaryButton(label: L10n.t("matches.schedule").uppercased(), action: { showSchedule = true })
|
|
||||||
MatchPrimaryButton(label: L10n.t("matches.new").uppercased(), action: { showNewMatch = true })
|
MatchPrimaryButton(label: L10n.t("matches.new").uppercased(), action: { showNewMatch = true })
|
||||||
}
|
|
||||||
if let activeTeam {
|
if let activeTeam {
|
||||||
TeamPickerBar(
|
TeamPickerBar(
|
||||||
team: activeTeam,
|
team: activeTeam,
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AppNavHost: View {
|
struct AppNavHost: View {
|
||||||
|
private enum RootScreen {
|
||||||
|
case splash
|
||||||
|
case login
|
||||||
|
case matches
|
||||||
|
}
|
||||||
|
|
||||||
@StateObject private var container = AppContainer()
|
@StateObject private var container = AppContainer()
|
||||||
|
@State private var root: RootScreen = .splash
|
||||||
|
/// Solo destinazioni sopra la root (account, recupero password).
|
||||||
@State private var path: [Routes] = []
|
@State private var path: [Routes] = []
|
||||||
@State private var wizardRoute: WizardRoute?
|
@State private var wizardRoute: WizardRoute?
|
||||||
@State private var broadcastRoute: BroadcastRoute?
|
@State private var broadcastRoute: BroadcastRoute?
|
||||||
@@ -17,46 +25,24 @@ struct AppNavHost: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: $path) {
|
NavigationStack(path: $path) {
|
||||||
SplashScreen(
|
rootContent
|
||||||
container: container,
|
|
||||||
onAuthenticated: { path = [.matches] },
|
|
||||||
onUnauthenticated: { path = [.login] }
|
|
||||||
)
|
|
||||||
.navigationDestination(for: Routes.self) { route in
|
.navigationDestination(for: Routes.self) { route in
|
||||||
switch route {
|
switch route {
|
||||||
case .splash:
|
|
||||||
EmptyView()
|
|
||||||
case .login:
|
|
||||||
LoginScreen(
|
|
||||||
container: container,
|
|
||||||
onLoggedIn: { path = [.matches] },
|
|
||||||
onForgotPassword: { path.append(.forgotPassword) }
|
|
||||||
)
|
|
||||||
case .forgotPassword:
|
case .forgotPassword:
|
||||||
ForgotPasswordScreen(
|
ForgotPasswordScreen(
|
||||||
container: container,
|
container: container,
|
||||||
onBack: { path.removeLast() }
|
onBack: { path.removeLast() }
|
||||||
)
|
)
|
||||||
case .matches:
|
|
||||||
MatchesScreen(
|
|
||||||
container: container,
|
|
||||||
refreshToken: matchesRefreshToken,
|
|
||||||
onOpenSetup: { matchId in
|
|
||||||
wizardRoute = WizardRoute(matchId: matchId, step: 1)
|
|
||||||
},
|
|
||||||
onOpenBroadcast: { sessionId in
|
|
||||||
broadcastRoute = BroadcastRoute(sessionId: sessionId)
|
|
||||||
},
|
|
||||||
onOpenAccount: { path.append(.account) }
|
|
||||||
)
|
|
||||||
.lockPortraitOrientation()
|
|
||||||
case .account:
|
case .account:
|
||||||
AccountScreen(
|
AccountScreen(
|
||||||
container: container,
|
container: container,
|
||||||
onBack: { path.removeLast() },
|
onBack: { path.removeLast() },
|
||||||
onLoggedOut: { path = [.login] }
|
onLoggedOut: {
|
||||||
|
path = []
|
||||||
|
root = .login
|
||||||
|
}
|
||||||
)
|
)
|
||||||
case .setup, .broadcast:
|
case .splash, .login, .matches, .setup, .broadcast:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +84,8 @@ struct AppNavHost: View {
|
|||||||
AppOrientation.lockPortrait()
|
AppOrientation.lockPortrait()
|
||||||
container.wizardSession.reset()
|
container.wizardSession.reset()
|
||||||
broadcastRoute = nil
|
broadcastRoute = nil
|
||||||
path = [.matches]
|
path = []
|
||||||
|
root = .matches
|
||||||
}
|
}
|
||||||
.keepScreenOn()
|
.keepScreenOn()
|
||||||
}
|
}
|
||||||
@@ -109,4 +96,44 @@ struct AppNavHost: View {
|
|||||||
}
|
}
|
||||||
.environmentObject(container)
|
.environmentObject(container)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var rootContent: some View {
|
||||||
|
switch root {
|
||||||
|
case .splash:
|
||||||
|
SplashScreen(
|
||||||
|
container: container,
|
||||||
|
onAuthenticated: {
|
||||||
|
path = []
|
||||||
|
root = .matches
|
||||||
|
},
|
||||||
|
onUnauthenticated: {
|
||||||
|
path = []
|
||||||
|
root = .login
|
||||||
|
}
|
||||||
|
)
|
||||||
|
case .login:
|
||||||
|
LoginScreen(
|
||||||
|
container: container,
|
||||||
|
onLoggedIn: {
|
||||||
|
path = []
|
||||||
|
root = .matches
|
||||||
|
},
|
||||||
|
onForgotPassword: { path.append(.forgotPassword) }
|
||||||
|
)
|
||||||
|
case .matches:
|
||||||
|
MatchesScreen(
|
||||||
|
container: container,
|
||||||
|
refreshToken: matchesRefreshToken,
|
||||||
|
onOpenSetup: { matchId in
|
||||||
|
wizardRoute = WizardRoute(matchId: matchId, step: 1)
|
||||||
|
},
|
||||||
|
onOpenBroadcast: { sessionId in
|
||||||
|
broadcastRoute = BroadcastRoute(sessionId: sessionId)
|
||||||
|
},
|
||||||
|
onOpenAccount: { path.append(.account) }
|
||||||
|
)
|
||||||
|
.lockPortraitOrientation()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import MatchLiveTv
|
||||||
|
|
||||||
|
final class AdaptiveBitrateTests: XCTestCase {
|
||||||
|
private func controller() -> AdaptiveBitrateController {
|
||||||
|
let abr = AdaptiveBitrateController()
|
||||||
|
abr.configure(ceiling: 2_500_000, floor: AdaptiveBitrateController.autoFloor)
|
||||||
|
return abr
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCongestionDropsTowardMeasuredAndRespectsFloor() {
|
||||||
|
let abr = controller()
|
||||||
|
let next = abr.onCongestion(measuredBitsPerSecond: 1_200_000)
|
||||||
|
XCTAssertEqual(next, 1_200_000 - 128_000)
|
||||||
|
XCTAssertEqual(abr.currentBitrate, 1_072_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCongestionDoesNotGoBelowFloor() {
|
||||||
|
let abr = controller()
|
||||||
|
abr.setFloor(1_500_000)
|
||||||
|
let next = abr.onCongestion(measuredBitsPerSecond: 400_000)
|
||||||
|
XCTAssertEqual(next, 1_500_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHealthyRampsUpAfterEnoughSamples() {
|
||||||
|
let abr = controller()
|
||||||
|
abr.onCongestion(measuredBitsPerSecond: 1_200_000)
|
||||||
|
for _ in 0..<7 {
|
||||||
|
XCTAssertNil(abr.onHealthy())
|
||||||
|
}
|
||||||
|
XCTAssertEqual(abr.onHealthy(), 1_072_000 + 250_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testThermalCeilingClampsCurrent() {
|
||||||
|
let abr = controller()
|
||||||
|
let applied = abr.setCeiling(1_250_000)
|
||||||
|
XCTAssertEqual(applied, 1_250_000)
|
||||||
|
XCTAssertEqual(abr.ceiling, 1_250_000)
|
||||||
|
XCTAssertLessThanOrEqual(abr.floor, abr.ceiling)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMinFloorBumpsCurrentUp() {
|
||||||
|
let abr = controller()
|
||||||
|
abr.onCongestion(measuredBitsPerSecond: 1_200_000)
|
||||||
|
XCTAssertEqual(abr.setFloor(2_000_000), 2_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAutoOptionsExclude1080On720Session() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StreamQualityLadder.availableIds(sessionQualityPreset: "720p_30_2.5mbps"),
|
||||||
|
[
|
||||||
|
StreamQualityLadder.auto,
|
||||||
|
"720p_30_1.5mbps",
|
||||||
|
"720p_30_2.5mbps",
|
||||||
|
"720p_30_4mbps"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMeasuredZeroWithoutCongestionDoesNotDrop() {
|
||||||
|
let abr = controller()
|
||||||
|
XCTAssertNil(abr.onMeasured(measuredBitsPerSecond: 0, congested: false))
|
||||||
|
XCTAssertEqual(abr.currentBitrate, 2_500_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMeasuredLowThroughputDropsWithoutCongestionFlag() {
|
||||||
|
let abr = controller()
|
||||||
|
XCTAssertEqual(
|
||||||
|
abr.onMeasured(measuredBitsPerSecond: 1_200_000, congested: false),
|
||||||
|
1_200_000 - 128_000
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testThermalDropThenRecoverRestoresUserFloor() {
|
||||||
|
let abr = controller()
|
||||||
|
abr.setFloor(1_500_000)
|
||||||
|
abr.setCeiling(1_000_000)
|
||||||
|
XCTAssertEqual(abr.floor, 1_000_000)
|
||||||
|
abr.setCeiling(2_500_000)
|
||||||
|
XCTAssertEqual(abr.floor, 1_000_000)
|
||||||
|
XCTAssertEqual(abr.setFloor(1_500_000), 1_500_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEncoderCeilingRisesWhenMinExceedsSessionTarget() {
|
||||||
|
let ceiling = StreamQualityLadder.encoderCeiling(
|
||||||
|
sessionTarget: 2_500_000,
|
||||||
|
minPresetId: "720p_30_4mbps"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(ceiling, 4_000_000)
|
||||||
|
XCTAssertEqual(StreamQualityLadder.encoderFloor(minPresetId: "720p_30_4mbps", ceiling: ceiling), 4_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,7 +96,7 @@ lines += [
|
|||||||
app_settings = """
|
app_settings = """
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 26;
|
CURRENT_PROJECT_VERSION = 32;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
|
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
@@ -107,7 +107,7 @@ app_settings = """
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.0.5;
|
MARKETING_VERSION = 2.0.10;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
|
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
API_BASE_URL = "https://www.matchlivetv.it";
|
API_BASE_URL = "https://www.matchlivetv.it";
|
||||||
@@ -122,10 +122,10 @@ app_debug_settings = app_settings + """
|
|||||||
test_settings = """
|
test_settings = """
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 26;
|
CURRENT_PROJECT_VERSION = 32;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
MARKETING_VERSION = 2.0.5;
|
MARKETING_VERSION = 2.0.10;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
|||||||
Reference in New Issue
Block a user