Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83a804a709 | ||
|
|
b8694a6b7b | ||
|
|
5daada81b0 | ||
|
|
d85bab30d1 | ||
|
|
7b3256c8a0 | ||
|
|
9bc89fceba | ||
|
|
d5ec266437 | ||
|
|
a448ad59ee | ||
|
|
74a156cedc | ||
|
|
97d787f758 |
@@ -0,0 +1,57 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class AccountsController < ApplicationController
|
||||||
|
def show
|
||||||
|
render json: user_json(current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
name = params[:name].to_s.strip
|
||||||
|
if name.blank?
|
||||||
|
return render json: { error: "Name is required" }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
if current_user.update(name: name)
|
||||||
|
render json: user_json(current_user)
|
||||||
|
else
|
||||||
|
render json: { error: current_user.errors.full_messages.join(", ") }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def password
|
||||||
|
result = Users::ChangePassword.call(
|
||||||
|
user: current_user,
|
||||||
|
current_password: params[:current_password],
|
||||||
|
password: params[:password],
|
||||||
|
password_confirmation: params[:password_confirmation]
|
||||||
|
)
|
||||||
|
|
||||||
|
unless result.ok?
|
||||||
|
return render json: { error: password_error_message(result.error) }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: { message: "Password updated" }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def user_json(user)
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
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"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
module Api
|
module Api
|
||||||
module V1
|
module V1
|
||||||
class AuthController < ApplicationController
|
class AuthController < ApplicationController
|
||||||
skip_before_action :authenticate_request!, only: %i[login refresh register]
|
skip_before_action :authenticate_request!, only: %i[login refresh register forgot_password]
|
||||||
|
|
||||||
def register
|
def register
|
||||||
user = User.new(
|
user = User.new(
|
||||||
@@ -42,6 +42,13 @@ module Api
|
|||||||
render json: user_json(current_user)
|
render json: user_json(current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
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."
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def token_response(user)
|
def token_response(user)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
module Public
|
||||||
|
class AccountsController < WebBaseController
|
||||||
|
before_action :require_login!
|
||||||
|
|
||||||
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
name = params[:name].to_s.strip
|
||||||
|
if name.blank?
|
||||||
|
flash.now[:alert] = t("flash.accounts.name_required")
|
||||||
|
return render :show, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
if current_user.update(name: name)
|
||||||
|
redirect_to public_account_path, notice: t("flash.accounts.profile_updated")
|
||||||
|
else
|
||||||
|
flash.now[:alert] = current_user.errors.full_messages.to_sentence
|
||||||
|
render :show, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_password
|
||||||
|
result = Users::ChangePassword.call(
|
||||||
|
user: current_user,
|
||||||
|
current_password: params[:current_password],
|
||||||
|
password: params[:password],
|
||||||
|
password_confirmation: params[:password_confirmation]
|
||||||
|
)
|
||||||
|
|
||||||
|
unless result.ok?
|
||||||
|
flash.now[:alert] = t("flash.accounts.password_#{result.error}")
|
||||||
|
return render :show, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_to public_account_path, notice: t("flash.accounts.password_updated")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,11 +4,7 @@ module Public
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
user = User.find_by(email: params[:email]&.downcase&.strip)
|
Users::RequestPasswordReset.call(email: params[:email])
|
||||||
if user
|
|
||||||
token = user.generate_password_reset!
|
|
||||||
UserMailer.password_reset(user, token).deliver_now
|
|
||||||
end
|
|
||||||
|
|
||||||
redirect_to public_login_path,
|
redirect_to public_login_path,
|
||||||
notice: t("flash.password_resets.email_sent")
|
notice: t("flash.password_resets.email_sent")
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ module Ops
|
|||||||
force_path_style: MatchLiveTv.replay_storage_force_path_style?
|
force_path_style: MatchLiveTv.replay_storage_force_path_style?
|
||||||
)
|
)
|
||||||
client.head_bucket(bucket: MatchLiveTv.replay_storage_bucket)
|
client.head_bucket(bucket: MatchLiveTv.replay_storage_bucket)
|
||||||
ok_finding("garage_storage:ok", "Garage storage OK")
|
ok_finding("garage_storage:head", "Garage storage OK")
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
|
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -116,31 +116,30 @@ module Streams
|
|||||||
raise Error, "ffmpeg non disponibile: #{e.message}"
|
raise Error, "ffmpeg non disponibile: #{e.message}"
|
||||||
end
|
end
|
||||||
|
|
||||||
# RTMP grezzo da telefono (overlay bruciato lato app); fallback HLS via proxy Rails.
|
# Remux verso YouTube: video+audio copy (AAC già da app/slate a 48k mono).
|
||||||
|
# HLS (ADTS) → FLV richiede -bsf:a aac_adtstoasc; RTMP ha già ASC in FLV tags.
|
||||||
def youtube_ffmpeg_args(intake_source, output)
|
def youtube_ffmpeg_args(intake_source, output)
|
||||||
mode, url = intake_source
|
mode, url = intake_source
|
||||||
|
common_head = [
|
||||||
|
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||||
|
"-fflags", "+genpts+discardcorrupt"
|
||||||
|
]
|
||||||
|
copy_tail = ["-c:v", "copy", "-c:a", "copy", "-f", "flv", output]
|
||||||
|
|
||||||
if mode == :rtmp
|
if mode == :rtmp
|
||||||
return [
|
return common_head + [
|
||||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
|
||||||
"-fflags", "+genpts+discardcorrupt",
|
|
||||||
"-analyzeduration", "10000000", "-probesize", "10000000",
|
"-analyzeduration", "10000000", "-probesize", "10000000",
|
||||||
"-rw_timeout", "15000000",
|
"-rw_timeout", "15000000",
|
||||||
"-i", url,
|
"-i", url
|
||||||
"-c:v", "copy",
|
] + copy_tail
|
||||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
|
||||||
"-bsf:a", "aac_adtstoasc",
|
|
||||||
"-f", "flv", output
|
|
||||||
]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
[
|
common_head + [
|
||||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
|
||||||
"-fflags", "+genpts+discardcorrupt",
|
|
||||||
"-rw_timeout", "15000000",
|
"-rw_timeout", "15000000",
|
||||||
"-live_start_index", "-1",
|
"-live_start_index", "-1",
|
||||||
"-i", url,
|
"-i", url,
|
||||||
"-c:v", "copy",
|
"-c:v", "copy",
|
||||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
"-c:a", "copy",
|
||||||
"-bsf:a", "aac_adtstoasc",
|
"-bsf:a", "aac_adtstoasc",
|
||||||
"-f", "flv", output
|
"-f", "flv", output
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
module Users
|
||||||
|
class ChangePassword
|
||||||
|
Result = Struct.new(:ok?, :error, keyword_init: true)
|
||||||
|
|
||||||
|
def self.call(user:, current_password:, password:, password_confirmation:)
|
||||||
|
new(
|
||||||
|
user: user,
|
||||||
|
current_password: current_password,
|
||||||
|
password: password,
|
||||||
|
password_confirmation: password_confirmation
|
||||||
|
).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(user:, current_password:, password:, password_confirmation:)
|
||||||
|
@user = user
|
||||||
|
@current_password = current_password.to_s
|
||||||
|
@password = password.to_s
|
||||||
|
@password_confirmation = password_confirmation.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
unless @user.authenticate(@current_password)
|
||||||
|
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)
|
||||||
|
@user.clear_password_reset!
|
||||||
|
Result.new(ok?: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
module Users
|
||||||
|
class RequestPasswordReset
|
||||||
|
def self.call(email:)
|
||||||
|
new(email: email).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(email:)
|
||||||
|
@email = email.to_s.downcase.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
user = User.find_by(email: @email)
|
||||||
|
return if user.nil?
|
||||||
|
|
||||||
|
token = user.generate_password_reset!
|
||||||
|
UserMailer.password_reset(user, token).deliver_now
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
<% elsif current_user.manageable_teams.any? %>
|
<% elsif current_user.manageable_teams.any? %>
|
||||||
· <%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first) %>
|
· <%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
· <%= link_to t("nav.account"), public_account_path %>
|
||||||
· <%= button_to t("nav.logout"), public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %>
|
· <%= button_to t("nav.logout"), public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
· <%= link_to t("nav.login"), public_login_path %>
|
· <%= link_to t("nav.login"), public_login_path %>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<% content_for :title, t("auth.account.meta_title") %>
|
||||||
|
<% content_for :meta_description, t("auth.account.meta_description") %>
|
||||||
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
|
<section class="auth-page">
|
||||||
|
<h1><%= t("auth.account.title") %></h1>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="font-size:1.1rem;margin-top:0"><%= t("auth.account.profile_heading") %></h2>
|
||||||
|
<%= form_with url: public_account_path, method: :patch, local: true do %>
|
||||||
|
<p>
|
||||||
|
<label for="account_email"><%= t("auth.email") %></label><br>
|
||||||
|
<input type="email" id="account_email" value="<%= current_user.email %>" readonly
|
||||||
|
class="input" autocomplete="username"
|
||||||
|
style="width:100%;opacity:0.75;cursor:not-allowed">
|
||||||
|
</p>
|
||||||
|
<p class="muted" style="margin-top:-0.5rem;font-size:0.9rem">
|
||||||
|
<%= t("auth.account.role_label", role: current_user.role) %>
|
||||||
|
</p>
|
||||||
|
<%= render "shared/input_toggle",
|
||||||
|
name: :name,
|
||||||
|
label: t("auth.account.name_label"),
|
||||||
|
value: current_user.name,
|
||||||
|
required: true,
|
||||||
|
autocomplete: "name",
|
||||||
|
masked: false %>
|
||||||
|
<%= submit_tag t("auth.account.save_profile"), class: "btn btn-primary" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size:1.1rem;margin-top:0"><%= t("auth.account.password_heading") %></h2>
|
||||||
|
<%= form_with url: public_account_password_path, method: :patch, local: true do %>
|
||||||
|
<%= render "shared/input_toggle",
|
||||||
|
name: :current_password,
|
||||||
|
label: t("auth.account.current_password_label"),
|
||||||
|
input_type: "password",
|
||||||
|
required: true,
|
||||||
|
autocomplete: "current-password" %>
|
||||||
|
<%= render "shared/input_toggle",
|
||||||
|
name: :password,
|
||||||
|
label: t("auth.account.new_password_label"),
|
||||||
|
input_type: "password",
|
||||||
|
required: true,
|
||||||
|
autocomplete: "new-password" %>
|
||||||
|
<%= render "shared/input_toggle",
|
||||||
|
name: :password_confirmation,
|
||||||
|
label: t("auth.password_confirmation"),
|
||||||
|
input_type: "password",
|
||||||
|
required: true,
|
||||||
|
autocomplete: "new-password" %>
|
||||||
|
<%= submit_tag t("auth.account.save_password"), class: "btn btn-primary" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<% if @subscription.admin_comped_reason.present? %>
|
<% if @subscription.admin_comped_reason.present? %>
|
||||||
(<%= @subscription.admin_comped_reason %>)
|
(<%= @subscription.admin_comped_reason %>)
|
||||||
<% end %>.
|
<% end %>.
|
||||||
<%= raw t("club.billing.comped.no_payment_html", email_link: mail_to("info@matchlive.it", "info@matchlive.it")) %>
|
<%= raw t("club.billing.comped.no_payment_html", email_link: mail_to("info@matchlivetv.it", "info@matchlivetv.it")) %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
@@ -40,6 +40,6 @@
|
|||||||
<div class="card" style="margin-top:32px;text-align:center">
|
<div class="card" style="margin-top:32px;text-align:center">
|
||||||
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
|
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
|
||||||
<p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p>
|
<p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p>
|
||||||
<%= mail_to "info@matchlive.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
|
<%= mail_to "info@matchlivetv.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top:24px;text-align:center">
|
<div class="card" style="margin-top:24px;text-align:center">
|
||||||
<p style="color:#aaa"><%= t("team.billing.contact_lead") %> <%= mail_to "info@matchlive.it", t("team.billing.contact_cta") %> <%= t("team.billing.contact_trailer") %></p>
|
<p style="color:#aaa"><%= t("team.billing.contact_lead") %> <%= mail_to "info@matchlivetv.it", t("team.billing.contact_cta") %> <%= t("team.billing.contact_trailer") %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="margin-top:20px"><%= link_to t("team.billing.club_payments_link"), public_club_billing_path(@team.club) %></p>
|
<p style="margin-top:20px"><%= link_to t("team.billing.club_payments_link"), public_club_billing_path(@team.club) %></p>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
<%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
|
<%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<%= link_to t("nav.account"), public_account_path, class: (request.path == "/account" ? "nav-link-item nav-active" : "nav-link-item") %>
|
||||||
<%= button_to t("nav.logout"), public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
|
<%= button_to t("nav.logout"), public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %>
|
<%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ module MatchLiveTv
|
|||||||
end
|
end
|
||||||
|
|
||||||
def privacy_controller_email
|
def privacy_controller_email
|
||||||
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlive.it")
|
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
|
||||||
end
|
end
|
||||||
|
|
||||||
def privacy_controller_address
|
def privacy_controller_address
|
||||||
|
|||||||
@@ -606,6 +606,13 @@ de:
|
|||||||
password_min_length: Das Passwort muss mindestens 8 Zeichen lang sein
|
password_min_length: Das Passwort muss mindestens 8 Zeichen lang sein
|
||||||
password_mismatch: Die Passwörter stimmen nicht überein
|
password_mismatch: Die Passwörter stimmen nicht überein
|
||||||
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden.
|
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden.
|
||||||
|
accounts:
|
||||||
|
name_required: Der Name ist erforderlich
|
||||||
|
profile_updated: Profil aktualisiert.
|
||||||
|
password_current_incorrect: Das aktuelle Passwort ist falsch
|
||||||
|
password_too_short: Das Passwort muss mindestens 8 Zeichen haben
|
||||||
|
password_mismatch: Die Passwörter stimmen nicht überein
|
||||||
|
password_updated: Passwort aktualisiert.
|
||||||
replay:
|
replay:
|
||||||
download_unavailable: Download nicht verfügbar
|
download_unavailable: Download nicht verfügbar
|
||||||
not_available: Replay nicht verfügbar
|
not_available: Replay nicht verfügbar
|
||||||
|
|||||||
@@ -606,6 +606,13 @@ en:
|
|||||||
password_min_length: Password must be at least 8 characters
|
password_min_length: Password must be at least 8 characters
|
||||||
password_mismatch: Passwords don't match
|
password_mismatch: Passwords don't match
|
||||||
password_updated: Password updated. You can now log in.
|
password_updated: Password updated. You can now log in.
|
||||||
|
accounts:
|
||||||
|
name_required: Name is required
|
||||||
|
profile_updated: Profile updated.
|
||||||
|
password_current_incorrect: Current password is incorrect
|
||||||
|
password_too_short: Password must be at least 8 characters
|
||||||
|
password_mismatch: Passwords do not match
|
||||||
|
password_updated: Password updated.
|
||||||
replay:
|
replay:
|
||||||
download_unavailable: Download not available
|
download_unavailable: Download not available
|
||||||
not_available: Replay not available
|
not_available: Replay not available
|
||||||
|
|||||||
@@ -606,6 +606,13 @@ es:
|
|||||||
password_min_length: La contraseña debe tener al menos 8 caracteres
|
password_min_length: La contraseña debe tener al menos 8 caracteres
|
||||||
password_mismatch: Las contraseñas no coinciden
|
password_mismatch: Las contraseñas no coinciden
|
||||||
password_updated: Contraseña actualizada. Ya puedes iniciar sesión.
|
password_updated: Contraseña actualizada. Ya puedes iniciar sesión.
|
||||||
|
accounts:
|
||||||
|
name_required: El nombre es obligatorio
|
||||||
|
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_mismatch: Las contraseñas no coinciden
|
||||||
|
password_updated: Contraseña actualizada.
|
||||||
replay:
|
replay:
|
||||||
download_unavailable: Descarga no disponible
|
download_unavailable: Descarga no disponible
|
||||||
not_available: Repetición no disponible
|
not_available: Repetición no disponible
|
||||||
|
|||||||
@@ -606,6 +606,13 @@ fr:
|
|||||||
password_min_length: Le mot de passe doit comporter au moins 8 caractères
|
password_min_length: Le mot de passe doit comporter au moins 8 caractères
|
||||||
password_mismatch: Les mots de passe ne correspondent pas
|
password_mismatch: Les mots de passe ne correspondent pas
|
||||||
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter.
|
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter.
|
||||||
|
accounts:
|
||||||
|
name_required: Le nom est obligatoire
|
||||||
|
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_mismatch: Les mots de passe ne correspondent pas
|
||||||
|
password_updated: Mot de passe mis à jour.
|
||||||
replay:
|
replay:
|
||||||
download_unavailable: Téléchargement non disponible
|
download_unavailable: Téléchargement non disponible
|
||||||
not_available: Replay non disponible
|
not_available: Replay non disponible
|
||||||
|
|||||||
@@ -606,6 +606,13 @@ it:
|
|||||||
password_min_length: La password deve avere almeno 8 caratteri
|
password_min_length: La password deve avere almeno 8 caratteri
|
||||||
password_mismatch: Le password non coincidono
|
password_mismatch: Le password non coincidono
|
||||||
password_updated: Password aggiornata. Ora puoi accedere.
|
password_updated: Password aggiornata. Ora puoi accedere.
|
||||||
|
accounts:
|
||||||
|
name_required: Il nome è obbligatorio
|
||||||
|
profile_updated: Profilo aggiornato.
|
||||||
|
password_current_incorrect: La password attuale non è corretta
|
||||||
|
password_too_short: La password deve avere almeno 8 caratteri
|
||||||
|
password_mismatch: Le password non coincidono
|
||||||
|
password_updated: Password aggiornata.
|
||||||
replay:
|
replay:
|
||||||
download_unavailable: Download non disponibile
|
download_unavailable: Download non disponibile
|
||||||
not_available: Replay non disponibile
|
not_available: Replay non disponibile
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ de:
|
|||||||
my_team: Mein Team
|
my_team: Mein Team
|
||||||
login: Anmelden
|
login: Anmelden
|
||||||
logout: Abmelden
|
logout: Abmelden
|
||||||
|
account: Konto
|
||||||
signup: Team registrieren
|
signup: Team registrieren
|
||||||
footer:
|
footer:
|
||||||
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
|
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
|
||||||
@@ -118,6 +119,18 @@ de:
|
|||||||
signup_link: registrieren Sie sich
|
signup_link: registrieren Sie sich
|
||||||
mobile_app_label: "Mobile App:"
|
mobile_app_label: "Mobile App:"
|
||||||
open_in_app: Einladung in der App öffnen
|
open_in_app: Einladung in der App öffnen
|
||||||
|
account:
|
||||||
|
meta_title: "Dein Konto — Match Live TV"
|
||||||
|
meta_description: Name und Passwort deines Match Live TV-Kontos verwalten.
|
||||||
|
title: Dein Konto
|
||||||
|
profile_heading: Profil
|
||||||
|
password_heading: Passwort ändern
|
||||||
|
name_label: Name
|
||||||
|
role_label: "Rolle: %{role}"
|
||||||
|
current_password_label: Aktuelles Passwort
|
||||||
|
new_password_label: "Neues Passwort (mind. 8 Zeichen)"
|
||||||
|
save_profile: Profil speichern
|
||||||
|
save_password: Passwort aktualisieren
|
||||||
common:
|
common:
|
||||||
privacy: Datenschutz
|
privacy: Datenschutz
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ en:
|
|||||||
my_team: My team
|
my_team: My team
|
||||||
login: Log in
|
login: Log in
|
||||||
logout: Log out
|
logout: Log out
|
||||||
|
account: Account
|
||||||
signup: Register a team
|
signup: Register a team
|
||||||
footer:
|
footer:
|
||||||
tagline: Every match, every event, for those who can't be there
|
tagline: Every match, every event, for those who can't be there
|
||||||
@@ -118,6 +119,18 @@ en:
|
|||||||
signup_link: sign up
|
signup_link: sign up
|
||||||
mobile_app_label: "Mobile app:"
|
mobile_app_label: "Mobile app:"
|
||||||
open_in_app: Open invitation in the app
|
open_in_app: Open invitation in the app
|
||||||
|
account:
|
||||||
|
meta_title: "Your account — Match Live TV"
|
||||||
|
meta_description: Manage your Match Live TV account name and password.
|
||||||
|
title: Your account
|
||||||
|
profile_heading: Profile
|
||||||
|
password_heading: Change password
|
||||||
|
name_label: Name
|
||||||
|
role_label: "Role: %{role}"
|
||||||
|
current_password_label: Current password
|
||||||
|
new_password_label: "New password (min. 8 characters)"
|
||||||
|
save_profile: Save profile
|
||||||
|
save_password: Update password
|
||||||
common:
|
common:
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ es:
|
|||||||
my_team: Mi equipo
|
my_team: Mi equipo
|
||||||
login: Acceder
|
login: Acceder
|
||||||
logout: Salir
|
logout: Salir
|
||||||
|
account: Cuenta
|
||||||
signup: Registrar equipo
|
signup: Registrar equipo
|
||||||
footer:
|
footer:
|
||||||
tagline: Cada partido, cada evento, para quien no puede estar
|
tagline: Cada partido, cada evento, para quien no puede estar
|
||||||
@@ -118,6 +119,18 @@ es:
|
|||||||
signup_link: regístrate
|
signup_link: regístrate
|
||||||
mobile_app_label: "App móvil:"
|
mobile_app_label: "App móvil:"
|
||||||
open_in_app: Abrir invitación en la app
|
open_in_app: Abrir invitación en la app
|
||||||
|
account:
|
||||||
|
meta_title: "Tu cuenta — Match Live TV"
|
||||||
|
meta_description: Gestiona el nombre y la contraseña de tu cuenta Match Live TV.
|
||||||
|
title: Tu cuenta
|
||||||
|
profile_heading: Perfil
|
||||||
|
password_heading: Cambiar contraseña
|
||||||
|
name_label: Nombre
|
||||||
|
role_label: "Rol: %{role}"
|
||||||
|
current_password_label: Contraseña actual
|
||||||
|
new_password_label: "Nueva contraseña (mín. 8 caracteres)"
|
||||||
|
save_profile: Guardar perfil
|
||||||
|
save_password: Actualizar contraseña
|
||||||
common:
|
common:
|
||||||
privacy: Privacidad
|
privacy: Privacidad
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ fr:
|
|||||||
my_team: Mon équipe
|
my_team: Mon équipe
|
||||||
login: Connexion
|
login: Connexion
|
||||||
logout: Déconnexion
|
logout: Déconnexion
|
||||||
|
account: Compte
|
||||||
signup: Inscrire une équipe
|
signup: Inscrire une équipe
|
||||||
footer:
|
footer:
|
||||||
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
|
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
|
||||||
@@ -118,6 +119,18 @@ fr:
|
|||||||
signup_link: inscrivez-vous
|
signup_link: inscrivez-vous
|
||||||
mobile_app_label: "Application mobile :"
|
mobile_app_label: "Application mobile :"
|
||||||
open_in_app: Ouvrir l'invitation dans l'application
|
open_in_app: Ouvrir l'invitation dans l'application
|
||||||
|
account:
|
||||||
|
meta_title: "Votre compte — Match Live TV"
|
||||||
|
meta_description: Gérez le nom et le mot de passe de votre compte Match Live TV.
|
||||||
|
title: Votre compte
|
||||||
|
profile_heading: Profil
|
||||||
|
password_heading: Changer le mot de passe
|
||||||
|
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)"
|
||||||
|
save_profile: Enregistrer le profil
|
||||||
|
save_password: Mettre à jour le mot de passe
|
||||||
common:
|
common:
|
||||||
privacy: Confidentialité
|
privacy: Confidentialité
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ it:
|
|||||||
my_team: La mia squadra
|
my_team: La mia squadra
|
||||||
login: Accedi
|
login: Accedi
|
||||||
logout: Esci
|
logout: Esci
|
||||||
|
account: Account
|
||||||
signup: Registra squadra
|
signup: Registra squadra
|
||||||
footer:
|
footer:
|
||||||
tagline: Ogni partita, ogni evento, per chi non può esserci
|
tagline: Ogni partita, ogni evento, per chi non può esserci
|
||||||
@@ -118,6 +119,18 @@ it:
|
|||||||
signup_link: registrati
|
signup_link: registrati
|
||||||
mobile_app_label: "App mobile:"
|
mobile_app_label: "App mobile:"
|
||||||
open_in_app: Apri invito nell'app
|
open_in_app: Apri invito nell'app
|
||||||
|
account:
|
||||||
|
meta_title: "Il tuo account — Match Live TV"
|
||||||
|
meta_description: Gestisci nome e password del tuo account Match Live TV.
|
||||||
|
title: Il tuo account
|
||||||
|
profile_heading: Profilo
|
||||||
|
password_heading: Cambia password
|
||||||
|
name_label: Nome
|
||||||
|
role_label: "Ruolo: %{role}"
|
||||||
|
current_password_label: Password attuale
|
||||||
|
new_password_label: "Nuova password (min. 8 caratteri)"
|
||||||
|
save_profile: Salva profilo
|
||||||
|
save_password: Aggiorna password
|
||||||
common:
|
common:
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
cookies: Cookie
|
cookies: Cookie
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ Rails.application.routes.draw do
|
|||||||
post "auth/logout", to: "auth#logout"
|
post "auth/logout", to: "auth#logout"
|
||||||
post "auth/refresh", to: "auth#refresh"
|
post "auth/refresh", to: "auth#refresh"
|
||||||
get "auth/me", to: "auth#me"
|
get "auth/me", to: "auth#me"
|
||||||
|
post "auth/password/forgot", to: "auth#forgot_password"
|
||||||
|
get "account", to: "accounts#show"
|
||||||
|
patch "account", to: "accounts#update"
|
||||||
|
patch "account/password", to: "accounts#password"
|
||||||
get "sports", to: "sports#index"
|
get "sports", to: "sports#index"
|
||||||
|
|
||||||
get "invitations/:token", to: "invitations#show"
|
get "invitations/:token", to: "invitations#show"
|
||||||
@@ -163,6 +167,9 @@ Rails.application.routes.draw do
|
|||||||
post "password/forgot", to: "password_resets#create"
|
post "password/forgot", to: "password_resets#create"
|
||||||
get "password/reset", to: "password_resets#edit", as: :password_reset
|
get "password/reset", to: "password_resets#edit", as: :password_reset
|
||||||
patch "password/reset", to: "password_resets#update"
|
patch "password/reset", to: "password_resets#update"
|
||||||
|
get "account", to: "accounts#show", as: :account
|
||||||
|
patch "account", to: "accounts#update"
|
||||||
|
patch "account/password", to: "accounts#update_password", as: :account_password
|
||||||
get "clubs/new", to: "clubs#new", as: :new_club
|
get "clubs/new", to: "clubs#new", as: :new_club
|
||||||
post "clubs", to: "clubs#create"
|
post "clubs", to: "clubs#create"
|
||||||
get "clubs/:id", to: "clubs#show", as: :club
|
get "clubs/:id", to: "clubs#show", as: :club
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"relation": [
|
||||||
|
"delegate_permission/common.handle_all_urls",
|
||||||
|
"delegate_permission/common.get_login_creds"
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"namespace": "android_app",
|
||||||
|
"package_name": "com.matchlivetv.match_live_tv",
|
||||||
|
"sha256_cert_fingerprints": [
|
||||||
|
"C3:F0:89:51:0B:00:3A:FE:10:BD:ED:68:45:1B:4F:1D:B8:5A:63:EE:8A:DA:F7:2F:5A:D6:1D:97:C9:A3:95:47",
|
||||||
|
"09:69:25:CD:8B:EC:BA:75:9A:5E:49:32:E1:35:BD:F1:AF:1E:5A:29:93:E2:65:85:8A:41:E2:11:DA:64:97:F0"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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(:auth_headers) do
|
||||||
|
post "/api/v1/auth/login", params: { email: user.email, password: "password123" }
|
||||||
|
token = JSON.parse(response.body).fetch("access_token")
|
||||||
|
{ "Authorization" => "Bearer #{token}" }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /api/v1/account" do
|
||||||
|
it "returns the current user profile" do
|
||||||
|
get "/api/v1/account", headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
body = JSON.parse(response.body)
|
||||||
|
expect(body).to include("email" => user.email, "name" => "Account User", "role" => "coach")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires authentication" do
|
||||||
|
get "/api/v1/account"
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "PATCH /api/v1/account" do
|
||||||
|
it "updates the name" do
|
||||||
|
patch "/api/v1/account", params: { name: "Nuovo Nome" }, headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(JSON.parse(response.body)["name"]).to eq("Nuovo Nome")
|
||||||
|
expect(user.reload.name).to eq("Nuovo Nome")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects a blank name" do
|
||||||
|
patch "/api/v1/account", params: { name: " " }, headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "PATCH /api/v1/account/password" do
|
||||||
|
it "changes the password with the current password" do
|
||||||
|
patch "/api/v1/account/password",
|
||||||
|
params: {
|
||||||
|
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
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects an incorrect current password" do
|
||||||
|
patch "/api/v1/account/password",
|
||||||
|
params: {
|
||||||
|
current_password: "wrong",
|
||||||
|
password: "newpass123",
|
||||||
|
password_confirmation: "newpass123"
|
||||||
|
},
|
||||||
|
headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(user.reload.authenticate("password123")).to be_truthy
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "POST /api/v1/auth/password/forgot" do
|
||||||
|
it "always returns ok and sends mail when the user exists" do
|
||||||
|
expect {
|
||||||
|
post "/api/v1/auth/password/forgot", params: { email: user.email }
|
||||||
|
}.to change { ActionMailer::Base.deliveries.size }.by(1)
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(user.reload.password_reset_digest).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns the same message for unknown emails" do
|
||||||
|
expect {
|
||||||
|
post "/api/v1/auth/password/forgot", params: { email: "nobody@example.com" }
|
||||||
|
}.not_to change { ActionMailer::Base.deliveries.size }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Streams::YoutubeRelay do
|
||||||
|
describe ".youtube_ffmpeg_args (private)" do
|
||||||
|
def args(mode, url)
|
||||||
|
described_class.send(:youtube_ffmpeg_args, [mode, url], "rtmps://a.rtmps.youtube.com/live2/key")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "remuxes RTMP with video and audio copy (no AAC re-encode)" do
|
||||||
|
cmd = args(:rtmp, "rtmp://mediamtx:1935/live/match_x")
|
||||||
|
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-f", "flv")
|
||||||
|
expect(cmd).not_to include("aac")
|
||||||
|
expect(cmd.join(" ")).not_to include("-b:a")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "remuxes HLS with AAC bitstream filter for FLV" do
|
||||||
|
cmd = args(:hls, "http://mediamtx:8888/live/match_x/index.m3u8")
|
||||||
|
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc", "-f", "flv")
|
||||||
|
expect(cmd).not_to include("-b:a")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Android App Links / Digital Asset Links
|
||||||
|
|
||||||
|
Play Console verifica `https://www.matchlivetv.it/.well-known/assetlinks.json`.
|
||||||
|
|
||||||
|
## File
|
||||||
|
|
||||||
|
- Servito da **edge** (nginx): `infra/nginx-edge/well-known/assetlinks.json`
|
||||||
|
- Specchio in Rails public: `backend/public/.well-known/assetlinks.json`
|
||||||
|
|
||||||
|
Package: `com.matchlivetv.match_live_tv`
|
||||||
|
Path App Links: `https://www.matchlivetv.it/join…` (`pathPrefix="/join"`)
|
||||||
|
|
||||||
|
Nel JSON ci sono:
|
||||||
|
|
||||||
|
1. SHA-256 del **upload key** (`native/android/keystore/matchlivetv-upload.jks`)
|
||||||
|
2. SHA-256 del certificato **App signing** di Play (da Play Console → Integrità dell’app)
|
||||||
|
|
||||||
|
Relazioni richieste da Play per la condivisione credenziali:
|
||||||
|
|
||||||
|
- `delegate_permission/common.handle_all_urls`
|
||||||
|
- `delegate_permission/common.get_login_creds`
|
||||||
|
|
||||||
|
Se Play rigenera il JSON, sostituisci `infra/nginx-edge/well-known/assetlinks.json` (e lo specchio in `backend/public/.well-known/`) e ricarica edge.
|
||||||
|
## Deploy edge (senza rebuild Rails)
|
||||||
|
|
||||||
|
Sul server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/matchlivetv/infra
|
||||||
|
# dopo git pull del repo
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env up -d edge
|
||||||
|
curl -sS -D- https://www.matchlivetv.it/.well-known/assetlinks.json | head
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifica Google:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://www.matchlivetv.it&relation=delegate_permission/common.handle_all_urls"
|
||||||
|
```
|
||||||
|
|
||||||
|
Poi in Play Console → Link diretti → ripeti i controlli dominio.
|
||||||
@@ -47,7 +47,7 @@ Poi tocca la card o conferma dal foglio: si apre il wizard (Partita → Trasmiss
|
|||||||
|
|
||||||
1. Nel wizard, seleziona **YouTube Live** (se il piano lo consente e il canale è pronto)
|
1. Nel wizard, seleziona **YouTube Live** (se il piano lo consente e il canale è pronto)
|
||||||
2. Completa i passi → **Camera**
|
2. Completa i passi → **Camera**
|
||||||
3. Il telefono invia RTMP a MediaMTX; il server avvia **automaticamente** overlay (tabellone + logo) e RTMPS verso YouTube — nessun intervento manuale
|
3. Il telefono invia RTMP a MediaMTX (con overlay tabellone già nel flusso, se attivo); il server avvia **automaticamente** il relay RTMPS verso YouTube (`Streams::YoutubeRelay`) — nessun intervento manuale
|
||||||
|
|
||||||
## Sviluppo locale
|
## Sviluppo locale
|
||||||
|
|
||||||
@@ -67,9 +67,9 @@ Vedi [YOUTUBE_MORNING_CHECKLIST.md](./YOUTUBE_MORNING_CHECKLIST.md) — APK **1.
|
|||||||
| YouTube disabilitato | Premium Light o Full |
|
| YouTube disabilitato | Premium Light o Full |
|
||||||
| YouTube non selezionabile | Token canale Match Live TV sul server (`admin` → YouTube piattaforma) |
|
| YouTube non selezionabile | Token canale Match Live TV sul server (`admin` → YouTube piattaforma) |
|
||||||
| OAuth «app non verificata» | Utente di test Google + Avanzate |
|
| OAuth «app non verificata» | Utente di test Google + Avanzate |
|
||||||
| Live non su YouTube | Pipeline automatica (`Youtube::LivePipeline`): overlay → ingest active → activate. Controlla `docker logs infra-sidekiq-1` per `[OverlayRelay] started` e `[YoutubeBroadcastActivate]`. |
|
| Live non su YouTube | Pipeline `Youtube::LivePipeline` + `YoutubeRelay`. Controlla `docker logs infra-sidekiq-1` per `[YoutubeRelay] started` / `[YoutubeBroadcastActivate]`. |
|
||||||
| YouTube «In programma» / copertina | Nessun intervento manuale: entro ~20–90s il server avvia overlay (tee RTMPS) e attiva la broadcast quando ingest è `active`. |
|
| YouTube «In programma» / copertina | Entro ~20–90s il server avvia il relay RTMPS e attiva la broadcast quando l’ingest è pronto; in pausa MediaMTX manda la slate. |
|
||||||
| YouTube resta in «waiting» | `log/overlay_relay_<session_id>.log` in **sidekiq**; poll ogni 5s (`StreamPublisherSyncJob`). Attivazione ritenta ~6 min. |
|
| YouTube resta in «waiting» | Log `log/youtube_relay_*.log` in **sidekiq**; poll `StreamPublisherSyncJob`. Attivazione ritenta ~6 min. |
|
||||||
| Banner «sync limitata: Cannot add event after closing» | APK aggiornato: il wizard non apre un secondo WebSocket `controller` dopo la camera. |
|
| Banner «sync limitata: Cannot add event after closing» | APK aggiornato: il wizard non apre un secondo WebSocket `controller` dopo la camera. |
|
||||||
| Timer «IN DIRETTA» sbagliato (es. 10:33 subito) | `started_at` arriva dal server al primo frame RTMP; fino ad allora il timer resta a 0. |
|
| Timer «IN DIRETTA» sbagliato (es. 10:33 subito) | `started_at` arriva dal server al primo frame RTMP; fino ad allora il timer resta a 0. |
|
||||||
| App 0 Mbps / 0 fps ma LIVE | RTMP non pubblica: controlla permessi camera, messaggio «Connessione RTMP»; relay YouTube non parte finché il telefono non è online su MediaMTX. |
|
| App 0 Mbps / 0 fps ma LIVE | RTMP non pubblica: controlla permessi camera, messaggio «Connessione RTMP»; relay YouTube non parte finché il telefono non è online su MediaMTX. |
|
||||||
|
|||||||
+25
-19
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Documento di riferimento per l'intero sistema: rete, container Docker, flussi video, replay, monitoraggio ops e rilasci.
|
Documento di riferimento per l'intero sistema: rete, container Docker, flussi video, replay, monitoraggio ops e rilasci.
|
||||||
|
|
||||||
**Ultimo aggiornamento:** 2026-06-14
|
**Ultimo aggiornamento:** 2026-07-29
|
||||||
**Produzione:** `eminux@192.168.1.146` → `/opt/matchlivetv`
|
**Produzione:** `eminux@192.168.1.146` → `/opt/matchlivetv`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -193,39 +193,41 @@ Vedi anche [`LIVE_STREAMING.md`](LIVE_STREAMING.md) per pause, overlay e player
|
|||||||
### Flusso ingest
|
### Flusso ingest
|
||||||
|
|
||||||
```
|
```
|
||||||
App Android (RTMP 720p, AAC mono)
|
App (Android/iOS) RTMP 720p AAC mono
|
||||||
|
+ overlay GPU (tabellone/watermark) se overlay_kind ≠ none
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
MediaMTX :1935 — path live/match_{uuid} (grezzo, telefono)
|
MediaMTX :1935 — path live/match_{uuid}
|
||||||
│
|
|
||||||
▼ overlay ffmpeg (Streams::OverlayRelay)
|
|
||||||
path live/match_{uuid}_air (tabellone + badge bruciati nel video)
|
|
||||||
│
|
│
|
||||||
├──► HLS :8888 ──► edge /hls/ ──► spettatori web
|
├──► HLS :8888 ──► edge /hls/ ──► spettatori web
|
||||||
└──► ffmpeg -c copy ──► YouTube RTMP (Streams::YoutubeRelay, Premium)
|
└──► (solo platform=youtube)
|
||||||
|
Streams::YoutubeRelay in Sidekiq
|
||||||
|
ffmpeg: -c:v copy, -c:a copy ──► YouTube RTMPS
|
||||||
```
|
```
|
||||||
|
|
||||||
| Componente | Ruolo |
|
| Componente | Ruolo |
|
||||||
|------------|--------|
|
|------------|--------|
|
||||||
| `Mediamtx::PathManager` | Crea path dinamici via API :9997 |
|
| Path dinamici via API :9997 | `Mediamtx::Client` (`create_path`, recording patch, …) |
|
||||||
| `alwaysAvailable` + slate | Copertina `/slates/offline.mp4` senza interrompere HLS |
|
| `alwaysAvailable` + slate | Copertina `/slates/offline.mp4` senza interrompere HLS |
|
||||||
| `Streams::OverlayRelay` | Ricodifica con PNG 1280×720 aggiornata ogni ~2s |
|
| Overlay tabellone | **App nativa** (non più ricodifica server) |
|
||||||
| `Streams::YoutubeRelay` | Legge HLS da `mediamtx:8888` (non più via Rails) |
|
| `Streams::YoutubeRelay` | Legge RTMP (o HLS) da MediaMTX; remux copy A/V → YouTube |
|
||||||
| `Mediamtx::PublisherSync` | Stato publisher online/offline in Rails |
|
| `Mediamtx::PublisherSync` | Publisher online/offline, go_live, recording on/off |
|
||||||
|
|
||||||
|
Dettaglio pause, player e ruolo ffmpeg: [`LIVE_STREAMING.md`](LIVE_STREAMING.md).
|
||||||
|
|
||||||
### Pausa e continuità
|
### Pausa e continuità
|
||||||
|
|
||||||
1. App chiama `PATCH /sessions/:id/pause` → stop RTMP.
|
1. App chiama `PATCH /sessions/:id/pause` → stop RTMP.
|
||||||
2. MediaMTX passa alla slate sullo **stesso path** (stesso URL HLS).
|
2. MediaMTX passa alla slate sullo **stesso path** (stesso URL HLS).
|
||||||
3. YouTube relay continua sulla stessa uscita (vede slate).
|
3. Se attivo, YouTube relay continua sulla stessa uscita (vede slate).
|
||||||
4. Ripresa: `resume` → app ripubblica RTMP; MediaMTX concatena camera senza nuovo URL.
|
4. Ripresa: `resume` → app ripubblica RTMP; MediaMTX concatena camera senza nuovo URL.
|
||||||
|
|
||||||
**Recording inline MediaMTX disabilitato** (`record: false` su path live): il recorder al switch slate→camera distruggeva il muxer HLS. I replay usano job dedicato (vedi sotto).
|
**Recording:** path creato con `record: false`; acceso solo con publisher online e entitlement (`PublisherSync`). I replay usano job dedicato (vedi sotto).
|
||||||
|
|
||||||
### Pagina live web
|
### Pagina live web
|
||||||
|
|
||||||
- URL: `/live/:session_id`
|
- URL: `/live/:session_id`
|
||||||
- Player: HLS.js su `HLS_PUBLIC_URL/live/match_{uuid}_air/index.m3u8`
|
- Player: HLS.js su `HLS_PUBLIC_URL/live/match_{uuid}/index.m3u8`
|
||||||
- Stato: `GET /live/:id/status.json` (badge, recovery buffer, no-store)
|
- Stato: `GET /live/:id/status.json` (badge, recovery buffer, no-store)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -278,8 +280,8 @@ App Android ◄──── REST API (/api/v1/...) ────► Rails
|
|||||||
│
|
│
|
||||||
Sidekiq ◄── Redis ◄──────┘
|
Sidekiq ◄── Redis ◄──────┘
|
||||||
│
|
│
|
||||||
├── HealthMonitorJob, UploadJob, overlay refresh
|
├── HealthMonitorJob, UploadJob (merge/thumbnail ffmpeg)
|
||||||
├── YoutubeRelay, OverlayRelay (ffmpeg)
|
├── YoutubeRelay (ffmpeg remux copy A/V, solo dirette YouTube)
|
||||||
└── PublishToYoutubeJob, purge replay, ecc.
|
└── PublishToYoutubeJob, purge replay, ecc.
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -395,10 +397,12 @@ cd infra && cp .env.example .env && docker compose up -d --build
|
|||||||
| Documento | Contenuto |
|
| Documento | Contenuto |
|
||||||
|-----------|-----------|
|
|-----------|-----------|
|
||||||
| [`infrastructure/SERVER_DEPLOYMENT.md`](infrastructure/SERVER_DEPLOYMENT.md) | Bootstrap server, NPM, cron, backup |
|
| [`infrastructure/SERVER_DEPLOYMENT.md`](infrastructure/SERVER_DEPLOYMENT.md) | Bootstrap server, NPM, cron, backup |
|
||||||
| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pause, overlay, HLS.js |
|
| [`infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md`](infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md) | Piano overflow relay YouTube su Hetzner Cloud (picchi) |
|
||||||
|
| [`ANDROID_APP_LINKS.md`](ANDROID_APP_LINKS.md) | Digital Asset Links / deep link Play (`assetlinks.json`) |
|
||||||
|
| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pausa, HLS, ruolo ffmpeg |
|
||||||
| [`REPLAY_MODULE.md`](REPLAY_MODULE.md) | Garage, retention, YouTube VOD |
|
| [`REPLAY_MODULE.md`](REPLAY_MODULE.md) | Garage, retention, YouTube VOD |
|
||||||
| [`OPS_MONITORING.md`](OPS_MONITORING.md) | ntfy, variabili ops, troubleshooting |
|
| [`OPS_MONITORING.md`](OPS_MONITORING.md) | ntfy, variabili ops, troubleshooting |
|
||||||
| [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md) | Grafica in stream |
|
| [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md) | Tabelloni e overlay (app nativa) |
|
||||||
| [`PRODUCT_PREMIUM.md`](PRODUCT_PREMIUM.md) | Piani e funzionalità premium |
|
| [`PRODUCT_PREMIUM.md`](PRODUCT_PREMIUM.md) | Piani e funzionalità premium |
|
||||||
| [`native/android/README.md`](../native/android/README.md) | App Android |
|
| [`native/android/README.md`](../native/android/README.md) | App Android |
|
||||||
|
|
||||||
@@ -412,5 +416,7 @@ cd infra && cp .env.example .env && docker compose up -d --build
|
|||||||
| 2026-06 | Replay: redirect 302 a `/media/` → Garage (fuori da Puma) |
|
| 2026-06 | Replay: redirect 302 a `/media/` → Garage (fuori da Puma) |
|
||||||
| 2026-06 | HLS live: edge → MediaMTX (fuori da Puma) |
|
| 2026-06 | HLS live: edge → MediaMTX (fuori da Puma) |
|
||||||
| 2026-06 | Ops: check `http_rails` + `http_public` separati, latenza p95 `UpLatencyTracker` |
|
| 2026-06 | Ops: check `http_rails` + `http_public` separati, latenza p95 `UpLatencyTracker` |
|
||||||
| 2026-06 | `RAILS_MAX_THREADS=5`; relay YouTube legge `mediamtx:8888` diretto |
|
| 2026-06 | `RAILS_MAX_THREADS=5`; relay YouTube da MediaMTX (non via Puma) |
|
||||||
| 2026-06 | Cleanup path MediaMTX orfani (`Mediamtx::CleanupOrphanPaths`); delete path sempre a fine diretta |
|
| 2026-06 | Cleanup path MediaMTX orfani (`Mediamtx::CleanupOrphanPaths`); delete path sempre a fine diretta |
|
||||||
|
| 2026-07 | Rimosso overlay server (`OverlayRelay`); tabellone bruciato in app; HLS su path camera (non `*_air`); `YoutubeRelay` = copy video + AAC |
|
||||||
|
| 2026-08 | `YoutubeRelay` = remux copy video+audio (niente ricodifica AAC); profilo app/slate AAC 48k mono |
|
||||||
|
|||||||
+49
-34
@@ -1,23 +1,25 @@
|
|||||||
# Diretta live — architettura MediaMTX
|
# Diretta live — architettura MediaMTX
|
||||||
|
|
||||||
## Idea (non è folle)
|
**Ultimo aggiornamento:** 2026-07-29
|
||||||
|
|
||||||
|
## Idea
|
||||||
|
|
||||||
MediaMTX espone **un unico flusso di uscita** per path (`live/match_{uuid}`):
|
MediaMTX espone **un unico flusso di uscita** per path (`live/match_{uuid}`):
|
||||||
|
|
||||||
1. **Telefono in onda** → publisher RTMP (camera).
|
1. **Telefono in onda** → publisher RTMP (camera + **overlay grafico bruciato in app**).
|
||||||
2. **Pausa / rete assente** → `alwaysAvailable` con file slate (`/slates/offline.mp4`) **senza interrompere** l’HLS verso il sito.
|
2. **Pausa / rete assente** → `alwaysAvailable` con file slate (`/slates/offline.mp4`) **senza interrompere** l’HLS verso il sito.
|
||||||
3. **YouTube** → `ffmpeg` in container Rails/Sidekiq legge **sempre** quell’uscita (`-c copy`) e inoltra a `rtmp://a.rtmp.youtube.com/live2/...` per tutta la sessione.
|
3. **YouTube** (solo se `platform=youtube`) → `Streams::YoutubeRelay` in **Sidekiq**: un processo `ffmpeg` legge RTMP/HLS da MediaMTX e inoltra a YouTube (`-c:v copy`, `-c:a copy`; l’AAC 48 kHz mono è già prodotto dall’app / slate).
|
||||||
|
|
||||||
Il telefono **non** invia la copertina: risparmia banda; lo switch è lato server.
|
Il telefono **non** invia la copertina di pausa: risparmia banda; lo switch slate ↔ camera è lato MediaMTX.
|
||||||
|
|
||||||
## Componenti
|
## Componenti
|
||||||
|
|
||||||
| Pezzo | Ruolo |
|
| Pezzo | Ruolo |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| App Android | RTMP 48 kHz mono, 720p — solo quando in onda |
|
| App Android / iOS | RTMP 720p AAC mono; overlay tabellone/watermark in GPU sul flusso |
|
||||||
| MediaMTX | Path dinamico, `alwaysAvailable` + slate |
|
| MediaMTX | Path dinamico, `alwaysAvailable` + slate, HLS |
|
||||||
| `Streams::YoutubeRelay` | Relay continuo verso YouTube (no hook wget su distroless) |
|
| `Streams::YoutubeRelay` | Relay continuo verso YouTube (solo Sidekiq con `YOUTUBE_RELAY_WORKER=1`) |
|
||||||
| `Mediamtx::PublisherSync` | Stato Rails da API paths (publisher online) |
|
| `Mediamtx::PublisherSync` | Stato Rails da API paths (publisher online) + recording on/off |
|
||||||
| `/hls/...` (edge → MediaMTX in prod; Rails proxy in dev) | Player web |
|
| `/hls/...` (edge → MediaMTX in prod; Rails proxy in dev) | Player web |
|
||||||
|
|
||||||
## Pausa e continuità
|
## Pausa e continuità
|
||||||
@@ -27,36 +29,44 @@ Il telefono **non** invia la copertina: risparmia banda; lo switch è lato serve
|
|||||||
3. MediaMTX → passa alla slate sul **medesimo path** (senza interrompere l’uscita HLS).
|
3. MediaMTX → passa alla slate sul **medesimo path** (senza interrompere l’uscita HLS).
|
||||||
4. **Ripresa** → API `resume` → `connecting`, recording on, app `resumeStream()`; MediaMTX concatena di nuovo camera sulla stessa uscita HLS.
|
4. **Ripresa** → API `resume` → `connecting`, recording on, app `resumeStream()`; MediaMTX concatena di nuovo camera sulla stessa uscita HLS.
|
||||||
5. **Sito** → **un solo** player HLS per tutta la sessione (mai reload in pausa/ripresa). Copertina brand (`brand/CopertinaCanale_6.png` → `infra/slates/offline.mp4`) muxata da MediaMTX; in pausa solo un messaggio leggero sopra il video.
|
5. **Sito** → **un solo** player HLS per tutta la sessione (mai reload in pausa/ripresa). Copertina brand (`brand/CopertinaCanale_6.png` → `infra/slates/offline.mp4`) muxata da MediaMTX; in pausa solo un messaggio leggero sopra il video.
|
||||||
6. **YouTube** → relay ffmpeg continuo; vede la stessa uscita MediaMTX.
|
6. **YouTube** → se attivo, il relay ffmpeg continua sulla stessa uscita MediaMTX (vede slate in pausa).
|
||||||
|
|
||||||
Non fare `patch` del path MediaMTX in pausa: ricarica il path e interrompe gli HLS reader.
|
Non fare `patch` del path MediaMTX in pausa: ricarica il path e interrompe gli HLS reader.
|
||||||
|
|
||||||
**Recording**: disabilitato sul path live (`record: false`) — il recorder MediaMTX al switch slate→camera distruggeva il muxer HLS (`too many reordered frames`). I replay vanno gestiti con job dedicato (vedi `REPLAY_MODULE.md`).
|
**Recording**: sul path live parte `record: false`; viene acceso via API solo con publisher online e entitlement (`PublisherSync`). I replay finiscono in Garage con job dedicato (vedi `REPLAY_MODULE.md`).
|
||||||
|
|
||||||
Il player web resta attivo finché `ready|available|online` sul path (non solo quando il telefono è `online`).
|
Il player web resta attivo finché `ready|available|online` sul path (non solo quando il telefono è `online`).
|
||||||
|
|
||||||
## Infrastruttura vs browser
|
## Infrastruttura vs browser
|
||||||
|
|
||||||
```
|
```
|
||||||
Telefono RTMP ──► MediaMTX path live/match_{uuid}
|
Telefono RTMP (camera + overlay app)
|
||||||
│
|
│
|
||||||
publisher ON ├─► camera (H.264/AAC)
|
▼
|
||||||
publisher OFF└─► alwaysAvailable → /slates/offline.mp4
|
MediaMTX path live/match_{uuid}
|
||||||
│
|
│
|
||||||
├─► HLS (segmenti ~1s) ──► edge /hls/ → MediaMTX (prod) o proxy Rails (dev) ──► player web (HLS.js)
|
publisher ON → camera H.264/AAC (già con tabellone se overlay ≠ none)
|
||||||
└─► RTMP lettura ──► Streams::YoutubeRelay (ffmpeg -c copy) ──► YouTube
|
publisher OFF → alwaysAvailable → /slates/offline.mp4
|
||||||
|
│
|
||||||
|
├──► HLS (segmenti ~1s) ──► edge /hls/ → MediaMTX ──► player web (HLS.js)
|
||||||
|
└──► (solo platform=youtube)
|
||||||
|
Streams::YoutubeRelay
|
||||||
|
ffmpeg: -c:v copy, -c:a copy ──► RTMPS YouTube
|
||||||
```
|
```
|
||||||
|
|
||||||
| Responsabilità | Dove |
|
| Responsabilità | Dove |
|
||||||
|----------------|------|
|
|----------------|------|
|
||||||
| Switch copertina ↔ live | **MediaMTX** (stesso path, stesso URL HLS) |
|
| Switch copertina ↔ live | **MediaMTX** (stesso path, stesso URL HLS) |
|
||||||
| Continuità YouTube | **Relay ffmpeg** sulla stessa uscita |
|
| Tabellone / watermark sullo stream | **App nativa** (GPU → RTMP) |
|
||||||
| Badge «In onda» / stato pausa | **Rails** (`status.json`, `PublisherSync`) |
|
| Continuità YouTube | **`Streams::YoutubeRelay`** (ffmpeg in Sidekiq) |
|
||||||
|
| Badge «In onda» / stato pausa | **Rails** (`status.json`, `PublisherSync`) + UI web |
|
||||||
| Uscire dal buffer copertina dopo ripresa | **Browser** (`pendingLiveRecovery`, reload HLS) |
|
| Uscire dal buffer copertina dopo ripresa | **Browser** (`pendingLiveRecovery`, reload HLS) |
|
||||||
| Fluidità playback | **Browser** (evitare seek continui su `liveSyncPosition`) |
|
| Fluidità playback | **Browser** (evitare seek continui su `liveSyncPosition`) |
|
||||||
|
|
||||||
Lo switch slate→camera **non** richiede un nuovo URL lato player: MediaMTX concatena sulla playlist. Il sito può però restare «indietro» nel buffer HLS (ancora segmenti della slate) anche con `publisher_online: true` — da qui i recovery controllati in `show.html.erb`, **senza** chiamare `jumpToLiveEdge()` a ogni frammento bufferizzato.
|
Lo switch slate→camera **non** richiede un nuovo URL lato player: MediaMTX concatena sulla playlist. Il sito può però restare «indietro» nel buffer HLS (ancora segmenti della slate) anche con `publisher_online: true` — da qui i recovery controllati in `show.html.erb`, **senza** chiamare `jumpToLiveEdge()` a ogni frammento bufferizzato.
|
||||||
|
|
||||||
|
URL HLS pubblico: `https://www.matchlivetv.it/hls/live/match_{uuid}/index.m3u8` (`StreamSession#effective_hls_path_name` = path camera, **non** più `*_air`).
|
||||||
|
|
||||||
### Player web — anti-scatti (HLS.js)
|
### Player web — anti-scatti (HLS.js)
|
||||||
|
|
||||||
Configurazione in `backend/app/views/public/live/show.html.erb`:
|
Configurazione in `backend/app/views/public/live/show.html.erb`:
|
||||||
@@ -67,27 +77,32 @@ Configurazione in `backend/app/views/public/live/show.html.erb`:
|
|||||||
- Sync iniziale una volta su `LEVEL_LOADED`; recovery solo se `liveEdgeLagSec() > 4` o `pendingLiveRecovery`.
|
- Sync iniziale una volta su `LEVEL_LOADED`; recovery solo se `liveEdgeLagSec() > 4` o `pendingLiveRecovery`.
|
||||||
- Interval 6s solo durante recovery attivo (`tryEscapeCoverBuffer`).
|
- Interval 6s solo durante recovery attivo (`tryEscapeCoverBuffer`).
|
||||||
|
|
||||||
### Grafica nel video (overlay server)
|
### Grafica nel video (overlay)
|
||||||
|
|
||||||
Tabellone, badge stato («In onda», «In pausa», «Copertina», …) e banner **Match Live TV** sono **bruciati nel flusso** da `Streams::OverlayRelay`:
|
**Non** c’è più un relay server `Streams::OverlayRelay` che ricodifica con PNG.
|
||||||
|
|
||||||
```
|
Il tabellone è composito **sul telefono** prima dell’RTMP (vedi [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md)):
|
||||||
Telefono → live/match_{uuid} (grezzo)
|
|
||||||
└─► ffmpeg + PNG 1280×720 (Streams::Overlay::Refresh ogni ~2s)
|
|
||||||
└─► live/match_{uuid}_air
|
|
||||||
├─► HLS (sito, anche fullscreen)
|
|
||||||
└─► YouTube relay (-c copy)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Streams::Overlay::SvgBuilder` + `rsvg-convert` generano `tmp/stream_overlays/{session_id}/overlay.png`
|
1. `OverlayState` da `effectiveOverlayKind` + `ScoreState`
|
||||||
- `Streams::Overlay::Refresh` + `bin/overlay_png_feeder.rb` aggiornano la PNG ogni ~2s (e su punteggio/stato); il feeder la invia a ffmpeg via FIFO perché `-loop 1` non rilegge il file su disco
|
2. `OverlayRenderer` / canvas → bitmap
|
||||||
- Volume Docker condiviso `stream_overlays` tra **rails** (ffmpeg) e **sidekiq** (OverlayRefreshJob); altrimenti il job scrive PNG su un filesystem diverso e badge/punteggio restano congelati
|
3. Filtro GPU sul encoder RTMP
|
||||||
- La pagina live **non** sovrappone più HTML sul player (evita incongruenze con fullscreen / YouTube)
|
|
||||||
- **Qualità video**: il relay normalizza a **30 fps CFR** (l’app RTMP può inviare timestamp errati) e ricodifica a **≥4.5 Mbps** (`fast`, no B-frame). Override: `OVERLAY_RELAY_VIDEO_KBPS`, `OVERLAY_RELAY_X264_PRESET`, `OVERLAY_RELAY_FPS`.
|
La pagina live può mostrare badge HTML di stato (in onda / pausa / attesa); non sostituisce il tabellone nel video.
|
||||||
|
|
||||||
|
### Ruolo di ffmpeg oggi
|
||||||
|
|
||||||
|
| Quando | Cosa | Peso CPU |
|
||||||
|
|--------|------|----------|
|
||||||
|
| Diretta `platform=youtube` | `YoutubeRelay`: demux + **copy video/audio** → RTMPS (remux) | Basso **per diretta**, scala con N processi |
|
||||||
|
| Diretta solo `matchlivetv` | Nessun ffmpeg in live | Quasi zero |
|
||||||
|
| Fine diretta | `UploadFromSession`: concat segmenti (`-c copy`) | Picco breve |
|
||||||
|
| Post-process | `GenerateThumbnail`: un frame → JPEG | Trascurabile |
|
||||||
|
|
||||||
|
Non esiste più la ricodifica H.264 server-side dell’overlay (era il carico principale).
|
||||||
|
|
||||||
### Punteggio (persistenza)
|
### Punteggio (persistenza)
|
||||||
|
|
||||||
L’app mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (`Scoring::SyncState`), poi refresh overlay.
|
L’app mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (o `score_action`) → `Scoring::SyncState`, poi aggiorna l’overlay locale in camera.
|
||||||
|
|
||||||
`GET /live/:id/status.json` resta per messaggi sotto il player e recovery HLS; `Cache-Control: no-store`.
|
`GET /live/:id/status.json` resta per messaggi sotto il player e recovery HLS; `Cache-Control: no-store`.
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
Il punteggio in diretta si gestisce tramite **link regia** condivisibile (WhatsApp, email, SMS…): non serve un account per chi fa il tabellone.
|
Il punteggio in diretta si gestisce tramite **link regia** condivisibile (WhatsApp, email, SMS…): non serve un account per chi fa il tabellone.
|
||||||
|
|
||||||
Esigenze custom: contatto `info@matchlive.it`.
|
Esigenze custom: contatto `info@matchlivetv.it`.
|
||||||
|
|
||||||
## Sito
|
## Sito
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Questo documento descrive come Match Live TV modella gli sport, applica i regolamenti di punteggio e li traduce in **tabelloni** (logica + controlli) e **overlay** (grafica sul video in diretta).
|
Questo documento descrive come Match Live TV modella gli sport, applica i regolamenti di punteggio e li traduce in **tabelloni** (logica + controlli) e **overlay** (grafica sul video in diretta).
|
||||||
|
|
||||||
|
**Importante (2026-07):** l’overlay video è composito **solo sull’app nativa** (Android/iOS) e viaggia già nel RTMP verso MediaMTX. Non esiste più un relay server (`Streams::OverlayRelay`) che ricodifica con PNG. Vedi anche [`LIVE_STREAMING.md`](LIVE_STREAMING.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Panoramica
|
## Panoramica
|
||||||
@@ -162,12 +164,14 @@ Esempio: il basket ammette `[basket, none]` — si può trasmettere con tabellon
|
|||||||
|
|
||||||
### Layout app mobile (Android e iOS)
|
### Layout app mobile (Android e iOS)
|
||||||
|
|
||||||
Sull’app nativa l’overlay è composito in GPU senza ricodifica aggiuntiva:
|
Sull’app nativa l’overlay è composito in GPU **prima** dell’encode RTMP (nessuna ricodifica server aggiuntiva):
|
||||||
|
|
||||||
1. `BroadcastScreen` costruisce un `OverlayState` a partire da `effectiveOverlayKind` e `ScoreState`.
|
1. `BroadcastScreen` costruisce un `OverlayState` a partire da `effectiveOverlayKind` e `ScoreState`.
|
||||||
2. `OverlayRenderer` → `OverlayCanvasRenderer` disegna gli elementi su bitmap trasparente.
|
2. `OverlayRenderer` → `OverlayCanvasRenderer` disegna gli elementi su bitmap trasparente.
|
||||||
3. Il bitmap viene applicato come filtro sul flusso RTMP (OpenGL su Android, HaishinKit su iOS).
|
3. Il bitmap viene applicato come filtro sul flusso RTMP (OpenGL su Android, HaishinKit su iOS).
|
||||||
|
|
||||||
|
MediaMTX e `YoutubeRelay` ricevono quindi un flusso già “brandizzato”; il relay YouTube fa solo remux (`-c copy` video e audio).
|
||||||
|
|
||||||
Elementi grafici (`OverlayCanvasRenderer`):
|
Elementi grafici (`OverlayCanvasRenderer`):
|
||||||
|
|
||||||
| Elemento | Quando è attivo |
|
| Elemento | Quando è attivo |
|
||||||
|
|||||||
@@ -1,56 +1,32 @@
|
|||||||
# YouTube Live — verificato ✅ (5 giu 2026)
|
# YouTube Live — checklist operativa
|
||||||
|
|
||||||
## Test completato automaticamente
|
**Aggiornato:** 2026-07-29
|
||||||
|
Pipeline attuale: telefono → MediaMTX → `Streams::YoutubeRelay` (ffmpeg remux copy A/V in Sidekiq) → YouTube. Overlay tabellone in **app**, non più `OverlayRelay` server.
|
||||||
|
|
||||||
Pipeline end-to-end **funzionante** sul server di produzione:
|
## Verifica rapida su server
|
||||||
|
|
||||||
| Step | Esito |
|
|
||||||
|------|--------|
|
|
||||||
| Setup broadcast + stream_key | ✅ |
|
|
||||||
| Overlay ffmpeg con tee RTMPS YouTube | ✅ |
|
|
||||||
| Copertina / tabellone su YouTube | ✅ |
|
|
||||||
| RTMP simulato (test pattern) | ✅ |
|
|
||||||
| Broadcast `lifecycle=live` | ✅ |
|
|
||||||
|
|
||||||
**Prova visiva:** https://www.youtube.com/watch?v=0WmCsW_Luik
|
|
||||||
(Titolo: *Tigers Volley vs Avversario prova* — sessione di test, ora terminata)
|
|
||||||
|
|
||||||
## APK da installare sul telefono
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Sul PC (già compilato):
|
|
||||||
mobile/build/app/outputs/flutter-apk/app-release.apk
|
|
||||||
# Versione: 1.2.10+13 — API https://www.matchlivetv.it
|
|
||||||
```
|
|
||||||
|
|
||||||
Installa sul Redmi (sostituisce versioni precedenti).
|
|
||||||
|
|
||||||
## Avvio diretta dall’app (2 minuti)
|
|
||||||
|
|
||||||
1. Partita → **YouTube Live** → wizard → **Camera**
|
|
||||||
2. Attendi sul passo rete che compaia il link YouTube (poll automatico, fino a 3 min)
|
|
||||||
3. Avvia: entro ~90s su YouTube compare **copertina**, poi video con tabellone quando il telefono pubblica RTMP
|
|
||||||
|
|
||||||
## Test server senza telefono
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh eminux@192.168.1.146
|
ssh eminux@192.168.1.146
|
||||||
docker exec infra-rails-1 bin/rails youtube:e2e
|
docker exec infra-rails-1 bin/rails youtube:e2e # se il task è ancora presente
|
||||||
docker exec infra-sidekiq-1 pgrep -a ffmpeg # deve contenere rtmps://...youtube.com/live2
|
docker exec infra-sidekiq-1 pgrep -a ffmpeg # deve contenere rtmps://...youtube.com/live2
|
||||||
|
docker logs infra-sidekiq-1 2>&1 | grep -E 'YoutubeRelay|YoutubeBroadcastActivate|LivePipeline' | tail -40
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Avvio diretta dall’app
|
||||||
|
|
||||||
|
1. Partita → **YouTube Live** → wizard → **Camera**
|
||||||
|
2. Attendi sul passo rete che compaia il link YouTube (poll automatico)
|
||||||
|
3. Avvia: entro ~90s su YouTube compare video (slate MediaMTX se il telefono non pubblica ancora; tabellone quando l’app pubblica RTMP con overlay)
|
||||||
|
|
||||||
## Se qualcosa non va
|
## Se qualcosa non va
|
||||||
|
|
||||||
| Sintomo | Cosa fare |
|
| Sintomo | Cosa fare |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| «YouTube temporaneamente occupato» | Attendi 15–20 min (rate limit Google). **Non** creare molte sessioni di fila. |
|
| «YouTube temporaneamente occupato» | Attendi 15–20 min (rate limit Google). **Non** creare molte sessioni di fila. |
|
||||||
| Pagina YouTube vuota | `docker logs infra-sidekiq-1 \| grep OverlayRelay` — manca `stream_key` o tee RTMPS |
|
| Pagina YouTube vuota | `docker logs infra-sidekiq-1 \| grep YoutubeRelay` — manca `stream_key` o relay non partito |
|
||||||
| App errore avvio diretta | APK 1.2.10+13; controlla rete verso matchlivetv.it |
|
| App 0 Mbps / 0 fps ma LIVE | RTMP non pubblica: permessi camera / messaggio «Connessione RTMP»; relay YouTube non parte finché MediaMTX non vede il publisher |
|
||||||
|
| YouTube resta in waiting | Log relay in sidekiq; `StreamPublisherSyncJob` + activate retry |
|
||||||
|
|
||||||
## Fix deployati (server)
|
## Note storiche
|
||||||
|
|
||||||
- Retry setup broadcast su rate limit (fino a 15 tentativi)
|
Checklist precedente (giu 2026) citava `OverlayRelay` / tee RTMPS dall’overlay server: **deprecato**. Non cercare più log `OverlayRelay` o `overlay_relay_*.log`.
|
||||||
- Un solo job setup per sessione (lock Redis)
|
|
||||||
- Throttle API YouTube globale
|
|
||||||
- Riavvio overlay quando arriva `stream_key` dopo start senza tee
|
|
||||||
- Activate solo con overlay attivo + broadcast pronta
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Match Live TV supporta due modalità YouTube, legate al piano società:
|
|||||||
| **Premium Light** | `matchlivetv_light` | Canale YouTube **Match Live TV** (gestito da te) |
|
| **Premium Light** | `matchlivetv_light` | Canale YouTube **Match Live TV** (gestito da te) |
|
||||||
| **Premium Full** | `team` | Canale YouTube **della società** (OAuth del club) |
|
| **Premium Full** | `team` | Canale YouTube **della società** (OAuth del club) |
|
||||||
|
|
||||||
Flusso tecnico: telefono → RTMP MediaMTX → (relay ffmpeg) → YouTube Live.
|
Flusso tecnico: telefono (camera + overlay app) → RTMP MediaMTX → `Streams::YoutubeRelay` (ffmpeg `-c:v copy -c:a copy`) → YouTube Live.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -85,5 +85,5 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
|
|||||||
## 6. Produzione
|
## 6. Produzione
|
||||||
|
|
||||||
- `YOUTUBE_REDIRECT_URI` deve essere **esattamente** quello registrato in Google (HTTPS, dominio pubblico).
|
- `YOUTUBE_REDIRECT_URI` deve essere **esattamente** quello registrato in Google (HTTPS, dominio pubblico).
|
||||||
- Il relay ffmpeg gira nel container MediaMTX (`runOnReady`).
|
- Il relay ffmpeg (`Streams::YoutubeRelay`) gira nel container **sidekiq** (`YOUTUBE_RELAY_WORKER=1`), non su MediaMTX `runOnReady`.
|
||||||
- Apri RTMP **1935** sul router verso il server.
|
- Apri RTMP **1935** sul router verso il server.
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
# Piano: overflow relay YouTube su Hetzner Cloud
|
||||||
|
|
||||||
|
**Stato:** piano / design — non implementato
|
||||||
|
**Ultimo aggiornamento:** 2026-07-29
|
||||||
|
**Contesto:** produzione attuale su host dedicato (es. Proxmox/Hetzner) con MediaMTX + Sidekiq sullo stesso box; obiettivo >10 live YouTube contemporanee senza abbandonare il middle-tier.
|
||||||
|
|
||||||
|
Documenti correlati: [`LIVE_STREAMING.md`](../LIVE_STREAMING.md), [`ARCHITECTURE.md`](../ARCHITECTURE.md), [`OPS_MONITORING.md`](../OPS_MONITORING.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Scenario
|
||||||
|
|
||||||
|
### Problema di prodotto
|
||||||
|
|
||||||
|
Match Live TV disaccoppia il telefono da YouTube:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Telefono (rete mobile instabile)
|
||||||
|
│ RTMP
|
||||||
|
▼
|
||||||
|
MediaMTX (sempre attivo, slate / alwaysAvailable)
|
||||||
|
│
|
||||||
|
├── HLS → sito
|
||||||
|
└── YoutubeRelay (ffmpeg) → RTMPS YouTube
|
||||||
|
```
|
||||||
|
|
||||||
|
Se cade solo il tratto telefono→MediaMTX, YouTube resta in onda (slate).
|
||||||
|
Se il telefono andasse diretto a YouTube (o a un ingest cloud senza hold), la live pubblica cadrebbe con la rete mobile. Inoltre YouTube non è pensato come “Go Live dall’app mobile” senza vincoli di canale: noi usiamo API + RTMPS dal server.
|
||||||
|
|
||||||
|
### Problema di capacità
|
||||||
|
|
||||||
|
Ogni diretta `platform=youtube` avvia un processo `ffmpeg` in Sidekiq (`Streams::YoutubeRelay`: video+audio **copy** / remux).
|
||||||
|
Il carico scala con **N processi**, non con gli spettatori HLS.
|
||||||
|
|
||||||
|
Sul box attuale (~4 CPU / 8 GB, stack completo sullo stesso host):
|
||||||
|
|
||||||
|
| Live YT contemporanee | Situazione attesa |
|
||||||
|
|-----------------------|-------------------|
|
||||||
|
| 1–4 | Comodo |
|
||||||
|
| 5–8 | Teso (CPU/RAM, latenza job Sidekiq) |
|
||||||
|
| ≥10 | Rischioso (relay instabili, watchdog, slate prolungate su YT) |
|
||||||
|
|
||||||
|
**Obiettivo:** mantenere MediaMTX (e Rails) sul dedicated; quando N supera la soglia comoda, **accendere capacità Hetzner Cloud solo per i relay**, per il tempo del picco, poi spegnerla.
|
||||||
|
|
||||||
|
### Cosa NON è questo piano
|
||||||
|
|
||||||
|
- Non sostituisce MediaMTX con Mux/AWS MediaLive.
|
||||||
|
- Non sposta l’ingest RTMP del telefono sul cloud (cold start e sticky URL sono incompatibili con “zero nodi a riposo”).
|
||||||
|
- Non è autoscaling del monolite Docker Compose intero.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Principio di progettazione
|
||||||
|
|
||||||
|
| Componente | Dove resta | Perché |
|
||||||
|
|------------|------------|--------|
|
||||||
|
| MediaMTX (RTMP ingest + slate + HLS) | **Dedicated sempre acceso** | URL stabile per l’app; continuità YouTube via slate |
|
||||||
|
| Rails / Postgres / Redis / Garage / edge | **Dedicated** (o gestiti fissi) | Stato, auth, replay; non sono il collo di bottiglia video live |
|
||||||
|
| `YoutubeRelay` (ffmpeg) | **Dedicated fino a soglia + overflow Cloud** | Unico pezzo che moltiplica CPU con N live YT |
|
||||||
|
| Job Sidekiq non-video (mail, ops, post-process) | Dedicated (coda separata) | Non devono competere con ffmpeg sui worker overflow |
|
||||||
|
|
||||||
|
Formula mentale:
|
||||||
|
|
||||||
|
```text
|
||||||
|
capacità_yt ≈ core_dedicati_relay + Σ core_vm_overflow_calde
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architettura target (overflow)
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ telefoni RTMP ]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ Dedicated (sempre on) │
|
||||||
|
│ MediaMTX · Rails · DB │
|
||||||
|
│ Redis · Garage · edge │
|
||||||
|
│ Sidekiq "core" │
|
||||||
|
│ (+ pochi relay locali) │
|
||||||
|
└───────────┬─────────────┘
|
||||||
|
│
|
||||||
|
intake RTMP/HLS │ (rete privata / tunnel / IP allowlist)
|
||||||
|
│
|
||||||
|
┌───────────────┼───────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
[relay-local] [overflow-1] [overflow-N]
|
||||||
|
Sidekiq+ffmpeg Hetzner Cloud Hetzner Cloud
|
||||||
|
YOUTUBE_RELAY=1 a ore a ore
|
||||||
|
│ │ │
|
||||||
|
└───────────────┴───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
YouTube RTMPS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ruolo delle VM overflow
|
||||||
|
|
||||||
|
Immagine minimale (o Compose snello):
|
||||||
|
|
||||||
|
- Sidekiq con `YOUTUBE_RELAY_WORKER=1`
|
||||||
|
- `ffmpeg` disponibile
|
||||||
|
- Connessione a **Redis** del dedicated (coda + lock `youtube_relay:owner:*`)
|
||||||
|
- Lettura intake da MediaMTX (RTMP preferito, HLS fallback come oggi)
|
||||||
|
- **Niente** Postgres scrittura diretta obbligatoria se i job relay usano già Redis + API; in pratica oggi Rails/Sidekiq legge Postgres → le VM overflow devono raggiungere anche DB **oppure** si isola una coda “relay-only” con worker che riceve payload già risolto (vedi §5.2)
|
||||||
|
|
||||||
|
Stato attuale del codice: `YoutubeRelay` gira in processo Sidekiq Rails completo (accesso a `StreamSession`, MediaMTX client, Redis PID/owner). Le VM overflow sono quindi **worker Sidekiq Rails**, non demoni ffmpeg nudi — almeno nella fase 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Colli di bottiglia (cosa può rompersi comunque)
|
||||||
|
|
||||||
|
Anche con overflow infinito di CPU, restano limiti. Ordine di probabilità/impatto:
|
||||||
|
|
||||||
|
### 4.1 MediaMTX sul dedicated (alto)
|
||||||
|
|
||||||
|
- N publisher RTMP + N reader (HLS siti + N ffmpeg che rileggono lo stesso path).
|
||||||
|
- Ogni relay in più è un **reader** su MediaMTX (RTMP pull o HLS).
|
||||||
|
- Sintomi: path `ready` flaky, HLS a scatti sul sito, relay che rientrano in fallback HLS, CPU MediaMTX alta anche con relay scarichi.
|
||||||
|
|
||||||
|
**Mitigazione futura (fuori da questo overflow):** secondo nodo MediaMTX o sharding path; per ora monitorare `readers`, CPU `mediamtx`, bitrate aggregato.
|
||||||
|
|
||||||
|
### 4.2 Uplink Internet del dedicated (alto)
|
||||||
|
|
||||||
|
- Telefoni → dedicated (ingresso RTMP).
|
||||||
|
- Dedicated → YouTube **se** i relay restano locali.
|
||||||
|
- Con overflow Cloud: il dedicated manda **copia dello stream** verso le VM (pull dalle VM = traffico **uscita** dal dedicated verso Hetzner Cloud), poi le VM mandano a YouTube.
|
||||||
|
|
||||||
|
Attenzione al verso:
|
||||||
|
|
||||||
|
| Dove gira il relay | Traffico tipico |
|
||||||
|
|--------------------|-----------------|
|
||||||
|
| Sul dedicated | In: telefoni. Out: N× verso YouTube |
|
||||||
|
| Su Hetzner Cloud | Out dedicated → Cloud (intake). Out Cloud → YouTube |
|
||||||
|
|
||||||
|
Se l’uplink casa/datacenter verso Internet è stretto, spostare i relay sul Cloud **sposta** il carico out YouTube fuori dal dedicated, ma **aggiunge** out dedicated→Cloud (stesso ordine di grandezza del video).
|
||||||
|
Vantaggio reale solo se:
|
||||||
|
|
||||||
|
- l’uplink del dedicated satura soprattutto per **CPU** (oggi sì), oppure
|
||||||
|
- dedicated e Cloud sono in **stessa regione Hetzner con rete privata** (traffico interno economico/veloce) e l’uscita verso YouTube esce dalla rete Hetzner Cloud (migliore peering).
|
||||||
|
|
||||||
|
**Decisione di rete obbligatoria prima di implementare** (vedi §6).
|
||||||
|
|
||||||
|
### 4.3 Redis / lock owner (medio)
|
||||||
|
|
||||||
|
Oggi:
|
||||||
|
|
||||||
|
- `youtube_relay:pid:%s` — PID locale al worker
|
||||||
|
- `youtube_relay:owner:%s` — `HOSTNAME` del worker
|
||||||
|
- `running?` considera attivo un owner remoto se TTL > 30s
|
||||||
|
|
||||||
|
Con più worker:
|
||||||
|
|
||||||
|
- Due ensure concorrenti possono avviare due ffmpeg (debounce 5s aiuta poco cross-host).
|
||||||
|
- `stop` da Rails manda job: deve raggiungere il worker **owner**, non un overflow a caso.
|
||||||
|
- Kill del PID funziona solo sulla macchina owner (`/proc`).
|
||||||
|
|
||||||
|
Serve disciplina di coda / routing (§5.3).
|
||||||
|
|
||||||
|
### 4.4 Cold start VM (medio)
|
||||||
|
|
||||||
|
Creare una CPX/CCX Hetzner: ordine di **30–90+ secondi** (API + boot + pull image + Sidekiq ready).
|
||||||
|
Se la 15ª live parte e non c’è capacità, la broadcast YouTube resta “in attesa” finché non c’è worker.
|
||||||
|
|
||||||
|
**Requisito prodotto:** overflow **a caldo** = pool minimo già acceso nei weekend / fasce note, non “da zero al fischio”.
|
||||||
|
|
||||||
|
### 4.5 YouTube / account / quote (basso–medio, esterno)
|
||||||
|
|
||||||
|
- Limiti API, token OAuth, ban temporanei, ingest RTMPS rifiutato.
|
||||||
|
- Non risolvibili con più CPU; restano nel runbook ops esistente.
|
||||||
|
|
||||||
|
### 4.6 Post-process replay (basso in live, alto a fine giornata)
|
||||||
|
|
||||||
|
`UploadFromSession` / thumbnail competono su Sidekiq e disco.
|
||||||
|
I worker overflow **non** devono prendere coda `default` di merge se sono pensati solo per relay — altrimenti spegnendo le VM a fine picco uccidete job a metà.
|
||||||
|
|
||||||
|
### 4.7 Costo e idle (operativo)
|
||||||
|
|
||||||
|
VM dimenticate accese = bolletta. Serve TTL, scale-in aggressivo, alert “overflow acceso da >Xh con 0 relay”.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Complessità di implementazione
|
||||||
|
|
||||||
|
### 5.1 Rete: come le VM leggono MediaMTX
|
||||||
|
|
||||||
|
Opzioni (sceglierne **una** in fase 0):
|
||||||
|
|
||||||
|
| Opzione | Pro | Contro |
|
||||||
|
|---------|-----|--------|
|
||||||
|
| **A. Stessa location Hetzner + private network** (dedicated Robot + Cloud nella stessa rete) | Bassa latenza, traffico interno, modello pulito | Richiede che il dedicated sia (o diventi) in ecosistema Hetzner collegabile |
|
||||||
|
| **B. WireGuard/Tailscale** dedicated ↔ Cloud | Funziona anche da Proxmox casa | Ops tunnel, MTU, failover; latenza |
|
||||||
|
| **C. Esporre RTMP/HLS intake in lettura** (IP allowlist VM overflow) | Semplice | Superficie attacco; non esporre senza auth/TLS dove possibile |
|
||||||
|
| **D. Relay locale che pusha a Cloud** | Dedicated controlla egress | Doppia hop, più pezzi |
|
||||||
|
|
||||||
|
Raccomandazione di piano: **A se il dedicated è/andrà su Hetzner; altrimenti B**. Evitare C in chiaro su WAN.
|
||||||
|
|
||||||
|
Variabili da prevedere sulle VM:
|
||||||
|
|
||||||
|
- `MEDIAMTX_INTERNAL_RTMP_URL` → URL raggiungibile dalle VM (non `rtmp://mediamtx:1935` Docker-locale)
|
||||||
|
- `MEDIAMTX_HLS_URL` → idem
|
||||||
|
- MediaMTX API (`:9997`) raggiungibile in privato per `intake_available?` / PublisherOnline, **mai** su Internet aperto
|
||||||
|
|
||||||
|
### 5.2 Accesso Postgres e segreti
|
||||||
|
|
||||||
|
Worker Sidekiq Rails oggi necessitano:
|
||||||
|
|
||||||
|
- `DATABASE_URL` (o replica read + job che non scrivono — irrealistico all’inizio)
|
||||||
|
- `REDIS_URL`
|
||||||
|
- `SECRET_KEY_BASE` / credenziali YouTube via DB
|
||||||
|
- stessi env di produzione (ridotti)
|
||||||
|
|
||||||
|
Complessità: ogni overflow è un **nodo fidato** della rete app.
|
||||||
|
Immagine Docker identica a `sidekiq` prod, env da secret manager o file scp/cloud-init, **nessuna** porta Rails pubblica sulle VM.
|
||||||
|
|
||||||
|
### 5.3 Code Sidekiq e affinità relay
|
||||||
|
|
||||||
|
Stato oggi: ensure/stop via job + processo locale con owner in Redis.
|
||||||
|
|
||||||
|
Per multi-host serve esplicitare:
|
||||||
|
|
||||||
|
1. **Coda dedicata** `youtube_relay` (solo worker con `YOUTUBE_RELAY_WORKER=1`).
|
||||||
|
2. **Cap locale**: ogni worker ha `RELAY_MAX_CONCURRENT` (es. cores−1).
|
||||||
|
3. **Scheduling**:
|
||||||
|
- Fase 1 (manuale): overflow sempre in ascolto sulla coda; Sidekiq distribuisce i job; `ensure_on_worker!` no-op se a cap (re-enqueue o lascia ad altro worker).
|
||||||
|
- Fase 2: controller di capacità che alza/abbassa N VM in base a `relay_attivi` e profondità coda.
|
||||||
|
4. **Stop**: job `YoutubeRelayStopJob` deve eseguire **solo sull’owner** (Sidekiq unique + check `owner == HOSTNAME`, altrimenti requeue con delay breve).
|
||||||
|
|
||||||
|
Nota: i PID in Redis non sono globalmente killabili — il modello owner è già abbozzato; va reso robusto cross-host.
|
||||||
|
|
||||||
|
### 5.4 Scale-out / scale-in (lifecycle VM)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Metriche → Decisione → hcloud CLI/API → cloud-init → Sidekiq ready → in coda
|
||||||
|
↘ fallisce → alert ops, non spegnere dedicated
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scale-out trigger (esempi):**
|
||||||
|
|
||||||
|
- `youtube_relay` depth > 0 per >60s **oppure**
|
||||||
|
- `relay_attivi_local >= RELAY_SOFT_CAP` **e** nuove sessioni YT in `connecting|live`
|
||||||
|
|
||||||
|
**Scale-in trigger:**
|
||||||
|
|
||||||
|
- `relay_attivi` sulle VM overflow = 0 per >15–30 min **e**
|
||||||
|
- fuori dalla finestra “weekend caldo” (opzionale)
|
||||||
|
|
||||||
|
**Warm pool:** sabato 8:00 → min 1–2 VM già up; domenica 23:00 → min 0.
|
||||||
|
|
||||||
|
### 5.5 Idempotenza e split-brain
|
||||||
|
|
||||||
|
Scenario da evitare: dedicated e overflow avviano entrambi ffmpeg sulla stessa `stream_key` → YouTube flappa.
|
||||||
|
|
||||||
|
Mitigazioni:
|
||||||
|
|
||||||
|
- Lock Redis con fencing token / TTL heartbeat rinnovato dal processo owner ogni N secondi
|
||||||
|
- Watchdog (`YoutubeIngestWatchdogJob`) deve rispettare owner remoto (già parzialmente così via TTL)
|
||||||
|
- Un solo writer della broadcast activate
|
||||||
|
|
||||||
|
### 5.6 Observability
|
||||||
|
|
||||||
|
Senza metriche l’overflow è cieco. Minimo:
|
||||||
|
|
||||||
|
| Metrica | Dove |
|
||||||
|
|---------|------|
|
||||||
|
| N relay ffmpeg vivi (local / per host) | Redis + `pgrep` / heartbeat |
|
||||||
|
| CPU MediaMTX, CPU Sidekiq | node exporter / docker stats |
|
||||||
|
| Profondità coda `youtube_relay` | Sidekiq API |
|
||||||
|
| Bitrate / errori ffmpeg per session | log già in `log/youtube_relay_*.log` |
|
||||||
|
| VM overflow accese, € stimati | tag Hetzner + cron report |
|
||||||
|
| Latenza intake Cloud (RTT dedicated↔VM) | check periodico |
|
||||||
|
|
||||||
|
Alert ntfy esistenti: estendere con `relay_overflow_saturated`, `mediamtx_cpu_high`, `overflow_vm_orphan`.
|
||||||
|
|
||||||
|
### 5.7 Sicurezza
|
||||||
|
|
||||||
|
- Firewall: VM → solo Redis, Postgres, MediaMTX (porte interne), YouTube egress 443
|
||||||
|
- Nessun SSH password; chiave o console Hetzner
|
||||||
|
- Stream key YouTube solo in DB/encrypted; non in user-data in chiaro se evitabile
|
||||||
|
- Image aggiornata; spegnimento = destroy, non “pausare” dischi dimenticati per mesi
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Decisioni aperte (bloccare prima del codice)
|
||||||
|
|
||||||
|
| # | Domanda | Impatto |
|
||||||
|
|---|---------|---------|
|
||||||
|
| D1 | Dedicated resta su LAN casa/Proxmox o migra/affianca Hetzner Robot? | Sceglie rete A vs B (§5.1) |
|
||||||
|
| D2 | Soft cap relay sul dedicated (es. 4? 6?) | Quando scatta overflow |
|
||||||
|
| D3 | Warm pool fisso weekend vs solo reactive | Cold start vs costo |
|
||||||
|
| D4 | Tipo VM (CPX shared vs CCX dedicated CPU) | €/live e jitter AAC |
|
||||||
|
| D5 | Stessa immagine `sidekiq` vs worker slim | Tempo boot e superficie |
|
||||||
|
| D6 | Chi orchestra le VM? (script cron su dedicated, Terraform, small Go/Ruby service) | Ops ownership |
|
||||||
|
|
||||||
|
Finché D1 non è chiusa, non stimare bandwitdh né SLA del picco.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Dimensionamento indicativo
|
||||||
|
|
||||||
|
Ipotesi: 720p, remux copy A/V — **~0.15–0.4 vCPU** medi per relay (picchi su analyze/reconnect).
|
||||||
|
|
||||||
|
| Target live YT | Dedicated (relay) | Overflow tipico | Note |
|
||||||
|
|----------------|-------------------|-----------------|------|
|
||||||
|
| ≤6 | Tutto locale | 0 | Situazione attuale “comoda” se box dedicato ai relay |
|
||||||
|
| 10–15 | soft cap 4–6 | 1× VM 4–8 vCPU | Warm consigliato in giornata evento |
|
||||||
|
| 20–30 | soft cap 4–6 | 2–4× VM | MediaMTX e uplink diventano critici |
|
||||||
|
| 50+ | softire anche ingest | N VM + 2° MediaMTX | Fuori scope overflow-only |
|
||||||
|
|
||||||
|
Questi numeri vanno **calibati** con un load test (N sessioni fake + ffmpeg contro slate MediaMTX) prima di promettere SLA commerciali.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Piano a fasi (pronti a reagire, non a over-build)
|
||||||
|
|
||||||
|
### Fase 0 — Misura e soglie (1–2 giorni ops)
|
||||||
|
|
||||||
|
- [ ] Dashboard/manuale: N live YT, CPU `sidekiq`, CPU `mediamtx`, uplink
|
||||||
|
- [ ] Definire `RELAY_SOFT_CAP` empirico sul dedicated
|
||||||
|
- [ ] Chiudere **D1** (rete)
|
||||||
|
- [ ] Load test controllato: 8–12 relay solo slate (senza telefoni) per vedere dove rompe
|
||||||
|
|
||||||
|
**Criterio go/no-go overflow:** saturazione CPU Sidekiq/ffmpeg **prima** di MediaMTX/uplink. Se saturano prima rete o MediaMTX, overflow Cloud non basta.
|
||||||
|
|
||||||
|
### Fase 1 — Overflow manuale “a caldo” (MVP ops)
|
||||||
|
|
||||||
|
- [ ] Snapshot/immagine Sidekiq relay-ready su Hetzner Cloud
|
||||||
|
- [ ] cloud-init: env, WireGuard o private net, `docker compose up sidekiq`
|
||||||
|
- [ ] Runbook: “accendi 1 VM”, verifica `YoutubeRelay` su HOSTNAME nuovo, “spegni”
|
||||||
|
- [ ] Soft cap sul dedicated: nuovi ensure preferiscono coda se locali pieni (anche patch minima)
|
||||||
|
- [ ] Alert se VM overflow up da >3h con 0 owner keys
|
||||||
|
|
||||||
|
**Reazione a incident:** operatore umano accende/spegne; nessun autoscaler ancora.
|
||||||
|
|
||||||
|
### Fase 2 — Routing coda robusto
|
||||||
|
|
||||||
|
- [ ] Coda `youtube_relay` isolata
|
||||||
|
- [ ] Heartbeat owner Redis
|
||||||
|
- [ ] Stop/ensure sticky all’owner
|
||||||
|
- [ ] Cap per worker + requeue
|
||||||
|
|
||||||
|
### Fase 3 — Autoscaling controllato
|
||||||
|
|
||||||
|
- [ ] Job/cron: se saturazione → `hcloud server create` fino a `OVERFLOW_MAX`
|
||||||
|
- [ ] Scale-in solo se idle e fuori warm window
|
||||||
|
- [ ] Budget mensile + kill switch
|
||||||
|
|
||||||
|
### Fase 4 — Solo se necessario
|
||||||
|
|
||||||
|
- Secondo MediaMTX / sharding
|
||||||
|
- ~~Audio copy se l’encoder telefono è già YouTube-compatibile (meno CPU)~~ → fatto (app AAC 48k mono + relay `-c:a copy`)
|
||||||
|
- Dedicated più grosso come baseline (spesso più economico dell’overflow cronico ogni weekend)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Runbook di reazione (anche prima dell’autoscaler)
|
||||||
|
|
||||||
|
### Sintomo: YouTube “in programma” / niente video con molte live
|
||||||
|
|
||||||
|
1. `docker stats` / CPU Sidekiq sul dedicated
|
||||||
|
2. Contare relay: log `[YoutubeRelay] started` vs sessioni `platform=youtube` live
|
||||||
|
3. Se CPU ~100% e MediaMTX ok → **accendere overflow** (Fase 1) o abbassare nuove live YT
|
||||||
|
4. Se MediaMTX alto / path non ready → overflow **non** aiuta; ridurre reader o spostare HLS, non aggiungere pull Cloud ciechi
|
||||||
|
|
||||||
|
### Sintomo: live YT che si spezza solo sulle VM Cloud
|
||||||
|
|
||||||
|
1. RTT e packet loss dedicated↔VM
|
||||||
|
2. Verificare che intake usi RTMP interno, non HLS pubblico via Internet
|
||||||
|
3. Controllare doppio owner / due ffmpeg sulla stessa key
|
||||||
|
|
||||||
|
### Sintomo: bolletta Cloud anomala
|
||||||
|
|
||||||
|
1. Lista server con label `matchlivetv-overflow`
|
||||||
|
2. Destroy idle
|
||||||
|
3. Abbassare warm pool
|
||||||
|
|
||||||
|
### Sintomo: job replay morti dopo scale-in
|
||||||
|
|
||||||
|
1. Verificare che overflow **non** consumi coda `default` / `recordings`
|
||||||
|
2. Riprocessare dead set Sidekiq
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Criteri di successo
|
||||||
|
|
||||||
|
| Criterio | Misura |
|
||||||
|
|----------|--------|
|
||||||
|
| Picco gestito | ≥15 live YT contemporanee stabili in test |
|
||||||
|
| Continuità prodotto | Drop telefono → slate YouTube ancora attiva (invariato) |
|
||||||
|
| Costo | Overflow acceso solo in finestre picco; €/h documentato |
|
||||||
|
| Ops | Runbook Fase 1 eseguibile in <10 min da operatore |
|
||||||
|
| Non regressione | Live solo-sito (`matchlivetv`) e replay invariati |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Riepilogo esecutivo
|
||||||
|
|
||||||
|
Il pezzo che manca oltre ~10 live YouTube è **CPU dei relay**, non l’idea MediaMTX.
|
||||||
|
Hetzner Cloud è la leva economica giusta se:
|
||||||
|
|
||||||
|
1. scalate **solo** i worker `YoutubeRelay`,
|
||||||
|
2. tenete MediaMTX caldo sul dedicated,
|
||||||
|
3. risolvete **rete privata** dedicated↔Cloud,
|
||||||
|
4. partite da **warm pool + runbook manuale**, poi automate.
|
||||||
|
|
||||||
|
Il rischio principale non è “creare una VM”, è **leggere N volte MediaMTX e saturare uplink/MediaMTX** mentre pensate di aver risolto solo la CPU. La Fase 0 (misura) decide se l’overflow è sufficiente o se serve anche più ingest/baseline hardware.
|
||||||
|
)
|
||||||
+1
-1
@@ -14,7 +14,7 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
|
|||||||
|
|
||||||
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||||
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||||
PRIVACY_CONTACT_EMAIL=privacy@matchlive.it
|
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||||
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
|
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
|
||||||
PASSWORD_RESET_EXPIRY_HOURS=2
|
PASSWORD_RESET_EXPIRY_HOURS=2
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ RAILS_LOG_LEVEL=info
|
|||||||
# Titolare trattamento (GDPR) — obbligatorio in produzione
|
# Titolare trattamento (GDPR) — obbligatorio in produzione
|
||||||
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||||
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||||
PRIVACY_CONTACT_EMAIL=privacy@matchlive.it
|
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||||
PRIVACY_CONTROLLER_VAT=
|
PRIVACY_CONTROLLER_VAT=
|
||||||
|
|
||||||
# Email transazionali (reset password, inviti)
|
# Email transazionali (reset password, inviti)
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ services:
|
|||||||
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
|
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
|
||||||
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
|
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
|
||||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||||
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlive.it}
|
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
||||||
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
||||||
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
||||||
PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-}
|
PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-}
|
||||||
@@ -150,6 +150,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./nginx-edge/nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./nginx-edge/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
- ./nginx-edge/maintenance.html:/usr/share/nginx/maintenance/index.html:ro
|
- ./nginx-edge/maintenance.html:/usr/share/nginx/maintenance/index.html:ro
|
||||||
|
- ./nginx-edge/well-known:/usr/share/nginx/well-known:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/edge-health"]
|
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/edge-health"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ services:
|
|||||||
RAILS_LOG_TO_STDOUT: "true"
|
RAILS_LOG_TO_STDOUT: "true"
|
||||||
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
||||||
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
||||||
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlive.it}
|
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
||||||
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlive.it>}
|
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlivetv.it>}
|
||||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
||||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
garage.prod.toml
|
||||||
|
prod-credentials.env
|
||||||
@@ -98,6 +98,13 @@ http {
|
|||||||
return 200 "ok\n";
|
return 200 "ok\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Android App Links (Play Console / Digital Asset Links)
|
||||||
|
location = /.well-known/assetlinks.json {
|
||||||
|
default_type application/json;
|
||||||
|
add_header Cache-Control "public, max-age=300";
|
||||||
|
alias /usr/share/nginx/well-known/assetlinks.json;
|
||||||
|
}
|
||||||
|
|
||||||
# Health check ops: pass-through senza pagina di cortesia.
|
# Health check ops: pass-through senza pagina di cortesia.
|
||||||
location = /up {
|
location = /up {
|
||||||
proxy_pass http://$rails_backend;
|
proxy_pass http://$rails_backend;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"relation": [
|
||||||
|
"delegate_permission/common.handle_all_urls",
|
||||||
|
"delegate_permission/common.get_login_creds"
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"namespace": "android_app",
|
||||||
|
"package_name": "com.matchlivetv.match_live_tv",
|
||||||
|
"sha256_cert_fingerprints": [
|
||||||
|
"C3:F0:89:51:0B:00:3A:FE:10:BD:ED:68:45:1B:4F:1D:B8:5A:63:EE:8A:DA:F7:2F:5A:D6:1D:97:C9:A3:95:47",
|
||||||
|
"09:69:25:CD:8B:EC:BA:75:9A:5E:49:32:E1:35:BD:F1:AF:1E:5A:29:93:E2:65:85:8A:41:E2:11:DA:64:97:F0"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import java.io.File
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
@@ -20,13 +24,18 @@ android {
|
|||||||
applicationId = "com.matchlivetv.match_live_tv"
|
applicationId = "com.matchlivetv.match_live_tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 26
|
versionCode = 31
|
||||||
versionName = "2.0.5-native"
|
versionName = "2.0.10-native"
|
||||||
|
|
||||||
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
||||||
?: "https://www.matchlivetv.it"
|
?: "https://www.matchlivetv.it"
|
||||||
buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"")
|
buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"")
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
// Anche in defaultConfig: AGP estrae simboli se le .so non sono già stripped.
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "SYMBOL_TABLE"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -47,6 +56,11 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
|
// Le .so AndroidX arrivano già stripped: AGP non genera metadata.
|
||||||
|
// Il task injectNativeDebugSymbolsInReleaseBundle le incorpora nell'AAB.
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "SYMBOL_TABLE"
|
||||||
|
}
|
||||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||||
signingConfigs.getByName("release")
|
signingConfigs.getByName("release")
|
||||||
} else {
|
} else {
|
||||||
@@ -108,3 +122,118 @@ dependencies {
|
|||||||
androidTestImplementation("androidx.test:core:1.6.1")
|
androidTestImplementation("androidx.test:core:1.6.1")
|
||||||
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
|
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Play Console: le .so prebuilt (AndroidX) sono già stripped → AGP salta l'estrazione
|
||||||
|
// (niente BUNDLE-METADATA/com.android.tools.build.debugsymbols). Generiamo i .so.sym
|
||||||
|
// come fa AGP (llvm-objcopy --strip-debug) e li iniettiamo nell'intermediary AAB.
|
||||||
|
fun findLlvmObjcopy(): File {
|
||||||
|
val ndkRoot = File(android.sdkDirectory, "ndk")
|
||||||
|
val ndkDirs = ndkRoot.listFiles()?.filter { it.isDirectory }?.sortedByDescending { it.name }.orEmpty()
|
||||||
|
for (ndk in ndkDirs) {
|
||||||
|
val candidate = ndk.resolve("toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy")
|
||||||
|
if (candidate.isFile) return candidate
|
||||||
|
}
|
||||||
|
error("llvm-objcopy non trovato sotto ${ndkRoot.absolutePath}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val injectNativeDebugSymbolsInReleaseBundle by tasks.registering {
|
||||||
|
description = "Embed native-debug-symbols.zip (.so.sym) into the release AAB intermediary"
|
||||||
|
val mergeNative = tasks.named("mergeReleaseNativeLibs")
|
||||||
|
val packageBundle = tasks.named("packageReleaseBundle")
|
||||||
|
dependsOn(mergeNative, packageBundle)
|
||||||
|
inputs.dir(
|
||||||
|
layout.buildDirectory.dir(
|
||||||
|
"intermediates/merged_native_libs/release/mergeReleaseNativeLibs/out/lib",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inputs.file(
|
||||||
|
layout.buildDirectory.file(
|
||||||
|
"intermediates/intermediary_bundle/release/packageReleaseBundle/intermediary-bundle.aab",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
outputs.file(
|
||||||
|
layout.buildDirectory.file(
|
||||||
|
"intermediates/intermediary_bundle/release/packageReleaseBundle/intermediary-bundle.aab",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
doLast {
|
||||||
|
val libsDir = layout.buildDirectory
|
||||||
|
.dir("intermediates/merged_native_libs/release/mergeReleaseNativeLibs/out/lib")
|
||||||
|
.get()
|
||||||
|
.asFile
|
||||||
|
val aabFile = layout.buildDirectory
|
||||||
|
.file("intermediates/intermediary_bundle/release/packageReleaseBundle/intermediary-bundle.aab")
|
||||||
|
.get()
|
||||||
|
.asFile
|
||||||
|
require(libsDir.isDirectory) { "Native libs missing: $libsDir" }
|
||||||
|
require(aabFile.isFile) { "Intermediary AAB missing: $aabFile" }
|
||||||
|
|
||||||
|
val objcopy = findLlvmObjcopy()
|
||||||
|
val workDir = layout.buildDirectory.dir("tmp/native-debug-symbols-inject").get().asFile
|
||||||
|
workDir.deleteRecursively()
|
||||||
|
workDir.mkdirs()
|
||||||
|
val symbolsRoot = workDir.resolve("symbols")
|
||||||
|
symbolsRoot.mkdirs()
|
||||||
|
|
||||||
|
var count = 0
|
||||||
|
libsDir.walkTopDown().filter { it.isFile && it.extension == "so" }.forEach { so ->
|
||||||
|
val rel = so.relativeTo(libsDir).invariantSeparatorsPath
|
||||||
|
val outRel = "$rel.sym"
|
||||||
|
val outFile = symbolsRoot.resolve(outRel)
|
||||||
|
outFile.parentFile.mkdirs()
|
||||||
|
// Allineato ad AGP SYMBOL_TABLE: llvm-objcopy --strip-debug → *.so.sym
|
||||||
|
val proc = ProcessBuilder(
|
||||||
|
objcopy.absolutePath,
|
||||||
|
"--strip-debug",
|
||||||
|
so.absolutePath,
|
||||||
|
outFile.absolutePath,
|
||||||
|
).redirectErrorStream(true).start()
|
||||||
|
val out = proc.inputStream.bufferedReader().readText()
|
||||||
|
check(proc.waitFor() == 0) {
|
||||||
|
"objcopy failed for $rel: $out"
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
check(count > 0) { "Nessuna .so da cui generare simboli in $libsDir" }
|
||||||
|
|
||||||
|
val symbolsZip = workDir.resolve("native-debug-symbols.zip")
|
||||||
|
ZipOutputStream(symbolsZip.outputStream().buffered()).use { zos ->
|
||||||
|
symbolsRoot.walkTopDown().filter { it.isFile }.forEach { f ->
|
||||||
|
val rel = f.relativeTo(symbolsRoot).invariantSeparatorsPath
|
||||||
|
zos.putNextEntry(ZipEntry(rel))
|
||||||
|
f.inputStream().use { it.copyTo(zos) }
|
||||||
|
zos.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val metaPath = "BUNDLE-METADATA/com.android.tools.build.debugsymbols/native-debug-symbols.zip"
|
||||||
|
val outAab = workDir.resolve("intermediary-with-symbols.aab")
|
||||||
|
ZipFile(aabFile).use { src ->
|
||||||
|
ZipOutputStream(outAab.outputStream().buffered()).use { dst ->
|
||||||
|
val entries = src.entries()
|
||||||
|
while (entries.hasMoreElements()) {
|
||||||
|
val entry = entries.nextElement()
|
||||||
|
if (entry.name == metaPath) continue
|
||||||
|
val newEntry = ZipEntry(entry.name)
|
||||||
|
newEntry.time = entry.time
|
||||||
|
// Deflate all entries when rewriting; STORED copy is fragile across ZipFile.
|
||||||
|
newEntry.method = ZipEntry.DEFLATED
|
||||||
|
dst.putNextEntry(newEntry)
|
||||||
|
src.getInputStream(entry).use { it.copyTo(dst) }
|
||||||
|
dst.closeEntry()
|
||||||
|
}
|
||||||
|
dst.putNextEntry(ZipEntry(metaPath))
|
||||||
|
symbolsZip.inputStream().use { it.copyTo(dst) }
|
||||||
|
dst.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outAab.copyTo(aabFile, overwrite = true)
|
||||||
|
logger.lifecycle(
|
||||||
|
"Injected $metaPath ($count .so.sym, ${symbolsZip.length()} bytes) into ${aabFile.name} via ${objcopy.name}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.matching { it.name == "signReleaseBundle" }.configureEach {
|
||||||
|
dependsOn(injectNativeDebugSymbolsInReleaseBundle)
|
||||||
|
}
|
||||||
|
|||||||
+7
-6
@@ -40,12 +40,13 @@ class ThermalStateManager(
|
|||||||
fun start() {
|
fun start() {
|
||||||
monitor.onStateChanged = { next ->
|
monitor.onStateChanged = { next ->
|
||||||
val previous = _state.value
|
val previous = _state.value
|
||||||
if (next == previous) return@onStateChanged
|
if (next != previous) {
|
||||||
Log.i(TAG, "[Thermal] $previous → $next")
|
Log.i(TAG, "[Thermal] $previous → $next")
|
||||||
_state.value = next
|
_state.value = next
|
||||||
_noticeMessage.value = userNotice(next, previous)
|
_noticeMessage.value = userNotice(next, previous)
|
||||||
applyAdaptation(next, force = false)
|
applyAdaptation(next, force = false)
|
||||||
updateCriticalWatch(next)
|
updateCriticalWatch(next)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
monitor.start()
|
monitor.start()
|
||||||
_state.value = monitor.currentState
|
_state.value = monitor.currentState
|
||||||
|
|||||||
@@ -13,6 +13,22 @@ data class LoginRequest(val email: String, val password: String)
|
|||||||
|
|
||||||
data class RefreshRequest(@Json(name = "refresh_token") val refreshToken: String)
|
data class RefreshRequest(@Json(name = "refresh_token") val refreshToken: String)
|
||||||
|
|
||||||
|
data class ForgotPasswordRequest(val email: String)
|
||||||
|
|
||||||
|
data class ForgotPasswordResponse(val message: String)
|
||||||
|
|
||||||
|
data class UpdateAccountRequest(val name: String)
|
||||||
|
|
||||||
|
data class ChangePasswordRequest(
|
||||||
|
@Json(name = "current_password") val currentPassword: String,
|
||||||
|
val password: String,
|
||||||
|
@Json(name = "password_confirmation") val passwordConfirmation: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MessageResponse(val message: String)
|
||||||
|
|
||||||
|
data class ApiErrorResponse(val error: String? = null)
|
||||||
|
|
||||||
data class LoginResponse(
|
data class LoginResponse(
|
||||||
val user: UserDto,
|
val user: UserDto,
|
||||||
@Json(name = "access_token") val accessToken: String,
|
@Json(name = "access_token") val accessToken: String,
|
||||||
|
|||||||
+12
@@ -23,6 +23,18 @@ interface MatchLiveApi {
|
|||||||
@POST("auth/logout")
|
@POST("auth/logout")
|
||||||
suspend fun logout()
|
suspend fun logout()
|
||||||
|
|
||||||
|
@POST("auth/password/forgot")
|
||||||
|
suspend fun forgotPassword(@Body body: ForgotPasswordRequest): ForgotPasswordResponse
|
||||||
|
|
||||||
|
@GET("account")
|
||||||
|
suspend fun account(): UserDto
|
||||||
|
|
||||||
|
@PATCH("account")
|
||||||
|
suspend fun updateAccount(@Body body: UpdateAccountRequest): UserDto
|
||||||
|
|
||||||
|
@PATCH("account/password")
|
||||||
|
suspend fun changePassword(@Body body: ChangePasswordRequest): MessageResponse
|
||||||
|
|
||||||
@GET("sports")
|
@GET("sports")
|
||||||
suspend fun sports(): List<SportDto>
|
suspend fun sports(): List<SportDto>
|
||||||
|
|
||||||
|
|||||||
+31
@@ -2,9 +2,13 @@ package com.matchlivetv.match_live_tv.data.repository
|
|||||||
|
|
||||||
import com.matchlivetv.match_live_tv.core.StoredSession
|
import com.matchlivetv.match_live_tv.core.StoredSession
|
||||||
import com.matchlivetv.match_live_tv.core.TokenStore
|
import com.matchlivetv.match_live_tv.core.TokenStore
|
||||||
|
import com.matchlivetv.match_live_tv.data.api.ChangePasswordRequest
|
||||||
|
import com.matchlivetv.match_live_tv.data.api.ForgotPasswordRequest
|
||||||
import com.matchlivetv.match_live_tv.data.api.LoginRequest
|
import com.matchlivetv.match_live_tv.data.api.LoginRequest
|
||||||
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
||||||
import com.matchlivetv.match_live_tv.data.api.RefreshRequest
|
import com.matchlivetv.match_live_tv.data.api.RefreshRequest
|
||||||
|
import com.matchlivetv.match_live_tv.data.api.UpdateAccountRequest
|
||||||
|
import com.matchlivetv.match_live_tv.domain.User
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
class AuthRepository(
|
class AuthRepository(
|
||||||
@@ -46,6 +50,33 @@ class AuthRepository(
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun forgotPassword(email: String): String {
|
||||||
|
return api.forgotPassword(ForgotPasswordRequest(email.trim())).message
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchAccount(): User = api.account().toDomain()
|
||||||
|
|
||||||
|
suspend fun updateName(name: String): User {
|
||||||
|
val user = api.updateAccount(UpdateAccountRequest(name.trim())).toDomain()
|
||||||
|
val stored = currentSession() ?: return user
|
||||||
|
tokenStore.saveSession(stored.copy(user = user))
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun changePassword(
|
||||||
|
currentPassword: String,
|
||||||
|
password: String,
|
||||||
|
passwordConfirmation: String,
|
||||||
|
) {
|
||||||
|
api.changePassword(
|
||||||
|
ChangePasswordRequest(
|
||||||
|
currentPassword = currentPassword,
|
||||||
|
password = password,
|
||||||
|
passwordConfirmation = passwordConfirmation,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun logout() {
|
suspend fun logout() {
|
||||||
runCatching { api.logout() }
|
runCatching { api.logout() }
|
||||||
tokenStore.clear()
|
tokenStore.clear()
|
||||||
|
|||||||
+11
-1
@@ -1,5 +1,9 @@
|
|||||||
package com.matchlivetv.match_live_tv.streaming
|
package com.matchlivetv.match_live_tv.streaming
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profilo RTMP allineato a MediaMTX slate + relay YouTube (copy audio/video).
|
||||||
|
* Audio: AAC 48 kHz mono 128 kbps — stesso contratto di `infra/scripts/generate_slate.sh`.
|
||||||
|
*/
|
||||||
data class BroadcastConfig(
|
data class BroadcastConfig(
|
||||||
val rtmpUrl: String,
|
val rtmpUrl: String,
|
||||||
val width: Int = 1280,
|
val width: Int = 1280,
|
||||||
@@ -11,7 +15,13 @@ data class BroadcastConfig(
|
|||||||
val portrait: Boolean = false,
|
val portrait: Boolean = false,
|
||||||
val maxReconnectAttempts: Int = 10,
|
val maxReconnectAttempts: Int = 10,
|
||||||
val reconnectDelayMs: Long = 5_000L,
|
val reconnectDelayMs: Long = 5_000L,
|
||||||
)
|
) {
|
||||||
|
companion object {
|
||||||
|
const val AUDIO_SAMPLE_RATE_HZ = 48_000
|
||||||
|
/** false = mono in RootEncoder `prepareAudio(sampleRate, isStereo, bitrate)`. */
|
||||||
|
const val AUDIO_STEREO = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum class BroadcastPhase {
|
enum class BroadcastPhase {
|
||||||
IDLE,
|
IDLE,
|
||||||
|
|||||||
+5
-1
@@ -316,7 +316,11 @@ class LiveBroadcastEngine(
|
|||||||
rotation = cfg.rotation,
|
rotation = cfg.rotation,
|
||||||
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
|
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
|
||||||
level = h264Level,
|
level = h264Level,
|
||||||
) && stream.prepareAudio(48_000, false, cfg.audioBitrate)
|
) && stream.prepareAudio(
|
||||||
|
BroadcastConfig.AUDIO_SAMPLE_RATE_HZ,
|
||||||
|
BroadcastConfig.AUDIO_STEREO,
|
||||||
|
cfg.audioBitrate,
|
||||||
|
)
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
Log.w(TAG, "preparePipeline: ${it.message}")
|
Log.w(TAG, "preparePipeline: ${it.message}")
|
||||||
false
|
false
|
||||||
|
|||||||
+313
@@ -0,0 +1,313 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.ui.account
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.matchlivetv.match_live_tv.R
|
||||||
|
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||||
|
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
||||||
|
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
|
||||||
|
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
|
||||||
|
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import retrofit2.HttpException
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AccountScreen(
|
||||||
|
container: AppContainer,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onLoggedOut: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
var role by remember { mutableStateOf("") }
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var currentPassword by remember { mutableStateOf("") }
|
||||||
|
var newPassword by remember { mutableStateOf("") }
|
||||||
|
var confirmPassword by remember { mutableStateOf("") }
|
||||||
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
var loadingProfile by remember { mutableStateOf(true) }
|
||||||
|
var savingProfile by remember { mutableStateOf(false) }
|
||||||
|
var savingPassword by remember { mutableStateOf(false) }
|
||||||
|
var loggingOut by remember { mutableStateOf(false) }
|
||||||
|
var profileMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
var profileError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var passwordMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
var passwordError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val errGeneric = stringResource(R.string.common_error_generic)
|
||||||
|
val profileSaved = stringResource(R.string.account_profile_saved)
|
||||||
|
val passwordSaved = stringResource(R.string.account_password_saved)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
runCatching { container.authRepository.fetchAccount() }
|
||||||
|
.onSuccess { user ->
|
||||||
|
email = user.email
|
||||||
|
name = user.name
|
||||||
|
role = user.role
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
profileError = apiErrorMessage(it) ?: errGeneric
|
||||||
|
}
|
||||||
|
loadingProfile = false
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchScreenScaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.account_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.common_close),
|
||||||
|
tint = MatchColors.TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MatchColors.Background,
|
||||||
|
titleContentColor = Color.White,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.account_profile_heading),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = {},
|
||||||
|
enabled = false,
|
||||||
|
label = { Text(stringResource(R.string.login_email)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = accountFieldColors(),
|
||||||
|
)
|
||||||
|
if (role.isNotBlank()) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.account_role, role),
|
||||||
|
color = MatchColors.TextSecondary,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text(stringResource(R.string.account_name_label)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !loadingProfile && !savingProfile,
|
||||||
|
colors = accountFieldColors(),
|
||||||
|
)
|
||||||
|
profileError?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(it, color = MatchColors.PrimaryRed)
|
||||||
|
}
|
||||||
|
profileMessage?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(it, color = MatchColors.TextSecondary)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
MatchPrimaryButton(
|
||||||
|
label = stringResource(R.string.account_save_profile),
|
||||||
|
loading = savingProfile,
|
||||||
|
enabled = !loadingProfile && name.isNotBlank() && !savingProfile,
|
||||||
|
onClick = {
|
||||||
|
savingProfile = true
|
||||||
|
profileError = null
|
||||||
|
profileMessage = null
|
||||||
|
scope.launch {
|
||||||
|
runCatching { container.authRepository.updateName(name) }
|
||||||
|
.onSuccess {
|
||||||
|
name = it.name
|
||||||
|
profileMessage = profileSaved
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
profileError = apiErrorMessage(it) ?: errGeneric
|
||||||
|
}
|
||||||
|
savingProfile = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.account_password_heading),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
PasswordField(
|
||||||
|
value = currentPassword,
|
||||||
|
onValueChange = { currentPassword = it },
|
||||||
|
label = stringResource(R.string.account_current_password),
|
||||||
|
visible = passwordVisible,
|
||||||
|
onToggleVisible = { passwordVisible = !passwordVisible },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
PasswordField(
|
||||||
|
value = newPassword,
|
||||||
|
onValueChange = { newPassword = it },
|
||||||
|
label = stringResource(R.string.account_new_password),
|
||||||
|
visible = passwordVisible,
|
||||||
|
onToggleVisible = { passwordVisible = !passwordVisible },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
PasswordField(
|
||||||
|
value = confirmPassword,
|
||||||
|
onValueChange = { confirmPassword = it },
|
||||||
|
label = stringResource(R.string.account_confirm_password),
|
||||||
|
visible = passwordVisible,
|
||||||
|
onToggleVisible = { passwordVisible = !passwordVisible },
|
||||||
|
)
|
||||||
|
passwordError?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(it, color = MatchColors.PrimaryRed)
|
||||||
|
}
|
||||||
|
passwordMessage?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(it, color = MatchColors.TextSecondary)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
MatchPrimaryButton(
|
||||||
|
label = stringResource(R.string.account_save_password),
|
||||||
|
loading = savingPassword,
|
||||||
|
enabled = currentPassword.isNotBlank() &&
|
||||||
|
newPassword.isNotBlank() &&
|
||||||
|
confirmPassword.isNotBlank() &&
|
||||||
|
!savingPassword,
|
||||||
|
onClick = {
|
||||||
|
savingPassword = true
|
||||||
|
passwordError = null
|
||||||
|
passwordMessage = null
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
container.authRepository.changePassword(
|
||||||
|
currentPassword = currentPassword,
|
||||||
|
password = newPassword,
|
||||||
|
passwordConfirmation = confirmPassword,
|
||||||
|
)
|
||||||
|
}.onSuccess {
|
||||||
|
currentPassword = ""
|
||||||
|
newPassword = ""
|
||||||
|
confirmPassword = ""
|
||||||
|
passwordMessage = passwordSaved
|
||||||
|
}.onFailure {
|
||||||
|
passwordError = apiErrorMessage(it) ?: errGeneric
|
||||||
|
}
|
||||||
|
savingPassword = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(40.dp))
|
||||||
|
MatchSecondaryButton(
|
||||||
|
label = stringResource(R.string.action_logout),
|
||||||
|
enabled = !loggingOut,
|
||||||
|
onClick = {
|
||||||
|
loggingOut = true
|
||||||
|
scope.launch {
|
||||||
|
container.authRepository.logout()
|
||||||
|
onLoggedOut()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PasswordField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
label: String,
|
||||||
|
visible: Boolean,
|
||||||
|
onToggleVisible: () -> Unit,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
label = { Text(label) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = onToggleVisible) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (visible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MatchColors.TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
colors = accountFieldColors(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun accountFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = Color.White,
|
||||||
|
unfocusedTextColor = Color.White,
|
||||||
|
disabledTextColor = MatchColors.TextSecondary,
|
||||||
|
focusedBorderColor = MatchColors.PrimaryRed,
|
||||||
|
unfocusedBorderColor = MatchColors.Outline,
|
||||||
|
disabledBorderColor = MatchColors.Outline,
|
||||||
|
focusedLabelColor = MatchColors.PrimaryRed,
|
||||||
|
unfocusedLabelColor = MatchColors.TextSecondary,
|
||||||
|
disabledLabelColor = MatchColors.TextSecondary,
|
||||||
|
cursorColor = MatchColors.PrimaryRed,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun apiErrorMessage(error: Throwable): String? {
|
||||||
|
if (error is HttpException) {
|
||||||
|
val body = error.response()?.errorBody()?.string().orEmpty()
|
||||||
|
val match = Regex("\"error\"\\s*:\\s*\"([^\"]+)\"").find(body)
|
||||||
|
if (match != null) return match.groupValues[1]
|
||||||
|
}
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.ui.login
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.matchlivetv.match_live_tv.R
|
||||||
|
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||||
|
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
||||||
|
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
|
||||||
|
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ForgotPasswordScreen(
|
||||||
|
container: AppContainer,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
var loading by remember { mutableStateOf(false) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var success by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val errGeneric = stringResource(R.string.common_error_generic)
|
||||||
|
val successMessage = stringResource(R.string.forgot_password_success)
|
||||||
|
|
||||||
|
MatchScreenScaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.forgot_password_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.common_close),
|
||||||
|
tint = MatchColors.TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MatchColors.Background,
|
||||||
|
titleContentColor = Color.White,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.forgot_password_lead),
|
||||||
|
color = MatchColors.TextSecondary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = { email = it },
|
||||||
|
label = { Text(stringResource(R.string.login_email)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !success,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { }),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = Color.White,
|
||||||
|
unfocusedTextColor = Color.White,
|
||||||
|
focusedBorderColor = MatchColors.PrimaryRed,
|
||||||
|
unfocusedBorderColor = MatchColors.Outline,
|
||||||
|
focusedLabelColor = MatchColors.PrimaryRed,
|
||||||
|
unfocusedLabelColor = MatchColors.TextSecondary,
|
||||||
|
cursorColor = MatchColors.PrimaryRed,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
error?.let {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(it, color = MatchColors.PrimaryRed, textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
if (success) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(successMessage, color = MatchColors.TextSecondary, textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
MatchPrimaryButton(
|
||||||
|
label = stringResource(R.string.forgot_password_submit),
|
||||||
|
loading = loading,
|
||||||
|
enabled = email.isNotBlank() && !loading && !success,
|
||||||
|
onClick = {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
scope.launch {
|
||||||
|
runCatching { container.authRepository.forgotPassword(email) }
|
||||||
|
.onSuccess { success = true }
|
||||||
|
.onFailure { error = it.message ?: errGeneric }
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -1,5 +1,6 @@
|
|||||||
package com.matchlivetv.match_live_tv.ui.login
|
package com.matchlivetv.match_live_tv.ui.login
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -38,6 +39,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.matchlivetv.match_live_tv.R
|
import com.matchlivetv.match_live_tv.R
|
||||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||||
@@ -53,6 +55,7 @@ import kotlinx.coroutines.launch
|
|||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
container: AppContainer,
|
container: AppContainer,
|
||||||
onLoggedIn: () -> Unit,
|
onLoggedIn: () -> Unit,
|
||||||
|
onForgotPassword: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var email by remember { mutableStateOf("") }
|
var email by remember { mutableStateOf("") }
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf("") }
|
||||||
@@ -170,6 +173,16 @@ fun LoginScreen(
|
|||||||
keyboardActions = KeyboardActions(onDone = { submitLogin() }),
|
keyboardActions = KeyboardActions(onDone = { submitLogin() }),
|
||||||
colors = matchTextFieldColors(),
|
colors = matchTextFieldColors(),
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.login_forgot_password),
|
||||||
|
color = MatchColors.TextSecondary,
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.End)
|
||||||
|
.clickable(onClick = onForgotPassword)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
error?.let {
|
error?.let {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
+5
-10
@@ -17,9 +17,9 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
|
||||||
import androidx.compose.material.icons.filled.DeleteOutline
|
import androidx.compose.material.icons.filled.DeleteOutline
|
||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -67,7 +67,7 @@ fun MatchesScreen(
|
|||||||
container: AppContainer,
|
container: AppContainer,
|
||||||
onOpenSetup: (matchId: String) -> Unit,
|
onOpenSetup: (matchId: String) -> Unit,
|
||||||
onOpenBroadcast: (sessionId: String) -> Unit,
|
onOpenBroadcast: (sessionId: String) -> Unit,
|
||||||
onLogout: () -> Unit,
|
onOpenAccount: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -179,15 +179,10 @@ fun MatchesScreen(
|
|||||||
tint = MatchColors.TextSecondary,
|
tint = MatchColors.TextSecondary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = {
|
IconButton(onClick = onOpenAccount) {
|
||||||
scope.launch {
|
|
||||||
container.authRepository.logout()
|
|
||||||
onLogout()
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.Logout,
|
imageVector = Icons.Filled.Person,
|
||||||
contentDescription = stringResource(R.string.action_logout),
|
contentDescription = stringResource(R.string.account_title),
|
||||||
tint = MatchColors.TextSecondary,
|
tint = MatchColors.TextSecondary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-7
@@ -7,7 +7,9 @@ import androidx.navigation.compose.composable
|
|||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||||
|
import com.matchlivetv.match_live_tv.ui.account.AccountScreen
|
||||||
import com.matchlivetv.match_live_tv.ui.broadcast.BroadcastScreen
|
import com.matchlivetv.match_live_tv.ui.broadcast.BroadcastScreen
|
||||||
|
import com.matchlivetv.match_live_tv.ui.login.ForgotPasswordScreen
|
||||||
import com.matchlivetv.match_live_tv.ui.login.LoginScreen
|
import com.matchlivetv.match_live_tv.ui.login.LoginScreen
|
||||||
import com.matchlivetv.match_live_tv.ui.matches.MatchesScreen
|
import com.matchlivetv.match_live_tv.ui.matches.MatchesScreen
|
||||||
import com.matchlivetv.match_live_tv.ui.splash.SplashScreen
|
import com.matchlivetv.match_live_tv.ui.splash.SplashScreen
|
||||||
@@ -34,11 +36,23 @@ fun AppNavHost(container: AppContainer) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.Login) {
|
composable(Routes.Login) {
|
||||||
LoginScreen(container) {
|
LoginScreen(
|
||||||
navController.navigate(Routes.Matches) {
|
container = container,
|
||||||
popUpTo(Routes.Login) { inclusive = true }
|
onLoggedIn = {
|
||||||
}
|
navController.navigate(Routes.Matches) {
|
||||||
}
|
popUpTo(Routes.Login) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onForgotPassword = {
|
||||||
|
navController.navigate(Routes.ForgotPassword)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.ForgotPassword) {
|
||||||
|
ForgotPasswordScreen(
|
||||||
|
container = container,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.Matches) {
|
composable(Routes.Matches) {
|
||||||
MatchesScreen(
|
MatchesScreen(
|
||||||
@@ -49,9 +63,18 @@ fun AppNavHost(container: AppContainer) {
|
|||||||
onOpenBroadcast = { sessionId ->
|
onOpenBroadcast = { sessionId ->
|
||||||
navController.navigate(Routes.broadcast(sessionId))
|
navController.navigate(Routes.broadcast(sessionId))
|
||||||
},
|
},
|
||||||
onLogout = {
|
onOpenAccount = {
|
||||||
|
navController.navigate(Routes.Account)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.Account) {
|
||||||
|
AccountScreen(
|
||||||
|
container = container,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onLoggedOut = {
|
||||||
navController.navigate(Routes.Login) {
|
navController.navigate(Routes.Login) {
|
||||||
popUpTo(Routes.Matches) { inclusive = true }
|
popUpTo(0) { inclusive = true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+2
@@ -3,7 +3,9 @@ package com.matchlivetv.match_live_tv.ui.navigation
|
|||||||
object Routes {
|
object Routes {
|
||||||
const val Splash = "splash"
|
const val Splash = "splash"
|
||||||
const val Login = "login"
|
const val Login = "login"
|
||||||
|
const val ForgotPassword = "forgot_password"
|
||||||
const val Matches = "matches"
|
const val Matches = "matches"
|
||||||
|
const val Account = "account"
|
||||||
const val Setup = "setup/{matchId}/{step}"
|
const val Setup = "setup/{matchId}/{step}"
|
||||||
const val Broadcast = "broadcast/{sessionId}"
|
const val Broadcast = "broadcast/{sessionId}"
|
||||||
|
|
||||||
|
|||||||
@@ -241,4 +241,21 @@ Dies kann nicht rückgängig gemacht werden.</string>
|
|||||||
<string name="login_email_placeholder">coach@team.com</string>
|
<string name="login_email_placeholder">coach@team.com</string>
|
||||||
<string name="action_delete">Löschen</string>
|
<string name="action_delete">Löschen</string>
|
||||||
<string name="wizard_color_hue">Farbton</string>
|
<string name="wizard_color_hue">Farbton</string>
|
||||||
|
<string name="login_forgot_password">Passwort vergessen?</string>
|
||||||
|
<string name="forgot_password_title">Passwort vergessen</string>
|
||||||
|
<string name="forgot_password_lead">Gib die E-Mail des Kontos ein: Wir senden dir einen Link zum Zurücksetzen des Passworts.</string>
|
||||||
|
<string name="forgot_password_submit">Reset-Link senden</string>
|
||||||
|
<string name="forgot_password_success">Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen.</string>
|
||||||
|
<string name="account_title">Konto</string>
|
||||||
|
<string name="account_profile_heading">Profil</string>
|
||||||
|
<string name="account_password_heading">Passwort ändern</string>
|
||||||
|
<string name="account_name_label">Name</string>
|
||||||
|
<string name="account_role">Rolle: %1$s</string>
|
||||||
|
<string name="account_current_password">Aktuelles Passwort</string>
|
||||||
|
<string name="account_new_password">Neues Passwort (mind. 8 Zeichen)</string>
|
||||||
|
<string name="account_confirm_password">Passwort bestätigen</string>
|
||||||
|
<string name="account_save_profile">Profil speichern</string>
|
||||||
|
<string name="account_save_password">Passwort aktualisieren</string>
|
||||||
|
<string name="account_profile_saved">Profil aktualisiert</string>
|
||||||
|
<string name="account_password_saved">Passwort aktualisiert</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -241,4 +241,21 @@ This cannot be undone.</string>
|
|||||||
<string name="login_email_placeholder">coach@team.com</string>
|
<string name="login_email_placeholder">coach@team.com</string>
|
||||||
<string name="action_delete">Delete</string>
|
<string name="action_delete">Delete</string>
|
||||||
<string name="wizard_color_hue">Hue</string>
|
<string name="wizard_color_hue">Hue</string>
|
||||||
|
<string name="login_forgot_password">Forgot password?</string>
|
||||||
|
<string name="forgot_password_title">Forgot password</string>
|
||||||
|
<string name="forgot_password_lead">Enter your account email: we will send you a link to reset your password.</string>
|
||||||
|
<string name="forgot_password_submit">Send reset link</string>
|
||||||
|
<string name="forgot_password_success">If the email is registered, you will receive a password reset link shortly.</string>
|
||||||
|
<string name="account_title">Account</string>
|
||||||
|
<string name="account_profile_heading">Profile</string>
|
||||||
|
<string name="account_password_heading">Change password</string>
|
||||||
|
<string name="account_name_label">Name</string>
|
||||||
|
<string name="account_role">Role: %1$s</string>
|
||||||
|
<string name="account_current_password">Current password</string>
|
||||||
|
<string name="account_new_password">New password (min. 8 characters)</string>
|
||||||
|
<string name="account_confirm_password">Confirm password</string>
|
||||||
|
<string name="account_save_profile">Save profile</string>
|
||||||
|
<string name="account_save_password">Update password</string>
|
||||||
|
<string name="account_profile_saved">Profile updated</string>
|
||||||
|
<string name="account_password_saved">Password updated</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -241,4 +241,21 @@ Esta acción no se puede deshacer.</string>
|
|||||||
<string name="login_email_placeholder">coach@equipo.com</string>
|
<string name="login_email_placeholder">coach@equipo.com</string>
|
||||||
<string name="action_delete">Eliminar</string>
|
<string name="action_delete">Eliminar</string>
|
||||||
<string name="wizard_color_hue">Tono</string>
|
<string name="wizard_color_hue">Tono</string>
|
||||||
|
<string name="login_forgot_password">¿Olvidaste la contraseña?</string>
|
||||||
|
<string name="forgot_password_title">Contraseña olvidada</string>
|
||||||
|
<string name="forgot_password_lead">Introduce el email de la cuenta: te enviaremos un enlace para restablecer la contraseña.</string>
|
||||||
|
<string name="forgot_password_submit">Enviar enlace</string>
|
||||||
|
<string name="forgot_password_success">Si el email está registrado, recibirás en breve un enlace para restablecer la contraseña.</string>
|
||||||
|
<string name="account_title">Cuenta</string>
|
||||||
|
<string name="account_profile_heading">Perfil</string>
|
||||||
|
<string name="account_password_heading">Cambiar contraseña</string>
|
||||||
|
<string name="account_name_label">Nombre</string>
|
||||||
|
<string name="account_role">Rol: %1$s</string>
|
||||||
|
<string name="account_current_password">Contraseña actual</string>
|
||||||
|
<string name="account_new_password">Nueva contraseña (mín. 8 caracteres)</string>
|
||||||
|
<string name="account_confirm_password">Confirmar contraseña</string>
|
||||||
|
<string name="account_save_profile">Guardar perfil</string>
|
||||||
|
<string name="account_save_password">Actualizar contraseña</string>
|
||||||
|
<string name="account_profile_saved">Perfil actualizado</string>
|
||||||
|
<string name="account_password_saved">Contraseña actualizada</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -241,4 +241,21 @@ Cette action est irréversible.</string>
|
|||||||
<string name="login_email_placeholder">coach@equipe.com</string>
|
<string name="login_email_placeholder">coach@equipe.com</string>
|
||||||
<string name="action_delete">Supprimer</string>
|
<string name="action_delete">Supprimer</string>
|
||||||
<string name="wizard_color_hue">Teinte</string>
|
<string name="wizard_color_hue">Teinte</string>
|
||||||
|
<string name="login_forgot_password">Mot de passe oublié ?</string>
|
||||||
|
<string name="forgot_password_title">Mot de passe oublié</string>
|
||||||
|
<string name="forgot_password_lead">Saisissez l’e-mail du compte : nous vous enverrons un lien pour réinitialiser le mot de passe.</string>
|
||||||
|
<string name="forgot_password_submit">Envoyer le lien</string>
|
||||||
|
<string name="forgot_password_success">Si l’e-mail est enregistré, vous recevrez bientôt un lien de réinitialisation.</string>
|
||||||
|
<string name="account_title">Compte</string>
|
||||||
|
<string name="account_profile_heading">Profil</string>
|
||||||
|
<string name="account_password_heading">Changer le mot de passe</string>
|
||||||
|
<string name="account_name_label">Nom</string>
|
||||||
|
<string name="account_role">Rôle : %1$s</string>
|
||||||
|
<string name="account_current_password">Mot de passe actuel</string>
|
||||||
|
<string name="account_new_password">Nouveau mot de passe (min. 8 caractères)</string>
|
||||||
|
<string name="account_confirm_password">Confirmer le mot de passe</string>
|
||||||
|
<string name="account_save_profile">Enregistrer le profil</string>
|
||||||
|
<string name="account_save_password">Mettre à jour le mot de passe</string>
|
||||||
|
<string name="account_profile_saved">Profil mis à jour</string>
|
||||||
|
<string name="account_password_saved">Mot de passe mis à jour</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -241,4 +241,21 @@ L\'operazione non si può annullare.</string>
|
|||||||
<string name="login_email_placeholder">coach@team.com</string>
|
<string name="login_email_placeholder">coach@team.com</string>
|
||||||
<string name="action_delete">Elimina</string>
|
<string name="action_delete">Elimina</string>
|
||||||
<string name="wizard_color_hue">Tonalità</string>
|
<string name="wizard_color_hue">Tonalità</string>
|
||||||
|
<string name="login_forgot_password">Password dimenticata?</string>
|
||||||
|
<string name="forgot_password_title">Password dimenticata</string>
|
||||||
|
<string name="forgot_password_lead">Inserisci l’email dell’account: ti invieremo un link per reimpostare la password.</string>
|
||||||
|
<string name="forgot_password_submit">Invia link di reset</string>
|
||||||
|
<string name="forgot_password_success">Se l’email è registrata, riceverai a breve un link per reimpostare la password.</string>
|
||||||
|
<string name="account_title">Account</string>
|
||||||
|
<string name="account_profile_heading">Profilo</string>
|
||||||
|
<string name="account_password_heading">Cambia password</string>
|
||||||
|
<string name="account_name_label">Nome</string>
|
||||||
|
<string name="account_role">Ruolo: %1$s</string>
|
||||||
|
<string name="account_current_password">Password attuale</string>
|
||||||
|
<string name="account_new_password">Nuova password (min. 8 caratteri)</string>
|
||||||
|
<string name="account_confirm_password">Conferma password</string>
|
||||||
|
<string name="account_save_profile">Salva profilo</string>
|
||||||
|
<string name="account_save_password">Aggiorna password</string>
|
||||||
|
<string name="account_profile_saved">Profilo aggiornato</string>
|
||||||
|
<string name="account_password_saved">Password aggiornata</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -55,6 +55,8 @@
|
|||||||
D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; };
|
D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; };
|
||||||
3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSecondaryButton.swift; path = MatchLiveTv/UI/Components/MatchSecondaryButton.swift; sourceTree = "<group>"; };
|
3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSecondaryButton.swift; path = MatchLiveTv/UI/Components/MatchSecondaryButton.swift; sourceTree = "<group>"; };
|
||||||
1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; };
|
1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; };
|
||||||
|
E13972C3F4664A89AE13D5D8 /* AccountScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AccountScreen.swift; path = MatchLiveTv/UI/Account/AccountScreen.swift; sourceTree = "<group>"; };
|
||||||
|
DDF38B8E56394DEBA022F812 /* ForgotPasswordScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ForgotPasswordScreen.swift; path = MatchLiveTv/UI/Login/ForgotPasswordScreen.swift; sourceTree = "<group>"; };
|
||||||
DA51BC65490E421AA3D16F02 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoginScreen.swift; path = MatchLiveTv/UI/Login/LoginScreen.swift; sourceTree = "<group>"; };
|
DA51BC65490E421AA3D16F02 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoginScreen.swift; path = MatchLiveTv/UI/Login/LoginScreen.swift; sourceTree = "<group>"; };
|
||||||
0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchesScreen.swift; path = MatchLiveTv/UI/Matches/MatchesScreen.swift; sourceTree = "<group>"; };
|
0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchesScreen.swift; path = MatchLiveTv/UI/Matches/MatchesScreen.swift; sourceTree = "<group>"; };
|
||||||
5DD00428364442C6B8C55DDF /* AppNavHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppNavHost.swift; path = MatchLiveTv/UI/Navigation/AppNavHost.swift; sourceTree = "<group>"; };
|
5DD00428364442C6B8C55DDF /* AppNavHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppNavHost.swift; path = MatchLiveTv/UI/Navigation/AppNavHost.swift; sourceTree = "<group>"; };
|
||||||
@@ -143,6 +145,8 @@
|
|||||||
D71CB837FE844932AA590D30 /* MatchScreenScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */; };
|
D71CB837FE844932AA590D30 /* MatchScreenScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */; };
|
||||||
52806453AD4C4E59BA2F05DE /* MatchSecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */; };
|
52806453AD4C4E59BA2F05DE /* MatchSecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */; };
|
||||||
FA601701F8684EE2BFDBBBFD /* MatchStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.swift */; };
|
FA601701F8684EE2BFDBBBFD /* MatchStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.swift */; };
|
||||||
|
A85ED43E903E4983B1438D11 /* AccountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13972C3F4664A89AE13D5D8 /* AccountScreen.swift */; };
|
||||||
|
195AF301A0AC4BA58F8EDE95 /* ForgotPasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF38B8E56394DEBA022F812 /* ForgotPasswordScreen.swift */; };
|
||||||
8C0225C7F87A4F9A8A0C2919 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA51BC65490E421AA3D16F02 /* LoginScreen.swift */; };
|
8C0225C7F87A4F9A8A0C2919 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA51BC65490E421AA3D16F02 /* LoginScreen.swift */; };
|
||||||
7293CD3C1B634333A59FA782 /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */; };
|
7293CD3C1B634333A59FA782 /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */; };
|
||||||
465980490F2D417E87241EBA /* AppNavHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD00428364442C6B8C55DDF /* AppNavHost.swift */; };
|
465980490F2D417E87241EBA /* AppNavHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD00428364442C6B8C55DDF /* AppNavHost.swift */; };
|
||||||
@@ -176,10 +180,10 @@
|
|||||||
9246A8532B66443A83535192 /* de.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 85B0F928887C44128434974E; };
|
9246A8532B66443A83535192 /* de.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 85B0F928887C44128434974E; };
|
||||||
5DA941A5928A4F638223030C /* es.lproj in Resources */ = {isa = PBXBuildFile; fileRef = E907C350C3E5410FB4A2038D; };
|
5DA941A5928A4F638223030C /* es.lproj in Resources */ = {isa = PBXBuildFile; fileRef = E907C350C3E5410FB4A2038D; };
|
||||||
B6A69164398F4F9FB84EC475 = {isa = PBXGroup; children = (86BA4622CE11450BAD7902B9, E068E1D4709041789B1E8F5A, 2165E0C34A8B43F28AC2B21C); sourceTree = "<group>"; };
|
B6A69164398F4F9FB84EC475 = {isa = PBXGroup; children = (86BA4622CE11450BAD7902B9, E068E1D4709041789B1E8F5A, 2165E0C34A8B43F28AC2B21C); sourceTree = "<group>"; };
|
||||||
86BA4622CE11450BAD7902B9 = {isa = PBXGroup; children = (4FC590728C2F47DE94AEADA3, 69F95F7CAF894139BA244AFF, 5E30D25B02704FAE9C0A4040, A59093CB86BB41FE9DF37182, C00F6C82C9A04A50B1D34BA3, 6F83B04D0BC84879A07C9BFE, 33DEE4675BAB413E9F69549B, 14FE2B5F844A482C91245755, AA9DF2F1C5F04EA198286266, 8EEFEB75DDB04C4986774D4B, C84694361A004982B4007CA5, 1AD71EE7F4A54818A6C427C9, 610902FBCEB7478286C88C08, DD9687B43B5840DD96FAD1C9, 905A6416560A4442AA7D2FCF, 9299E98557E04C5F8DC736F8, 49E1D454901048A58DCE7C41, A90696A40A584C5794938FDA, 2C3F8293D48D44A6A569F452, 347DADA50AE44484ABF19903, 69794703A6D74DB9AA3D16A2, D274E6DA2B7B47C9822FB517, E4BAEE4AB7834E19BA57C4A1, 1060DB32269548BE895EFA7F, CA265A4F2CAA41DA8F68A35C, 0381775B0B1D4378A8BF289A, 85D4CBF80A8F447790235E7C, 2EA97648294C4D98B3D85EFD, FA4A1042B44B41E0AE768E0C, 97DF04B6B21743BC9615E634, 1B031A4D51634E96BBD3D8DA, BC442280384D4194A66F25D2, EF6E8429A9DF475E9C964A2A, AA419130C5A248DCA3B4A016, 8CF76F8D690C405D9FCC4B8E, E1A1C8A803FB4CA1BA741E57, 195EFB33D08D484489FD721B, DB0DF408F60149008C9644C0, E9F0583912AA42F787751A7F, FB19CB746D6146D9ABD1A1C4, CD8C48EA12DB45FF978FA049, 838A41F3BF1E42BDAA2CE2E6, 2074992AD4E94430B9524FB3, B51507FF41FB4B80831D70E3, 8EA7E4972BB74FDD884370D0, CC2B124ACD024538B20863B8, 31B0AEBA3A0140D2BC9698DC, 0C84054AB3574CC5B8056694, D7434175C1E849D7A8308896, 3DE0F41386A741B2A6FC538B, 1F6A499FE7D94EF498DA9A84, DA51BC65490E421AA3D16F02, 0A439BB96CCB4C038EEF44DD, 5DD00428364442C6B8C55DDF, 9F958AB307244896BD8948D2, 21C2A6AB0E164C8CBDF9CF7F, 1702AF57020940DEBF0F9BA0, 3004C07E608644AFB636C0C5, DC39718A2EC74B078A0C5DE9, BCEB22897600469E93762209, 13BC17CE70034FEE97707B21, AAAD219E4FA94CF885472711, 29F84FF3F1E74E49A3EA5101, 1875AE0A77B94DF0921EE457, 3168221266004A33BA35042C, C97603C3A54342799CBD0C03, 5AFEAFB3E8F047B9A2B0DD04, CE7A5A0E6D9A49EA95918E79, 68CB1416AC24491CAA4CE87A, F68654552B254000B6F0BE97, 1255FF9134D246ECBE1DF72C, 6DFD25C0855F482DBEFD8757, 12C3FA28B28146B9A8E3A70A, 5D760AAFB9C34DACBFC2387A, 85B0F928887C44128434974E, E907C350C3E5410FB4A2038D); name = MatchLiveTv; sourceTree = "<group>"; };
|
86BA4622CE11450BAD7902B9 = {isa = PBXGroup; children = (4FC590728C2F47DE94AEADA3, 69F95F7CAF894139BA244AFF, 5E30D25B02704FAE9C0A4040, A59093CB86BB41FE9DF37182, C00F6C82C9A04A50B1D34BA3, 6F83B04D0BC84879A07C9BFE, 33DEE4675BAB413E9F69549B, 14FE2B5F844A482C91245755, AA9DF2F1C5F04EA198286266, 8EEFEB75DDB04C4986774D4B, C84694361A004982B4007CA5, 1AD71EE7F4A54818A6C427C9, 610902FBCEB7478286C88C08, DD9687B43B5840DD96FAD1C9, 905A6416560A4442AA7D2FCF, 9299E98557E04C5F8DC736F8, 49E1D454901048A58DCE7C41, A90696A40A584C5794938FDA, 2C3F8293D48D44A6A569F452, 347DADA50AE44484ABF19903, 69794703A6D74DB9AA3D16A2, D274E6DA2B7B47C9822FB517, E4BAEE4AB7834E19BA57C4A1, 1060DB32269548BE895EFA7F, CA265A4F2CAA41DA8F68A35C, 0381775B0B1D4378A8BF289A, 85D4CBF80A8F447790235E7C, 2EA97648294C4D98B3D85EFD, FA4A1042B44B41E0AE768E0C, 97DF04B6B21743BC9615E634, 1B031A4D51634E96BBD3D8DA, BC442280384D4194A66F25D2, EF6E8429A9DF475E9C964A2A, AA419130C5A248DCA3B4A016, 8CF76F8D690C405D9FCC4B8E, E1A1C8A803FB4CA1BA741E57, 195EFB33D08D484489FD721B, DB0DF408F60149008C9644C0, E9F0583912AA42F787751A7F, FB19CB746D6146D9ABD1A1C4, CD8C48EA12DB45FF978FA049, 838A41F3BF1E42BDAA2CE2E6, 2074992AD4E94430B9524FB3, B51507FF41FB4B80831D70E3, 8EA7E4972BB74FDD884370D0, CC2B124ACD024538B20863B8, 31B0AEBA3A0140D2BC9698DC, 0C84054AB3574CC5B8056694, D7434175C1E849D7A8308896, 3DE0F41386A741B2A6FC538B, 1F6A499FE7D94EF498DA9A84, E13972C3F4664A89AE13D5D8, DDF38B8E56394DEBA022F812, DA51BC65490E421AA3D16F02, 0A439BB96CCB4C038EEF44DD, 5DD00428364442C6B8C55DDF, 9F958AB307244896BD8948D2, 21C2A6AB0E164C8CBDF9CF7F, 1702AF57020940DEBF0F9BA0, 3004C07E608644AFB636C0C5, DC39718A2EC74B078A0C5DE9, BCEB22897600469E93762209, 13BC17CE70034FEE97707B21, AAAD219E4FA94CF885472711, 29F84FF3F1E74E49A3EA5101, 1875AE0A77B94DF0921EE457, 3168221266004A33BA35042C, C97603C3A54342799CBD0C03, 5AFEAFB3E8F047B9A2B0DD04, CE7A5A0E6D9A49EA95918E79, 68CB1416AC24491CAA4CE87A, F68654552B254000B6F0BE97, 1255FF9134D246ECBE1DF72C, 6DFD25C0855F482DBEFD8757, 12C3FA28B28146B9A8E3A70A, 5D760AAFB9C34DACBFC2387A, 85B0F928887C44128434974E, E907C350C3E5410FB4A2038D); name = MatchLiveTv; sourceTree = "<group>"; };
|
||||||
E068E1D4709041789B1E8F5A = {isa = PBXGroup; children = (73BB032A0DD74458B0E716C6, C186E88B35A0443494AE6E8E, 5751DB993C52460D960B8067, 0E243C00009048F6A778323C, 634AD5C2E53B4C098582284A, B7DE055717014393B3F62D5A, C3C1AC6562ED4A908D7C8B0A, 610A4D03FA384F0287AAF825); name = MatchLiveTvTests; sourceTree = "<group>"; };
|
E068E1D4709041789B1E8F5A = {isa = PBXGroup; children = (73BB032A0DD74458B0E716C6, C186E88B35A0443494AE6E8E, 5751DB993C52460D960B8067, 0E243C00009048F6A778323C, 634AD5C2E53B4C098582284A, B7DE055717014393B3F62D5A, C3C1AC6562ED4A908D7C8B0A, 610A4D03FA384F0287AAF825); name = MatchLiveTvTests; sourceTree = "<group>"; };
|
||||||
2165E0C34A8B43F28AC2B21C = {isa = PBXGroup; children = (6FF24BB96E9C40F6A9F6E25A, 8F679C9C03AE47D6A028DBAC); name = Products; sourceTree = "<group>"; };
|
2165E0C34A8B43F28AC2B21C = {isa = PBXGroup; children = (6FF24BB96E9C40F6A9F6E25A, 8F679C9C03AE47D6A028DBAC); name = Products; sourceTree = "<group>"; };
|
||||||
3B35A518C56047A48825F0F8 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (F7624153A1C84C218B602FFB, D973F2B503854C6AA61A1559, FE2D8C8359CC4DA189DF5DC6, B607A146AE754F6A819FB186, B0D93B493FD0427CA72F5862, 3C223B19BA2149C5B7E5CC5A, 3D119DE97DE5452D903F364C, 8ABF00BDC15C400A9089FC28, 7D2649B9131F4DDA83F68B72, AB71730B7CA24D3E8667BCFF, ADA58448C2344DADAA90F212, 95F7871858C44379A62012BA, 3FBBEF8DFF5E4F5D9E34C860, 91694276C76B438F8B52448F, 51D849AFAC8D4F57BEF05CAB, D771BABE58A3439CA1768F7F, 23C149E317BD416D9CF0B171, 43C57FF23CB347AC99284468, 8661E674C82545C4AEC0A7CE, E133D004CF0A4FF7A51A47B1, 72090ACA64CB4885BE7AB333, D80261301CA740568D8807D2, 9ECD8B486DF84890BD39B108, C217B8AD49D04976A6D7FC7E, 978AAF829F654537AC473F52, 032C790773A848B7A0434C67, A37BC192D6C9433F83FABAA8, 99F1D8A337EE46C8BDD34F60, F024262D0D0F4B759E1A4F14, FE9E0B76650246E490722317, BD642A0BD19D424985FBF1A3, B48421E7BD5D48E499948E2E, C6207D98BB484493BEDD4BBB, E715CA8C60E6439683E1DA0E, 35F5E2E5FB0D4B9FA475ADA4, 6D8B0F1792E440C19F49AD03, CC563D93928C438AB0B78E99, 9343740F4EB94F609A4D1A51, D71C1DFF234742DA81D23AF7, DADD5004759C409F82D4FEE2, 957381C0C3A447E7A3240ACD, 326AEF0FBFE74201BE70D2DD, DCC6F79099C641DF9EA6F36F, CA82A2382C4A45BE88276507, 37E8D25C18014E0C8139D84D, 67A37D0BB28F4BD1872BACA6, 7AA96446B33C4ECCB90DDFD0, 599290557AE4430EA30582D7, D71CB837FE844932AA590D30, 52806453AD4C4E59BA2F05DE, FA601701F8684EE2BFDBBBFD, 8C0225C7F87A4F9A8A0C2919, 7293CD3C1B634333A59FA782, 465980490F2D417E87241EBA, 044E04EEE71D472EA524C555, 52CEBF8B18834CB8B650FE16, 94ADCCD464DD43149D0C0E60, F23C2967ED7140EB8DCB2D2A, 47AD4ADC4ACE4F6DA7AD708C, 8B0A9A9DBAF346199EBA21B3, D826E98233304F5FB27B27C3, 579A3A8D49DF43E7B11942C6, D0BEA6E726C04A95A8CC0018, 15DA64BB40B14C9B899A3729, 4DF6B21EFDD54DF999385511, AEF63662B6B5454F92986710, 78179BA533074F1ABBB7391E, 45E14846E79845CE9C20C5EC, 2049733B63794135A4BFBB53); runOnlyForDeploymentPostprocessing = 0; };
|
3B35A518C56047A48825F0F8 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (F7624153A1C84C218B602FFB, D973F2B503854C6AA61A1559, FE2D8C8359CC4DA189DF5DC6, B607A146AE754F6A819FB186, B0D93B493FD0427CA72F5862, 3C223B19BA2149C5B7E5CC5A, 3D119DE97DE5452D903F364C, 8ABF00BDC15C400A9089FC28, 7D2649B9131F4DDA83F68B72, AB71730B7CA24D3E8667BCFF, ADA58448C2344DADAA90F212, 95F7871858C44379A62012BA, 3FBBEF8DFF5E4F5D9E34C860, 91694276C76B438F8B52448F, 51D849AFAC8D4F57BEF05CAB, D771BABE58A3439CA1768F7F, 23C149E317BD416D9CF0B171, 43C57FF23CB347AC99284468, 8661E674C82545C4AEC0A7CE, E133D004CF0A4FF7A51A47B1, 72090ACA64CB4885BE7AB333, D80261301CA740568D8807D2, 9ECD8B486DF84890BD39B108, C217B8AD49D04976A6D7FC7E, 978AAF829F654537AC473F52, 032C790773A848B7A0434C67, A37BC192D6C9433F83FABAA8, 99F1D8A337EE46C8BDD34F60, F024262D0D0F4B759E1A4F14, FE9E0B76650246E490722317, BD642A0BD19D424985FBF1A3, B48421E7BD5D48E499948E2E, C6207D98BB484493BEDD4BBB, E715CA8C60E6439683E1DA0E, 35F5E2E5FB0D4B9FA475ADA4, 6D8B0F1792E440C19F49AD03, CC563D93928C438AB0B78E99, 9343740F4EB94F609A4D1A51, D71C1DFF234742DA81D23AF7, DADD5004759C409F82D4FEE2, 957381C0C3A447E7A3240ACD, 326AEF0FBFE74201BE70D2DD, DCC6F79099C641DF9EA6F36F, CA82A2382C4A45BE88276507, 37E8D25C18014E0C8139D84D, 67A37D0BB28F4BD1872BACA6, 7AA96446B33C4ECCB90DDFD0, 599290557AE4430EA30582D7, D71CB837FE844932AA590D30, 52806453AD4C4E59BA2F05DE, FA601701F8684EE2BFDBBBFD, A85ED43E903E4983B1438D11, 195AF301A0AC4BA58F8EDE95, 8C0225C7F87A4F9A8A0C2919, 7293CD3C1B634333A59FA782, 465980490F2D417E87241EBA, 044E04EEE71D472EA524C555, 52CEBF8B18834CB8B650FE16, 94ADCCD464DD43149D0C0E60, F23C2967ED7140EB8DCB2D2A, 47AD4ADC4ACE4F6DA7AD708C, 8B0A9A9DBAF346199EBA21B3, D826E98233304F5FB27B27C3, 579A3A8D49DF43E7B11942C6, D0BEA6E726C04A95A8CC0018, 15DA64BB40B14C9B899A3729, 4DF6B21EFDD54DF999385511, AEF63662B6B5454F92986710, 78179BA533074F1ABBB7391E, 45E14846E79845CE9C20C5EC, 2049733B63794135A4BFBB53); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
729C230D5EB64A76BCD517B4 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (6B75AFC14BBF4E2B97762ECB, 9BA38C8D635F4770A232AE85, 1889CDB168794D81A5B24699, E25CB4FB944141D0AAB57EDF, 6BE3B2533E6A4E3DA32EA972, B88859D9B34E4F289913DDFC, B8CC446DB3AE4BABB4D739D1, 6CCB67439127403180D9C697); runOnlyForDeploymentPostprocessing = 0; };
|
729C230D5EB64A76BCD517B4 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (6B75AFC14BBF4E2B97762ECB, 9BA38C8D635F4770A232AE85, 1889CDB168794D81A5B24699, E25CB4FB944141D0AAB57EDF, 6BE3B2533E6A4E3DA32EA972, B88859D9B34E4F289913DDFC, B8CC446DB3AE4BABB4D739D1, 6CCB67439127403180D9C697); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
6B4D5383389142E7AE9C4F41 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (C44DD19CDB6545A381B61D20, 7C8A2DFFAB6B4CB8AA3E4FE8, 8D479F8873F94073938D737E, 9A894DBC632C4904BC5D9A78, 9246A8532B66443A83535192, 5DA941A5928A4F638223030C); runOnlyForDeploymentPostprocessing = 0; };
|
6B4D5383389142E7AE9C4F41 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (C44DD19CDB6545A381B61D20, 7C8A2DFFAB6B4CB8AA3E4FE8, 8D479F8873F94073938D737E, 9A894DBC632C4904BC5D9A78, 9246A8532B66443A83535192, 5DA941A5928A4F638223030C); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
AA7CFC56BCC040A4AB487E2C = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
AA7CFC56BCC040A4AB487E2C = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
|
|||||||
@@ -78,6 +78,23 @@ enum L10n {
|
|||||||
"action.exit": "Esci",
|
"action.exit": "Esci",
|
||||||
"action.login": "Accedi",
|
"action.login": "Accedi",
|
||||||
"action.logout": "Esci",
|
"action.logout": "Esci",
|
||||||
|
"account.confirm.password": "Conferma password",
|
||||||
|
"account.current.password": "Password attuale",
|
||||||
|
"account.name.label": "Nome",
|
||||||
|
"account.new.password": "Nuova password (min. 8 caratteri)",
|
||||||
|
"account.password.heading": "Cambia password",
|
||||||
|
"account.password.saved": "Password aggiornata",
|
||||||
|
"account.profile.heading": "Profilo",
|
||||||
|
"account.profile.saved": "Profilo aggiornato",
|
||||||
|
"account.role": "Ruolo: %1$@",
|
||||||
|
"account.save.password": "Aggiorna password",
|
||||||
|
"account.save.profile": "Salva profilo",
|
||||||
|
"account.title": "Account",
|
||||||
|
"forgot.password.lead": "Inserisci l'email dell'account: ti invieremo un link per reimpostare la password.",
|
||||||
|
"forgot.password.submit": "Invia link di reset",
|
||||||
|
"forgot.password.success": "Se l'email è registrata, riceverai a breve un link per reimpostare la password.",
|
||||||
|
"forgot.password.title": "Password dimenticata",
|
||||||
|
"login.forgot.password": "Password dimenticata?",
|
||||||
"action.ok": "OK",
|
"action.ok": "OK",
|
||||||
"action.save": "Salva",
|
"action.save": "Salva",
|
||||||
"api.error.decoding": "Risposta del server non valida",
|
"api.error.decoding": "Risposta del server non valida",
|
||||||
@@ -352,6 +369,23 @@ enum L10n {
|
|||||||
"action.exit": "Exit",
|
"action.exit": "Exit",
|
||||||
"action.login": "Log in",
|
"action.login": "Log in",
|
||||||
"action.logout": "Log out",
|
"action.logout": "Log out",
|
||||||
|
"account.confirm.password": "Confirm password",
|
||||||
|
"account.current.password": "Current password",
|
||||||
|
"account.name.label": "Name",
|
||||||
|
"account.new.password": "New password (min. 8 characters)",
|
||||||
|
"account.password.heading": "Change password",
|
||||||
|
"account.password.saved": "Password updated",
|
||||||
|
"account.profile.heading": "Profile",
|
||||||
|
"account.profile.saved": "Profile updated",
|
||||||
|
"account.role": "Role: %1$@",
|
||||||
|
"account.save.password": "Update password",
|
||||||
|
"account.save.profile": "Save profile",
|
||||||
|
"account.title": "Account",
|
||||||
|
"forgot.password.lead": "Enter your account email: we'll send you a link to reset your password.",
|
||||||
|
"forgot.password.submit": "Send reset link",
|
||||||
|
"forgot.password.success": "If the email is registered, you will receive a password reset link shortly.",
|
||||||
|
"forgot.password.title": "Forgot password",
|
||||||
|
"login.forgot.password": "Forgot password?",
|
||||||
"action.ok": "OK",
|
"action.ok": "OK",
|
||||||
"action.save": "Save",
|
"action.save": "Save",
|
||||||
"api.error.decoding": "Invalid server response",
|
"api.error.decoding": "Invalid server response",
|
||||||
@@ -626,6 +660,23 @@ enum L10n {
|
|||||||
"action.exit": "Quitter",
|
"action.exit": "Quitter",
|
||||||
"action.login": "Connexion",
|
"action.login": "Connexion",
|
||||||
"action.logout": "Déconnexion",
|
"action.logout": "Déconnexion",
|
||||||
|
"account.confirm.password": "Confirmer le mot de passe",
|
||||||
|
"account.current.password": "Mot de passe actuel",
|
||||||
|
"account.name.label": "Nom",
|
||||||
|
"account.new.password": "Nouveau mot de passe (min. 8 caractères)",
|
||||||
|
"account.password.heading": "Changer le mot de passe",
|
||||||
|
"account.password.saved": "Mot de passe mis à jour",
|
||||||
|
"account.profile.heading": "Profil",
|
||||||
|
"account.profile.saved": "Profil mis à jour",
|
||||||
|
"account.role": "Rôle : %1$@",
|
||||||
|
"account.save.password": "Mettre à jour le mot de passe",
|
||||||
|
"account.save.profile": "Enregistrer le profil",
|
||||||
|
"account.title": "Compte",
|
||||||
|
"forgot.password.lead": "Saisissez l'e-mail du compte : nous vous enverrons un lien pour réinitialiser le mot de passe.",
|
||||||
|
"forgot.password.submit": "Envoyer le lien",
|
||||||
|
"forgot.password.success": "Si l'e-mail est enregistré, vous recevrez bientôt un lien de réinitialisation.",
|
||||||
|
"forgot.password.title": "Mot de passe oublié",
|
||||||
|
"login.forgot.password": "Mot de passe oublié ?",
|
||||||
"action.ok": "OK",
|
"action.ok": "OK",
|
||||||
"action.save": "Enregistrer",
|
"action.save": "Enregistrer",
|
||||||
"api.error.decoding": "Réponse du serveur non valide",
|
"api.error.decoding": "Réponse du serveur non valide",
|
||||||
@@ -900,6 +951,23 @@ enum L10n {
|
|||||||
"action.exit": "Beenden",
|
"action.exit": "Beenden",
|
||||||
"action.login": "Anmelden",
|
"action.login": "Anmelden",
|
||||||
"action.logout": "Abmelden",
|
"action.logout": "Abmelden",
|
||||||
|
"account.confirm.password": "Passwort bestätigen",
|
||||||
|
"account.current.password": "Aktuelles Passwort",
|
||||||
|
"account.name.label": "Name",
|
||||||
|
"account.new.password": "Neues Passwort (mind. 8 Zeichen)",
|
||||||
|
"account.password.heading": "Passwort ändern",
|
||||||
|
"account.password.saved": "Passwort aktualisiert",
|
||||||
|
"account.profile.heading": "Profil",
|
||||||
|
"account.profile.saved": "Profil aktualisiert",
|
||||||
|
"account.role": "Rolle: %1$@",
|
||||||
|
"account.save.password": "Passwort aktualisieren",
|
||||||
|
"account.save.profile": "Profil speichern",
|
||||||
|
"account.title": "Konto",
|
||||||
|
"forgot.password.lead": "Gib die E-Mail des Kontos ein: Wir senden dir einen Link zum Zurücksetzen des Passworts.",
|
||||||
|
"forgot.password.submit": "Reset-Link senden",
|
||||||
|
"forgot.password.success": "Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen.",
|
||||||
|
"forgot.password.title": "Passwort vergessen",
|
||||||
|
"login.forgot.password": "Passwort vergessen?",
|
||||||
"action.ok": "OK",
|
"action.ok": "OK",
|
||||||
"action.save": "Speichern",
|
"action.save": "Speichern",
|
||||||
"api.error.decoding": "Ungültige Serverantwort",
|
"api.error.decoding": "Ungültige Serverantwort",
|
||||||
@@ -1174,6 +1242,23 @@ enum L10n {
|
|||||||
"action.exit": "Salir",
|
"action.exit": "Salir",
|
||||||
"action.login": "Acceder",
|
"action.login": "Acceder",
|
||||||
"action.logout": "Salir",
|
"action.logout": "Salir",
|
||||||
|
"account.confirm.password": "Confirmar contraseña",
|
||||||
|
"account.current.password": "Contraseña actual",
|
||||||
|
"account.name.label": "Nombre",
|
||||||
|
"account.new.password": "Nueva contraseña (mín. 8 caracteres)",
|
||||||
|
"account.password.heading": "Cambiar contraseña",
|
||||||
|
"account.password.saved": "Contraseña actualizada",
|
||||||
|
"account.profile.heading": "Perfil",
|
||||||
|
"account.profile.saved": "Perfil actualizado",
|
||||||
|
"account.role": "Rol: %1$@",
|
||||||
|
"account.save.password": "Actualizar contraseña",
|
||||||
|
"account.save.profile": "Guardar perfil",
|
||||||
|
"account.title": "Cuenta",
|
||||||
|
"forgot.password.lead": "Introduce el email de la cuenta: te enviaremos un enlace para restablecer la contraseña.",
|
||||||
|
"forgot.password.submit": "Enviar enlace",
|
||||||
|
"forgot.password.success": "Si el email está registrado, recibirás en breve un enlace para restablecer la contraseña.",
|
||||||
|
"forgot.password.title": "Contraseña olvidada",
|
||||||
|
"login.forgot.password": "¿Olvidaste la contraseña?",
|
||||||
"action.ok": "OK",
|
"action.ok": "OK",
|
||||||
"action.save": "Guardar",
|
"action.save": "Guardar",
|
||||||
"api.error.decoding": "Respuesta del servidor no válida",
|
"api.error.decoding": "Respuesta del servidor no válida",
|
||||||
|
|||||||
@@ -13,6 +13,34 @@ struct RefreshRequest: Encodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ForgotPasswordRequest: Encodable {
|
||||||
|
let email: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ForgotPasswordResponse: Decodable {
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateAccountRequest: Encodable {
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChangePasswordRequest: Encodable {
|
||||||
|
let currentPassword: String
|
||||||
|
let password: String
|
||||||
|
let passwordConfirmation: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case currentPassword = "current_password"
|
||||||
|
case password
|
||||||
|
case passwordConfirmation = "password_confirmation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MessageResponse: Decodable {
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
|
|
||||||
struct LoginResponse: Decodable {
|
struct LoginResponse: Decodable {
|
||||||
let user: UserDto
|
let user: UserDto
|
||||||
let accessToken: String
|
let accessToken: String
|
||||||
|
|||||||
@@ -73,6 +73,33 @@ final class MatchLiveAPI: @unchecked Sendable {
|
|||||||
try await postVoid("auth/logout")
|
try await postVoid("auth/logout")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func forgotPassword(email: String) async throws -> ForgotPasswordResponse {
|
||||||
|
try await post("auth/password/forgot", body: ForgotPasswordRequest(email: email))
|
||||||
|
}
|
||||||
|
|
||||||
|
func account() async throws -> UserDto {
|
||||||
|
try await get("account")
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAccount(name: String) async throws -> UserDto {
|
||||||
|
try await patch("account", body: UpdateAccountRequest(name: name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func changePassword(
|
||||||
|
currentPassword: String,
|
||||||
|
password: String,
|
||||||
|
passwordConfirmation: String
|
||||||
|
) async throws -> MessageResponse {
|
||||||
|
try await patch(
|
||||||
|
"account/password",
|
||||||
|
body: ChangePasswordRequest(
|
||||||
|
currentPassword: currentPassword,
|
||||||
|
password: password,
|
||||||
|
passwordConfirmation: passwordConfirmation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Sports & Teams
|
// MARK: - Sports & Teams
|
||||||
|
|
||||||
func sports() async throws -> [SportDto] {
|
func sports() async throws -> [SportDto] {
|
||||||
@@ -267,6 +294,7 @@ final class MatchLiveAPI: @unchecked Sendable {
|
|||||||
return path.hasSuffix("/auth/login")
|
return path.hasSuffix("/auth/login")
|
||||||
|| path.hasSuffix("/auth/refresh")
|
|| path.hasSuffix("/auth/refresh")
|
||||||
|| path.hasSuffix("/auth/register")
|
|| path.hasSuffix("/auth/register")
|
||||||
|
|| path.hasSuffix("/auth/password/forgot")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,31 @@ final class AuthRepository {
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func forgotPassword(email: String) async throws -> String {
|
||||||
|
try await api.forgotPassword(email: email.trimmingCharacters(in: .whitespacesAndNewlines)).message
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAccount() async throws -> User {
|
||||||
|
try await api.account().toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateName(_ name: String) async throws -> User {
|
||||||
|
let user = try await api.updateAccount(name: name.trimmingCharacters(in: .whitespacesAndNewlines)).toDomain()
|
||||||
|
if var stored = tokenStore.session {
|
||||||
|
stored = StoredSession(user: user, accessToken: stored.accessToken, refreshToken: stored.refreshToken)
|
||||||
|
tokenStore.saveSession(stored)
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func changePassword(currentPassword: String, password: String, passwordConfirmation: String) async throws {
|
||||||
|
_ = try await api.changePassword(
|
||||||
|
currentPassword: currentPassword,
|
||||||
|
password: password,
|
||||||
|
passwordConfirmation: passwordConfirmation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func logout() async {
|
func logout() async {
|
||||||
try? await api.logout()
|
try? await api.logout()
|
||||||
tokenStore.clear()
|
tokenStore.clear()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Profilo RTMP allineato a MediaMTX slate + YoutubeRelay (copy A/V).
|
||||||
|
/// Audio: AAC 48 kHz mono 128 kbps.
|
||||||
struct BroadcastConfig: Sendable {
|
struct BroadcastConfig: Sendable {
|
||||||
let rtmpUrl: String
|
let rtmpUrl: String
|
||||||
var width: Int = 1280
|
var width: Int = 1280
|
||||||
@@ -11,6 +13,9 @@ struct BroadcastConfig: Sendable {
|
|||||||
var portrait: Bool = false
|
var portrait: Bool = false
|
||||||
var maxReconnectAttempts: Int = 10
|
var maxReconnectAttempts: Int = 10
|
||||||
var reconnectDelayMs: Int = 5_000
|
var reconnectDelayMs: Int = 5_000
|
||||||
|
|
||||||
|
static let audioSampleRateHz: Float64 = 48_000
|
||||||
|
static let audioChannels: UInt32 = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BroadcastPhase: String, Sendable {
|
enum BroadcastPhase: String, Sendable {
|
||||||
|
|||||||
@@ -214,6 +214,14 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
if let microphone = AVCaptureDevice.default(for: .audio) {
|
if let microphone = AVCaptureDevice.default(for: .audio) {
|
||||||
try await mixer.attachAudio(microphone, track: 0)
|
try await mixer.attachAudio(microphone, track: 0)
|
||||||
}
|
}
|
||||||
|
// AAC 48 kHz mono — allineato ad Android, slate MediaMTX e YoutubeRelay (-c:a copy).
|
||||||
|
try await mixer.setAudioMixerSettings(
|
||||||
|
AudioMixerSettings(
|
||||||
|
sampleRate: BroadcastConfig.audioSampleRateHz,
|
||||||
|
channels: BroadcastConfig.audioChannels,
|
||||||
|
tracks: [0: AudioMixerTrackSettings(downmix: true, channelMap: [0])]
|
||||||
|
)
|
||||||
|
)
|
||||||
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
|
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
|
||||||
try await mixer.attachVideo(camera, track: 0) { videoUnit in
|
try await mixer.attachVideo(camera, track: 0) { videoUnit in
|
||||||
videoUnit.isVideoMirrored = false
|
videoUnit.isVideoMirrored = false
|
||||||
@@ -355,10 +363,10 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
var audioSettings = await stream.audioSettings
|
var audioSettings = await stream.audioSettings
|
||||||
audioSettings = AudioCodecSettings(
|
audioSettings = AudioCodecSettings(
|
||||||
bitRate: config.audioBitrate,
|
bitRate: config.audioBitrate,
|
||||||
downmix: audioSettings.downmix,
|
downmix: true,
|
||||||
channelMap: audioSettings.channelMap,
|
channelMap: [0],
|
||||||
sampleRate: 48_000,
|
sampleRate: BroadcastConfig.audioSampleRateHz,
|
||||||
format: audioSettings.format
|
format: .aac
|
||||||
)
|
)
|
||||||
try await stream.setAudioSettings(audioSettings)
|
try await stream.setAudioSettings(audioSettings)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AccountScreen: View {
|
||||||
|
@ObservedObject var container: AppContainer
|
||||||
|
let onBack: () -> Void
|
||||||
|
let onLoggedOut: () -> Void
|
||||||
|
|
||||||
|
@State private var email = ""
|
||||||
|
@State private var role = ""
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var currentPassword = ""
|
||||||
|
@State private var newPassword = ""
|
||||||
|
@State private var confirmPassword = ""
|
||||||
|
@State private var passwordVisible = false
|
||||||
|
@State private var loadingProfile = true
|
||||||
|
@State private var savingProfile = false
|
||||||
|
@State private var savingPassword = false
|
||||||
|
@State private var loggingOut = false
|
||||||
|
@State private var profileMessage: String?
|
||||||
|
@State private var profileError: String?
|
||||||
|
@State private var passwordMessage: String?
|
||||||
|
@State private var passwordError: String?
|
||||||
|
@State private var languageTick = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
MatchScreenScaffold(
|
||||||
|
topBar: {
|
||||||
|
HStack {
|
||||||
|
Button(action: onBack) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
Text(L10n.t("account.title"))
|
||||||
|
.font(MatchTypography.headlineMedium)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text(L10n.t("account.profile.heading"))
|
||||||
|
.font(MatchTypography.titleMedium)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
disabledField(title: L10n.t("login.email"), value: email)
|
||||||
|
if !role.isEmpty {
|
||||||
|
Text(L10n.t("account.role", role))
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
MatchAccountTextField(title: L10n.t("account.name.label"), text: $name)
|
||||||
|
|
||||||
|
if let profileError {
|
||||||
|
Text(profileError).foregroundStyle(MatchColors.primaryRed)
|
||||||
|
}
|
||||||
|
if let profileMessage {
|
||||||
|
Text(profileMessage).foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchPrimaryButton(
|
||||||
|
label: L10n.t("account.save.profile"),
|
||||||
|
action: saveProfile,
|
||||||
|
enabled: !loadingProfile && !name.trimmingCharacters(in: .whitespaces).isEmpty && !savingProfile,
|
||||||
|
loading: savingProfile
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(L10n.t("account.password.heading"))
|
||||||
|
.font(MatchTypography.titleMedium)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.top, 16)
|
||||||
|
|
||||||
|
MatchAccountSecureField(
|
||||||
|
title: L10n.t("account.current.password"),
|
||||||
|
text: $currentPassword,
|
||||||
|
visible: $passwordVisible
|
||||||
|
)
|
||||||
|
MatchAccountSecureField(
|
||||||
|
title: L10n.t("account.new.password"),
|
||||||
|
text: $newPassword,
|
||||||
|
visible: $passwordVisible
|
||||||
|
)
|
||||||
|
MatchAccountSecureField(
|
||||||
|
title: L10n.t("account.confirm.password"),
|
||||||
|
text: $confirmPassword,
|
||||||
|
visible: $passwordVisible
|
||||||
|
)
|
||||||
|
|
||||||
|
if let passwordError {
|
||||||
|
Text(passwordError).foregroundStyle(MatchColors.primaryRed)
|
||||||
|
}
|
||||||
|
if let passwordMessage {
|
||||||
|
Text(passwordMessage).foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchPrimaryButton(
|
||||||
|
label: L10n.t("account.save.password"),
|
||||||
|
action: savePassword,
|
||||||
|
enabled: !currentPassword.isEmpty && !newPassword.isEmpty && !confirmPassword.isEmpty && !savingPassword,
|
||||||
|
loading: savingPassword
|
||||||
|
)
|
||||||
|
|
||||||
|
MatchSecondaryButton(
|
||||||
|
label: L10n.t("action.logout"),
|
||||||
|
action: logout,
|
||||||
|
enabled: !loggingOut
|
||||||
|
)
|
||||||
|
.padding(.top, 24)
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.task { await loadAccount() }
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
|
||||||
|
languageTick += 1
|
||||||
|
}
|
||||||
|
.id(languageTick)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func disabledField(title: String, value: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
Text(value.isEmpty ? "—" : value)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadAccount() async {
|
||||||
|
loadingProfile = true
|
||||||
|
do {
|
||||||
|
let user = try await container.authRepository.fetchAccount()
|
||||||
|
email = user.email
|
||||||
|
name = user.name
|
||||||
|
role = user.role
|
||||||
|
} catch {
|
||||||
|
profileError = UserFacingError.message(for: error) ?? L10n.t("common.error.generic")
|
||||||
|
}
|
||||||
|
loadingProfile = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveProfile() {
|
||||||
|
savingProfile = true
|
||||||
|
profileError = nil
|
||||||
|
profileMessage = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let user = try await container.authRepository.updateName(name)
|
||||||
|
name = user.name
|
||||||
|
profileMessage = L10n.t("account.profile.saved")
|
||||||
|
} catch {
|
||||||
|
profileError = UserFacingError.message(for: error) ?? L10n.t("common.error.generic")
|
||||||
|
}
|
||||||
|
savingProfile = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func savePassword() {
|
||||||
|
savingPassword = true
|
||||||
|
passwordError = nil
|
||||||
|
passwordMessage = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await container.authRepository.changePassword(
|
||||||
|
currentPassword: currentPassword,
|
||||||
|
password: newPassword,
|
||||||
|
passwordConfirmation: confirmPassword
|
||||||
|
)
|
||||||
|
currentPassword = ""
|
||||||
|
newPassword = ""
|
||||||
|
confirmPassword = ""
|
||||||
|
passwordMessage = L10n.t("account.password.saved")
|
||||||
|
} catch {
|
||||||
|
passwordError = UserFacingError.message(for: error) ?? L10n.t("common.error.generic")
|
||||||
|
}
|
||||||
|
savingPassword = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func logout() {
|
||||||
|
loggingOut = true
|
||||||
|
Task {
|
||||||
|
await container.authRepository.logout()
|
||||||
|
onLoggedOut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MatchAccountTextField: View {
|
||||||
|
let title: String
|
||||||
|
@Binding var text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
TextField("", text: $text)
|
||||||
|
.padding(12)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MatchAccountSecureField: View {
|
||||||
|
let title: String
|
||||||
|
@Binding var text: String
|
||||||
|
@Binding var visible: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
HStack {
|
||||||
|
Group {
|
||||||
|
if visible {
|
||||||
|
TextField("", text: $text)
|
||||||
|
} else {
|
||||||
|
SecureField("", text: $text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
Button(action: { visible.toggle() }) {
|
||||||
|
Image(systemName: visible ? "eye.slash" : "eye")
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ForgotPasswordScreen: View {
|
||||||
|
@ObservedObject var container: AppContainer
|
||||||
|
let onBack: () -> Void
|
||||||
|
|
||||||
|
@State private var email = ""
|
||||||
|
@State private var loading = false
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var success = false
|
||||||
|
@State private var languageTick = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
MatchScreenScaffold(
|
||||||
|
topBar: {
|
||||||
|
HStack {
|
||||||
|
Button(action: onBack) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
Text(L10n.t("forgot.password.title"))
|
||||||
|
.font(MatchTypography.headlineMedium)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text(L10n.t("forgot.password.lead"))
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(L10n.t("login.email"))
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
TextField("", text: $email)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.disabled(success)
|
||||||
|
.padding(12)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Text(error)
|
||||||
|
.foregroundStyle(MatchColors.primaryRed)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
if success {
|
||||||
|
Text(L10n.t("forgot.password.success"))
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchPrimaryButton(
|
||||||
|
label: L10n.t("forgot.password.submit"),
|
||||||
|
action: submit,
|
||||||
|
enabled: !email.trimmingCharacters(in: .whitespaces).isEmpty && !loading && !success,
|
||||||
|
loading: loading
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
|
||||||
|
languageTick += 1
|
||||||
|
}
|
||||||
|
.id(languageTick)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() {
|
||||||
|
loading = true
|
||||||
|
error = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
_ = try await container.authRepository.forgotPassword(email: email)
|
||||||
|
success = true
|
||||||
|
} catch {
|
||||||
|
self.error = UserFacingError.message(for: error) ?? L10n.t("common.error.generic")
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
struct LoginScreen: View {
|
struct LoginScreen: View {
|
||||||
@ObservedObject var container: AppContainer
|
@ObservedObject var container: AppContainer
|
||||||
let onLoggedIn: () -> Void
|
let onLoggedIn: () -> Void
|
||||||
|
let onForgotPassword: () -> Void
|
||||||
|
|
||||||
@State private var email = ""
|
@State private var email = ""
|
||||||
@State private var password = ""
|
@State private var password = ""
|
||||||
@@ -37,6 +38,16 @@ struct LoginScreen: View {
|
|||||||
MatchSecureField(title: L10n.t("login.password"), text: $password, visible: $passwordVisible)
|
MatchSecureField(title: L10n.t("login.password"), text: $password, visible: $passwordVisible)
|
||||||
}
|
}
|
||||||
.padding(.top, 32)
|
.padding(.top, 32)
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button(action: onForgotPassword) {
|
||||||
|
Text(L10n.t("login.forgot.password"))
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.underline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 12)
|
||||||
if let error {
|
if let error {
|
||||||
Text(error)
|
Text(error)
|
||||||
.foregroundStyle(MatchColors.primaryRed)
|
.foregroundStyle(MatchColors.primaryRed)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ struct MatchesScreen: View {
|
|||||||
var refreshToken: Int = 0
|
var refreshToken: Int = 0
|
||||||
let onOpenSetup: (String) -> Void
|
let onOpenSetup: (String) -> Void
|
||||||
let onOpenBroadcast: (String) -> Void
|
let onOpenBroadcast: (String) -> Void
|
||||||
let onLogout: () -> Void
|
let onOpenAccount: () -> Void
|
||||||
|
|
||||||
@State private var loading = true
|
@State private var loading = true
|
||||||
@State private var refreshing = false
|
@State private var refreshing = false
|
||||||
@@ -37,15 +37,12 @@ struct MatchesScreen: View {
|
|||||||
}
|
}
|
||||||
.accessibilityLabel(L10n.t("language.label"))
|
.accessibilityLabel(L10n.t("language.label"))
|
||||||
Button {
|
Button {
|
||||||
Task {
|
onOpenAccount()
|
||||||
await container.authRepository.logout()
|
|
||||||
onLogout()
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
Image(systemName: "person.crop.circle")
|
||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(L10n.t("action.logout"))
|
.accessibilityLabel(L10n.t("account.title"))
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|||||||
@@ -27,7 +27,16 @@ struct AppNavHost: View {
|
|||||||
case .splash:
|
case .splash:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
case .login:
|
case .login:
|
||||||
LoginScreen(container: container) { path = [.matches] }
|
LoginScreen(
|
||||||
|
container: container,
|
||||||
|
onLoggedIn: { path = [.matches] },
|
||||||
|
onForgotPassword: { path.append(.forgotPassword) }
|
||||||
|
)
|
||||||
|
case .forgotPassword:
|
||||||
|
ForgotPasswordScreen(
|
||||||
|
container: container,
|
||||||
|
onBack: { path.removeLast() }
|
||||||
|
)
|
||||||
case .matches:
|
case .matches:
|
||||||
MatchesScreen(
|
MatchesScreen(
|
||||||
container: container,
|
container: container,
|
||||||
@@ -38,9 +47,15 @@ struct AppNavHost: View {
|
|||||||
onOpenBroadcast: { sessionId in
|
onOpenBroadcast: { sessionId in
|
||||||
broadcastRoute = BroadcastRoute(sessionId: sessionId)
|
broadcastRoute = BroadcastRoute(sessionId: sessionId)
|
||||||
},
|
},
|
||||||
onLogout: { path = [.login] }
|
onOpenAccount: { path.append(.account) }
|
||||||
)
|
)
|
||||||
.lockPortraitOrientation()
|
.lockPortraitOrientation()
|
||||||
|
case .account:
|
||||||
|
AccountScreen(
|
||||||
|
container: container,
|
||||||
|
onBack: { path.removeLast() },
|
||||||
|
onLoggedOut: { path = [.login] }
|
||||||
|
)
|
||||||
case .setup, .broadcast:
|
case .setup, .broadcast:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import Foundation
|
|||||||
enum Routes: Hashable {
|
enum Routes: Hashable {
|
||||||
case splash
|
case splash
|
||||||
case login
|
case login
|
||||||
|
case forgotPassword
|
||||||
case matches
|
case matches
|
||||||
|
case account
|
||||||
case setup(matchId: String, step: Int)
|
case setup(matchId: String, step: Int)
|
||||||
case broadcast(sessionId: String)
|
case broadcast(sessionId: String)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,17 @@ if ssh "$SERVER" 'command -v rsync >/dev/null 2>&1'; then
|
|||||||
--exclude '.git' \
|
--exclude '.git' \
|
||||||
--exclude 'native/android/build' \
|
--exclude 'native/android/build' \
|
||||||
--exclude 'native/android/.gradle' \
|
--exclude 'native/android/.gradle' \
|
||||||
|
--exclude 'native/android/app/build' \
|
||||||
|
--exclude 'native/android/dist' \
|
||||||
|
--exclude 'native/android/keystore' \
|
||||||
|
--exclude 'native/android/keystore.properties' \
|
||||||
|
--exclude 'native/android/local.properties' \
|
||||||
--exclude 'backend/log' \
|
--exclude 'backend/log' \
|
||||||
--exclude 'backend/tmp' \
|
--exclude 'backend/tmp' \
|
||||||
--exclude 'backend/.bundle' \
|
--exclude 'backend/.bundle' \
|
||||||
--exclude 'infra/.env' \
|
--exclude 'infra/.env' \
|
||||||
|
--exclude 'infra/garage/garage.prod.toml' \
|
||||||
|
--exclude 'infra/garage/prod-credentials.env' \
|
||||||
--exclude 'log/' \
|
--exclude 'log/' \
|
||||||
--exclude 'node_modules' \
|
--exclude 'node_modules' \
|
||||||
"$SRC/" "$SERVER:$DEST/"
|
"$SRC/" "$SERVER:$DEST/"
|
||||||
@@ -24,7 +31,10 @@ else
|
|||||||
echo "rsync non disponibile sul server, uso tar+scp..."
|
echo "rsync non disponibile sul server, uso tar+scp..."
|
||||||
tar -C "$SRC" -czf /tmp/matchlivetv-deploy.tar.gz \
|
tar -C "$SRC" -czf /tmp/matchlivetv-deploy.tar.gz \
|
||||||
--exclude='./native/android/build' --exclude='./native/android/.gradle' \
|
--exclude='./native/android/build' --exclude='./native/android/.gradle' \
|
||||||
--exclude='./backend/log' --exclude='./backend/tmp' --exclude='./infra/.env' --exclude='./.git' .
|
--exclude='./native/android/app/build' --exclude='./native/android/dist' \
|
||||||
|
--exclude='./native/android/keystore' --exclude='./native/android/keystore.properties' \
|
||||||
|
--exclude='./backend/log' --exclude='./backend/tmp' --exclude='./infra/.env' \
|
||||||
|
--exclude='./infra/garage/garage.prod.toml' --exclude='./.git' .
|
||||||
scp /tmp/matchlivetv-deploy.tar.gz "$SERVER:~/matchlivetv-deploy.tar.gz"
|
scp /tmp/matchlivetv-deploy.tar.gz "$SERVER:~/matchlivetv-deploy.tar.gz"
|
||||||
ssh "$SERVER" "mkdir -p $DEST && tar -xzf ~/matchlivetv-deploy.tar.gz -C $DEST"
|
ssh "$SERVER" "mkdir -p $DEST && tar -xzf ~/matchlivetv-deploy.tar.gz -C $DEST"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user