Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f92ab3a813 | ||
|
|
bc2257efd2 | ||
|
|
4af4fa68ac | ||
|
|
45bdda7c95 | ||
|
|
b926531447 | ||
|
|
da6fc1d523 | ||
|
|
1d1cbf9f3f | ||
|
|
dd9901519a | ||
|
|
53449c5d3c | ||
|
|
db908e3109 | ||
|
|
5857f60d79 |
@@ -55,6 +55,8 @@ class SessionChannel < ApplicationCable::Channel
|
||||
Sessions::Pause.new(session).call
|
||||
when "resume_stream"
|
||||
Sessions::Resume.new(session).call
|
||||
when "set_min_quality"
|
||||
Sessions::SetMinQuality.new(session, preset: data["min_quality_preset"]).call
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -9,13 +9,23 @@ module Admin
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if params[:password].blank? || params[:password].length < 8
|
||||
flash.now[:alert] = t("admin.flash.password_too_short")
|
||||
if params[:password] != params[:password_confirmation]
|
||||
flash.now[:alert] = t("admin.flash.password_mismatch")
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if params[:password] != params[:password_confirmation]
|
||||
flash.now[:alert] = t("admin.flash.password_mismatch")
|
||||
if (code = PasswordComplexity.violation(params[:password]))
|
||||
key = case code
|
||||
when :blank, :too_short then :password_too_short
|
||||
when :too_long then :password_too_long
|
||||
else :password_too_weak
|
||||
end
|
||||
flash.now[:alert] = t("admin.flash.#{key}")
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if PasswordComplexity.same_as_current?(current_admin_account, params[:password])
|
||||
flash.now[:alert] = t("admin.flash.password_same_as_current")
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ module Api
|
||||
def update
|
||||
name = params[:name].to_s.strip
|
||||
if name.blank?
|
||||
return render json: { error: "Name is required" }, status: :unprocessable_entity
|
||||
return render json: { error: I18n.t("flash.accounts.name_required") }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if current_user.update(name: name)
|
||||
@@ -30,7 +30,7 @@ module Api
|
||||
return render json: { error: password_error_message(result.error) }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
render json: { message: "Password updated" }
|
||||
render json: { message: I18n.t("flash.accounts.password_updated") }
|
||||
end
|
||||
|
||||
private
|
||||
@@ -46,10 +46,13 @@ module Api
|
||||
|
||||
def password_error_message(code)
|
||||
case code
|
||||
when :current_incorrect then "Current password is incorrect"
|
||||
when :too_short then "Password must be at least 8 characters"
|
||||
when :mismatch then "Passwords do not match"
|
||||
else "Unable to update password"
|
||||
when :current_incorrect then I18n.t("flash.accounts.password_current_incorrect")
|
||||
when :too_short then I18n.t("flash.accounts.password_too_short")
|
||||
when :too_long then I18n.t("flash.accounts.password_too_long")
|
||||
when :too_weak then I18n.t("flash.accounts.password_too_weak")
|
||||
when :same_as_current then I18n.t("flash.accounts.password_same_as_current")
|
||||
when :mismatch then I18n.t("flash.accounts.password_mismatch")
|
||||
else I18n.t("flash.accounts.password_update_failed")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,18 +22,18 @@ module Api
|
||||
if user&.authenticate(params[:password])
|
||||
render json: token_response(user), status: :ok
|
||||
else
|
||||
render json: { error: "Invalid credentials" }, status: :unauthorized
|
||||
render json: { error: I18n.t("flash.sessions.invalid_credentials") }, status: :unauthorized
|
||||
end
|
||||
end
|
||||
|
||||
def logout
|
||||
render json: { message: "Logged out" }
|
||||
render json: { message: I18n.t("flash.sessions.logged_out") }
|
||||
end
|
||||
|
||||
def refresh
|
||||
payload = JsonWebToken.decode(params[:refresh_token] || bearer_token)
|
||||
user = User.find_by(id: payload&.dig(:user_id))
|
||||
return render json: { error: "Invalid token" }, status: :unauthorized unless user
|
||||
return render json: { error: I18n.t("flash.sessions.invalid_token") }, status: :unauthorized unless user
|
||||
|
||||
render json: token_response(user)
|
||||
end
|
||||
@@ -45,7 +45,7 @@ module Api
|
||||
def forgot_password
|
||||
Users::RequestPasswordReset.call(email: params[:email])
|
||||
render json: {
|
||||
message: "If the email is registered, you will receive a password reset link shortly."
|
||||
message: I18n.t("flash.password_resets.email_sent")
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ module Api
|
||||
current_bitrate: params[:current_bitrate],
|
||||
target_bitrate: params[:target_bitrate],
|
||||
fps: params[:fps],
|
||||
thermal_state: sanitized_thermal_state,
|
||||
last_seen_at: Time.current
|
||||
)
|
||||
sync_publisher_when_streaming!(params[:fps].to_f)
|
||||
@@ -141,6 +142,13 @@ module Api
|
||||
}
|
||||
end
|
||||
|
||||
def min_quality
|
||||
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
|
||||
render json: session_json(@session)
|
||||
rescue ArgumentError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def youtube_stats
|
||||
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
|
||||
Youtube::BroadcastService.new(@session.match.team)
|
||||
@@ -160,6 +168,11 @@ module Api
|
||||
.find(params[:id])
|
||||
end
|
||||
|
||||
def sanitized_thermal_state
|
||||
value = params[:thermal_state].to_s.downcase.presence
|
||||
value if value.in?(%w[nominal fair serious critical])
|
||||
end
|
||||
|
||||
def session_params
|
||||
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
|
||||
end
|
||||
@@ -187,6 +200,7 @@ module Api
|
||||
youtube_ready: session.youtube_ready?,
|
||||
privacy_status: session.privacy_status,
|
||||
quality_preset: session.quality_preset,
|
||||
min_quality_preset: session.min_quality_preset,
|
||||
target_bitrate: session.target_bitrate,
|
||||
started_at: session.started_at,
|
||||
disconnection_count: session.disconnection_count
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
class ApplicationController < ActionController::API
|
||||
include ActionController::HttpAuthentication::Token::ControllerMethods
|
||||
|
||||
before_action :set_api_locale
|
||||
before_action :authenticate_request!
|
||||
|
||||
attr_reader :current_user
|
||||
|
||||
private
|
||||
|
||||
def set_api_locale
|
||||
explicit = LocaleResolver.normalize(request.headers["X-Locale"])
|
||||
I18n.locale = explicit ||
|
||||
LocaleResolver.from_accept_language(request.headers["Accept-Language"]) ||
|
||||
I18n.default_locale
|
||||
end
|
||||
|
||||
def authenticate_request!
|
||||
token = bearer_token
|
||||
payload = JsonWebToken.decode(token)
|
||||
@current_user = User.find_by(id: payload[:user_id]) if payload
|
||||
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user
|
||||
render json: { error: I18n.t("flash.sessions.unauthorized") }, status: :unauthorized unless @current_user
|
||||
end
|
||||
|
||||
def bearer_token
|
||||
|
||||
@@ -29,8 +29,8 @@ module Public
|
||||
)
|
||||
|
||||
unless result.ok?
|
||||
flash.now[:alert] = t("flash.accounts.password_#{result.error}")
|
||||
return render :show, status: :unprocessable_entity
|
||||
redirect_to public_account_path, alert: t("flash.accounts.password_#{result.error}")
|
||||
return
|
||||
end
|
||||
|
||||
redirect_to public_account_path, notice: t("flash.accounts.password_updated")
|
||||
|
||||
@@ -11,17 +11,8 @@ module Public
|
||||
load_club_billing_context_for_pricing
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_club_billing_context_for_pricing
|
||||
return unless logged_in?
|
||||
|
||||
@club = current_user.primary_club
|
||||
return unless @club
|
||||
|
||||
@subscription = @club.subscription
|
||||
@team = @club.teams.order(:name).first
|
||||
@entitlements = @team&.entitlements
|
||||
def support
|
||||
@app_store_review_chrome = true
|
||||
end
|
||||
|
||||
def privacy
|
||||
@@ -38,5 +29,18 @@ module Public
|
||||
|
||||
def pallavolo
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_club_billing_context_for_pricing
|
||||
return unless logged_in?
|
||||
|
||||
@club = current_user.primary_club
|
||||
return unless @club
|
||||
|
||||
@subscription = @club.subscription
|
||||
@team = @club.teams.order(:name).first
|
||||
@entitlements = @team&.entitlements
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,7 +13,7 @@ module Public
|
||||
def edit
|
||||
@user = User.find_by_password_reset_token(params[:token])
|
||||
if @user.nil? || @user.password_reset_expired?
|
||||
redirect_to new_public_password_reset_path,
|
||||
redirect_to public_password_forgot_path,
|
||||
alert: t("flash.password_resets.invalid_or_expired_link")
|
||||
return
|
||||
end
|
||||
@@ -23,19 +23,25 @@ module Public
|
||||
def update
|
||||
@user = User.find_by_password_reset_token(params[:token])
|
||||
if @user.nil? || @user.password_reset_expired?
|
||||
redirect_to new_public_password_reset_path,
|
||||
redirect_to public_password_forgot_path,
|
||||
alert: t("flash.password_resets.invalid_or_expired_link")
|
||||
return
|
||||
end
|
||||
|
||||
if params[:password].blank? || params[:password].length < 8
|
||||
flash.now[:alert] = t("flash.password_resets.password_min_length")
|
||||
if params[:password] != params[:password_confirmation]
|
||||
flash.now[:alert] = t("flash.password_resets.password_mismatch")
|
||||
@token = params[:token]
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if params[:password] != params[:password_confirmation]
|
||||
flash.now[:alert] = t("flash.password_resets.password_mismatch")
|
||||
if (code = PasswordComplexity.violation(params[:password]))
|
||||
flash.now[:alert] = t("flash.password_resets.password_#{code == :blank ? :too_short : code}")
|
||||
@token = params[:token]
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if PasswordComplexity.same_as_current?(@user, params[:password])
|
||||
flash.now[:alert] = t("flash.password_resets.password_same_as_current")
|
||||
@token = params[:token]
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
@@ -7,7 +7,7 @@ module Public
|
||||
layout "regia"
|
||||
|
||||
protect_from_forgery with: :null_session
|
||||
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop]
|
||||
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop min_quality]
|
||||
|
||||
before_action :set_session_from_token
|
||||
|
||||
@@ -61,8 +61,20 @@ module Public
|
||||
render json: status_payload
|
||||
end
|
||||
|
||||
def min_quality
|
||||
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
|
||||
render json: status_payload
|
||||
rescue ArgumentError => e
|
||||
render json: status_payload.merge(error: e.message), status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def camera_telemetry_payload(publisher_online)
|
||||
cam = @session.device_states.find_by(device_role: "camera")
|
||||
(cam&.as_regia_payload || {}).merge(publisher_online: publisher_online)
|
||||
end
|
||||
|
||||
def set_session_from_token
|
||||
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
|
||||
return if @session
|
||||
@@ -86,7 +98,10 @@ module Public
|
||||
home_name: @session.match.team.name,
|
||||
away_name: @session.match.opponent_name,
|
||||
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
|
||||
score: score&.as_cable_payload
|
||||
score: score&.as_cable_payload,
|
||||
min_quality_preset: @session.min_quality_preset,
|
||||
min_quality_options: Sessions::SelectQuality.allowed_min_ids(@session.quality_preset),
|
||||
telemetry: camera_telemetry_payload(publisher_online)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,6 +11,7 @@ module Public
|
||||
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
||||
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
||||
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
||||
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
|
||||
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
|
||||
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
|
||||
]
|
||||
|
||||
@@ -54,15 +54,34 @@ module Public
|
||||
copyPrompt: t("regia.js.copy_prompt"),
|
||||
shareRegiaText: t("regia.js.share_regia_text"),
|
||||
shareLiveText: t("regia.js.share_live_text"),
|
||||
minQualityUpdated: t("regia.js.min_quality_updated"),
|
||||
minQualityError: t("regia.js.min_quality_error"),
|
||||
minQualityAuto: t("regia.min_quality.auto"),
|
||||
previewWaiting: t("regia.preview_waiting"),
|
||||
streamEnded: t("regia.preview_ended"),
|
||||
previewHide: t("regia.preview_hide"),
|
||||
previewShow: t("regia.preview_show"),
|
||||
previewOffHint: t("regia.preview_off_hint"),
|
||||
resumeLabel: t("regia.resume"),
|
||||
pauseLabel: t("regia.pause"),
|
||||
endedBadge: t("regia.status.ended"),
|
||||
pausedBadge: t("regia.status.paused"),
|
||||
liveBadge: t("regia.status.live"),
|
||||
waitingBadge: t("regia.status.waiting"),
|
||||
subtitlePrefix: t("regia.subtitle_prefix")
|
||||
subtitlePrefix: t("regia.subtitle_prefix"),
|
||||
telemetryWaiting: t("regia.telemetry.waiting"),
|
||||
telemetryStale: t("regia.telemetry.stale"),
|
||||
telemetryJustNow: t("regia.telemetry.just_now"),
|
||||
telemetrySecondsAgo: t("regia.telemetry.seconds_ago"),
|
||||
telemetryPublisherOn: t("regia.telemetry.publisher_on"),
|
||||
telemetryPublisherOff: t("regia.telemetry.publisher_off"),
|
||||
telemetryThermalNominal: t("regia.telemetry.thermal_nominal"),
|
||||
telemetryThermalFair: t("regia.telemetry.thermal_fair"),
|
||||
telemetryThermalSerious: t("regia.telemetry.thermal_serious"),
|
||||
telemetryThermalCritical: t("regia.telemetry.thermal_critical"),
|
||||
telemetryBitrateMax: t("regia.telemetry.bitrate_max"),
|
||||
telemetryBitrateTitle: t("regia.telemetry.bitrate_title"),
|
||||
telemetryBitrateTap: t("regia.telemetry.bitrate_tap")
|
||||
}.to_json
|
||||
end
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class AdminAccount < ApplicationRecord
|
||||
include PasswordComplexity
|
||||
|
||||
has_secure_password
|
||||
|
||||
validates :username, presence: true, uniqueness: true
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Criteri "di mercato" (stile Cognito/Auth0 bilanciato):
|
||||
# - minimo 8 caratteri (max 72 per bcrypt)
|
||||
# - almeno 3 classi su 4: minuscole, maiuscole, numeri, simboli
|
||||
module PasswordComplexity
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
MIN_LENGTH = 8
|
||||
MAX_LENGTH = 72
|
||||
REQUIRED_CLASSES = 3
|
||||
|
||||
CLASS_CHECKS = {
|
||||
lowercase: /[a-z]/,
|
||||
uppercase: /[A-Z]/,
|
||||
digit: /\d/,
|
||||
symbol: /[^A-Za-z0-9]/
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
def violation(password)
|
||||
value = password.to_s
|
||||
return :blank if value.blank?
|
||||
return :too_short if value.length < MIN_LENGTH
|
||||
return :too_long if value.bytesize > MAX_LENGTH
|
||||
return :too_weak unless strong_enough?(value)
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def strong_enough?(password)
|
||||
matched = CLASS_CHECKS.count { |_, pattern| password.match?(pattern) }
|
||||
matched >= REQUIRED_CLASSES
|
||||
end
|
||||
|
||||
# Confronta con il digest già salvato (prima di assegnare la nuova password).
|
||||
def same_as_current?(record, password)
|
||||
return false if password.blank? || !record.respond_to?(:authenticate)
|
||||
|
||||
record.authenticate(password).present?
|
||||
end
|
||||
|
||||
def requirement_summary
|
||||
I18n.t("password_policy.hint")
|
||||
end
|
||||
end
|
||||
|
||||
included do
|
||||
validate :password_meets_complexity_policy, if: -> { password.present? }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def password_meets_complexity_policy
|
||||
case PasswordComplexity.violation(password)
|
||||
when :too_short
|
||||
errors.add(:password, :too_short, count: MIN_LENGTH)
|
||||
when :too_long
|
||||
errors.add(:password, :too_long, count: MAX_LENGTH)
|
||||
when :too_weak
|
||||
errors.add(:password, :complexity)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -16,4 +16,17 @@ class DeviceState < ApplicationRecord
|
||||
status: stream_session.status
|
||||
}
|
||||
end
|
||||
|
||||
def as_regia_payload
|
||||
{
|
||||
battery: battery_level,
|
||||
network: network_type,
|
||||
signal: signal_strength,
|
||||
bitrate: current_bitrate,
|
||||
target_bitrate: target_bitrate,
|
||||
fps: fps,
|
||||
thermal_state: thermal_state,
|
||||
last_seen_at: last_seen_at&.iso8601
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,6 +14,7 @@ class StreamSession < ApplicationRecord
|
||||
validates :platform, inclusion: { in: PLATFORMS }
|
||||
validates :status, inclusion: { in: STATUSES }
|
||||
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
|
||||
validates :min_quality_preset, inclusion: { in: Sessions::SelectQuality::MIN_QUALITY_IDS }
|
||||
|
||||
before_validation :normalize_privacy_status
|
||||
before_validation :ensure_publish_token, on: :create
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class User < ApplicationRecord
|
||||
include PasswordComplexity
|
||||
|
||||
ROLES = %w[admin coach parent volunteer].freeze
|
||||
|
||||
has_secure_password
|
||||
|
||||
@@ -9,11 +9,12 @@ module Sessions
|
||||
@session.pause! if @session.may_pause?
|
||||
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
||||
begin
|
||||
Mediamtx::Client.new.set_path_recording(@session, enabled: false)
|
||||
mtx = Mediamtx::Client.new
|
||||
mtx.set_path_recording(@session, enabled: false)
|
||||
mtx.set_always_available(@session, enabled: true)
|
||||
rescue Mediamtx::Client::Error => e
|
||||
Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}")
|
||||
Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}")
|
||||
end
|
||||
Mediamtx::Client.new.set_always_available(@session, enabled: true)
|
||||
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
|
||||
log_event("paused")
|
||||
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
||||
|
||||
@@ -3,6 +3,8 @@ module Sessions
|
||||
class SelectQuality
|
||||
UPLOAD_HEADROOM = 0.8
|
||||
UPLOAD_HEADROOM_1080P = 1.0
|
||||
AUTO_ID = "auto"
|
||||
AUTO_FLOOR_BITRATE = 800_000
|
||||
|
||||
PRESETS = [
|
||||
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
|
||||
@@ -11,6 +13,32 @@ module Sessions
|
||||
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
|
||||
].freeze
|
||||
|
||||
MIN_QUALITY_IDS = ([AUTO_ID] + PRESETS.map { |preset| preset[:id] }).freeze
|
||||
|
||||
def self.floor_bitrate(preset_id)
|
||||
return AUTO_FLOOR_BITRATE if preset_id.blank? || preset_id == AUTO_ID
|
||||
|
||||
PRESETS.find { |preset| preset[:id] == preset_id }&.fetch(:target_bitrate) || AUTO_FLOOR_BITRATE
|
||||
end
|
||||
|
||||
def self.allowed_min_ids(session_preset)
|
||||
ids = [AUTO_ID] + PRESETS.reject { |preset| preset[:id].start_with?("1080p") }.reverse.map { |preset| preset[:id] }
|
||||
ids << "1080p_30_4.5mbps" if session_preset.to_s.start_with?("1080p")
|
||||
ids
|
||||
end
|
||||
|
||||
def self.display_name(preset_id)
|
||||
return AUTO_ID if preset_id.blank? || preset_id == AUTO_ID
|
||||
|
||||
preset = PRESETS.find { |item| item[:id] == preset_id }
|
||||
return preset_id unless preset
|
||||
|
||||
resolution = preset[:id].start_with?("1080p") ? "1080p" : "720p"
|
||||
mbps = preset[:target_bitrate] / 1_000_000.0
|
||||
mbps_text = (mbps == mbps.to_i) ? mbps.to_i.to_s : format("%.1f", mbps)
|
||||
"#{resolution} · #{mbps_text} Mbps"
|
||||
end
|
||||
|
||||
def self.call(upload_mbps:)
|
||||
new(upload_mbps: upload_mbps).call
|
||||
end
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Sessions
|
||||
class SetMinQuality
|
||||
def initialize(session, preset:)
|
||||
@session = session
|
||||
@preset = preset.to_s.strip
|
||||
end
|
||||
|
||||
def call
|
||||
id = normalize
|
||||
unless SelectQuality.allowed_min_ids(@session.quality_preset).include?(id)
|
||||
raise ArgumentError, "Qualità minima non valida"
|
||||
end
|
||||
|
||||
return @session if @session.min_quality_preset == id
|
||||
|
||||
@session.update!(min_quality_preset: id)
|
||||
@session.stream_events.create!(
|
||||
event_type: "quality_changed",
|
||||
occurred_at: Time.current,
|
||||
metadata: { "min_quality_preset" => id }
|
||||
)
|
||||
SessionChannel.broadcast_message(
|
||||
@session,
|
||||
{ type: "command", action: "set_min_quality", min_quality_preset: id }
|
||||
)
|
||||
@session
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize
|
||||
return SelectQuality::AUTO_ID if @preset.blank? || @preset == SelectQuality::AUTO_ID
|
||||
|
||||
@preset
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -23,15 +23,23 @@ module Users
|
||||
return Result.new(ok?: false, error: :current_incorrect)
|
||||
end
|
||||
|
||||
if @password.blank? || @password.length < 8
|
||||
return Result.new(ok?: false, error: :too_short)
|
||||
end
|
||||
|
||||
if @password != @password_confirmation
|
||||
return Result.new(ok?: false, error: :mismatch)
|
||||
end
|
||||
|
||||
@user.update!(password: @password)
|
||||
if (code = PasswordComplexity.violation(@password))
|
||||
return Result.new(ok?: false, error: code == :blank ? :too_short : code)
|
||||
end
|
||||
|
||||
if PasswordComplexity.same_as_current?(@user, @password)
|
||||
return Result.new(ok?: false, error: :same_as_current)
|
||||
end
|
||||
|
||||
unless @user.update(password: @password)
|
||||
complexity_error = @user.errors.details[:password]&.any? { |d| d[:error] == :complexity }
|
||||
return Result.new(ok?: false, error: complexity_error ? :too_weak : :too_short)
|
||||
end
|
||||
|
||||
@user.clear_password_reset!
|
||||
Result.new(ok?: true)
|
||||
end
|
||||
|
||||
@@ -8,17 +8,17 @@
|
||||
<%= render "shared/meta_tags" %>
|
||||
<%= yield :head %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=44">
|
||||
<link rel="stylesheet" href="/marketing.css?v=50">
|
||||
</head>
|
||||
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||
<%= render "shared/cookie_banner" %>
|
||||
<%= render "shared/marketing_nav" %>
|
||||
<%= render(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %>
|
||||
<main>
|
||||
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
||||
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
||||
<%= yield %>
|
||||
</main>
|
||||
<%= render "shared/marketing_footer" %>
|
||||
<%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %>
|
||||
<script src="/branding-form.js?v=1" defer></script>
|
||||
<script src="/roster-form.js?v=1" defer></script>
|
||||
<script src="/password-toggle.js?v=2" defer></script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=44">
|
||||
<link rel="stylesheet" href="/marketing.css?v=50">
|
||||
<link rel="stylesheet" href="/live.css?v=26">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<meta name="theme-color" content="#0a0a0e">
|
||||
<title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="/regia.css?v=1">
|
||||
<link rel="stylesheet" href="/regia.css?v=11">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
<body class="regia-body">
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
<div class="card">
|
||||
<h2 style="font-size:1.1rem;margin-top:0"><%= t("auth.account.password_heading") %></h2>
|
||||
<p class="muted" style="font-size:0.9rem"><%= t("password_policy.hint") %></p>
|
||||
<%= form_with url: public_account_password_path, method: :patch, local: true do %>
|
||||
<%= render "shared/input_toggle",
|
||||
name: :current_password,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<% content_for :meta_description, t("auth.invitation.meta_description") %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="card" style="max-width:480px">
|
||||
<section class="auth-page">
|
||||
<div class="card">
|
||||
<h1><%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %></h1>
|
||||
<p><%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %></p>
|
||||
<p class="muted" style="font-size:0.9rem;margin-bottom:16px">
|
||||
@@ -23,4 +24,5 @@
|
||||
<%= t("auth.invitation.mobile_app_label") %>
|
||||
<a href="matchlivetv://join/<%= @token %>"><%= t("auth.invitation.open_in_app") %></a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
|
||||
<h3><%= t("legal.cookies.s4_1_title") %></h3>
|
||||
<p><%= t("legal.cookies.s4_1_intro") %></p>
|
||||
<div class="table-scroll">
|
||||
<table class="legal-table">
|
||||
<thead>
|
||||
<tr><th><%= t("legal.cookies.table_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr>
|
||||
@@ -65,6 +66,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3><%= t("legal.cookies.s4_2_title") %></h3>
|
||||
<p>
|
||||
@@ -75,6 +77,7 @@
|
||||
<% else %>
|
||||
<p class="muted"><%= t("legal.cookies.s4_2_inactive") %></p>
|
||||
<% end %>
|
||||
<div class="table-scroll">
|
||||
<table class="legal-table">
|
||||
<thead>
|
||||
<tr><th><%= t("legal.cookies.table2_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr>
|
||||
@@ -100,6 +103,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p>
|
||||
<%= raw t(
|
||||
"legal.cookies.s4_2_p2_html",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
<%= render "shared/plan_cards" %>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table class="compare-table">
|
||||
<thead>
|
||||
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
|
||||
@@ -36,6 +37,7 @@
|
||||
<tr><td><%= t("pages.pricing.table_price") %></td><td><%= t("pages.pricing.table_price_free") %></td><td><%= raw t("pages.pricing.table_price_light_html") %></td><td><%= raw t("pages.pricing.table_price_full_html") %></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:32px;text-align:center">
|
||||
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
|
||||
<section>
|
||||
<h2><%= t("legal.privacy.s5_title") %></h2>
|
||||
<div class="table-scroll">
|
||||
<table class="legal-table">
|
||||
<thead>
|
||||
<tr><th><%= t("legal.privacy.s5_col_purpose") %></th><th><%= t("legal.privacy.s5_col_basis") %></th></tr>
|
||||
@@ -108,6 +109,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
@@ -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>
|
||||
@@ -4,6 +4,7 @@
|
||||
<section class="auth-page">
|
||||
<h1><%= t("auth.password_reset.title") %></h1>
|
||||
<div class="card">
|
||||
<p class="muted" style="font-size:0.9rem"><%= t("password_policy.hint") %></p>
|
||||
<%= form_with url: public_password_reset_path, method: :patch, local: true do %>
|
||||
<%= hidden_field_tag :token, @token %>
|
||||
<%= render "shared/input_toggle",
|
||||
|
||||
@@ -21,4 +21,4 @@
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set" style="margin-top:12px"><%= t("regia.board.close_set") %></button>
|
||||
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set"><%= t("regia.board.close_set") %></button>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
||||
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
|
||||
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
|
||||
data-min-quality-url="<%= j public_regia_min_quality_path(params[:token]) %>"
|
||||
data-min-quality="<%= j @session.min_quality_preset %>"
|
||||
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
||||
data-hls-url="<%= j @session.hls_playback_url %>"
|
||||
data-home-name="<%= j @team.name %>"
|
||||
@@ -29,40 +31,62 @@
|
||||
data-initial-paused="<%= @session.paused? %>">
|
||||
|
||||
<header class="regia-header">
|
||||
<div class="regia-header__text">
|
||||
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
||||
<p id="regia-subtitle"><%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %></p>
|
||||
</div>
|
||||
<span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>">
|
||||
<%= @stream_closed ? t("regia.status.ended") : (@session.paused? ? t("regia.status.paused") : (@on_air ? t("regia.status.live") : t("regia.status.waiting"))) %>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<nav class="regia-toolbar" aria-label="<%= t("regia.links_nav") %>">
|
||||
<% if @live_share_url.present? %>
|
||||
<div class="regia-share-group">
|
||||
<p class="regia-share-label"><%= t("regia.live_share_label") %></p>
|
||||
<div class="regia-share">
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-live" style="margin:0">
|
||||
<%= t("regia.share_live") %>
|
||||
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.live_share_label") %>">
|
||||
<span class="regia-tool-group__label"><%= t("regia.live_share_label") %></span>
|
||||
<div class="regia-tool-group__btns">
|
||||
<button type="button" class="regia-icon-btn" id="btn-share-live" aria-label="<%= t("regia.share_live") %>" title="<%= t("regia.share_live") %>">
|
||||
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||
</button>
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-live" style="margin:0">
|
||||
<%= t("regia.copy_link") %>
|
||||
<button type="button" class="regia-icon-btn" id="btn-copy-live" aria-label="<%= t("regia.copy_live") %>" title="<%= t("regia.copy_live") %>">
|
||||
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="regia-share-group">
|
||||
<p class="regia-share-label"><%= t("regia.regia_share_label") %></p>
|
||||
<div class="regia-share">
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-regia" style="margin:0">
|
||||
<%= t("regia.share_regia") %>
|
||||
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.regia_share_label") %>">
|
||||
<span class="regia-tool-group__label"><%= t("regia.regia_share_label") %></span>
|
||||
<div class="regia-tool-group__btns">
|
||||
<button type="button" class="regia-icon-btn" id="btn-share-regia" aria-label="<%= t("regia.share_regia") %>" title="<%= t("regia.share_regia") %>">
|
||||
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||
</button>
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-regia" style="margin:0">
|
||||
<%= t("regia.copy_link") %>
|
||||
<button type="button" class="regia-icon-btn" id="btn-copy-regia" aria-label="<%= t("regia.copy_regia") %>" title="<%= t("regia.copy_regia") %>">
|
||||
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% 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 %>
|
||||
<video id="regia-preview" playsinline muted autoplay></video>
|
||||
<% end %>
|
||||
@@ -70,14 +94,55 @@
|
||||
<%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="regia-scoreboard">
|
||||
<%= render regia_board_partial(@match) %>
|
||||
</section>
|
||||
|
||||
<% unless @stream_closed %>
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause"><%= @session.paused? ? t("regia.resume") : t("regia.pause") %></button>
|
||||
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop"><%= t("regia.stop") %></button>
|
||||
<section class="regia-telemetry" id="regia-telemetry" aria-label="<%= t("regia.telemetry.label") %>">
|
||||
<div class="regia-telemetry__row">
|
||||
<span class="regia-chip" id="tel-battery" title="<%= t("regia.telemetry.battery") %>">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 4h-1V3h-6v1H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H8V6h8v14z"/></svg>
|
||||
<span class="regia-chip__label"><%= t("regia.telemetry.battery") %></span>
|
||||
<span class="regia-chip__value">—</span>
|
||||
</span>
|
||||
<span class="regia-chip" id="tel-network" title="<%= t("regia.telemetry.network") %>">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/></svg>
|
||||
<span class="regia-chip__label"><%= t("regia.telemetry.network") %></span>
|
||||
<span class="regia-chip__value">—</span>
|
||||
</span>
|
||||
<button type="button" class="regia-chip regia-chip--action" id="tel-bitrate" aria-expanded="false" aria-controls="regia-quality" title="<%= t("regia.telemetry.bitrate_tap") %>">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 13h2v-2H3v2zm4 4h2v-6H7v6zm4 4h2V7h-2v14zm4-8h2V9h-2v4zm4-4h2V5h-2v4z"/></svg>
|
||||
<span class="regia-chip__label"><%= t("regia.telemetry.bitrate") %></span>
|
||||
<span class="regia-chip__value">—</span>
|
||||
<span class="regia-chip__hint" hidden></span>
|
||||
</button>
|
||||
<span class="regia-chip" id="tel-fps" title="<%= t("regia.telemetry.fps") %>">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
|
||||
<span class="regia-chip__label"><%= t("regia.telemetry.fps") %></span>
|
||||
<span class="regia-chip__value">—</span>
|
||||
</span>
|
||||
<span class="regia-chip" id="tel-thermal" title="<%= t("regia.telemetry.thermal") %>">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 13V5c0-1.66-1.34-3-3-3S9 3.34 9 5v8c-1.21.91-2 2.37-2 4 0 2.76 2.24 5 5 5s5-2.24 5-5c0-1.63-.79-3.09-2-4zm-3 8c-1.66 0-3-1.34-3-3 0-1.19.7-2.23 1.71-2.72L11 14.73V5c0-.55.45-1 1-1s1 .45 1 1v9.73l.29.18C14.3 15.77 15 16.81 15 18c0 1.66-1.34 3-3 3z"/></svg>
|
||||
<span class="regia-chip__label"><%= t("regia.telemetry.thermal") %></span>
|
||||
<span class="regia-chip__value">—</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="regia-quality" id="regia-quality" hidden>
|
||||
<p class="regia-quality__hint"><%= t("regia.min_quality.recommend") %></p>
|
||||
<label for="min-quality"><%= t("regia.min_quality.label") %></label>
|
||||
<select id="min-quality" title="<%= t("regia.min_quality.hint") %>">
|
||||
<% Sessions::SelectQuality.allowed_min_ids(@session.quality_preset).each do |id| %>
|
||||
<option value="<%= id %>" <%= "selected" if @session.min_quality_preset == id %>>
|
||||
<%= id == Sessions::SelectQuality::AUTO_ID ? t("regia.min_quality.auto") : Sessions::SelectQuality.display_name(id) %>
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<p class="regia-telemetry__meta" id="tel-meta"><%= t("regia.telemetry.waiting") %></p>
|
||||
</section>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -94,4 +159,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/regia.js?v=8"></script>
|
||||
<script src="/regia.js?v=18"></script>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
required: true,
|
||||
minlength: 8,
|
||||
autocomplete: "new-password" %>
|
||||
<p class="muted" style="font-size:0.9rem;margin-top:-0.5rem"><%= t("password_policy.hint") %></p>
|
||||
<%= render "shared/input_toggle",
|
||||
name: "user[password_confirmation]",
|
||||
id: "user_password_confirmation",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<h3 style="margin-top:28px;font-size:1.1rem"><%= t("billing.documents.table_heading") %></h3>
|
||||
<% if payments.any? %>
|
||||
<div class="table-scroll">
|
||||
<table class="data billing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -60,6 +61,7 @@
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<p style="color:#888"><%= t("billing.documents.no_payments") %></p>
|
||||
<% end %>
|
||||
|
||||
@@ -58,10 +58,18 @@
|
||||
}
|
||||
|
||||
toggle.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(menu.hidden);
|
||||
});
|
||||
|
||||
// Keep parent mobile nav open while interacting with the switcher UI.
|
||||
root.addEventListener("click", function (e) {
|
||||
if (e.target.closest(".lang-switcher__toggle")) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", function (e) {
|
||||
if (!root.contains(e.target)) setOpen(false);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||
</div>
|
||||
<div>
|
||||
<%= link_to t("common.support"), public_support_path %> ·
|
||||
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
||||
<%= link_to t("footer.live"), public_live_index_path %> ·
|
||||
<%= link_to t("common.faq"), public_faq_path %> ·
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %>
|
||||
<footer class="site-footer">
|
||||
<div class="wrap">
|
||||
<div>
|
||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||
</div>
|
||||
<div>
|
||||
<%= link_to t("common.support"), public_support_path %> ·
|
||||
<%= link_to t("common.privacy"), public_privacy_path %> ·
|
||||
<%= link_to t("common.cookies"), public_cookies_path %> ·
|
||||
<%= link_to t("common.terms"), public_termini_path %>
|
||||
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
||||
</div>
|
||||
<div class="site-footer__legal">
|
||||
<p><%= t("footer.copyright") %></p>
|
||||
<p><%= t("footer.responsibility") %></p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -19,6 +19,15 @@
|
||||
|
||||
<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 root_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("nav.home"), root_path, class: (request.path == "/" ? "nav-active" : nil) %>
|
||||
<%= link_to t("nav.features"), public_features_path, class: (request.path == "/funzionalita" ? "nav-active" : nil) %>
|
||||
<%= link_to t("nav.pricing"), public_prezzi_path, class: (request.path == "/prezzi" ? "nav-active" : nil) %>
|
||||
@@ -40,9 +49,6 @@
|
||||
<%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %>
|
||||
<%= link_to t("nav.signup"), public_signup_path, class: "btn btn-primary nav-btn" %>
|
||||
<% end %>
|
||||
<div class="nav-lang">
|
||||
<%= render "shared/language_switcher" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -78,8 +84,16 @@
|
||||
|
||||
if (backdrop) backdrop.addEventListener("click", closeMenu);
|
||||
|
||||
function shouldCloseNavOnControl(el) {
|
||||
// Language toggle must keep the mobile menu open; only a locale choice may close it.
|
||||
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", closeMenu);
|
||||
el.addEventListener("click", function () {
|
||||
if (shouldCloseNavOnControl(el)) closeMenu();
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("resize", function () {
|
||||
|
||||
@@ -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.
|
||||
config.action_view.annotate_rendered_view_with_filenames = true
|
||||
|
||||
# Uncomment if you wish to allow Action Cable access from any origin.
|
||||
# config.action_cable.disable_request_forgery_protection = true
|
||||
# Permette Action Cable dall'emulatore Android (10.0.2.2) e da altri host locali.
|
||||
config.action_cable.disable_request_forgery_protection = true
|
||||
|
||||
# Raise error when a before_action's only/except options reference missing actions.
|
||||
config.action_controller.raise_on_missing_callback_actions = true
|
||||
|
||||
@@ -81,6 +81,12 @@ module MatchLiveTv
|
||||
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
|
||||
end
|
||||
|
||||
# Email di supporto (App Store Support URL e contatti assistenza).
|
||||
# Preferisce SUPPORT_CONTACT_EMAIL; default = contatto commerciale già usato nel sito.
|
||||
def support_email
|
||||
ENV["SUPPORT_CONTACT_EMAIL"].presence || "info@matchlivetv.it"
|
||||
end
|
||||
|
||||
def privacy_controller_address
|
||||
ENV.fetch("PRIVACY_CONTROLLER_ADDRESS", "Via Guido De Ruggiero, 89 - 20142 - Milano (MI)")
|
||||
end
|
||||
|
||||
@@ -18,6 +18,9 @@ de:
|
||||
logout_success: Abgemeldet
|
||||
password_current_incorrect: Das aktuelle Passwort ist falsch
|
||||
password_too_short: Das neue Passwort muss mindestens 8 Zeichen lang sein
|
||||
password_too_long: Das neue Passwort darf höchstens 72 Zeichen lang sein
|
||||
password_too_weak: Das neue Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten
|
||||
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
|
||||
password_mismatch: Die Passwörter stimmen nicht überein
|
||||
password_updated: Passwort aktualisiert
|
||||
ops_acknowledged: Vorfall übernommen
|
||||
@@ -53,7 +56,7 @@ de:
|
||||
edit:
|
||||
title: Passwort ändern
|
||||
current_password_label: Aktuelles Passwort
|
||||
new_password_label: "Neues Passwort (mind. 8 Zeichen)"
|
||||
new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)"
|
||||
confirm_password_label: Neues Passwort bestätigen
|
||||
submit: Passwort speichern
|
||||
cancel: Abbrechen
|
||||
|
||||
@@ -18,6 +18,9 @@ en:
|
||||
logout_success: Signed out
|
||||
password_current_incorrect: Current password is incorrect
|
||||
password_too_short: The new password must be at least 8 characters long
|
||||
password_too_long: New password cannot exceed 72 characters
|
||||
password_too_weak: "New password must include at least 3 of: lowercase, uppercase, numbers and symbols"
|
||||
password_same_as_current: New password must be different from the current password
|
||||
password_mismatch: Passwords do not match
|
||||
password_updated: Password updated
|
||||
ops_acknowledged: Incident acknowledged
|
||||
@@ -53,7 +56,7 @@ en:
|
||||
edit:
|
||||
title: Change password
|
||||
current_password_label: Current password
|
||||
new_password_label: "New password (min. 8 characters)"
|
||||
new_password_label: "New password (min. 8, at least 3 character types)"
|
||||
confirm_password_label: Confirm new password
|
||||
submit: Save password
|
||||
cancel: Cancel
|
||||
|
||||
@@ -18,6 +18,9 @@ es:
|
||||
logout_success: Sesión cerrada
|
||||
password_current_incorrect: La contraseña actual no es correcta
|
||||
password_too_short: La nueva contraseña debe tener al menos 8 caracteres
|
||||
password_too_long: La nueva contraseña no puede superar los 72 caracteres
|
||||
password_too_weak: "La nueva contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||
password_same_as_current: La nueva contraseña debe ser distinta de la actual
|
||||
password_mismatch: Las contraseñas no coinciden
|
||||
password_updated: Contraseña actualizada
|
||||
ops_acknowledged: Incidencia asumida
|
||||
@@ -53,7 +56,7 @@ es:
|
||||
edit:
|
||||
title: Cambiar contraseña
|
||||
current_password_label: Contraseña actual
|
||||
new_password_label: "Nueva contraseña (mín. 8 caracteres)"
|
||||
new_password_label: "Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)"
|
||||
confirm_password_label: Confirma la nueva contraseña
|
||||
submit: Guardar contraseña
|
||||
cancel: Cancelar
|
||||
|
||||
@@ -18,6 +18,9 @@ fr:
|
||||
logout_success: Déconnecté
|
||||
password_current_incorrect: Le mot de passe actuel est incorrect
|
||||
password_too_short: Le nouveau mot de passe doit contenir au moins 8 caractères
|
||||
password_too_long: Le nouveau mot de passe ne peut pas dépasser 72 caractères
|
||||
password_too_weak: "Le nouveau mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||
password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel"
|
||||
password_mismatch: Les mots de passe ne correspondent pas
|
||||
password_updated: Mot de passe mis à jour
|
||||
ops_acknowledged: Incident pris en charge
|
||||
@@ -53,7 +56,7 @@ fr:
|
||||
edit:
|
||||
title: Changer le mot de passe
|
||||
current_password_label: Mot de passe actuel
|
||||
new_password_label: "Nouveau mot de passe (min. 8 caractères)"
|
||||
new_password_label: "Nouveau mot de passe (min. 8, au moins 3 types de caractères)"
|
||||
confirm_password_label: Confirmer le nouveau mot de passe
|
||||
submit: Enregistrer le mot de passe
|
||||
cancel: Annuler
|
||||
|
||||
@@ -18,6 +18,9 @@ it:
|
||||
logout_success: Disconnesso
|
||||
password_current_incorrect: Password attuale non corretta
|
||||
password_too_short: La nuova password deve avere almeno 8 caratteri
|
||||
password_too_long: La nuova password non può superare i 72 caratteri
|
||||
password_too_weak: "La nuova password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||
password_same_as_current: La nuova password deve essere diversa da quella attuale
|
||||
password_mismatch: Le password non coincidono
|
||||
password_updated: Password aggiornata
|
||||
ops_acknowledged: Incidente preso in carico
|
||||
@@ -53,7 +56,7 @@ it:
|
||||
edit:
|
||||
title: Cambia password
|
||||
current_password_label: Password attuale
|
||||
new_password_label: "Nuova password (min. 8 caratteri)"
|
||||
new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)"
|
||||
confirm_password_label: Conferma nuova password
|
||||
submit: Salva password
|
||||
cancel: Annulla
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
de:
|
||||
password_policy:
|
||||
hint: "Mindestens 8 Zeichen, mit mindestens 3 aus: Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen."
|
||||
activerecord:
|
||||
attributes:
|
||||
user:
|
||||
password: Passwort
|
||||
admin_account:
|
||||
password: Passwort
|
||||
errors:
|
||||
models:
|
||||
user:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "ist zu kurz (mindestens %{count} Zeichen)"
|
||||
too_long: "ist zu lang (höchstens %{count} Zeichen)"
|
||||
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten"
|
||||
admin_account:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "ist zu kurz (mindestens %{count} Zeichen)"
|
||||
too_long: "ist zu lang (höchstens %{count} Zeichen)"
|
||||
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten"
|
||||
club:
|
||||
back_to_club: "← Verein"
|
||||
sport_label: Hauptsportart
|
||||
@@ -410,16 +432,51 @@ de:
|
||||
paused: Pausiert
|
||||
live: Live
|
||||
waiting: Wartet
|
||||
live_share_label: "Live-Link (Zuschauer)"
|
||||
live_share_label: Live
|
||||
share_live: Livestream teilen
|
||||
share_action: Teilen
|
||||
copy_link: Link kopieren
|
||||
regia_share_label: "Regie-Link (Punktestand)"
|
||||
copy_action: Kopieren
|
||||
copy_live: Live kopieren
|
||||
copy_regia: Regie kopieren
|
||||
links_nav: Schnellaktionen
|
||||
regia_share_label: Regie
|
||||
stream_controls_label: Live
|
||||
share_regia: Regie-Link teilen
|
||||
preview_ended: Livestream beendet
|
||||
preview_waiting: Vorschau wartet auf Signal…
|
||||
preview_hide: Vorschau ausblenden
|
||||
preview_show: Vorschau anzeigen
|
||||
preview_off_hint: Vorschau aus · kein Video-Download
|
||||
pause: Pausieren
|
||||
resume: Livestream fortsetzen
|
||||
stop: Livestream beenden
|
||||
min_quality:
|
||||
label: Minimale Übertragungsqualität
|
||||
summary: Qualität
|
||||
hint: Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.
|
||||
recommend: "Automatisch wird empfohlen: passt sich an Netz und Telefon an, ohne das Maximum zu erzwingen."
|
||||
auto: Automatisch
|
||||
telemetry:
|
||||
label: Telefon
|
||||
waiting: Warte auf das Telefon…
|
||||
stale: Telefon antwortet nicht
|
||||
just_now: jetzt
|
||||
seconds_ago: "vor %{n}s"
|
||||
publisher_on: RTMP ok
|
||||
publisher_off: RTMP fehlt
|
||||
battery: Akku
|
||||
network: Netz
|
||||
bitrate: Bitrate
|
||||
bitrate_max: "max %{n}"
|
||||
bitrate_title: "%{current} Mbps aktuell · max. %{target} Mbps"
|
||||
bitrate_tap: Tippen, um die Mindestqualität festzulegen
|
||||
fps: FPS
|
||||
thermal: Temp.
|
||||
thermal_nominal: OK
|
||||
thermal_fair: Warm
|
||||
thermal_serious: Heiß
|
||||
thermal_critical: Kritisch
|
||||
modal:
|
||||
set_won_title: Satz gewonnen
|
||||
confirm: Satz beenden
|
||||
@@ -464,6 +521,8 @@ de:
|
||||
copy_prompt: "Link kopieren:"
|
||||
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
|
||||
share_live_text: "Sieh dir die Übertragung auf Match Live TV an:"
|
||||
min_quality_updated: Mindestqualität aktualisiert
|
||||
min_quality_error: Fehler beim Aktualisieren der Mindestqualität
|
||||
recordings:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
@@ -600,10 +659,16 @@ de:
|
||||
welcome_back: Willkommen zurück!
|
||||
invalid_credentials: E-Mail oder Passwort ungültig
|
||||
logged_out: Abgemeldet
|
||||
unauthorized: Nicht autorisiert
|
||||
invalid_token: Ungültiges Token
|
||||
password_resets:
|
||||
email_sent: Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen des Passworts.
|
||||
invalid_or_expired_link: Link ungültig oder abgelaufen. Fordere ein neues Zurücksetzen des Passworts an.
|
||||
password_min_length: Das Passwort muss mindestens 8 Zeichen lang sein
|
||||
password_too_short: Das Passwort muss mindestens 8 Zeichen lang sein
|
||||
password_too_long: Das Passwort darf höchstens 72 Zeichen lang sein
|
||||
password_too_weak: Das Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten
|
||||
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
|
||||
password_mismatch: Die Passwörter stimmen nicht überein
|
||||
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden.
|
||||
accounts:
|
||||
@@ -611,8 +676,12 @@ de:
|
||||
profile_updated: Profil aktualisiert.
|
||||
password_current_incorrect: Das aktuelle Passwort ist falsch
|
||||
password_too_short: Das Passwort muss mindestens 8 Zeichen haben
|
||||
password_too_long: Das Passwort darf höchstens 72 Zeichen lang sein
|
||||
password_too_weak: Das Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten
|
||||
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
|
||||
password_mismatch: Die Passwörter stimmen nicht überein
|
||||
password_updated: Passwort aktualisiert.
|
||||
password_update_failed: Passwort konnte nicht aktualisiert werden
|
||||
replay:
|
||||
download_unavailable: Download nicht verfügbar
|
||||
not_available: Replay nicht verfügbar
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
en:
|
||||
password_policy:
|
||||
hint: "At least 8 characters, including 3 of: lowercase, uppercase, numbers and symbols."
|
||||
activerecord:
|
||||
errors:
|
||||
models:
|
||||
user:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "is too short (minimum is %{count} characters)"
|
||||
too_long: "is too long (maximum is %{count} characters)"
|
||||
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
|
||||
admin_account:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "is too short (minimum is %{count} characters)"
|
||||
too_long: "is too long (maximum is %{count} characters)"
|
||||
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
|
||||
club:
|
||||
back_to_club: "← Club"
|
||||
sport_label: Main sport
|
||||
@@ -410,16 +427,51 @@ en:
|
||||
paused: Paused
|
||||
live: Live
|
||||
waiting: Waiting
|
||||
live_share_label: "Live link (viewers)"
|
||||
live_share_label: Live
|
||||
share_live: Share live link
|
||||
share_action: Share
|
||||
copy_link: Copy link
|
||||
regia_share_label: "Control room link (score)"
|
||||
copy_action: Copy
|
||||
copy_live: Copy live
|
||||
copy_regia: Copy room
|
||||
links_nav: Quick actions
|
||||
regia_share_label: Control
|
||||
stream_controls_label: Live
|
||||
share_regia: Share control room link
|
||||
preview_ended: Stream ended
|
||||
preview_waiting: Waiting for signal…
|
||||
preview_hide: Hide preview
|
||||
preview_show: Show preview
|
||||
preview_off_hint: Preview off · no video download
|
||||
pause: Pause stream
|
||||
resume: Resume stream
|
||||
stop: End stream
|
||||
min_quality:
|
||||
label: Minimum streaming quality
|
||||
summary: Quality
|
||||
hint: The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.
|
||||
recommend: "Automatic is recommended: it adapts to the network and the phone, without forcing maximum quality."
|
||||
auto: Automatic
|
||||
telemetry:
|
||||
label: Phone
|
||||
waiting: Waiting for the phone…
|
||||
stale: Phone not responding
|
||||
just_now: now
|
||||
seconds_ago: "%{n}s ago"
|
||||
publisher_on: RTMP ok
|
||||
publisher_off: RTMP down
|
||||
battery: Battery
|
||||
network: Network
|
||||
bitrate: Bitrate
|
||||
bitrate_max: "max %{n}"
|
||||
bitrate_title: "%{current} Mbps current · max %{target} Mbps"
|
||||
bitrate_tap: Tap to set the minimum quality
|
||||
fps: FPS
|
||||
thermal: Temp.
|
||||
thermal_nominal: OK
|
||||
thermal_fair: Warm
|
||||
thermal_serious: Hot
|
||||
thermal_critical: Critical
|
||||
modal:
|
||||
set_won_title: Set won
|
||||
confirm: Close set
|
||||
@@ -464,6 +516,8 @@ en:
|
||||
copy_prompt: "Copy the link:"
|
||||
share_regia_text: "Open this link to manage the live score:"
|
||||
share_live_text: "Watch the live stream on Match Live TV:"
|
||||
min_quality_updated: Minimum quality updated
|
||||
min_quality_error: Error updating minimum quality
|
||||
recordings:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
@@ -600,10 +654,16 @@ en:
|
||||
welcome_back: Welcome back!
|
||||
invalid_credentials: Invalid email or password
|
||||
logged_out: Logged out
|
||||
unauthorized: Unauthorized
|
||||
invalid_token: Invalid token
|
||||
password_resets:
|
||||
email_sent: If the email is registered, you'll receive a password reset link shortly.
|
||||
invalid_or_expired_link: Invalid or expired link. Request a new password reset.
|
||||
password_min_length: Password must be at least 8 characters
|
||||
password_too_short: Password must be at least 8 characters
|
||||
password_too_long: Password cannot exceed 72 characters
|
||||
password_too_weak: "Password must include at least 3 of: lowercase, uppercase, numbers and symbols"
|
||||
password_same_as_current: New password must be different from the current password
|
||||
password_mismatch: Passwords don't match
|
||||
password_updated: Password updated. You can now log in.
|
||||
accounts:
|
||||
@@ -611,8 +671,12 @@ en:
|
||||
profile_updated: Profile updated.
|
||||
password_current_incorrect: Current password is incorrect
|
||||
password_too_short: Password must be at least 8 characters
|
||||
password_too_long: Password cannot exceed 72 characters
|
||||
password_too_weak: "Password must include at least 3 of: lowercase, uppercase, numbers and symbols"
|
||||
password_same_as_current: New password must be different from the current password
|
||||
password_mismatch: Passwords do not match
|
||||
password_updated: Password updated.
|
||||
password_update_failed: Unable to update password
|
||||
replay:
|
||||
download_unavailable: Download not available
|
||||
not_available: Replay not available
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
es:
|
||||
password_policy:
|
||||
hint: "Mínimo 8 caracteres, con al menos 3 entre: minúsculas, mayúsculas, números y símbolos."
|
||||
activerecord:
|
||||
attributes:
|
||||
user:
|
||||
password: Contraseña
|
||||
admin_account:
|
||||
password: Contraseña
|
||||
errors:
|
||||
models:
|
||||
user:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "es demasiado corta (mínimo %{count} caracteres)"
|
||||
too_long: "es demasiado larga (máximo %{count} caracteres)"
|
||||
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||
admin_account:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "es demasiado corta (mínimo %{count} caracteres)"
|
||||
too_long: "es demasiado larga (máximo %{count} caracteres)"
|
||||
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||
club:
|
||||
back_to_club: "← Club"
|
||||
sport_label: Deporte principal
|
||||
@@ -410,16 +432,51 @@ es:
|
||||
paused: En pausa
|
||||
live: En directo
|
||||
waiting: En espera
|
||||
live_share_label: "Enlace del directo (espectadores)"
|
||||
live_share_label: Directo
|
||||
share_live: Compartir directo
|
||||
share_action: Compartir
|
||||
copy_link: Copiar enlace
|
||||
regia_share_label: "Enlace de control (marcador)"
|
||||
copy_action: Copiar
|
||||
copy_live: Copiar directo
|
||||
copy_regia: Copiar control
|
||||
links_nav: Acciones rápidas
|
||||
regia_share_label: Control
|
||||
stream_controls_label: Live
|
||||
share_regia: Compartir enlace de control
|
||||
preview_ended: Directo finalizado
|
||||
preview_waiting: Vista previa a la espera de señal…
|
||||
preview_hide: Ocultar vista previa
|
||||
preview_show: Mostrar vista previa
|
||||
preview_off_hint: Vista previa apagada · sin descarga de vídeo
|
||||
pause: Pausar directo
|
||||
resume: Reanudar directo
|
||||
stop: Finalizar directo
|
||||
min_quality:
|
||||
label: Calidad mínima de transmisión
|
||||
summary: Calidad
|
||||
hint: El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.
|
||||
recommend: "Se recomienda Automática: se adapta a la red y al teléfono, sin forzar el máximo."
|
||||
auto: Automática
|
||||
telemetry:
|
||||
label: Teléfono
|
||||
waiting: Esperando al teléfono…
|
||||
stale: El teléfono no responde
|
||||
just_now: ahora
|
||||
seconds_ago: "hace %{n}s"
|
||||
publisher_on: RTMP ok
|
||||
publisher_off: RTMP ausente
|
||||
battery: Batería
|
||||
network: Red
|
||||
bitrate: Bitrate
|
||||
bitrate_max: "máx %{n}"
|
||||
bitrate_title: "%{current} Mbps actuales · máximo %{target} Mbps"
|
||||
bitrate_tap: Toca para definir la calidad mínima
|
||||
fps: FPS
|
||||
thermal: Temp.
|
||||
thermal_nominal: OK
|
||||
thermal_fair: Caliente
|
||||
thermal_serious: Muy caliente
|
||||
thermal_critical: Crítica
|
||||
modal:
|
||||
set_won_title: Set ganado
|
||||
confirm: Cerrar set
|
||||
@@ -464,6 +521,8 @@ es:
|
||||
copy_prompt: "Copia el enlace:"
|
||||
share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
|
||||
share_live_text: "Mira la retransmisión en Match Live TV:"
|
||||
min_quality_updated: Calidad mínima actualizada
|
||||
min_quality_error: Error al actualizar la calidad mínima
|
||||
recordings:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
@@ -600,10 +659,16 @@ es:
|
||||
welcome_back: "¡Bienvenido de nuevo!"
|
||||
invalid_credentials: Correo o contraseña no válidos
|
||||
logged_out: Sesión cerrada
|
||||
unauthorized: No autorizado
|
||||
invalid_token: Token no válido
|
||||
password_resets:
|
||||
email_sent: Si el correo está registrado, recibirás en breve un enlace para restablecer la contraseña.
|
||||
invalid_or_expired_link: Enlace no válido o caducado. Solicita un nuevo restablecimiento de contraseña.
|
||||
password_min_length: La contraseña debe tener al menos 8 caracteres
|
||||
password_too_short: La contraseña debe tener al menos 8 caracteres
|
||||
password_too_long: La contraseña no puede superar los 72 caracteres
|
||||
password_too_weak: "La contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||
password_same_as_current: La nueva contraseña debe ser distinta de la actual
|
||||
password_mismatch: Las contraseñas no coinciden
|
||||
password_updated: Contraseña actualizada. Ya puedes iniciar sesión.
|
||||
accounts:
|
||||
@@ -611,8 +676,12 @@ es:
|
||||
profile_updated: Perfil actualizado.
|
||||
password_current_incorrect: La contraseña actual no es correcta
|
||||
password_too_short: La contraseña debe tener al menos 8 caracteres
|
||||
password_too_long: La contraseña no puede superar los 72 caracteres
|
||||
password_too_weak: "La contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||
password_same_as_current: La nueva contraseña debe ser distinta de la actual
|
||||
password_mismatch: Las contraseñas no coinciden
|
||||
password_updated: Contraseña actualizada.
|
||||
password_update_failed: No se pudo actualizar la contraseña
|
||||
replay:
|
||||
download_unavailable: Descarga no disponible
|
||||
not_available: Repetición no disponible
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
fr:
|
||||
password_policy:
|
||||
hint: "Au moins 8 caractères, avec au moins 3 parmi : minuscules, majuscules, chiffres et symboles."
|
||||
activerecord:
|
||||
attributes:
|
||||
user:
|
||||
password: Mot de passe
|
||||
admin_account:
|
||||
password: Mot de passe
|
||||
errors:
|
||||
models:
|
||||
user:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "est trop court (minimum %{count} caractères)"
|
||||
too_long: "est trop long (maximum %{count} caractères)"
|
||||
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||
admin_account:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "est trop court (minimum %{count} caractères)"
|
||||
too_long: "est trop long (maximum %{count} caractères)"
|
||||
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||
club:
|
||||
back_to_club: "← Club"
|
||||
sport_label: Sport principal
|
||||
@@ -410,16 +432,51 @@ fr:
|
||||
paused: En pause
|
||||
live: En direct
|
||||
waiting: En attente
|
||||
live_share_label: "Lien direct (spectateurs)"
|
||||
live_share_label: Direct
|
||||
share_live: Partager le direct
|
||||
share_action: Partager
|
||||
copy_link: Copier le lien
|
||||
regia_share_label: "Lien régie (score)"
|
||||
copy_action: Copier
|
||||
copy_live: Copier live
|
||||
copy_regia: Copier régie
|
||||
links_nav: Actions rapides
|
||||
regia_share_label: Régie
|
||||
stream_controls_label: Live
|
||||
share_regia: Partager le lien régie
|
||||
preview_ended: Direct terminé
|
||||
preview_waiting: Aperçu en attente du signal…
|
||||
preview_hide: Masquer l'aperçu
|
||||
preview_show: Afficher l'aperçu
|
||||
preview_off_hint: Aperçu désactivé · aucun téléchargement vidéo
|
||||
pause: Mettre en pause
|
||||
resume: Reprendre le direct
|
||||
stop: Terminer le direct
|
||||
min_quality:
|
||||
label: Qualité minimale de transmission
|
||||
summary: Qualité
|
||||
hint: L'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.
|
||||
recommend: "Automatique est conseillé : s'adapte au réseau et au téléphone, sans forcer le maximum."
|
||||
auto: Automatique
|
||||
telemetry:
|
||||
label: Téléphone
|
||||
waiting: En attente du téléphone…
|
||||
stale: Téléphone sans réponse
|
||||
just_now: maintenant
|
||||
seconds_ago: "il y a %{n}s"
|
||||
publisher_on: RTMP ok
|
||||
publisher_off: RTMP absent
|
||||
battery: Batterie
|
||||
network: Réseau
|
||||
bitrate: Débit
|
||||
bitrate_max: "max %{n}"
|
||||
bitrate_title: "%{current} Mbps actuels · max %{target} Mbps"
|
||||
bitrate_tap: Touchez pour définir la qualité minimale
|
||||
fps: FPS
|
||||
thermal: Temp.
|
||||
thermal_nominal: OK
|
||||
thermal_fair: Chaude
|
||||
thermal_serious: Très chaude
|
||||
thermal_critical: Critique
|
||||
modal:
|
||||
set_won_title: Set gagné
|
||||
confirm: Terminer le set
|
||||
@@ -464,6 +521,8 @@ fr:
|
||||
copy_prompt: "Copiez le lien :"
|
||||
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
|
||||
share_live_text: "Regardez le direct sur Match Live TV :"
|
||||
min_quality_updated: Qualité minimale mise à jour
|
||||
min_quality_error: Erreur de mise à jour de la qualité minimale
|
||||
recordings:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
@@ -600,10 +659,16 @@ fr:
|
||||
welcome_back: Bon retour !
|
||||
invalid_credentials: E-mail ou mot de passe invalide
|
||||
logged_out: Déconnecté
|
||||
unauthorized: Non autorisé
|
||||
invalid_token: Jeton invalide
|
||||
password_resets:
|
||||
email_sent: Si l'e-mail est enregistré, tu recevras bientôt un lien pour réinitialiser le mot de passe.
|
||||
invalid_or_expired_link: Lien invalide ou expiré. Demande une nouvelle réinitialisation du mot de passe.
|
||||
password_min_length: Le mot de passe doit comporter au moins 8 caractères
|
||||
password_too_short: Le mot de passe doit comporter au moins 8 caractères
|
||||
password_too_long: Le mot de passe ne peut pas dépasser 72 caractères
|
||||
password_too_weak: "Le mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||
password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel"
|
||||
password_mismatch: Les mots de passe ne correspondent pas
|
||||
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter.
|
||||
accounts:
|
||||
@@ -611,8 +676,12 @@ fr:
|
||||
profile_updated: Profil mis à jour.
|
||||
password_current_incorrect: Le mot de passe actuel est incorrect
|
||||
password_too_short: Le mot de passe doit contenir au moins 8 caractères
|
||||
password_too_long: Le mot de passe ne peut pas dépasser 72 caractères
|
||||
password_too_weak: "Le mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||
password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel"
|
||||
password_mismatch: Les mots de passe ne correspondent pas
|
||||
password_updated: Mot de passe mis à jour.
|
||||
password_update_failed: Impossible de mettre à jour le mot de passe
|
||||
replay:
|
||||
download_unavailable: Téléchargement non disponible
|
||||
not_available: Replay non disponible
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
it:
|
||||
password_policy:
|
||||
hint: "Minimo 8 caratteri, con almeno 3 tra: minuscole, maiuscole, numeri e simboli."
|
||||
activerecord:
|
||||
errors:
|
||||
models:
|
||||
user:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "è troppo corta (minimo %{count} caratteri)"
|
||||
too_long: "è troppo lunga (massimo %{count} caratteri)"
|
||||
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli"
|
||||
admin_account:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "è troppo corta (minimo %{count} caratteri)"
|
||||
too_long: "è troppo lunga (massimo %{count} caratteri)"
|
||||
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli"
|
||||
club:
|
||||
back_to_club: "← Società"
|
||||
sport_label: Sport principale
|
||||
@@ -410,16 +427,51 @@ it:
|
||||
paused: In pausa
|
||||
live: In onda
|
||||
waiting: In attesa
|
||||
live_share_label: "Link diretta (spettatori)"
|
||||
live_share_label: Diretta
|
||||
share_live: Condividi diretta
|
||||
share_action: Condividi
|
||||
copy_link: Copia link
|
||||
regia_share_label: "Link regia (punteggio)"
|
||||
copy_action: Copia
|
||||
copy_live: Copia live
|
||||
copy_regia: Copia regia
|
||||
links_nav: Azioni rapide
|
||||
regia_share_label: Regia
|
||||
stream_controls_label: Live
|
||||
share_regia: Condividi link regia
|
||||
preview_ended: Diretta terminata
|
||||
preview_waiting: Anteprima in attesa del segnale…
|
||||
preview_hide: Nascondi anteprima
|
||||
preview_show: Mostra anteprima
|
||||
preview_off_hint: Anteprima spenta · nessun download video
|
||||
pause: Metti in pausa
|
||||
resume: Riprendi diretta
|
||||
stop: Chiudi diretta
|
||||
min_quality:
|
||||
label: Qualità minima di trasmissione
|
||||
summary: Qualità
|
||||
hint: L'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.
|
||||
recommend: "Consigliata Automatica: si adatta alla rete e al telefono, senza forzare il massimo."
|
||||
auto: Automatica
|
||||
telemetry:
|
||||
label: Telefono
|
||||
waiting: In attesa del telefono…
|
||||
stale: Telefono non risponde
|
||||
just_now: ora
|
||||
seconds_ago: "%{n}s fa"
|
||||
publisher_on: RTMP ok
|
||||
publisher_off: RTMP assente
|
||||
battery: Batteria
|
||||
network: Rete
|
||||
bitrate: Bitrate
|
||||
bitrate_max: "max %{n}"
|
||||
bitrate_title: "%{current} Mbps attuali · massimo %{target} Mbps"
|
||||
bitrate_tap: Tocca per impostare la qualità minima
|
||||
fps: FPS
|
||||
thermal: Temp.
|
||||
thermal_nominal: OK
|
||||
thermal_fair: Calda
|
||||
thermal_serious: Molto calda
|
||||
thermal_critical: Critica
|
||||
modal:
|
||||
set_won_title: Set vinto
|
||||
confirm: Chiudi set
|
||||
@@ -464,6 +516,8 @@ it:
|
||||
copy_prompt: "Copia il link:"
|
||||
share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
|
||||
share_live_text: "Guarda la diretta su Match Live TV:"
|
||||
min_quality_updated: Qualità minima aggiornata
|
||||
min_quality_error: Errore aggiornamento qualità minima
|
||||
recordings:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
@@ -600,10 +654,19 @@ it:
|
||||
welcome_back: Bentornato!
|
||||
invalid_credentials: Email o password non validi
|
||||
logged_out: Disconnesso
|
||||
unauthorized: Non autorizzato
|
||||
invalid_token: Token non valido
|
||||
password_resets:
|
||||
email_sent: Se l'email è registrata, riceverai a breve un link per reimpostare la password.
|
||||
invalid_or_expired_link: Link non valido o scaduto. Richiedi un nuovo reset password.
|
||||
password_min_length: La password deve avere almeno 8 caratteri
|
||||
password_too_short: La password deve avere almeno 8 caratteri
|
||||
password_too_long: La password non può superare i 72 caratteri
|
||||
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||
password_same_as_current: La nuova password deve essere diversa da quella attuale
|
||||
password_too_short: La password deve avere almeno 8 caratteri
|
||||
password_too_long: La password non può superare i 72 caratteri
|
||||
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||
password_mismatch: Le password non coincidono
|
||||
password_updated: Password aggiornata. Ora puoi accedere.
|
||||
accounts:
|
||||
@@ -611,8 +674,14 @@ it:
|
||||
profile_updated: Profilo aggiornato.
|
||||
password_current_incorrect: La password attuale non è corretta
|
||||
password_too_short: La password deve avere almeno 8 caratteri
|
||||
password_too_long: La password non può superare i 72 caratteri
|
||||
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||
password_same_as_current: La nuova password deve essere diversa da quella attuale
|
||||
password_too_long: La password non può superare i 72 caratteri
|
||||
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||
password_mismatch: Le password non coincidono
|
||||
password_updated: Password aggiornata.
|
||||
password_update_failed: Impossibile aggiornare la password
|
||||
replay:
|
||||
download_unavailable: Download non disponibile
|
||||
not_available: Replay non disponibile
|
||||
|
||||
@@ -195,3 +195,18 @@ de:
|
||||
s6_body_html: "Um die von der DSGVO vorgesehenen Rechte auszuüben (Zugang, Löschung, Widerspruch, Widerruf der Einwilligung), schreiben Sie an %{email_link}. Details finden Sie im %{privacy_doc_link}."
|
||||
s6_privacy_doc_link_text: Datenschutzdokument
|
||||
manage_button: Cookie-Einstellungen verwalten
|
||||
support:
|
||||
title: Match Live TV Support
|
||||
meta_description: "Match Live TV Hilfe: Zugangsprobleme, Livestreams, Teamverwaltung und Support-Kontaktdaten."
|
||||
h1: Match Live TV Support
|
||||
intro: "Brauchen Sie Hilfe mit Match Live TV? Bei Problemen mit dem Zugang, der Einrichtung von Livestreams, der Spielverwaltung oder der Nutzung der App können Sie unseren Support kontaktieren."
|
||||
email_label: "Support-E-Mail:"
|
||||
access_title: Zugangsprobleme
|
||||
access_body: "Wenn Sie sich nicht anmelden können, prüfen Sie die Zugangsdaten, die Sie von Ihrem Sportverein erhalten haben. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support."
|
||||
live_title: Probleme während eines Livestreams
|
||||
live_body: "Prüfen Sie Ihre Internetverbindung und versuchen Sie erneut, die Übertragung in der App zu starten. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support und geben Sie Gerät, App-Version und eine kurze Beschreibung des Problems an."
|
||||
team_title: Teamverwaltung
|
||||
team_body: "Konten und Übertragungsberechtigungen werden vom Sportverein verwaltet."
|
||||
privacy_title: Datenschutz
|
||||
privacy_body_html: "Informationen zur Verarbeitung personenbezogener Daten finden Sie auf der Seite %{privacy_link}."
|
||||
privacy_link_text: Datenschutz
|
||||
|
||||
@@ -195,3 +195,18 @@ en:
|
||||
s6_body_html: "To exercise the rights provided by the GDPR (access, erasure, objection, withdrawal of consent) write to %{email_link}. Details are in the %{privacy_doc_link}."
|
||||
s6_privacy_doc_link_text: privacy document
|
||||
manage_button: Manage cookie preferences
|
||||
support:
|
||||
title: Match Live TV Support
|
||||
meta_description: "Match Live TV help: access issues, live streaming, team management and support contact details."
|
||||
h1: Match Live TV Support
|
||||
intro: "Need help with Match Live TV? For access issues, live stream setup, match management or app usage, you can contact our support team."
|
||||
email_label: "Support email:"
|
||||
access_title: Access issues
|
||||
access_body: "If you cannot sign in, check the credentials provided by your sports club. If the problem persists, contact support."
|
||||
live_title: Issues during a live stream
|
||||
live_body: "Check your Internet connection and try starting the broadcast from the app again. If the problem persists, contact support and include your device, app version and a short description of the issue."
|
||||
team_title: Team management
|
||||
team_body: "Accounts and broadcasting permissions are managed by the sports club."
|
||||
privacy_title: Privacy
|
||||
privacy_body_html: "For information on personal data processing, see the %{privacy_link} page."
|
||||
privacy_link_text: Privacy
|
||||
|
||||
@@ -195,3 +195,18 @@ es:
|
||||
s6_body_html: "Para ejercer los derechos previstos por el RGPD (acceso, supresión, oposición, revocación del consentimiento) escribe a %{email_link}. Más detalles en el %{privacy_doc_link}."
|
||||
s6_privacy_doc_link_text: documento de privacidad
|
||||
manage_button: Gestionar preferencias de cookies
|
||||
support:
|
||||
title: Soporte Match Live TV
|
||||
meta_description: "Ayuda de Match Live TV: problemas de acceso, directos, gestión del equipo y datos de contacto del soporte."
|
||||
h1: Soporte Match Live TV
|
||||
intro: "¿Necesitas ayuda con Match Live TV? Para problemas de acceso, configuración de directos, gestión de partidos o uso de la app puedes contactar con nuestro soporte."
|
||||
email_label: "Correo de soporte:"
|
||||
access_title: Problemas de acceso
|
||||
access_body: "Si no puedes acceder, verifica las credenciales recibidas de tu club deportivo. Si el problema continúa, contacta con el soporte."
|
||||
live_title: Problemas durante un directo
|
||||
live_body: "Verifica la conexión a Internet e intenta de nuevo iniciar la transmisión desde la app. Si el problema continúa, contacta con el soporte indicando el dispositivo, la versión de la app y una breve descripción del problema."
|
||||
team_title: Gestión del equipo
|
||||
team_body: "Las cuentas y los permisos de transmisión los gestiona el club deportivo."
|
||||
privacy_title: Privacidad
|
||||
privacy_body_html: "Para información sobre el tratamiento de datos personales, consulta la página %{privacy_link}."
|
||||
privacy_link_text: Privacidad
|
||||
|
||||
@@ -195,3 +195,18 @@ fr:
|
||||
s6_body_html: "Pour exercer les droits prévus par le RGPD (accès, effacement, opposition, retrait du consentement), écrivez à %{email_link}. Détails dans le %{privacy_doc_link}."
|
||||
s6_privacy_doc_link_text: document de confidentialité
|
||||
manage_button: Gérer les préférences de cookies
|
||||
support:
|
||||
title: Support Match Live TV
|
||||
meta_description: "Assistance Match Live TV : problèmes d'accès, directs, gestion d'équipe et coordonnées du support."
|
||||
h1: Support Match Live TV
|
||||
intro: "Besoin d'aide avec Match Live TV ? Pour les problèmes d'accès, la configuration des directs, la gestion des matchs ou l'utilisation de l'application, vous pouvez contacter notre support."
|
||||
email_label: "E-mail du support :"
|
||||
access_title: Problèmes d'accès
|
||||
access_body: "Si vous ne parvenez pas à vous connecter, vérifiez les identifiants fournis par votre club sportif. Si le problème persiste, contactez le support."
|
||||
live_title: Problèmes pendant un direct
|
||||
live_body: "Vérifiez votre connexion Internet et réessayez de démarrer la diffusion depuis l'application. Si le problème persiste, contactez le support en indiquant l'appareil, la version de l'application et une brève description du problème."
|
||||
team_title: Gestion de l'équipe
|
||||
team_body: "Les comptes et les autorisations de diffusion sont gérés par le club sportif."
|
||||
privacy_title: Confidentialité
|
||||
privacy_body_html: "Pour les informations sur le traitement des données personnelles, consultez la page %{privacy_link}."
|
||||
privacy_link_text: Confidentialité
|
||||
|
||||
@@ -195,3 +195,18 @@ it:
|
||||
s6_body_html: "Per esercitare i diritti previsti dal GDPR (accesso, cancellazione, opposizione, revoca consenso) scrivi a %{email_link}. Dettagli nel %{privacy_doc_link}."
|
||||
s6_privacy_doc_link_text: documento privacy
|
||||
manage_button: Gestisci preferenze cookie
|
||||
support:
|
||||
title: Supporto Match Live TV
|
||||
meta_description: "Assistenza Match Live TV: problemi di accesso, dirette live, gestione squadra e contatti del supporto."
|
||||
h1: Supporto Match Live TV
|
||||
intro: "Hai bisogno di assistenza con Match Live TV? Per problemi di accesso, configurazione delle dirette, gestione delle partite o utilizzo 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
|
||||
|
||||
@@ -103,7 +103,7 @@ de:
|
||||
password_reset:
|
||||
meta_title: "Neues Passwort — Match Live TV"
|
||||
title: Neues Passwort wählen
|
||||
new_password_label: "Neues Passwort (min. 8 Zeichen)"
|
||||
new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)"
|
||||
submit: Passwort speichern
|
||||
back_to_login: Zurück zur Anmeldung
|
||||
invitation:
|
||||
@@ -128,11 +128,12 @@ de:
|
||||
name_label: Name
|
||||
role_label: "Rolle: %{role}"
|
||||
current_password_label: Aktuelles Passwort
|
||||
new_password_label: "Neues Passwort (mind. 8 Zeichen)"
|
||||
new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)"
|
||||
save_profile: Profil speichern
|
||||
save_password: Passwort aktualisieren
|
||||
common:
|
||||
privacy: Datenschutz
|
||||
support: Support
|
||||
cookies: Cookies
|
||||
terms: AGB
|
||||
pricing: Preise
|
||||
|
||||
@@ -103,7 +103,7 @@ en:
|
||||
password_reset:
|
||||
meta_title: "New password — Match Live TV"
|
||||
title: Choose a new password
|
||||
new_password_label: "New password (min. 8 characters)"
|
||||
new_password_label: "New password (min. 8, at least 3 character types)"
|
||||
submit: Save password
|
||||
back_to_login: Back to login
|
||||
invitation:
|
||||
@@ -128,11 +128,12 @@ en:
|
||||
name_label: Name
|
||||
role_label: "Role: %{role}"
|
||||
current_password_label: Current password
|
||||
new_password_label: "New password (min. 8 characters)"
|
||||
new_password_label: "New password (min. 8, at least 3 character types)"
|
||||
save_profile: Save profile
|
||||
save_password: Update password
|
||||
common:
|
||||
privacy: Privacy
|
||||
support: Support
|
||||
cookies: Cookies
|
||||
terms: Terms
|
||||
pricing: Pricing
|
||||
|
||||
@@ -103,7 +103,7 @@ es:
|
||||
password_reset:
|
||||
meta_title: "Nueva contraseña — Match Live TV"
|
||||
title: Elige una nueva contraseña
|
||||
new_password_label: "Nueva contraseña (mín. 8 caracteres)"
|
||||
new_password_label: "Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)"
|
||||
submit: Guardar contraseña
|
||||
back_to_login: Volver al inicio de sesión
|
||||
invitation:
|
||||
@@ -128,11 +128,12 @@ es:
|
||||
name_label: Nombre
|
||||
role_label: "Rol: %{role}"
|
||||
current_password_label: Contraseña actual
|
||||
new_password_label: "Nueva contraseña (mín. 8 caracteres)"
|
||||
new_password_label: "Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)"
|
||||
save_profile: Guardar perfil
|
||||
save_password: Actualizar contraseña
|
||||
common:
|
||||
privacy: Privacidad
|
||||
support: Soporte
|
||||
cookies: Cookies
|
||||
terms: Términos
|
||||
pricing: Precios
|
||||
|
||||
@@ -103,7 +103,7 @@ fr:
|
||||
password_reset:
|
||||
meta_title: "Nouveau mot de passe — Match Live TV"
|
||||
title: Choisissez un nouveau mot de passe
|
||||
new_password_label: "Nouveau mot de passe (8 caractères min.)"
|
||||
new_password_label: "Nouveau mot de passe (min. 8, au moins 3 types de caractères)"
|
||||
submit: Enregistrer le mot de passe
|
||||
back_to_login: Retour à la connexion
|
||||
invitation:
|
||||
@@ -128,11 +128,12 @@ fr:
|
||||
name_label: Nom
|
||||
role_label: "Rôle : %{role}"
|
||||
current_password_label: Mot de passe actuel
|
||||
new_password_label: "Nouveau mot de passe (min. 8 caractères)"
|
||||
new_password_label: "Nouveau mot de passe (min. 8, au moins 3 types de caractères)"
|
||||
save_profile: Enregistrer le profil
|
||||
save_password: Mettre à jour le mot de passe
|
||||
common:
|
||||
privacy: Confidentialité
|
||||
support: Support
|
||||
cookies: Cookies
|
||||
terms: Conditions
|
||||
pricing: Tarifs
|
||||
|
||||
@@ -103,7 +103,7 @@ it:
|
||||
password_reset:
|
||||
meta_title: "Nuova password — Match Live TV"
|
||||
title: Scegli una nuova password
|
||||
new_password_label: "Nuova password (min. 8 caratteri)"
|
||||
new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)"
|
||||
submit: Salva password
|
||||
back_to_login: Torna al login
|
||||
invitation:
|
||||
@@ -128,11 +128,12 @@ it:
|
||||
name_label: Nome
|
||||
role_label: "Ruolo: %{role}"
|
||||
current_password_label: Password attuale
|
||||
new_password_label: "Nuova password (min. 8 caratteri)"
|
||||
new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)"
|
||||
save_profile: Salva profilo
|
||||
save_password: Aggiorna password
|
||||
common:
|
||||
privacy: Privacy
|
||||
support: Supporto
|
||||
cookies: Cookie
|
||||
terms: Termini
|
||||
pricing: Prezzi
|
||||
|
||||
@@ -55,6 +55,7 @@ Rails.application.routes.draw do
|
||||
post :claim_pairing
|
||||
post :regia_link
|
||||
post :network_test
|
||||
patch :min_quality
|
||||
get :youtube_stats
|
||||
end
|
||||
end
|
||||
@@ -143,6 +144,7 @@ Rails.application.routes.draw do
|
||||
post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause
|
||||
post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume
|
||||
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
|
||||
patch "regia/:token/min_quality", to: "public/regia#min_quality", as: :public_regia_min_quality
|
||||
|
||||
root to: "public/pages#home"
|
||||
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
|
||||
@@ -154,6 +156,7 @@ Rails.application.routes.draw do
|
||||
get "prezzi", to: "pages#pricing", as: :prezzi
|
||||
get "pricing", to: redirect("/prezzi")
|
||||
get "privacy", to: "pages#privacy", as: :privacy
|
||||
get "support", to: "pages#support", as: :support
|
||||
get "cookie", to: "pages#cookies", as: :cookies
|
||||
get "cookies", to: redirect("/cookie")
|
||||
get "termini", to: "pages#terms", as: :termini
|
||||
@@ -169,6 +172,7 @@ Rails.application.routes.draw do
|
||||
patch "password/reset", to: "password_resets#update"
|
||||
get "account", to: "accounts#show", as: :account
|
||||
patch "account", to: "accounts#update"
|
||||
get "account/password", to: redirect("/account"), as: nil
|
||||
patch "account/password", to: "accounts#update_password", as: :account_password
|
||||
get "clubs/new", to: "clubs#new", as: :new_club
|
||||
post "clubs", to: "clubs#create"
|
||||
|
||||
@@ -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.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -138,6 +138,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
||||
t.datetime "last_seen_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "thermal_state"
|
||||
t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true
|
||||
t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id"
|
||||
end
|
||||
@@ -255,6 +256,29 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
||||
t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id"
|
||||
end
|
||||
|
||||
create_table "stream_nodes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "slug", null: false
|
||||
t.string "hostname", null: false
|
||||
t.string "role", default: "home", null: false
|
||||
t.string "status", default: "ready", null: false
|
||||
t.string "rtmp_base_url", null: false
|
||||
t.string "hls_base_url", null: false
|
||||
t.string "api_base_url", null: false
|
||||
t.string "internal_rtmp_url"
|
||||
t.string "internal_hls_url"
|
||||
t.integer "max_publishers", default: 6, null: false
|
||||
t.integer "max_relays", default: 6, null: false
|
||||
t.string "provider", default: "local", null: false
|
||||
t.string "provider_instance_id"
|
||||
t.datetime "last_health_at"
|
||||
t.jsonb "metadata", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["role"], name: "index_stream_nodes_on_role"
|
||||
t.index ["slug"], name: "index_stream_nodes_on_slug", unique: true
|
||||
t.index ["status"], name: "index_stream_nodes_on_status"
|
||||
end
|
||||
|
||||
create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "match_id", null: false
|
||||
t.uuid "user_id", null: false
|
||||
@@ -280,10 +304,13 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "regia_token_digest"
|
||||
t.datetime "regia_token_expires_at"
|
||||
t.uuid "stream_node_id"
|
||||
t.string "min_quality_preset", default: "auto", null: false
|
||||
t.index ["match_id"], name: "index_stream_sessions_on_match_id"
|
||||
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
||||
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
||||
t.index ["status"], name: "index_stream_sessions_on_status"
|
||||
t.index ["stream_node_id"], name: "index_stream_sessions_on_stream_node_id"
|
||||
t.index ["user_id"], name: "index_stream_sessions_on_user_id"
|
||||
end
|
||||
|
||||
@@ -411,6 +438,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
||||
add_foreign_key "score_states", "stream_sessions"
|
||||
add_foreign_key "stream_events", "stream_sessions"
|
||||
add_foreign_key "stream_sessions", "matches"
|
||||
add_foreign_key "stream_sessions", "stream_nodes"
|
||||
add_foreign_key "stream_sessions", "users"
|
||||
add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
|
||||
add_foreign_key "subscriptions", "clubs"
|
||||
|
||||
+4
-4
@@ -1,18 +1,18 @@
|
||||
load Rails.root.join("db/seeds/plans.rb")
|
||||
|
||||
AdminAccount.find_or_create_by!(username: "admin") do |a|
|
||||
a.password = "admin"
|
||||
a.password = "AdminPass123"
|
||||
end
|
||||
|
||||
coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u|
|
||||
u.name = "Coach Demo"
|
||||
u.password = "password123"
|
||||
u.password = "Password123"
|
||||
u.role = "coach"
|
||||
end
|
||||
|
||||
admin = User.find_or_create_by!(email: "admin@matchlivetv.test") do |u|
|
||||
u.name = "Admin"
|
||||
u.password = "password123"
|
||||
u.password = "Password123"
|
||||
u.role = "admin"
|
||||
end
|
||||
|
||||
@@ -41,5 +41,5 @@ match = team.matches.find_or_create_by!(opponent_name: "ASD Eagles Pavia") do |m
|
||||
m.phase = "Semifinale"
|
||||
end
|
||||
|
||||
puts "Seed OK: coach@matchlivetv.test / password123"
|
||||
puts "Seed OK: coach@matchlivetv.test / Password123"
|
||||
puts "Club: #{club.name}, Team: #{team.name}, Match: #{match.opponent_name}"
|
||||
|
||||
+107
-16
@@ -199,6 +199,17 @@ body.nav-menu-open { overflow: hidden; }
|
||||
body.nav-menu-open .site-chrome {
|
||||
z-index: 1300;
|
||||
}
|
||||
/* Keep the close control above the full-screen sheet; hide duplicate mast brand. */
|
||||
body.nav-menu-open .site-masthead {
|
||||
z-index: 1320;
|
||||
background: transparent;
|
||||
border-bottom-color: transparent;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
body.nav-menu-open .mast-brand {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.nav-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
@@ -236,16 +247,50 @@ body.nav-menu-open { overflow: hidden; }
|
||||
.nav-panel {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 72px 24px 32px;
|
||||
padding-top: calc(72px + env(safe-area-inset-top, 0px));
|
||||
padding: 16px 24px 32px;
|
||||
padding-top: calc(16px + env(safe-area-inset-top, 0px));
|
||||
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.nav-mobile-head {
|
||||
order: -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin: 0 0 8px;
|
||||
padding: 0 52px 16px 0; /* room for the close (X) control on the right */
|
||||
border-bottom: 1px solid #252530;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-mobile-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
}
|
||||
.nav-mobile-brand:hover { text-decoration: none; opacity: 0.92; }
|
||||
.nav-mobile-brand .brand { font-size: 1.05rem; }
|
||||
.nav-mobile-brand-logo {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-panel > a,
|
||||
.nav-panel .nav-link-item {
|
||||
display: block;
|
||||
@@ -263,11 +308,13 @@ body.nav-menu-open { overflow: hidden; }
|
||||
}
|
||||
.nav-actions {
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
margin-top: 24px;
|
||||
padding: 24px 0 0;
|
||||
border-top: 1px solid #252530;
|
||||
gap: 0;
|
||||
margin-top: 8px;
|
||||
padding: 0;
|
||||
border-top: none;
|
||||
width: 100%;
|
||||
}
|
||||
.nav-actions .nav-link-item {
|
||||
display: block;
|
||||
@@ -289,10 +336,14 @@ body.nav-menu-open { overflow: hidden; }
|
||||
border-radius: 10px;
|
||||
}
|
||||
.nav-lang {
|
||||
margin-left: 0;
|
||||
margin-top: 4px;
|
||||
margin: 0;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-lang .lang-switcher__menu {
|
||||
/* Keep the list inside the open mobile sheet */
|
||||
z-index: 1320;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,14 +393,22 @@ body.nav-menu-open { overflow: hidden; }
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px 20px;
|
||||
}
|
||||
.nav-mobile-head {
|
||||
display: contents;
|
||||
}
|
||||
.nav-mobile-brand {
|
||||
display: none;
|
||||
}
|
||||
.nav-lang {
|
||||
order: 2;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.nav-actions {
|
||||
order: 1;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.nav-lang {
|
||||
margin-left: 2px;
|
||||
}
|
||||
.nav-backdrop { display: none !important; }
|
||||
}
|
||||
.btn { display: inline-block; padding: 10px 18px; border-radius: 8px; font-weight: 700; font-size: 0.9rem; border: none; cursor: pointer; text-decoration: none; }
|
||||
@@ -1230,7 +1289,22 @@ body.nav-menu-open { overflow: hidden; }
|
||||
.hero-split .hero-cta { flex-direction: column; }
|
||||
.hero-split .hero-cta .btn { width: 100%; text-align: center; }
|
||||
}
|
||||
.section { padding: 40px 0; }
|
||||
.section {
|
||||
padding-top: 40px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
/* Keep horizontal inset when .section shares a node with .wrap (padding shorthand must not win). */
|
||||
.section.wrap {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.section h2 { font-size: 1.6rem; margin: 0 0 20px; text-align: center; }
|
||||
.steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; }
|
||||
.step { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 22px; }
|
||||
@@ -1363,7 +1437,7 @@ body.nav-menu-open { overflow: hidden; }
|
||||
.site-footer__legal { flex: 1 1 100%; margin-top: 4px; }
|
||||
.site-footer__legal p { margin: 0 0 6px; line-height: 1.45; }
|
||||
.site-footer__legal p:last-child { margin-bottom: 0; }
|
||||
.compare-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 0.9rem; }
|
||||
.compare-table { width: 100%; min-width: 520px; border-collapse: collapse; margin-top: 0; font-size: 0.9rem; }
|
||||
.compare-table th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; }
|
||||
.compare-table th { color: #aaa; font-weight: 600; }
|
||||
.billing-documents h2 { margin-top: 0; }
|
||||
@@ -1372,11 +1446,14 @@ body.nav-menu-open { overflow: hidden; }
|
||||
.card { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
||||
|
||||
.seo-prose { max-width: 720px; margin: 0 auto; color: #bbb; line-height: 1.65; }
|
||||
.seo-prose h2 { color: #fff; font-size: 1.25rem; margin: 28px 0 12px; }
|
||||
.seo-prose h2 { color: #fff; font-size: 1.25rem; margin: 28px 0 12px; text-align: left; }
|
||||
.seo-prose h2:first-child { margin-top: 0; }
|
||||
.seo-prose p { margin: 0 0 14px; }
|
||||
.seo-prose ul { margin: 0 0 16px; padding-left: 1.25rem; }
|
||||
.seo-prose a { color: #e53935; }
|
||||
@media (max-width: 899px) {
|
||||
.seo-prose h2 { font-size: 1.15rem; line-height: 1.35; }
|
||||
}
|
||||
.seo-page .seo-lead { color: #aaa; max-width: 640px; line-height: 1.55; margin-bottom: 28px; }
|
||||
.faq-list { max-width: 720px; margin: 0 auto; }
|
||||
.faq-item {
|
||||
@@ -1412,7 +1489,8 @@ body.nav-menu-open { overflow: hidden; }
|
||||
.legal-doc a { color: #e53935; }
|
||||
.legal-meta { color: #888; font-size: 0.88rem; margin-bottom: 24px; }
|
||||
.legal-back { margin-top: 32px; }
|
||||
.legal-table { width: 100%; border-collapse: collapse; margin: 12px 0 16px; font-size: 0.88rem; }
|
||||
.legal-doc .table-scroll { margin: 12px 0 16px; }
|
||||
.legal-table { width: 100%; min-width: 560px; border-collapse: collapse; margin: 0; font-size: 0.88rem; }
|
||||
.legal-table th, .legal-table td { border: 1px solid #2a2a36; padding: 10px 12px; text-align: left; vertical-align: top; }
|
||||
.legal-table th { background: #14141c; color: #ccc; }
|
||||
.legal-accept {
|
||||
@@ -1447,6 +1525,19 @@ body.nav-menu-open { overflow: hidden; }
|
||||
.auth-forgot a { color: #e53935; }
|
||||
table.data { width: 100%; border-collapse: collapse; }
|
||||
table.data th, table.data td { padding: 8px; border-bottom: 1px solid #2a2a36; text-align: left; }
|
||||
/* Wide roster/match tables: scroll inside the card instead of expanding the page. */
|
||||
@media (max-width: 899px) {
|
||||
.card:has(table.data),
|
||||
.team-streaming-staff:has(table.data),
|
||||
.billing-documents:has(table.data) {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
max-width: 100%;
|
||||
}
|
||||
table.data {
|
||||
min-width: 520px;
|
||||
}
|
||||
}
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
|
||||
+277
-41
@@ -4,6 +4,8 @@
|
||||
--regia-surface: #14141c;
|
||||
--regia-red: #e53935;
|
||||
--regia-yellow: #f9a825;
|
||||
--regia-green: #43a047;
|
||||
--regia-orange: #fb8c00;
|
||||
--regia-text: #f5f5f5;
|
||||
--regia-muted: #9ca3af;
|
||||
--regia-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
@@ -22,37 +24,166 @@
|
||||
.regia-page {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 16px calc(24px + var(--regia-safe-bottom));
|
||||
padding: 8px 12px calc(12px + var(--regia-safe-bottom));
|
||||
}
|
||||
|
||||
.regia-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.regia-header__text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.regia-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.regia-header p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.85rem;
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--regia-muted);
|
||||
}
|
||||
|
||||
.regia-badge {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
padding: 4px 10px;
|
||||
margin-top: 0;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.regia-badge--live { background: #2e7d32; color: #fff; }
|
||||
.regia-badge--wait { background: #444; color: #ddd; }
|
||||
.regia-badge--ended { background: #374151; color: #ddd; }
|
||||
|
||||
.regia-toolbar {
|
||||
margin: 8px 0 6px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.regia-tool-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.regia-tool-group__label {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--regia-muted);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.regia-tool-group__btns {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.regia-tool-group--stream {
|
||||
margin-left: auto;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid #333;
|
||||
}
|
||||
|
||||
.regia-icon-btn {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid #333;
|
||||
border-radius: 10px;
|
||||
background: #16161f;
|
||||
color: var(--regia-text);
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.regia-icon-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.regia-icon-btn svg.icon-fill {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.regia-icon-btn svg.icon-outline {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.regia-icon-btn .icon-eye-off,
|
||||
.regia-icon-btn .icon-play {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.regia-icon-btn.is-off .icon-eye {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.regia-icon-btn.is-off .icon-eye-off {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.regia-icon-btn.is-paused .icon-pause {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.regia-icon-btn.is-paused .icon-play {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.regia-icon-btn:active { transform: scale(0.96); }
|
||||
|
||||
.regia-icon-btn.is-off {
|
||||
color: var(--regia-yellow);
|
||||
border-color: #5a4a1a;
|
||||
}
|
||||
|
||||
.regia-icon-btn--danger {
|
||||
color: var(--regia-red);
|
||||
border-color: #7a2a28;
|
||||
}
|
||||
|
||||
.regia-preview-wrap {
|
||||
margin: 6px 0 10px;
|
||||
}
|
||||
|
||||
.regia-preview-wrap.is-off {
|
||||
margin: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.regia-preview {
|
||||
margin: 14px 0;
|
||||
margin: 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
@@ -90,29 +221,29 @@
|
||||
.regia-scoreboard {
|
||||
background: var(--regia-surface);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.regia-sets {
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--regia-muted);
|
||||
margin: 0 0 8px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.regia-score-line {
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.regia-partials {
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--regia-muted);
|
||||
margin: 8px 0 0;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.regia-team-row {
|
||||
@@ -120,7 +251,7 @@
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.regia-team-name {
|
||||
@@ -131,7 +262,7 @@
|
||||
}
|
||||
|
||||
.regia-points {
|
||||
font-size: 2rem;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -146,7 +277,7 @@
|
||||
.regia-btn {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 16px 12px;
|
||||
padding: 10px 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
@@ -156,8 +287,8 @@
|
||||
|
||||
.regia-btn:active { transform: scale(0.98); }
|
||||
.regia-btn--point { background: var(--regia-red); color: #fff; }
|
||||
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 12px; }
|
||||
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 10px; }
|
||||
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 8px; }
|
||||
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 8px; padding: 12px; }
|
||||
.regia-btn--outline {
|
||||
background: transparent;
|
||||
border: 1px solid #444;
|
||||
@@ -173,26 +304,6 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.regia-share-group {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.regia-share-label {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--regia-muted);
|
||||
}
|
||||
|
||||
.regia-share {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.regia-share .regia-btn { flex: 1; font-size: 0.9rem; padding: 12px; }
|
||||
|
||||
.regia-toast {
|
||||
position: fixed;
|
||||
bottom: calc(16px + var(--regia-safe-bottom));
|
||||
@@ -240,7 +351,7 @@
|
||||
text-align: center;
|
||||
color: var(--regia-muted);
|
||||
font-size: 0.72rem;
|
||||
padding-top: 24px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.regia-clock-line {
|
||||
@@ -255,3 +366,128 @@
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.regia-quality {
|
||||
margin: 6px 0 0;
|
||||
padding: 8px 10px 10px;
|
||||
background: var(--regia-surface);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.regia-quality[hidden] { display: none !important; }
|
||||
|
||||
.regia-quality__hint {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.35;
|
||||
color: var(--regia-muted);
|
||||
}
|
||||
|
||||
.regia-quality label {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
color: var(--regia-muted);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.regia-quality select {
|
||||
width: 100%;
|
||||
background: #0a0a0e;
|
||||
color: var(--regia-text);
|
||||
border: 1px solid #2a2a35;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.regia-telemetry {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.regia-telemetry__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.regia-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
padding: 6px 2px 5px;
|
||||
border-radius: 8px;
|
||||
background: var(--regia-surface);
|
||||
color: var(--regia-text);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.regia-chip--action {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.regia-chip--action:focus-visible {
|
||||
outline: 2px solid var(--regia-yellow);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.regia-chip--action.is-open {
|
||||
border-color: #3a3a48;
|
||||
}
|
||||
|
||||
.regia-chip svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.regia-chip__label {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--regia-muted);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.regia-chip__value {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.regia-chip__hint {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
color: var(--regia-muted);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.regia-chip__hint[hidden] { display: none; }
|
||||
|
||||
.regia-chip.is-ok { color: var(--regia-green); }
|
||||
.regia-chip.is-warn { color: var(--regia-orange); }
|
||||
.regia-chip.is-bad { color: var(--regia-red); }
|
||||
|
||||
.regia-telemetry__meta {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.65rem;
|
||||
color: var(--regia-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.regia-telemetry.is-stale .regia-telemetry__meta {
|
||||
color: var(--regia-yellow);
|
||||
}
|
||||
|
||||
+262
-5
@@ -37,13 +37,32 @@
|
||||
shareLiveText: "Guarda la diretta su Match Live TV:",
|
||||
previewWaiting: "Anteprima in attesa del segnale…",
|
||||
streamEnded: "Diretta terminata",
|
||||
previewHide: "Nascondi anteprima",
|
||||
previewShow: "Mostra anteprima",
|
||||
previewOffHint: "Anteprima spenta · nessun download video",
|
||||
resumeLabel: "Riprendi diretta",
|
||||
pauseLabel: "Metti in pausa",
|
||||
endedBadge: "Terminata",
|
||||
pausedBadge: "In pausa",
|
||||
liveBadge: "In onda",
|
||||
waitingBadge: "In attesa",
|
||||
subtitlePrefix: "Regia ·"
|
||||
subtitlePrefix: "Regia ·",
|
||||
minQualityUpdated: "Qualità minima aggiornata",
|
||||
minQualityError: "Errore aggiornamento qualità minima",
|
||||
minQualityAuto: "Automatica",
|
||||
telemetryWaiting: "In attesa del telefono…",
|
||||
telemetryStale: "Telefono non risponde",
|
||||
telemetryJustNow: "ora",
|
||||
telemetrySecondsAgo: "%{n}s fa",
|
||||
telemetryPublisherOn: "RTMP ok",
|
||||
telemetryPublisherOff: "RTMP assente",
|
||||
telemetryThermalNominal: "OK",
|
||||
telemetryThermalFair: "Calda",
|
||||
telemetryThermalSerious: "Calda",
|
||||
telemetryThermalCritical: "Critica",
|
||||
telemetryBitrateMax: "max %{n}",
|
||||
telemetryBitrateTitle: "%{current} Mbps attuali · massimo %{target} Mbps",
|
||||
telemetryBitrateTap: "Tocca per impostare la qualità minima"
|
||||
};
|
||||
|
||||
function loadI18n() {
|
||||
@@ -77,6 +96,7 @@
|
||||
pauseUrl: root.dataset.pauseUrl,
|
||||
resumeUrl: root.dataset.resumeUrl,
|
||||
stopUrl: root.dataset.stopUrl,
|
||||
minQualityUrl: root.dataset.minQualityUrl,
|
||||
cableUrl: root.dataset.cableUrl,
|
||||
hlsUrl: root.dataset.hlsUrl,
|
||||
homeName: root.dataset.homeName,
|
||||
@@ -102,15 +122,28 @@
|
||||
modalCancel: document.getElementById("modal-cancel"),
|
||||
preview: document.getElementById("regia-preview"),
|
||||
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
||||
previewWrap: document.getElementById("regia-preview-wrap"),
|
||||
btnTogglePreview: document.getElementById("btn-toggle-preview"),
|
||||
btnPause: document.getElementById("btn-pause"),
|
||||
subtitle: document.getElementById("regia-subtitle")
|
||||
minQuality: document.getElementById("min-quality"),
|
||||
subtitle: document.getElementById("regia-subtitle"),
|
||||
telemetry: document.getElementById("regia-telemetry"),
|
||||
telBattery: document.getElementById("tel-battery"),
|
||||
telNetwork: document.getElementById("tel-network"),
|
||||
telBitrate: document.getElementById("tel-bitrate"),
|
||||
telFps: document.getElementById("tel-fps"),
|
||||
telThermal: document.getElementById("tel-thermal"),
|
||||
telMeta: document.getElementById("tel-meta"),
|
||||
qualityPanel: document.getElementById("regia-quality")
|
||||
};
|
||||
|
||||
let pendingAction = null;
|
||||
let hls = null;
|
||||
let previewStarted = false;
|
||||
let previewWanted = true;
|
||||
let wasPausedPreview = false;
|
||||
let streamPaused = root.dataset.initialPaused === "true";
|
||||
const PREVIEW_PREF_KEY = "mltv-regia-preview";
|
||||
|
||||
function hlsUrlFresh() {
|
||||
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
|
||||
@@ -139,8 +172,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
function readPreviewPref() {
|
||||
try {
|
||||
return localStorage.getItem(PREVIEW_PREF_KEY) !== "0";
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function writePreviewPref(on) {
|
||||
try {
|
||||
localStorage.setItem(PREVIEW_PREF_KEY, on ? "1" : "0");
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function syncPreviewToggleUi() {
|
||||
if (!els.previewWrap || !els.btnTogglePreview) return;
|
||||
const label = previewWanted ? I18N.previewHide : I18N.previewShow;
|
||||
els.previewWrap.classList.toggle("is-off", !previewWanted);
|
||||
els.btnTogglePreview.classList.toggle("is-off", !previewWanted);
|
||||
els.btnTogglePreview.setAttribute("aria-pressed", previewWanted ? "true" : "false");
|
||||
els.btnTogglePreview.setAttribute("aria-label", label);
|
||||
els.btnTogglePreview.title = previewWanted ? I18N.previewHide : I18N.previewOffHint;
|
||||
}
|
||||
|
||||
function setPreviewWanted(on) {
|
||||
previewWanted = !!on;
|
||||
writePreviewPref(previewWanted);
|
||||
syncPreviewToggleUi();
|
||||
if (previewWanted) {
|
||||
startPreview();
|
||||
} else {
|
||||
destroyPreview();
|
||||
}
|
||||
}
|
||||
|
||||
function refreshPreviewForCover() {
|
||||
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed) return;
|
||||
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed || !previewWanted) return;
|
||||
destroyPreview();
|
||||
previewStarted = false;
|
||||
startPreview();
|
||||
@@ -243,7 +311,10 @@
|
||||
if (!els.btnPause) return;
|
||||
const paused = !!(data.paused || data.status === "paused");
|
||||
streamPaused = paused;
|
||||
els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel;
|
||||
const label = paused ? I18N.resumeLabel : I18N.pauseLabel;
|
||||
els.btnPause.classList.toggle("is-paused", paused);
|
||||
els.btnPause.setAttribute("aria-label", label);
|
||||
els.btnPause.title = label;
|
||||
}
|
||||
|
||||
function setBadge(data) {
|
||||
@@ -364,6 +435,11 @@
|
||||
}
|
||||
|
||||
const paused = data.paused || data.status === "paused";
|
||||
if (!previewWanted) {
|
||||
destroyPreview();
|
||||
wasPausedPreview = paused;
|
||||
return;
|
||||
}
|
||||
if (data.on_air) {
|
||||
if (!previewStarted) {
|
||||
startPreview();
|
||||
@@ -382,6 +458,119 @@
|
||||
wasPausedPreview = paused;
|
||||
}
|
||||
|
||||
function mbpsNumber(bps) {
|
||||
const n = Number(bps);
|
||||
if (!n || n <= 0) return null;
|
||||
const mbps = n >= 10_000 ? n / 1e6 : n / 1e3;
|
||||
return mbps >= 10 ? mbps.toFixed(0) : mbps.toFixed(1);
|
||||
}
|
||||
|
||||
function formatAge(iso) {
|
||||
if (!iso) return null;
|
||||
const then = Date.parse(iso);
|
||||
if (!then) return null;
|
||||
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
|
||||
if (secs < 8) return I18N.telemetryJustNow;
|
||||
return fillTemplate(I18N.telemetrySecondsAgo, { n: secs });
|
||||
}
|
||||
|
||||
function setChip(el, text, level, hint) {
|
||||
if (!el) return;
|
||||
const valueEl = el.querySelector(".regia-chip__value") || el;
|
||||
valueEl.textContent = text;
|
||||
el.classList.toggle("is-ok", level === "ok");
|
||||
el.classList.toggle("is-warn", level === "warn");
|
||||
el.classList.toggle("is-bad", level === "bad");
|
||||
const hintEl = el.querySelector(".regia-chip__hint");
|
||||
if (hintEl) {
|
||||
hintEl.textContent = hint || "";
|
||||
hintEl.hidden = !hint;
|
||||
}
|
||||
}
|
||||
|
||||
function formatThermal(state) {
|
||||
const key = String(state || "").toLowerCase();
|
||||
if (key === "nominal") return { text: I18N.telemetryThermalNominal, level: "ok" };
|
||||
if (key === "fair") return { text: I18N.telemetryThermalFair, level: "warn" };
|
||||
if (key === "serious") return { text: I18N.telemetryThermalSerious, level: "bad" };
|
||||
if (key === "critical") return { text: I18N.telemetryThermalCritical, level: "bad" };
|
||||
return { text: "—", level: null };
|
||||
}
|
||||
|
||||
function bitrateLevel(current, target, paused) {
|
||||
if (paused || !current || current <= 0) return null;
|
||||
if (target && target > 0) {
|
||||
const ratio = current / target;
|
||||
if (ratio >= 0.7) return "ok";
|
||||
if (ratio >= 0.4) return "warn";
|
||||
return "bad";
|
||||
}
|
||||
const mbps = current >= 10_000 ? current / 1e6 : current / 1e3;
|
||||
if (mbps >= 2.5) return "ok";
|
||||
if (mbps >= 1.5) return "warn";
|
||||
return "bad";
|
||||
}
|
||||
|
||||
function bitrateTitle(currentMbps, targetMbps) {
|
||||
const tap = I18N.telemetryBitrateTap;
|
||||
if (currentMbps && targetMbps) {
|
||||
return `${fillTemplate(I18N.telemetryBitrateTitle, { current: currentMbps, target: targetMbps })} · ${tap}`;
|
||||
}
|
||||
if (currentMbps) return `${currentMbps} Mbps · ${tap}`;
|
||||
return tap;
|
||||
}
|
||||
|
||||
function syncTelemetry(data) {
|
||||
if (!els.telemetry) return;
|
||||
const tel = data.telemetry || {};
|
||||
const battery = tel.battery;
|
||||
const fps = tel.fps;
|
||||
const bitrate = tel.bitrate;
|
||||
const target = tel.target_bitrate || tel.targetBitrate;
|
||||
const lastSeen = tel.last_seen_at || tel.lastSeenAt;
|
||||
const ageSecs = lastSeen ? Math.max(0, Math.round((Date.now() - Date.parse(lastSeen)) / 1000)) : null;
|
||||
const stale = ageSecs == null || ageSecs > 25;
|
||||
const publishing = !!tel.publisher_online || !!tel.publisherOnline;
|
||||
|
||||
let battLevel = null;
|
||||
let battText = "—";
|
||||
if (battery != null && battery !== "") {
|
||||
battText = `${battery}%`;
|
||||
if (battery <= 15) battLevel = "bad";
|
||||
else if (battery <= 25) battLevel = "warn";
|
||||
}
|
||||
setChip(els.telBattery, battText, battLevel);
|
||||
setChip(els.telNetwork, tel.network || "—");
|
||||
|
||||
const currentMbps = mbpsNumber(bitrate);
|
||||
const targetMbps = mbpsNumber(target);
|
||||
const rateText = currentMbps || "—";
|
||||
const rateHint = currentMbps && targetMbps
|
||||
? fillTemplate(I18N.telemetryBitrateMax, { n: targetMbps })
|
||||
: null;
|
||||
if (els.telBitrate) els.telBitrate.title = bitrateTitle(currentMbps, targetMbps);
|
||||
setChip(els.telBitrate, rateText, bitrateLevel(Number(bitrate), Number(target), data.paused), rateHint);
|
||||
|
||||
let fpsLevel = null;
|
||||
if (fps != null && fps !== "" && Number(fps) > 0 && Number(fps) < 20) fpsLevel = "warn";
|
||||
setChip(els.telFps, fps && Number(fps) > 0 ? String(fps) : "—", fpsLevel);
|
||||
|
||||
const thermal = formatThermal(tel.thermal_state || tel.thermalState);
|
||||
setChip(els.telThermal, thermal.text, thermal.level);
|
||||
|
||||
els.telemetry.classList.toggle("is-stale", stale && !data.paused && !data.stream_closed);
|
||||
if (!els.telMeta) return;
|
||||
if (stale && !lastSeen) {
|
||||
els.telMeta.textContent = I18N.telemetryWaiting;
|
||||
return;
|
||||
}
|
||||
const parts = [publishing ? I18N.telemetryPublisherOn : I18N.telemetryPublisherOff];
|
||||
const age = formatAge(lastSeen);
|
||||
if (age) parts.push(age);
|
||||
if (stale && !data.paused && !data.stream_closed) parts.push(I18N.telemetryStale);
|
||||
els.telMeta.textContent = parts.join(" · ");
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
|
||||
@@ -389,6 +578,8 @@
|
||||
const data = await res.json();
|
||||
applyScorePayload(data);
|
||||
setBadge(data);
|
||||
syncMinQuality(data);
|
||||
syncTelemetry(data);
|
||||
syncPreviewFromStatus(data);
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -401,7 +592,7 @@
|
||||
}
|
||||
|
||||
function startPreview() {
|
||||
if (cfg.streamClosed || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
||||
if (cfg.streamClosed || !previewWanted || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
||||
|
||||
previewStarted = true;
|
||||
const url = hlsUrlFresh();
|
||||
@@ -454,6 +645,8 @@
|
||||
const wasPaused = streamPaused;
|
||||
applyScorePayload(data);
|
||||
setBadge(data);
|
||||
syncMinQuality(data);
|
||||
syncTelemetry(data);
|
||||
syncPreviewFromStatus(data);
|
||||
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
|
||||
}
|
||||
@@ -473,6 +666,70 @@
|
||||
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
|
||||
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
|
||||
|
||||
let minQualityBusy = false;
|
||||
|
||||
function setQualityPanelOpen(open) {
|
||||
if (!els.qualityPanel || !els.telBitrate) return;
|
||||
els.qualityPanel.hidden = !open;
|
||||
els.telBitrate.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
els.telBitrate.classList.toggle("is-open", open);
|
||||
if (open && els.minQuality) {
|
||||
window.requestAnimationFrame(() => els.minQuality.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function syncMinQuality(data) {
|
||||
if (!els.minQuality || minQualityBusy) return;
|
||||
const value = data.min_quality_preset || "auto";
|
||||
if (els.minQuality.value !== value) els.minQuality.value = value;
|
||||
}
|
||||
|
||||
async function setMinQuality(preset) {
|
||||
if (!cfg.minQualityUrl) return;
|
||||
minQualityBusy = true;
|
||||
try {
|
||||
const res = await fetch(cfg.minQualityUrl, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ min_quality_preset: preset })
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || I18N.minQualityError);
|
||||
minQualityBusy = false;
|
||||
syncMinQuality(data);
|
||||
toast(I18N.minQualityUpdated);
|
||||
} catch (e) {
|
||||
toast(e.message || I18N.minQualityError);
|
||||
} finally {
|
||||
minQualityBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
els.minQuality?.addEventListener("change", () => {
|
||||
setMinQuality(els.minQuality.value);
|
||||
});
|
||||
|
||||
els.telBitrate?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const opening = !!(els.qualityPanel && els.qualityPanel.hidden);
|
||||
setQualityPanelOpen(opening);
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!els.qualityPanel || els.qualityPanel.hidden) return;
|
||||
if (els.qualityPanel.contains(e.target) || els.telBitrate?.contains(e.target)) return;
|
||||
setQualityPanelOpen(false);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") setQualityPanelOpen(false);
|
||||
});
|
||||
|
||||
els.btnTogglePreview?.addEventListener("click", () => setPreviewWanted(!previewWanted));
|
||||
previewWanted = readPreviewPref();
|
||||
syncPreviewToggleUi();
|
||||
if (!previewWanted) destroyPreview();
|
||||
|
||||
async function shareLink(url, title, text) {
|
||||
if (!url) {
|
||||
toast(I18N.linkUnavailable);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe PasswordComplexity do
|
||||
describe ".violation" do
|
||||
it "accepts a password with 3 character classes" do
|
||||
expect(described_class.violation("NewPass123")).to be_nil
|
||||
end
|
||||
|
||||
it "accepts symbols instead of one letter class" do
|
||||
expect(described_class.violation("newpass1!")).to be_nil
|
||||
end
|
||||
|
||||
it "rejects passwords that are too short" do
|
||||
expect(described_class.violation("Ab1!")).to eq(:too_short)
|
||||
end
|
||||
|
||||
it "rejects passwords with fewer than 3 classes" do
|
||||
expect(described_class.violation("password123")).to eq(:too_weak)
|
||||
expect(described_class.violation("PASSWORD123")).to eq(:too_weak)
|
||||
expect(described_class.violation("Password")).to eq(:too_weak)
|
||||
end
|
||||
end
|
||||
|
||||
describe ".same_as_current?" do
|
||||
let!(:user) { User.create!(email: "same@example.com", name: "Same", password: "Password123", role: "coach") }
|
||||
|
||||
it "detects when the new password matches the current one" do
|
||||
expect(described_class.same_as_current?(user, "Password123")).to eq(true)
|
||||
expect(described_class.same_as_current?(user, "OtherPass123")).to eq(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe "User validation" do
|
||||
it "blocks weak passwords on create" do
|
||||
user = User.new(email: "weak@example.com", name: "Weak", password: "password123", role: "coach")
|
||||
expect(user).not_to be_valid
|
||||
expect(user.errors[:password]).to be_present
|
||||
end
|
||||
|
||||
it "allows strong passwords on create" do
|
||||
user = User.new(email: "strong@example.com", name: "Strong", password: "NewPass123", role: "coach")
|
||||
expect(user).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,9 +1,9 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Account API", type: :request do
|
||||
let!(:user) { User.create!(email: "account@example.com", name: "Account User", password: "password123", role: "coach") }
|
||||
let!(:user) { User.create!(email: "account@example.com", name: "Account User", password: "Password123", role: "coach") }
|
||||
let(:auth_headers) do
|
||||
post "/api/v1/auth/login", params: { email: user.email, password: "password123" }
|
||||
post "/api/v1/auth/login", params: { email: user.email, password: "Password123" }
|
||||
token = JSON.parse(response.body).fetch("access_token")
|
||||
{ "Authorization" => "Bearer #{token}" }
|
||||
end
|
||||
@@ -40,25 +40,63 @@ RSpec.describe "Account API", type: :request do
|
||||
it "changes the password with the current password" do
|
||||
patch "/api/v1/account/password",
|
||||
params: {
|
||||
current_password: "password123",
|
||||
password: "newpass123",
|
||||
password_confirmation: "newpass123"
|
||||
current_password: "Password123",
|
||||
password: "NewPass123",
|
||||
password_confirmation: "NewPass123"
|
||||
},
|
||||
headers: auth_headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(user.reload.authenticate("newpass123")).to be_truthy
|
||||
expect(user.reload.authenticate("NewPass123")).to be_truthy
|
||||
end
|
||||
|
||||
it "rejects an incorrect current password" do
|
||||
patch "/api/v1/account/password",
|
||||
params: {
|
||||
current_password: "wrong",
|
||||
password: "newpass123",
|
||||
password_confirmation: "newpass123"
|
||||
password: "NewPass123",
|
||||
password_confirmation: "NewPass123"
|
||||
},
|
||||
headers: auth_headers
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(user.reload.authenticate("password123")).to be_truthy
|
||||
expect(user.reload.authenticate("Password123")).to be_truthy
|
||||
end
|
||||
|
||||
it "rejects a password that fails complexity rules" do
|
||||
patch "/api/v1/account/password",
|
||||
params: {
|
||||
current_password: "Password123",
|
||||
password: "newpass123",
|
||||
password_confirmation: "newpass123"
|
||||
},
|
||||
headers: auth_headers.merge("Accept-Language" => "en")
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(JSON.parse(response.body)["error"]).to match(/3 of/i)
|
||||
expect(user.reload.authenticate("Password123")).to be_truthy
|
||||
end
|
||||
|
||||
it "rejects reusing the current password" do
|
||||
patch "/api/v1/account/password",
|
||||
params: {
|
||||
current_password: "Password123",
|
||||
password: "Password123",
|
||||
password_confirmation: "Password123"
|
||||
},
|
||||
headers: auth_headers.merge("Accept-Language" => "en")
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(JSON.parse(response.body)["error"]).to match(/different/i)
|
||||
expect(user.reload.authenticate("Password123")).to be_truthy
|
||||
end
|
||||
|
||||
it "localizes password errors from Accept-Language" do
|
||||
patch "/api/v1/account/password",
|
||||
params: {
|
||||
current_password: "Password123",
|
||||
password: "Password123",
|
||||
password_confirmation: "Password123"
|
||||
},
|
||||
headers: auth_headers.merge("Accept-Language" => "it")
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(JSON.parse(response.body)["error"]).to eq("La nuova password deve essere diversa da quella attuale")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -77,5 +115,13 @@ RSpec.describe "Account API", type: :request do
|
||||
}.not_to change { ActionMailer::Base.deliveries.size }
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it "localizes the response message from Accept-Language" do
|
||||
post "/api/v1/auth/password/forgot",
|
||||
params: { email: user.email },
|
||||
headers: { "Accept-Language" => "fr" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(JSON.parse(response.body)["message"]).to include("réinitialiser")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Auth API", type: :request do
|
||||
let!(:user) { User.create!(email: "test@example.com", name: "Test", password: "password123", role: "coach") }
|
||||
let!(:user) { User.create!(email: "test@example.com", name: "Test", password: "Password123", role: "coach") }
|
||||
|
||||
it "logs in with valid credentials" do
|
||||
post "/api/v1/auth/login", params: { email: user.email, password: "password123" }
|
||||
post "/api/v1/auth/login", params: { email: user.email, password: "Password123" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(JSON.parse(response.body)).to have_key("access_token")
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Public regia", type: :request do
|
||||
let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "password123", role: "coach") }
|
||||
let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "Password123", role: "coach") }
|
||||
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
||||
@@ -12,7 +12,18 @@ RSpec.describe "Public regia", type: :request do
|
||||
get public_regia_path(token)
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("Condividi diretta")
|
||||
expect(response.body).to include("Copia live")
|
||||
expect(response.body).to include("Condividi link regia")
|
||||
expect(response.body).to include("Copia regia")
|
||||
expect(response.body).to include(">Diretta<")
|
||||
expect(response.body).to include(">Regia<")
|
||||
expect(response.body).to include("regia-tool-group")
|
||||
expect(response.body).to include("Nascondi anteprima")
|
||||
expect(response.body).to include('id="btn-pause"')
|
||||
expect(response.body).to include('id="btn-stop"')
|
||||
expect(response.body).to include("Qualità minima di trasmissione")
|
||||
expect(response.body).to include('id="tel-bitrate"')
|
||||
expect(response.body).to include('aria-controls="regia-quality"')
|
||||
expect(response.body).to include("/live/#{session.id}")
|
||||
expect(response.body).not_to include("Aruba")
|
||||
end
|
||||
@@ -156,4 +167,39 @@ RSpec.describe "Public regia", type: :request do
|
||||
get public_regia_path(token)
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it "aggiorna la qualità minima di trasmissione" do
|
||||
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||
patch public_regia_min_quality_path(token), params: { min_quality_preset: "720p_30_1.5mbps" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(session.reload.min_quality_preset).to eq("720p_30_1.5mbps")
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["min_quality_preset"]).to eq("720p_30_1.5mbps")
|
||||
end
|
||||
|
||||
it "espone la telemetria della camera nello status" do
|
||||
session.device_states.create!(
|
||||
device_role: "camera",
|
||||
battery_level: 81,
|
||||
network_type: "WiFi",
|
||||
current_bitrate: 2_400_000,
|
||||
target_bitrate: 4_500_000,
|
||||
fps: 30,
|
||||
thermal_state: "nominal",
|
||||
last_seen_at: Time.current
|
||||
)
|
||||
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||
get public_regia_path(token)
|
||||
expect(response.body).to include('id="regia-telemetry"')
|
||||
|
||||
get public_regia_status_path(token)
|
||||
expect(response).to have_http_status(:ok)
|
||||
tel = response.parsed_body["telemetry"]
|
||||
expect(tel["battery"]).to eq(81)
|
||||
expect(tel["network"]).to eq("WiFi")
|
||||
expect(tel["fps"]).to eq(30)
|
||||
expect(tel["bitrate"]).to eq(2_400_000)
|
||||
expect(tel["thermal_state"]).to eq("nominal")
|
||||
expect(tel).to have_key("publisher_online")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Public support page", type: :request do
|
||||
it "è pubblica, indicizzabile e mostra l'email di supporto configurata" do
|
||||
get public_support_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include(MatchLiveTv.support_email)
|
||||
expect(response.body).to include("mailto:#{MatchLiveTv.support_email}")
|
||||
expect(response.body).to include(I18n.t("legal.support.h1", locale: :it))
|
||||
expect(response.body).to include('rel="canonical"')
|
||||
expect(response.body).to include(public_support_path)
|
||||
expect(response.body).to include(public_privacy_path)
|
||||
end
|
||||
|
||||
it "non espone CTA o link commerciali (App Store Review)" do
|
||||
get public_support_path
|
||||
body = response.body
|
||||
|
||||
expect(body).not_to include(public_prezzi_path)
|
||||
expect(body).not_to include(public_signup_path)
|
||||
expect(body).not_to include('href="/prezzi"')
|
||||
expect(body).not_to include('href="/signup"')
|
||||
expect(body).not_to include(I18n.t("nav.signup", locale: :it))
|
||||
expect(body).not_to include(I18n.t("nav.pricing", locale: :it))
|
||||
expect(body).not_to include(I18n.t("common.pricing", locale: :it))
|
||||
expect(body).not_to match(/\bPremium Light\b/i)
|
||||
expect(body).not_to match(/\bPremium Full\b/i)
|
||||
expect(body).not_to match(/\bpiano Free\b/i)
|
||||
expect(body).not_to match(/\bAbbonati\b/i)
|
||||
expect(body).not_to match(/\bAcquista\b/i)
|
||||
end
|
||||
|
||||
{
|
||||
"it" => "Supporto Match Live TV",
|
||||
"en" => "Match Live TV Support",
|
||||
"fr" => "Support Match Live TV",
|
||||
"de" => "Match Live TV Support",
|
||||
"es" => "Soporte Match Live TV"
|
||||
}.each do |locale, heading|
|
||||
it "renderizza correttamente in #{locale} senza translation missing" do
|
||||
cookies[:mltv_locale] = locale
|
||||
get public_support_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include(heading)
|
||||
expect(response.body).not_to include("translation missing")
|
||||
end
|
||||
end
|
||||
|
||||
it "include /support nella sitemap" do
|
||||
get "/sitemap.xml"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("#{MatchLiveTv.app_public_url.chomp('/')}/support")
|
||||
end
|
||||
end
|
||||
@@ -11,4 +11,11 @@ RSpec.describe Sessions::SelectQuality do
|
||||
|
||||
expect(preset[:id]).to eq("1080p_30_4.5mbps")
|
||||
end
|
||||
|
||||
it "offre 1080p come qualità minima solo se la sessione è 1080p" do
|
||||
expect(described_class.allowed_min_ids("720p_30_2.5mbps")).to eq(
|
||||
%w[auto 720p_30_1.5mbps 720p_30_2.5mbps 720p_30_4mbps]
|
||||
)
|
||||
expect(described_class.allowed_min_ids("1080p_30_4.5mbps")).to include("1080p_30_4.5mbps")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Sessions::SetMinQuality do
|
||||
let!(:user) { User.create!(email: "abr@test.it", name: "U", password: "Password123", role: "coach") }
|
||||
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
||||
let!(:session) do
|
||||
StreamSession.create!(
|
||||
match: match,
|
||||
user: user,
|
||||
platform: "matchlivetv",
|
||||
status: "live",
|
||||
quality_preset: "720p_30_2.5mbps"
|
||||
)
|
||||
end
|
||||
|
||||
it "salva il pavimento e notifica i client" do
|
||||
expect(SessionChannel).to receive(:broadcast_message).with(
|
||||
session,
|
||||
hash_including(action: "set_min_quality", min_quality_preset: "720p_30_1.5mbps")
|
||||
)
|
||||
|
||||
described_class.new(session, preset: "720p_30_1.5mbps").call
|
||||
|
||||
expect(session.reload.min_quality_preset).to eq("720p_30_1.5mbps")
|
||||
expect(session.stream_events.where(event_type: "quality_changed")).to exist
|
||||
end
|
||||
|
||||
it "rifiuta 1080p se la sessione è 720p" do
|
||||
expect {
|
||||
described_class.new(session, preset: "1080p_30_4.5mbps").call
|
||||
}.to raise_error(ArgumentError)
|
||||
end
|
||||
|
||||
it "accetta auto come qualità minima" do
|
||||
session.update!(min_quality_preset: "720p_30_1.5mbps")
|
||||
described_class.new(session, preset: "auto").call
|
||||
expect(session.reload.min_quality_preset).to eq("auto")
|
||||
end
|
||||
end
|
||||
@@ -38,10 +38,10 @@ Solo se la società vuole il **proprio** canale invece di Match Live TV:
|
||||
|
||||
Nella home app (**Partite**):
|
||||
|
||||
- **Partita programmata** — scegli dal calendario una gara già inserita (sito o app)
|
||||
- **Nuova partita** — programma data/ora oppure **Avvia subito** senza orario
|
||||
- Oppure tocca una gara già in calendario (sito o app)
|
||||
|
||||
Poi tocca la card o conferma dal foglio: si apre il wizard (Partita → Trasmissione → …).
|
||||
Poi conferma dal foglio o tocca la card: si apre il wizard (Partita → Trasmissione → …).
|
||||
|
||||
## 4. Avvia diretta
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# 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
|
||||
**Riferimento Android (produzione / telefono / Play):** `2.0.5-native` (`versionCode` **26**), API `https://www.matchlivetv.it`
|
||||
**Stato iOS attuale:** marketing `2.0.5`, build `26` — i18n **allineato** (Login, hub, sheet/dialog, wizard, broadcast)
|
||||
**Aggiornato:** 8 agosto 2026
|
||||
**Riferimento Android (produzione / telefono / Play):** `2.0.10-native` (`versionCode` **31**), API `https://www.matchlivetv.it`
|
||||
**Stato iOS attuale:** marketing `2.0.10`, build `31` — i18n **allineato** (Login, hub, sheet/dialog, wizard, broadcast, account/forgot)
|
||||
|
||||
Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog, wizard, broadcast) + bump versione, **senza** i bug di sessione/crash già risolti su Android 2.0.5.
|
||||
Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog, wizard, broadcast, account) + bump versione, **senza** i bug di sessione/crash già risolti su Android.
|
||||
|
||||
---
|
||||
|
||||
@@ -59,7 +59,7 @@ Queste sono regressioni/bug già visti su Android; **non ripeterli** su iOS.
|
||||
| Broadcast + score dialog | Sì | `broadcast.*` / `score.*` | **Fatto** |
|
||||
| Catalogo stringhe | `values*` (~230) | `AppLanguage.swift` L10n (~225, no FGS) | **Fatto** |
|
||||
| Logout UI | Icona | Icona SF Symbol | **Fatto** |
|
||||
| Versione | `2.0.5-native` / **26** | `2.0.5` / **26** | **Fatto** |
|
||||
| Versione | `2.0.10-native` / **31** | `2.0.10` / **31** | **Fatto** |
|
||||
| RTMP ingest | `RtmpIngestUrl.kt` | `MediaUrl.swift` | OK |
|
||||
|
||||
### Residui fuori scope Android (opzionali)
|
||||
@@ -141,7 +141,7 @@ xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \
|
||||
|
||||
Nessuna modifica server richiesta.
|
||||
|
||||
Versione store target: **`2.0.5` / build `26`** (parità con Android `2.0.5-native` / versionCode `26`).
|
||||
Versione store target: **`2.0.10` / build `31`** (parità con Android `2.0.10-native` / versionCode `31`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# iOS — allineamento password policy + errori API localizzati
|
||||
|
||||
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
|
||||
**Android di riferimento:** `2.0.10-native` (`versionCode` **31**)
|
||||
**iOS (allineato):** marketing `2.0.10` / build `31`
|
||||
**API produzione:** `https://www.matchlivetv.it`
|
||||
|
||||
---
|
||||
|
||||
## Contesto (già in produzione backend dopo questo rilascio)
|
||||
|
||||
### Policy password (server)
|
||||
|
||||
Definita in `backend/app/models/concerns/password_complexity.rb`:
|
||||
|
||||
- minimo **8** caratteri, massimo **72** byte (bcrypt)
|
||||
- almeno **3 classi su 4**: minuscole, maiuscole, numeri, simboli
|
||||
- in **cambio password** e **reset**: la nuova password **non** può coincidere con quella attuale
|
||||
|
||||
Validazione ActiveModel su `User` / `AdminAccount`. Endpoint che la applicano:
|
||||
|
||||
| Endpoint | Note |
|
||||
|----------|------|
|
||||
| `PATCH /api/v1/account/password` | App native + stessi codici errore |
|
||||
| `PATCH /account/password` (web) | Flash I18n |
|
||||
| reset password pubblico | Stesse regole |
|
||||
| registrazione (`auth/register`) | Validazione modello |
|
||||
|
||||
### Errori API localizzati
|
||||
|
||||
`ApplicationController#set_api_locale` legge, in ordine:
|
||||
|
||||
1. header `X-Locale`
|
||||
2. `Accept-Language`
|
||||
3. `I18n.default_locale` (fallback; **retrocompatibile** se l’app vecchia non manda nulla)
|
||||
|
||||
Risposta invariata: `{ "error": "<messaggio già tradotto>" }` (o `{ "message": "..." }` su alcuni successi).
|
||||
|
||||
Chiavi rilevanti (`backend/config/locales/app.{it,en,fr,de,es}.yml`):
|
||||
|
||||
- `flash.accounts.password_too_short`
|
||||
- `flash.accounts.password_too_long`
|
||||
- `flash.accounts.password_too_weak`
|
||||
- `flash.accounts.password_same_as_current`
|
||||
- `flash.accounts.password_current_incorrect`
|
||||
- `flash.accounts.password_mismatch`
|
||||
- `flash.accounts.password_updated` / `name_required`
|
||||
- analoghi in `flash.password_resets.*` e `flash.sessions.*`
|
||||
|
||||
---
|
||||
|
||||
## Stato iOS vs Android
|
||||
|
||||
| Area | Android 2.0.10 | iOS | Stato |
|
||||
|------|----------------|-----|-------|
|
||||
| Header `Accept-Language` su tutte le request API | OkHttp interceptor | `MatchLiveAPI.request` + `multipartPatch` | **OK** |
|
||||
| Hint UI nuova password | `account_new_password` | `account.new.password` | **OK** |
|
||||
| Schermata Account | AccountScreen | AccountScreen | **OK** |
|
||||
| Forgot password | ForgotPasswordScreen | ForgotPasswordScreen | **OK** |
|
||||
| Parsing errori API | body `error` | `APIError.friendlyHttpMessage` | **OK** |
|
||||
| Client-side pre-check complessità | No | No | N/A (come Android) |
|
||||
| Versione store | `2.0.10-native` / **31** | `2.0.10` / **31** | **OK** |
|
||||
| Signup in-app | Non principale | Nessuna UI register | N/A (signup web) |
|
||||
|
||||
### File iOS
|
||||
|
||||
```
|
||||
native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift
|
||||
native/ios/MatchLiveTv/Core/AppLanguage.swift
|
||||
native/ios/MatchLiveTv/UI/Account/AccountScreen.swift
|
||||
native/ios/MatchLiveTv/UI/Login/ForgotPasswordScreen.swift
|
||||
native/ios/MatchLiveTv/Resources/Info.plist
|
||||
native/ios/generate_xcodeproj.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Criterio “fatto”
|
||||
|
||||
- [x] `Accept-Language` su login, me, account, change password, forgot (`MatchLiveAPI`)
|
||||
- [x] Hint campo nuova password parla di “min. 8 / 3 tipi”
|
||||
- [x] Catalogo stringhe account/forgot in it/en/fr/de/es
|
||||
- [x] Versione bumpata a `2.0.10` / `31`
|
||||
- [ ] QA manuale errori password in lingua UI (weak / same / mismatch / success)
|
||||
|
||||
Quando 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_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||
# Opzionale: email dedicata di supporto (default = info@matchlivetv.it)
|
||||
# SUPPORT_CONTACT_EMAIL=info@matchlivetv.it
|
||||
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
|
||||
PASSWORD_RESET_EXPIRY_HOURS=2
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ RAILS_LOG_LEVEL=info
|
||||
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||
# Opzionale: email di supporto App Store (default = info@matchlivetv.it)
|
||||
# SUPPORT_CONTACT_EMAIL=info@matchlivetv.it
|
||||
PRIVACY_CONTROLLER_VAT=
|
||||
|
||||
# Email transazionali (reset password, inviti)
|
||||
|
||||
@@ -73,6 +73,7 @@ services:
|
||||
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
|
||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
||||
SUPPORT_CONTACT_EMAIL: ${SUPPORT_CONTACT_EMAIL:-}
|
||||
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
||||
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
||||
PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-}
|
||||
|
||||
@@ -59,6 +59,8 @@ services:
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
MEDIAMTX_API_URL: http://mediamtx:9997
|
||||
MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://127.0.0.1:1935}
|
||||
MEDIAMTX_HLS_URL: ${MEDIAMTX_HLS_URL:-http://mediamtx:8888}
|
||||
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:3000/hls}
|
||||
MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}
|
||||
RAILS_WEBHOOK_URL: http://rails:3000
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min}
|
||||
@@ -71,6 +73,7 @@ services:
|
||||
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
||||
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
||||
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
||||
SUPPORT_CONTACT_EMAIL: ${SUPPORT_CONTACT_EMAIL:-}
|
||||
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlivetv.it>}
|
||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*.iml
|
||||
/captures/
|
||||
/app/build/
|
||||
/dist/
|
||||
.DS_Store
|
||||
keystore.properties
|
||||
keystore/
|
||||
|
||||
+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.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||
import com.matchlivetv.match_live_tv.core.AppLocale
|
||||
import com.matchlivetv.match_live_tv.core.ProvideAppLocale
|
||||
import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost
|
||||
import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val container by lazy { (application as MatchLiveTvApplication).container }
|
||||
@@ -33,9 +39,11 @@ class MainActivity : ComponentActivity() {
|
||||
setContent {
|
||||
ProvideAppLocale {
|
||||
MatchLiveTheme {
|
||||
Box(Modifier.semantics { testTagsAsResourceId = true }) {
|
||||
AppNavHost(container)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.matchlivetv.match_live_tv.data
|
||||
|
||||
import android.content.Context
|
||||
import com.matchlivetv.match_live_tv.core.AppConfig
|
||||
import com.matchlivetv.match_live_tv.core.AppLocale
|
||||
import com.matchlivetv.match_live_tv.core.TokenStore
|
||||
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
||||
import com.matchlivetv.match_live_tv.data.cable.SessionCableService
|
||||
@@ -37,10 +38,20 @@ class AppContainer(context: Context) {
|
||||
accessToken?.let { header("Authorization", "Bearer $it") }
|
||||
header("Accept", "application/json")
|
||||
header("Content-Type", "application/json")
|
||||
header("Accept-Language", apiLanguageTag())
|
||||
}.build()
|
||||
chain.proceed(request)
|
||||
}
|
||||
|
||||
private fun apiLanguageTag(): String {
|
||||
val tag = AppLocale.currentTag(appContext)
|
||||
return if (tag.isBlank()) {
|
||||
java.util.Locale.getDefault().language.ifBlank { "it" }
|
||||
} else {
|
||||
tag
|
||||
}
|
||||
}
|
||||
|
||||
private val okHttp = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
|
||||
@@ -183,6 +183,7 @@ data class StreamSessionDto(
|
||||
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
|
||||
@Json(name = "target_fps") val targetFps: Int? = null,
|
||||
@Json(name = "quality_preset") val qualityPreset: String? = null,
|
||||
@Json(name = "min_quality_preset") val minQualityPreset: String? = null,
|
||||
@Json(name = "started_at") val startedAt: String? = null,
|
||||
val score: ScoreStateDto? = null,
|
||||
) {
|
||||
@@ -201,6 +202,7 @@ data class StreamSessionDto(
|
||||
targetBitrate = targetBitrate ?: 2_500_000,
|
||||
targetFps = targetFps ?: 30,
|
||||
qualityPreset = qualityPreset ?: "720p_30_2.5mbps",
|
||||
minQualityPreset = minQualityPreset ?: "auto",
|
||||
startedAt = startedAt,
|
||||
score = score?.toDomain(),
|
||||
)
|
||||
@@ -285,6 +287,10 @@ data class CreateSessionRequest(
|
||||
@Json(name = "youtube_channel") val youtubeChannel: String? = null,
|
||||
)
|
||||
|
||||
data class MinQualityRequest(
|
||||
@Json(name = "min_quality_preset") val minQualityPreset: String,
|
||||
)
|
||||
|
||||
data class UpdateMatchBody(
|
||||
@Json(name = "opponent_name") val opponentName: String? = null,
|
||||
val location: String? = null,
|
||||
@@ -392,4 +398,5 @@ data class TelemetryRequest(
|
||||
@Json(name = "current_bitrate") val currentBitrate: Int? = null,
|
||||
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
|
||||
val fps: Int? = null,
|
||||
@Json(name = "thermal_state") val thermalState: String? = null,
|
||||
)
|
||||
|
||||
+6
@@ -117,6 +117,12 @@ interface MatchLiveApi {
|
||||
@PATCH("sessions/{sessionId}/resume")
|
||||
suspend fun resumeSession(@Path("sessionId") sessionId: String): StreamSessionDto
|
||||
|
||||
@PATCH("sessions/{sessionId}/min_quality")
|
||||
suspend fun setMinQuality(
|
||||
@Path("sessionId") sessionId: String,
|
||||
@Body body: MinQualityRequest,
|
||||
): StreamSessionDto
|
||||
|
||||
@POST("sessions/{sessionId}/network_test")
|
||||
suspend fun networkTest(
|
||||
@Path("sessionId") sessionId: String,
|
||||
|
||||
+2
@@ -33,6 +33,7 @@ class SessionCableService(
|
||||
var onPauseStream: (() -> Unit)? = null
|
||||
var onResumeStream: (() -> Unit)? = null
|
||||
var onEndStream: (() -> Unit)? = null
|
||||
var onMinQuality: ((String) -> Unit)? = null
|
||||
|
||||
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
|
||||
intentionalDisconnect = false
|
||||
@@ -109,6 +110,7 @@ class SessionCableService(
|
||||
"pause_stream" -> onPauseStream?.invoke()
|
||||
"resume_stream" -> onResumeStream?.invoke()
|
||||
"stop_stream" -> onEndStream?.invoke()
|
||||
"set_min_quality" -> (payload["min_quality_preset"] as? String)?.let { onMinQuality?.invoke(it) }
|
||||
}
|
||||
"stream_event" -> when (payload["event"] as? String) {
|
||||
"paused" -> onPauseStream?.invoke()
|
||||
|
||||
+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.MatchLiveApi
|
||||
import com.matchlivetv.match_live_tv.data.api.MinQualityRequest
|
||||
import com.matchlivetv.match_live_tv.data.api.NetworkTestRequest
|
||||
import com.matchlivetv.match_live_tv.data.api.NetworkTestResponse
|
||||
import com.matchlivetv.match_live_tv.domain.StreamSession
|
||||
@@ -38,6 +39,9 @@ class SessionRepository(
|
||||
suspend fun resumeSession(sessionId: String): StreamSession =
|
||||
api.resumeSession(sessionId).toDomain()
|
||||
|
||||
suspend fun setMinQuality(sessionId: String, preset: String): StreamSession =
|
||||
api.setMinQuality(sessionId, MinQualityRequest(minQualityPreset = preset)).toDomain()
|
||||
|
||||
suspend fun submitNetworkTest(
|
||||
sessionId: String,
|
||||
downloadMbps: Double,
|
||||
@@ -61,6 +65,7 @@ class SessionRepository(
|
||||
currentBitrate: Int? = null,
|
||||
targetBitrate: Int? = null,
|
||||
fps: Int? = null,
|
||||
thermalState: String? = null,
|
||||
) {
|
||||
api.postTelemetry(
|
||||
sessionId,
|
||||
@@ -72,6 +77,7 @@ class SessionRepository(
|
||||
currentBitrate = currentBitrate,
|
||||
targetBitrate = targetBitrate,
|
||||
fps = fps,
|
||||
thermalState = thermalState,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ data class StreamSession(
|
||||
val targetBitrate: Int = 2_500_000,
|
||||
val targetFps: Int = 30,
|
||||
val qualityPreset: String = "720p_30_2.5mbps",
|
||||
val minQualityPreset: String = "auto",
|
||||
val startedAt: String? = null,
|
||||
val score: ScoreState? = null,
|
||||
) {
|
||||
@@ -133,6 +134,8 @@ data class StreamSession(
|
||||
|
||||
val isPaused: Boolean get() = status == "paused"
|
||||
|
||||
val isEnded: Boolean get() = status == "ended" || status == "error"
|
||||
|
||||
fun watchShareUrl(): String? = when {
|
||||
platform == "youtube" && !youtubeWatchUrl.isNullOrBlank() -> youtubeWatchUrl
|
||||
!shareUrl.isNullOrBlank() -> shareUrl
|
||||
|
||||
+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 height: Int = 720,
|
||||
val videoBitrate: Int = 2_500_000,
|
||||
val minVideoBitrate: Int = 800_000,
|
||||
val audioBitrate: Int = 128_000,
|
||||
val fps: Int = 30,
|
||||
val rotation: Int = 0,
|
||||
|
||||
+37
-4
@@ -39,6 +39,7 @@ class LiveBroadcastEngine(
|
||||
private var publishInProgress = false
|
||||
private var currentBitrateKbps: Long = 0
|
||||
private var currentFps: Int = 0
|
||||
private val adaptiveBitrate = AdaptiveBitrateController()
|
||||
private val pausingIntentionally = AtomicBoolean(false)
|
||||
private val reconnectAttempts = AtomicInteger(0)
|
||||
private val overlayRenderer = OverlayRenderer(appContext)
|
||||
@@ -133,6 +134,7 @@ class LiveBroadcastEngine(
|
||||
fun startBroadcast(broadcastConfig: BroadcastConfig) {
|
||||
mainHandler.post {
|
||||
config = broadcastConfig
|
||||
adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
|
||||
reconnectAttempts.set(0)
|
||||
streamStartPending = true
|
||||
publishInProgress = false
|
||||
@@ -250,6 +252,7 @@ class LiveBroadcastEngine(
|
||||
fun resumeBroadcast(broadcastConfig: BroadcastConfig) {
|
||||
mainHandler.post {
|
||||
config = broadcastConfig
|
||||
adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
|
||||
reconnectAttempts.set(0)
|
||||
streamStartPending = true
|
||||
publishInProgress = false
|
||||
@@ -264,21 +267,45 @@ class LiveBroadcastEngine(
|
||||
}
|
||||
}
|
||||
|
||||
/** Adattamento termico: riduce bitrate/FPS senza fermare lo stream. */
|
||||
/** Adattamento termico: aggiorna il soffitto ABR e l'FPS senza fermare lo stream. */
|
||||
fun applyThermalProfile(videoBitrate: Int, fps: Int) {
|
||||
mainHandler.post {
|
||||
val current = config ?: return@post
|
||||
if (current.videoBitrate == videoBitrate && current.fps == fps) return@post
|
||||
adaptiveBitrate.setCeiling(videoBitrate)
|
||||
val applied = adaptiveBitrate.setFloor(current.minVideoBitrate)
|
||||
if (current.videoBitrate == videoBitrate && current.fps == fps) {
|
||||
applyAdaptiveBitrate(applied)
|
||||
return@post
|
||||
}
|
||||
config = current.copy(videoBitrate = videoBitrate, fps = fps)
|
||||
genericStream?.let { stream ->
|
||||
runCatching { stream.setVideoBitrateOnFly(videoBitrate) }
|
||||
.onFailure { Log.w(TAG, "applyThermalProfile bitrate: ${it.message}") }
|
||||
applyAdaptiveBitrate(applied)
|
||||
runCatching { stream.getGlInterface().setForceRender(true, fps) }
|
||||
.onFailure { Log.w(TAG, "applyThermalProfile fps: ${it.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setMinQualityFloor(floorBitrate: Int, ceilingBitrate: Int? = null) {
|
||||
mainHandler.post {
|
||||
val current = config
|
||||
if (current != null) {
|
||||
config = current.copy(
|
||||
videoBitrate = ceilingBitrate ?: current.videoBitrate,
|
||||
minVideoBitrate = floorBitrate,
|
||||
)
|
||||
}
|
||||
applyAdaptiveBitrate(adaptiveBitrate.setFloor(floorBitrate))
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyAdaptiveBitrate(bitrate: Int) {
|
||||
genericStream?.let { stream ->
|
||||
runCatching { stream.setVideoBitrateOnFly(bitrate) }
|
||||
.onFailure { Log.w(TAG, "ABR bitrate: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun obtainStream(cfg: BroadcastConfig): GenericStream {
|
||||
val existing = genericStream
|
||||
if (existing != null) return existing
|
||||
@@ -552,6 +579,12 @@ class LiveBroadcastEngine(
|
||||
override fun onNewBitrate(bitrate: Long) {
|
||||
mainHandler.post {
|
||||
currentBitrateKbps = bitrate / 1000
|
||||
val congestion = runCatching {
|
||||
genericStream?.getStreamClient()?.hasCongestion() == true
|
||||
}.getOrDefault(false)
|
||||
adaptiveBitrate.onMeasured(bitrate, congestion)?.let { next ->
|
||||
applyAdaptiveBitrate(next)
|
||||
}
|
||||
emitMetrics()
|
||||
}
|
||||
}
|
||||
|
||||
+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 {
|
||||
val dims = dimensions(session.qualityPreset)
|
||||
val ceiling = StreamQualityLadder.encoderCeiling(session.targetBitrate, session.minQualityPreset)
|
||||
val floor = StreamQualityLadder.encoderFloor(session.minQualityPreset, ceiling)
|
||||
return BroadcastConfig(
|
||||
rtmpUrl = rtmpUrl,
|
||||
width = dims.width,
|
||||
height = dims.height,
|
||||
videoBitrate = session.targetBitrate,
|
||||
videoBitrate = ceiling,
|
||||
minVideoBitrate = floor,
|
||||
fps = session.targetFps,
|
||||
)
|
||||
}
|
||||
|
||||
+142
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -29,6 +30,7 @@ import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material.icons.filled.Videocam
|
||||
@@ -58,6 +60,8 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil.compose.AsyncImage
|
||||
import com.matchlivetv.match_live_tv.R
|
||||
@@ -65,6 +69,7 @@ import com.matchlivetv.match_live_tv.core.resolveMediaUrl
|
||||
import com.matchlivetv.match_live_tv.core.DeviceHealthSnapshot
|
||||
import com.matchlivetv.match_live_tv.core.ThermalState
|
||||
import com.matchlivetv.match_live_tv.domain.ScoreState
|
||||
import com.matchlivetv.match_live_tv.streaming.StreamQualityLadder
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge
|
||||
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||
|
||||
@@ -109,9 +114,27 @@ fun BroadcastControlsOverlay(
|
||||
bitrateKbps: Long,
|
||||
networkType: String,
|
||||
deviceHealth: DeviceHealthSnapshot,
|
||||
minQualityPreset: String = StreamQualityLadder.AUTO,
|
||||
sessionQualityPreset: String = "720p_30_2.5mbps",
|
||||
onSelectMinQuality: (String) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showTerminateConfirm by remember { mutableStateOf(false) }
|
||||
var showMinQualityPicker by remember { mutableStateOf(false) }
|
||||
val autoLabel = stringResource(R.string.broadcast_min_quality_auto)
|
||||
|
||||
if (showMinQualityPicker) {
|
||||
MinQualityPickerDialog(
|
||||
selectedId = minQualityPreset,
|
||||
sessionQualityPreset = sessionQualityPreset,
|
||||
autoLabel = autoLabel,
|
||||
onSelect = { id ->
|
||||
showMinQualityPicker = false
|
||||
onSelectMinQuality(id)
|
||||
},
|
||||
onDismiss = { showMinQualityPicker = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showTerminateConfirm) {
|
||||
AlertDialog(
|
||||
@@ -174,6 +197,7 @@ fun BroadcastControlsOverlay(
|
||||
bitrateKbps = bitrateKbps,
|
||||
networkType = networkType,
|
||||
deviceHealth = deviceHealth,
|
||||
minQualityLabel = StreamQualityLadder.displayName(minQualityPreset, autoLabel),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -195,6 +219,11 @@ fun BroadcastControlsOverlay(
|
||||
contentDescription = stringResource(R.string.broadcast_share_regia_cd),
|
||||
onClick = onShareRegia,
|
||||
)
|
||||
SideIconButton(
|
||||
icon = Icons.Default.Tune,
|
||||
contentDescription = stringResource(R.string.broadcast_min_quality_cd),
|
||||
onClick = { showMinQualityPicker = true },
|
||||
)
|
||||
if (onCloseSet != null) {
|
||||
SideIconButton(
|
||||
icon = Icons.Default.Check,
|
||||
@@ -287,6 +316,7 @@ private fun BroadcastTelemetryPanel(
|
||||
bitrateKbps: Long,
|
||||
networkType: String,
|
||||
deviceHealth: DeviceHealthSnapshot,
|
||||
minQualityLabel: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val labelStyle = MaterialTheme.typography.labelSmall
|
||||
@@ -321,6 +351,11 @@ private fun BroadcastTelemetryPanel(
|
||||
color = MatchColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
minQualityLabel,
|
||||
style = labelStyle,
|
||||
color = MatchColors.AccentYellow,
|
||||
)
|
||||
Text(
|
||||
"${networkType} · ${deviceHealth.batteryPercent}%",
|
||||
style = labelStyle,
|
||||
@@ -604,6 +639,113 @@ private fun TeamLogoThumbnail(url: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MinQualityPickerDialog(
|
||||
selectedId: String,
|
||||
sessionQualityPreset: String,
|
||||
autoLabel: String,
|
||||
onSelect: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val options = StreamQualityLadder.availableIds(sessionQualityPreset)
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.widthIn(max = 720.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MatchColors.Surface)
|
||||
.border(1.dp, MatchColors.Outline, RoundedCornerShape(16.dp))
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.broadcast_min_quality_title),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.action_cancel), color = MatchColors.TextSecondary)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.broadcast_min_quality_hint),
|
||||
color = MatchColors.TextSecondary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
options.forEach { id ->
|
||||
MinQualityOptionChip(
|
||||
label = StreamQualityLadder.displayName(id, autoLabel),
|
||||
selected = id == selectedId,
|
||||
onClick = { onSelect(id) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MinQualityOptionChip(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
val border = if (selected) MatchColors.AccentYellow else MatchColors.Outline
|
||||
val background = if (selected) {
|
||||
MatchColors.AccentYellow.copy(alpha = 0.16f)
|
||||
} else {
|
||||
MatchColors.SurfaceElevated
|
||||
}
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(background)
|
||||
.border(1.dp, border, shape)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 8.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MatchColors.AccentYellow,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
Text(
|
||||
label,
|
||||
color = if (selected) MatchColors.AccentYellow else Color.White,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SideIconButton(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user