Stabilizza regia, live web e sync punteggi app nativa.

Corregge scadenza token regia oltre le 8h, tabellone HTML sulla pagina live,
pulsanti pausa/ripresa in regia, link admin e broadcast ActionCable diretto.
App Android: overlay branding, sync score da regia via WebSocket con reconnect e poll.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 09:30:43 +02:00
parent 08e800120a
commit ae36d17adb
55 changed files with 2819 additions and 285 deletions

View File

@@ -3,7 +3,7 @@ module CableBroadcastable
class_methods do
def broadcast_message(session, data)
broadcast_to(session, method: "receive_message", data: data)
broadcast_to(session, data)
end
end
end

View File

@@ -22,5 +22,22 @@ module Admin
Rails.logger.error("[admin] stop session #{@session.id}: #{e.class} #{e.message}")
redirect_to admin_session_path(@session), alert: "Errore durante la chiusura: #{e.message}"
end
def regia_link
@session = StreamSession.find(params[:id])
if @session.terminal?
redirect_to admin_session_path(@session), alert: "Sessione terminata: impossibile generare il link regia."
return
end
access = Sessions::RegiaAccess.new(@session)
token = access.issue_token!
redirect_to admin_session_path(@session),
notice: "Link regia generato.",
flash: {
regia_url: access.url(token),
regia_expires_at: @session.regia_token_expires_at&.iso8601
}
end
end
end

View File

@@ -1,6 +1,9 @@
module Api
module V1
class MatchesController < BaseController
include Api::UrlHelper
include BrandingAttachments
before_action :set_team, only: %i[index create]
before_action :set_match, only: %i[show update destroy]
@@ -14,6 +17,7 @@ module Api
def create
match = @team.matches.create!(match_params)
attach_opponent_logo(match)
render json: match_json(match), status: :created
end
@@ -24,7 +28,9 @@ module Api
def update
attrs = match_params.to_h
normalize_scoring_rules!(attrs)
normalize_opponent_color!(attrs)
@match.update!(attrs)
attach_opponent_logo(@match)
render json: match_json(@match)
end
@@ -49,18 +55,17 @@ module Api
def set_match
team_ids = current_user.streamable_teams.map(&:id)
@match = Match.where(team_id: team_ids).find(params[:id])
@match = Match.includes(:team).where(team_id: team_ids).find(params[:id])
end
def match_params
params.require(:match).permit(
:opponent_name, :location, :scheduled_at, :sport, :sets_to_win,
:category,
:category, :opponent_primary_color,
scoring_rules: %i[points_per_set points_deciding_set min_point_lead]
)
end
# Regolamento standard: scoring_rules assente o {} → default FIPAV lato Scoring::Rules.
def normalize_scoring_rules!(attrs)
return unless attrs.key?("scoring_rules")
@@ -68,12 +73,27 @@ module Api
attrs["scoring_rules"] = {} if rules.blank?
end
def normalize_opponent_color!(attrs)
return unless attrs.key?("opponent_primary_color")
attrs["opponent_primary_color"] = normalize_hex_color(
attrs["opponent_primary_color"],
Match::DEFAULT_OPPONENT_COLOR
)
end
def attach_opponent_logo(match)
file = params[:opponent_logo_file]
match.opponent_logo_file.attach(file) if file.present?
end
def match_json(match, detail: false)
active = active_session_for(match)
team = match.team
{
id: match.id,
team_id: match.team_id,
team_name: match.team.name,
team_name: team.name,
opponent_name: match.opponent_name,
location: match.location,
scheduled_at: match.scheduled_at,
@@ -81,6 +101,11 @@ module Api
sets_to_win: match.sets_to_win,
scoring_rules: match.scoring_rules.presence,
category: match.category,
home_primary_color: team.effective_primary_color,
home_secondary_color: team.effective_secondary_color,
home_logo_url: api_absolute_url(team.effective_logo_url),
opponent_primary_color: match.effective_opponent_primary_color,
opponent_logo_url: api_absolute_url(match.opponent_logo_url),
active_session_id: active&.id,
active_session_status: active&.status,
stream_completed: match.stream_completed?,

View File

@@ -100,13 +100,31 @@ module Api
end
def network_test
upload_mbps = params[:upload_mbps].to_f
preset = Sessions::SelectQuality.call(upload_mbps: upload_mbps)
@session.update!(
quality_preset: preset[:id],
target_bitrate: preset[:target_bitrate],
target_fps: preset[:target_fps]
)
@session.stream_events.create!(
event_type: "network_test",
metadata: params.permit(:download_mbps, :upload_mbps, :latency_ms, :network_type).to_h,
metadata: params.permit(:download_mbps, :upload_mbps, :latency_ms, :network_type)
.to_h.merge(
"quality_preset" => preset[:id],
"target_bitrate" => preset[:target_bitrate]
),
occurred_at: Time.current
)
ready = params[:upload_mbps].to_f >= (@session.target_bitrate / 1_000_000.0 * 0.8)
render json: { ready: ready, target_upload_mbps: @session.target_bitrate / 1_000_000.0 }
target_upload = preset[:target_bitrate] / 1_000_000.0
ready = upload_mbps >= (target_upload * Sessions::SelectQuality::UPLOAD_HEADROOM)
render json: {
ready: ready,
target_upload_mbps: target_upload,
quality_preset: preset[:id],
target_bitrate: preset[:target_bitrate],
target_fps: preset[:target_fps]
}
end
def youtube_stats

View File

@@ -1,6 +1,9 @@
module Api
module V1
class TeamsController < BaseController
include Api::UrlHelper
include BrandingAttachments
def index
teams = current_user.streamable_teams
render json: teams.map { |t| team_json(t) }
@@ -48,6 +51,7 @@ module Api
def update
team = current_user.manageable_teams.find(params[:id])
team.update!(team_params)
attach_team_logo(team)
render json: team_json(team)
end
@@ -69,7 +73,19 @@ module Api
private
def team_params
params.require(:team).permit(:name, :sport, :logo_url)
p = params.require(:team).permit(:name, :sport, :logo_url, :primary_color, :secondary_color)
if p[:primary_color].present?
p[:primary_color] = normalize_hex_color(p[:primary_color], p[:primary_color])
end
if p[:secondary_color].present?
p[:secondary_color] = normalize_hex_color(p[:secondary_color], p[:secondary_color])
end
p
end
def attach_team_logo(team)
file = params[:logo_file]
team.logo_file.attach(file) if file.present?
end
def team_json(team, detail: false)
@@ -80,7 +96,7 @@ module Api
id: team.id,
name: team.name,
sport: team.sport,
logo_url: team.effective_logo_url,
logo_url: api_absolute_url(team.effective_logo_url),
primary_color: team.effective_primary_color,
secondary_color: team.effective_secondary_color,
club_id: team.club_id,

View File

@@ -0,0 +1,16 @@
module Api
module UrlHelper
extend ActiveSupport::Concern
private
def api_absolute_url(path_or_url)
return nil if path_or_url.blank?
return path_or_url if path_or_url.match?(/\Ahttps?:\/\//i)
base = request.base_url
path = path_or_url.start_with?("/") ? path_or_url : "/#{path_or_url}"
"#{base}#{path}"
end
end
end

View File

@@ -6,7 +6,7 @@ module Public
layout "regia"
protect_from_forgery with: :null_session
skip_before_action :verify_authenticity_token, only: %i[score pause stop]
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop]
before_action :set_session_from_token
@@ -44,6 +44,13 @@ module Public
render json: status_payload
end
def resume
Sessions::Resume.new(@session).call
render json: status_payload
rescue Teams::EntitlementError => e
render json: status_payload.merge(error: e.message), status: :unprocessable_entity
end
def stop
Sessions::Stop.new(@session).call
render json: status_payload
@@ -55,8 +62,7 @@ module Public
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
return if @session
render file: Rails.root.join("public/404.html"), status: :not_found, layout: false
throw :abort
head :not_found
end
def status_payload

View File

@@ -19,4 +19,22 @@ module AdminHelper
mins = minutes % 60
hours.positive? ? "#{hours}h #{mins}m" : "#{mins} min"
end
def admin_session_watch_links(session)
links = []
if session.matchlivetv_platform?
links << { label: "Pagina live", url: session.watch_page_url }
end
youtube = session.youtube_watch_url
links << { label: "YouTube", url: youtube } if youtube.present?
links
end
def admin_regia_expires_label(iso_time)
return if iso_time.blank?
Time.zone.parse(iso_time).strftime("%d/%m/%Y %H:%M")
rescue ArgumentError, TypeError
nil
end
end

View File

@@ -61,6 +61,36 @@ module Public
"#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}"
end
def live_scorebug_columns(score_state)
unless score_state
return {
labels: ["1"],
home: ["0"],
away: ["0"],
live_index: 0
}
end
partials = Array(score_state.set_partials)
labels = partials.map { |p| (p["set"] || p[:set]).to_s }
home = partials.map { |p| (p["home"] || p[:home]).to_s }
away = partials.map { |p| (p["away"] || p[:away]).to_s }
labels << score_state.current_set.to_s
home << score_state.home_points.to_s
away << score_state.away_points.to_s
{
labels: labels,
home: home,
away: away,
live_index: labels.length - 1
}
end
def live_scorebug_team_label(name, max: 16)
n = name.to_s
n.length <= max ? n : "#{n[0, max - 1]}"
end
# Colore ospite sul tabellone: secondario società se leggibile su bianco, altrimenti blu visitatore.
def live_scorebug_away_color(team)
club = team.club

View File

@@ -2,11 +2,16 @@ class Match < ApplicationRecord
belongs_to :team
has_many :stream_sessions, dependent: :destroy
has_one_attached :opponent_logo_file
ACTIVE_SESSION_STATUSES = %w[live connecting reconnecting paused].freeze
DEFAULT_OPPONENT_COLOR = "#1E3A8A"
validates :opponent_name, presence: true
validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 }
validates :opponent_primary_color, format: { with: Brandable::HEX_COLOR }, allow_blank: true
validate :scoring_rules_shape, if: -> { scoring_rules.present? }
validate :opponent_logo_file_type, if: -> { opponent_logo_file.attached? }
# category: campionato / descrizione torneo (facoltativo, es. "Serie C").
@@ -63,6 +68,16 @@ class Match < ApplicationRecord
stream_sessions.where(status: %w[ended error]).exists?
end
def opponent_logo_url
return unless opponent_logo_file.attached?
Rails.application.routes.url_helpers.rails_blob_path(opponent_logo_file, only_path: true)
end
def effective_opponent_primary_color
opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR
end
# Hub app coach: dirette da riprendere o partite programmate future.
# Le bozze «Avversario» abbandonate (senza sessione) non compaiono.
def coach_hub_visible?
@@ -77,6 +92,12 @@ class Match < ApplicationRecord
private
def opponent_logo_file_type
return if opponent_logo_file.content_type.in?(%w[image/png image/jpeg image/webp])
errors.add(:opponent_logo_file, "deve essere PNG, JPEG o WebP")
end
def scoring_rules_shape
rules = scoring_rules.is_a?(Hash) ? scoring_rules : {}
%w[points_per_set points_deciding_set min_point_lead].each do |key|

View File

@@ -131,6 +131,12 @@ class StreamSession < ApplicationRecord
status.in?(%w[ended error])
end
def regia_token_active?
regia_token_digest.present? &&
regia_token_expires_at&.future? &&
!terminal?
end
def resumable?
status.in?(%w[connecting live reconnecting paused])
end

View File

@@ -13,7 +13,9 @@ module Sessions
def rotate!
token = SecureRandom.urlsafe_base64(24)
expires = [DEFAULT_TTL.from_now, session_end_cap].compact.min
expires = DEFAULT_TTL.from_now
cap = session_end_cap
expires = [expires, cap].min if cap&.future?
@session.update!(
regia_token_digest: digest(token),
regia_token_expires_at: expires

View File

@@ -0,0 +1,29 @@
module Sessions
# Sceglie il preset più alto supportato dall'upload misurato (test rete).
class SelectQuality
UPLOAD_HEADROOM = 0.8
PRESETS = [
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
{ id: "720p_30_4mbps", target_bitrate: 4_000_000, target_fps: 30 },
{ id: "720p_30_2.5mbps", target_bitrate: 2_500_000, target_fps: 30 },
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
].freeze
def self.call(upload_mbps:)
new(upload_mbps: upload_mbps).call
end
def initialize(upload_mbps:)
@upload_mbps = upload_mbps.to_f
end
def call
PRESETS.find { |preset| upload_sufficient?(preset) } || PRESETS.last
end
def upload_sufficient?(preset)
@upload_mbps >= (preset[:target_bitrate] / 1_000_000.0 * UPLOAD_HEADROOM)
end
end
end

View File

@@ -91,7 +91,7 @@
<% if @active_sessions.any? %>
<table class="admin-table">
<thead>
<tr><th>Partita</th><th>Stato</th><th>Inizio</th><th></th></tr>
<tr><th>Partita</th><th>Stato</th><th>Inizio</th><th>Link</th><th></th></tr>
</thead>
<tbody id="active-sessions-body">
<% @active_sessions.each do |s| %>
@@ -99,6 +99,12 @@
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
<td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td>
<td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || "—" %></td>
<td class="admin-link-compact">
<% admin_session_watch_links(s).each do |link| %>
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
<% end %>
<%= link_to "Regia", admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
</td>
<td class="admin-actions">
<%= link_to "Dettaglio", admin_session_path(s) %>
<%= button_to "Termina", stop_admin_session_path(s), method: :post, class: "admin-btn admin-btn--danger admin-btn--sm", form: { data: { turbo_confirm: "Terminare questa sessione?" } } %>

View File

@@ -0,0 +1,41 @@
<% watch_links = admin_session_watch_links(session) %>
<section class="admin-links-panel">
<h3>Link diretta</h3>
<% if watch_links.any? %>
<ul class="admin-link-list">
<% watch_links.each do |link| %>
<li>
<span class="admin-link-label"><%= link[:label] %></span>
<%= link_to link[:url], link[:url], target: "_blank", rel: "noopener", class: "admin-link-url" %>
</li>
<% end %>
</ul>
<% else %>
<p class="muted">Nessun link video disponibile per questa sessione.</p>
<% end %>
<h3>Link regia</h3>
<% if session.terminal? %>
<p class="muted">Sessione terminata — link regia non disponibile.</p>
<% else %>
<% if flash[:regia_url].present? %>
<p class="admin-link-generated">
<span class="admin-link-label">Regia</span>
<%= link_to flash[:regia_url], flash[:regia_url], target: "_blank", rel: "noopener", class: "admin-link-url" %>
</p>
<p><code class="admin-link-code"><%= flash[:regia_url] %></code></p>
<% if (expires = admin_regia_expires_label(flash[:regia_expires_at])) %>
<p class="muted">Valido fino a <%= expires %></p>
<% end %>
<% elsif session.regia_token_active? %>
<p class="muted">Esiste già un link regia attivo (lURL non è recuperabile). Generane uno nuovo se serve condividerlo di nuovo.</p>
<% else %>
<p class="muted">Nessun link regia attivo. Genera un link da condividere con chi gestisce il punteggio.</p>
<% end %>
<%= button_to "Genera link regia",
regia_link_admin_session_path(session),
method: :post,
class: "admin-btn admin-btn--secondary admin-btn--sm",
form: { class: "admin-inline-form" } %>
<% end %>
</section>

View File

@@ -1,12 +1,18 @@
<h2>Stream Sessions</h2>
<table class="admin-table">
<thead><tr><th>Match</th><th>Status</th><th>Disconnects</th><th></th></tr></thead>
<thead><tr><th>Match</th><th>Status</th><th>Disconnects</th><th>Link</th><th></th></tr></thead>
<tbody>
<% @sessions.each do |s| %>
<tr>
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
<td><span class="badge <%= s.status == 'live' ? 'badge--live' : 'badge--paused' %>"><%= s.status %></span></td>
<td><%= s.disconnection_count %></td>
<td class="admin-link-compact">
<% admin_session_watch_links(s).each do |link| %>
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
<% end %>
<%= link_to "Regia", admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
</td>
<td><%= link_to "Dettaglio", admin_session_path(s) %></td>
</tr>
<% end %>

View File

@@ -10,8 +10,11 @@
</p>
<% end %>
<p>Match: <%= @session.match.team.name %> vs <%= @session.match.opponent_name %></p>
<%= render "admin/sessions/links", session: @session %>
<% if @session.youtube_broadcast_id %>
<p>YouTube: <a href="https://studio.youtube.com/video/<%= @session.youtube_broadcast_id %>/livestreaming" target="_blank">Broadcast</a></p>
<p>YouTube Studio: <a href="https://studio.youtube.com/video/<%= @session.youtube_broadcast_id %>/livestreaming" target="_blank" rel="noopener">Broadcast</a></p>
<% end %>
<p>RTMP ingest: <code><%= @session.rtmp_ingest_url %></code></p>

View File

@@ -7,7 +7,7 @@
<%= render "shared/meta_tags" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=36">
<link rel="stylesheet" href="/live.css?v=22">
<link rel="stylesheet" href="/live.css?v=23">
<%= yield :head %>
</head>
<body<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>

View File

@@ -0,0 +1,47 @@
<% score = @session.score_state %>
<% team = @match.team %>
<% cols = live_scorebug_columns(score) %>
<% home_color = team.effective_primary_color %>
<% away_color = live_scorebug_away_color(team) %>
<div class="live-player-overlays" id="live-overlays">
<div
id="live-scorebug"
class="live-scorebug"
style="--sb-home-primary: <%= home_color %>; --sb-away-primary: <%= away_color %>;"
data-home-name="<%= j live_scorebug_team_label(team.name) %>"
data-away-name="<%= j live_scorebug_team_label(@match.opponent_name) %>"
>
<table class="live-scorebug__table" aria-label="Tabellone">
<caption class="visually-hidden">Punteggio live</caption>
<thead>
<tr>
<th class="live-scorebug__corner" scope="col">Squadra</th>
<% cols[:labels].each do |label| %>
<th class="live-scorebug__col-set live-scorebug__col-h" scope="col"><%= label %></th>
<% end %>
</tr>
</thead>
<tbody>
<tr class="live-scorebug__row--home">
<th class="live-scorebug__team" scope="row"><%= live_scorebug_team_label(team.name) %></th>
<% cols[:home].each_with_index do |val, i| %>
<td class="live-scorebug__pts<%= " live-scorebug__pts--live-home" if i == cols[:live_index] %>"><%= val %></td>
<% end %>
</tr>
<tr class="live-scorebug__row--away">
<th class="live-scorebug__team" scope="row"><%= live_scorebug_team_label(@match.opponent_name) %></th>
<% cols[:away].each_with_index do |val, i| %>
<td class="live-scorebug__pts<%= " live-scorebug__pts--live-away" if i == cols[:live_index] %>"><%= val %></td>
<% end %>
</tr>
</tbody>
</table>
<div class="live-scorebug__brand" aria-hidden="true">
<span class="live-scorebug__brand-text">MATCH <span class="live-scorebug__brand-accent">LIVE TV</span></span>
</div>
</div>
<span
id="live-stream-badge"
class="live-ovl-badge live-ovl-badge--right <%= @stream_closed ? "badge-ended" : (@session.paused? ? "badge-wait" : (@on_air ? "badge-live" : "badge-wait")) %>"
><%= @stream_closed ? "Terminata" : (@session.paused? ? "In pausa" : (@on_air ? "LIVE" : "In attesa")) %></span>
</div>

View File

@@ -1,7 +1,7 @@
<% club_name = @match.team.club&.name %>
<% content_for :title do %><%= club_name %> · <%= @match.team.name %> vs <%= @match.opponent_name %> — Diretta live<% end %>
<% content_for :meta_description do %>Segui in diretta live <%= club_name %> — <%= @match.team.name %> vs <%= @match.opponent_name %><% if @match.location.present? %> — <%= @match.location %><% end %>. Guarda dal browser senza installare app.<% end %>
<% content_for :robots, "noindex, follow" %>
<% content_for :robots, @session.publicly_listed? ? "index, follow" : "noindex, nofollow" %>
<% content_for :head do %>
<% unless @stream_closed || @session.platform == "youtube" %>
@@ -41,7 +41,7 @@
<% else %>
<div class="live-player-wrap">
<video id="player" controls playsinline muted autoplay poster="/images/copertina-canale.png"></video>
<%# Tabellone e watermark sono bruciati nel video dall'app mobile (overlay client-side). %>
<%= render "public/live/scorebug", match: @match, session: @session %>
<button type="button" id="play-hint" class="live-play-hint" hidden>
▶ Avvia la diretta
</button>
@@ -58,6 +58,88 @@
const pausedMsg = document.getElementById("paused-msg");
const awaitingMsg = document.getElementById("awaiting-msg");
const playHint = document.getElementById("play-hint");
const scorebug = document.getElementById("live-scorebug");
const streamBadge = document.getElementById("live-stream-badge");
const homeName = "<%= j @match.team.name %>";
const awayName = "<%= j @match.opponent_name %>";
function abbrevName(name, max = 16) {
const n = String(name || "");
return n.length <= max ? n : `${n.slice(0, max - 1)}…`;
}
function scoreColumnsFromPayload(score) {
if (!score) return { labels: ["1"], home: ["0"], away: ["0"], liveIndex: 0 };
const partials = score.set_partials || score.setPartials || [];
const labels = partials.map((p) => String(p.set ?? p["set"] ?? ""));
const home = partials.map((p) => String(p.home ?? p["home"] ?? "0"));
const away = partials.map((p) => String(p.away ?? p["away"] ?? "0"));
const currentSet = score.current_set ?? score.currentSet ?? 1;
labels.push(String(currentSet));
home.push(String(score.home_points ?? score.homePoints ?? 0));
away.push(String(score.away_points ?? score.awayPoints ?? 0));
return { labels, home, away, liveIndex: labels.length - 1 };
}
function renderLiveScorebug(score) {
if (!scorebug) return;
const cols = scoreColumnsFromPayload(score);
const homeLabel = abbrevName(scorebug.dataset.homeName || homeName);
const awayLabel = abbrevName(scorebug.dataset.awayName || awayName);
const headCells = cols.labels
.map((label) => `<th class="live-scorebug__col-set live-scorebug__col-h" scope="col">${label}</th>`)
.join("");
const homeCells = cols.home
.map((val, i) => {
const live = i === cols.liveIndex ? " live-scorebug__pts--live-home" : "";
return `<td class="live-scorebug__pts${live}">${val}</td>`;
})
.join("");
const awayCells = cols.away
.map((val, i) => {
const live = i === cols.liveIndex ? " live-scorebug__pts--live-away" : "";
return `<td class="live-scorebug__pts${live}">${val}</td>`;
})
.join("");
scorebug.innerHTML = `
<table class="live-scorebug__table" aria-label="Tabellone">
<caption class="visually-hidden">Punteggio live</caption>
<thead><tr>
<th class="live-scorebug__corner" scope="col">Squadra</th>${headCells}
</tr></thead>
<tbody>
<tr class="live-scorebug__row--home">
<th class="live-scorebug__team" scope="row">${homeLabel}</th>${homeCells}
</tr>
<tr class="live-scorebug__row--away">
<th class="live-scorebug__team" scope="row">${awayLabel}</th>${awayCells}
</tr>
</tbody>
</table>
<div class="live-scorebug__brand" aria-hidden="true">
<span class="live-scorebug__brand-text">MATCH <span class="live-scorebug__brand-accent">LIVE TV</span></span>
</div>`;
}
function syncStreamBadge(data) {
if (!streamBadge) return;
if (data.stream_closed) {
streamBadge.textContent = "Terminata";
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-ended";
return;
}
const paused = !!(data.paused || data.status === "paused");
if (paused) {
streamBadge.textContent = "In pausa";
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
} else if (data.on_air) {
streamBadge.textContent = "LIVE";
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-live";
} else {
streamBadge.textContent = "In attesa";
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
}
}
let hls = null;
let playerStarted = false;
@@ -247,6 +329,9 @@
return;
}
renderLiveScorebug(data.score);
syncStreamBadge(data);
const onAir = !!data.on_air;
const sessionLive = !!data.live;

View File

@@ -12,6 +12,7 @@
data-status-url="<%= j public_regia_status_path(params[:token]) %>"
data-score-url="<%= j public_regia_score_path(params[:token]) %>"
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
data-hls-url="<%= j @session.hls_playback_url %>"
@@ -20,13 +21,14 @@
data-share-url="<%= j @share_url %>"
data-share-title="<%= j @share_title %>"
data-points-target="<%= Scoring::Rules.from_match(@match).points_target(@score.current_set) %>"
data-stream-closed="<%= @stream_closed %>">
data-stream-closed="<%= @stream_closed %>"
data-initial-paused="<%= @session.paused? %>">
<header class="regia-header">
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
<p>Regia · Set <%= @score.current_set %></p>
<span id="regia-badge" class="regia-badge <%= @on_air ? 'regia-badge--live' : (@stream_closed ? 'regia-badge--ended' : 'regia-badge--wait') %>">
<%= @stream_closed ? "Terminata" : (@on_air ? "In onda" : "In attesa") %>
<span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>">
<%= @stream_closed ? "Terminata" : (@session.paused? ? "In pausa" : (@on_air ? "In onda" : "In attesa")) %>
</span>
</header>
@@ -76,7 +78,7 @@
</section>
<% unless @stream_closed %>
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause">Metti in pausa</button>
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause"><%= @session.paused? ? "Riprendi diretta" : "Metti in pausa" %></button>
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop">Chiudi diretta</button>
<% end %>
</div>
@@ -94,4 +96,4 @@
</div>
</div>
<script src="/regia.js?v=2"></script>
<script src="/regia.js?v=4"></script>

View File

@@ -85,7 +85,10 @@ Rails.application.routes.draw do
resources :billing_invoices, only: %i[index new create edit update], controller: "billing_invoices"
end
resources :sessions, only: %i[index show] do
member { post :stop }
member do
post :stop
post :regia_link
end
end
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
end
@@ -105,6 +108,7 @@ Rails.application.routes.draw do
get "regia/:token/status.json", to: "public/regia#status", as: :public_regia_status
patch "regia/:token/score", to: "public/regia#score", as: :public_regia_score
post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause
post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
root to: "public/pages#home"

View File

@@ -0,0 +1,5 @@
class AddOpponentBrandingToMatches < ActiveRecord::Migration[7.2]
def change
add_column :matches, :opponent_primary_color, :string
end
end

View File

@@ -233,6 +233,90 @@ body.admin-body {
align-items: center;
}
.admin-links-panel {
margin: 1.25rem 0 1.5rem;
padding: 1rem 1.1rem;
background: var(--card-bg, #14141c);
border: 1px solid var(--card-border, #2a2a36);
border-radius: 8px;
}
.admin-links-panel h3 {
margin: 0 0 0.6rem;
font-size: 0.95rem;
}
.admin-links-panel h3 + h3 {
margin-top: 1.1rem;
}
.admin-link-list {
list-style: none;
margin: 0;
padding: 0;
}
.admin-link-list li,
.admin-link-generated {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
align-items: baseline;
margin-bottom: 0.45rem;
}
.admin-link-label {
min-width: 6.5rem;
color: var(--muted, #9ca3af);
font-size: 0.85rem;
}
.admin-link-url {
color: var(--red, #e53935);
word-break: break-all;
}
.admin-link-code {
display: block;
margin: 0.35rem 0 0.5rem;
padding: 0.45rem 0.6rem;
background: #0a0a0e;
border-radius: 6px;
font-size: 0.78rem;
word-break: break-all;
}
.admin-link-compact {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.admin-link-chip {
display: inline-block;
padding: 0.15rem 0.45rem;
border-radius: 999px;
background: #2a2a36;
color: #eee;
font-size: 0.72rem;
text-decoration: none;
}
.admin-link-chip:hover {
background: #3a3a48;
color: #fff;
}
.admin-link-chip--regia {
background: #3d2a00;
color: #f9a825;
}
.admin-inline-form {
display: inline-block;
margin-top: 0.35rem;
}
.admin-input {
padding: 0.45rem 0.6rem;
border: 1px solid #444;

View File

@@ -7,6 +7,7 @@
statusUrl: root.dataset.statusUrl,
scoreUrl: root.dataset.scoreUrl,
pauseUrl: root.dataset.pauseUrl,
resumeUrl: root.dataset.resumeUrl,
stopUrl: root.dataset.stopUrl,
cableUrl: root.dataset.cableUrl,
hlsUrl: root.dataset.hlsUrl,
@@ -30,13 +31,15 @@
modalConfirm: document.getElementById("modal-confirm"),
modalCancel: document.getElementById("modal-cancel"),
preview: document.getElementById("regia-preview"),
previewPlaceholder: document.getElementById("regia-preview-placeholder")
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
btnPause: document.getElementById("btn-pause")
};
let pendingAction = null;
let hls = null;
let previewStarted = false;
let wasPausedPreview = false;
let streamPaused = root.dataset.initialPaused === "true";
function hlsUrlFresh() {
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
@@ -108,7 +111,15 @@
}
}
function syncPauseButton(data) {
if (!els.btnPause) return;
const paused = !!(data.paused || data.status === "paused");
streamPaused = paused;
els.btnPause.textContent = paused ? "Riprendi diretta" : "Metti in pausa";
}
function setBadge(data) {
syncPauseButton(data);
if (data.stream_closed) {
els.badge.textContent = "Terminata";
els.badge.className = "regia-badge regia-badge--ended";
@@ -139,6 +150,8 @@
}
async function handleAction(action) {
const prevHome = els.homePoints?.textContent;
const prevAway = document.getElementById("away-points")?.textContent;
try {
const data = await postScore(action);
applyScorePayload(data);
@@ -152,6 +165,9 @@
openModal("Partita vinta", `${winner} ha vinto la partita. Chiudere la diretta?`, () => stopStream());
}
} catch (e) {
if (prevHome != null && els.homePoints) els.homePoints.textContent = prevHome;
const awayEl = document.getElementById("away-points");
if (prevAway != null && awayEl) awayEl.textContent = prevAway;
toast(e.message || "Errore");
}
}
@@ -284,10 +300,19 @@
showPreviewPlaceholder("Diretta terminata");
}
async function pauseStream() {
await fetch(cfg.pauseUrl, { method: "POST", headers: { Accept: "application/json" } });
toast("Diretta in pausa — copertina in onda");
pollStatus();
async function togglePauseStream() {
const url = streamPaused ? cfg.resumeUrl : cfg.pauseUrl;
const res = await fetch(url, { method: "POST", headers: { Accept: "application/json" } });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || (streamPaused ? "Errore ripresa diretta" : "Errore pausa diretta"));
}
const data = await res.json();
const wasPaused = streamPaused;
applyScorePayload(data);
setBadge(data);
syncPreviewFromStatus(data);
toast(wasPaused ? "Diretta ripresa" : "Diretta in pausa — copertina in onda");
}
async function stopStream() {
@@ -302,7 +327,7 @@
return data;
}
document.getElementById("btn-pause")?.addEventListener("click", () => pauseStream().catch((e) => toast(e.message)));
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
async function shareLink() {

View File

@@ -22,4 +22,24 @@ RSpec.describe "Public regia", type: :request do
expect(response).to have_http_status(:ok)
expect(session.score_state.reload.home_points).to eq(1)
end
it "mette in pausa e riprende la diretta" do
token = Sessions::RegiaAccess.new(session).issue_token!
post public_regia_pause_path(token)
expect(response).to have_http_status(:ok)
expect(session.reload.status).to eq("paused")
post public_regia_resume_path(token)
expect(response).to have_http_status(:ok)
expect(session.reload.status).to eq("connecting")
end
it "emette token valido anche oltre le 8 ore dalla partenza" do
session.update!(started_at: 9.hours.ago)
token = Sessions::RegiaAccess.new(session).issue_token!
session.reload
expect(session.regia_token_expires_at).to be > Time.current
get public_regia_path(token)
expect(response).to have_http_status(:ok)
end
end

View File

@@ -12,7 +12,7 @@ android {
applicationId = "com.matchlivetv.match_live_tv"
minSdk = 24
targetSdk = 35
versionCode = 14
versionCode = 15
versionName = "2.0.0-native"
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
@@ -74,6 +74,8 @@ dependencies {
testImplementation("junit:junit:4.13.2")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("com.github.pedroSG94.RootEncoder:library:2.5.5")
androidTestImplementation("androidx.test.ext:junit:1.2.1")

View File

@@ -28,6 +28,7 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">

View File

@@ -0,0 +1,40 @@
package com.matchlivetv.match_live_tv.core
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
private val HEX_PATTERN = Regex("^#?[0-9A-Fa-f]{6}$")
fun normalizeHexColor(input: String?, fallback: String = "#E53935"): String {
val raw = input?.trim().orEmpty()
if (raw.isEmpty()) return fallback
val withHash = if (raw.startsWith("#")) raw else "#$raw"
return if (HEX_PATTERN.matches(withHash)) withHash.uppercase() else fallback
}
fun parseColorHex(hex: String?, fallbackArgb: Int): Int {
val normalized = normalizeHexColor(hex, "")
if (normalized.isEmpty()) return fallbackArgb
return runCatching {
Color(android.graphics.Color.parseColor(normalized)).toArgb()
}.getOrDefault(fallbackArgb)
}
fun colorToHex(color: Color): String {
val argb = color.toArgb()
return String.format("#%06X", 0xFFFFFF and argb)
}
fun hsvToHex(h: Float, s: Float, v: Float): String =
colorToHex(Color(android.graphics.Color.HSVToColor(floatArrayOf(h, s, v))))
private val PLACEHOLDER_TEAM_COLORS = listOf(
"#E53935", "#1E88E5", "#43A047", "#FB8C00",
"#8E24AA", "#00897B", "#5E35B1", "#F4511E",
)
/** Colore decorativo per squadre senza branding completo (logo + colore). */
fun placeholderTeamColor(seed: String): String {
val index = kotlin.math.abs(seed.hashCode()) % PLACEHOLDER_TEAM_COLORS.size
return PLACEHOLDER_TEAM_COLORS[index]
}

View File

@@ -6,16 +6,25 @@ import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.os.Build
import android.os.PowerManager
enum class ThermalLevel {
NORMAL,
WARM,
HOT,
CRITICAL,
}
data class DeviceHealthSnapshot(
val batteryPercent: Int,
val batteryTempC: Float?,
val thermalLevel: ThermalLevel,
val thermalLabel: String,
)
object DeviceTelemetry {
fun batteryLevel(context: Context): Int {
val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
?: return 100
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
if (level < 0 || scale <= 0) return 100
return ((level * 100f) / scale).toInt().coerceIn(0, 100)
}
fun batteryLevel(context: Context): Int = snapshot(context).batteryPercent
fun networkType(context: Context): String = runCatching {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@@ -28,4 +37,67 @@ object DeviceTelemetry {
else -> "Sconosciuto"
}
}.getOrDefault("Sconosciuto")
fun snapshot(context: Context): DeviceHealthSnapshot {
val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val batteryPercent = readBatteryPercent(batteryIntent)
val batteryTempC = readBatteryTemperatureC(batteryIntent)
val thermalLevel = resolveThermalLevel(context, batteryTempC)
return DeviceHealthSnapshot(
batteryPercent = batteryPercent,
batteryTempC = batteryTempC,
thermalLevel = thermalLevel,
thermalLabel = thermalLabel(thermalLevel),
)
}
private fun readBatteryPercent(intent: Intent?): Int {
if (intent == null) return 100
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
if (level < 0 || scale <= 0) return 100
return ((level * 100f) / scale).toInt().coerceIn(0, 100)
}
private fun readBatteryTemperatureC(intent: Intent?): Float? {
val raw = intent?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, -1) ?: -1
if (raw <= 0) return null
return raw / 10f
}
private fun resolveThermalLevel(context: Context, batteryTempC: Float?): ThermalLevel {
val fromStatus = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
when (pm.currentThermalStatus) {
PowerManager.THERMAL_STATUS_NONE,
PowerManager.THERMAL_STATUS_LIGHT,
-> ThermalLevel.NORMAL
PowerManager.THERMAL_STATUS_MODERATE -> ThermalLevel.WARM
PowerManager.THERMAL_STATUS_SEVERE -> ThermalLevel.HOT
PowerManager.THERMAL_STATUS_CRITICAL,
PowerManager.THERMAL_STATUS_EMERGENCY,
PowerManager.THERMAL_STATUS_SHUTDOWN,
-> ThermalLevel.CRITICAL
else -> null
}
} else {
null
}
val fromTemp = batteryTempC?.let { tempFromBattery(it) }
return listOfNotNull(fromStatus, fromTemp).maxByOrNull { it.ordinal } ?: ThermalLevel.NORMAL
}
private fun tempFromBattery(tempC: Float): ThermalLevel = when {
tempC >= 45f -> ThermalLevel.CRITICAL
tempC >= 42f -> ThermalLevel.HOT
tempC >= 38f -> ThermalLevel.WARM
else -> ThermalLevel.NORMAL
}
private fun thermalLabel(level: ThermalLevel): String = when (level) {
ThermalLevel.NORMAL -> "OK"
ThermalLevel.WARM -> "Caldo"
ThermalLevel.HOT -> "Surriscaldamento"
ThermalLevel.CRITICAL -> "Troppo caldo"
}
}

View File

@@ -0,0 +1,11 @@
package com.matchlivetv.match_live_tv.core
fun resolveMediaUrl(path: String?): String? {
val value = path?.trim().orEmpty()
if (value.isEmpty()) return null
if (value.startsWith("http://", ignoreCase = true) || value.startsWith("https://", ignoreCase = true)) {
return value
}
val base = AppConfig.apiBaseUrl
return if (value.startsWith("/")) "$base$value" else "$base/$value"
}

View File

@@ -12,6 +12,7 @@ import com.matchlivetv.match_live_tv.data.repository.SessionRepository
import com.matchlivetv.match_live_tv.data.scoring.ScoreController
import com.matchlivetv.match_live_tv.BuildConfig
import com.matchlivetv.match_live_tv.streaming.LiveBroadcastCoordinator
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayLogoCache
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.CoroutineScope
@@ -69,18 +70,19 @@ class AppContainer(context: Context) {
val authRepository = AuthRepository(api, tokenStore) { token ->
accessToken = token
OverlayLogoCache.configure(token)
}
val matchRepository = MatchRepository(api, tokenStore)
val matchRepository = MatchRepository(api, tokenStore, appContext)
val sessionRepository = SessionRepository(api)
val scoreRepository = ScoreRepository(api)
val sessionCable = SessionCableService(moshi)
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val sessionCable = SessionCableService(moshi, appScope)
val scoreController = ScoreController(scoreRepository, sessionCable, appScope)
val wizardSession = WizardSessionHolder()
@@ -88,6 +90,9 @@ class AppContainer(context: Context) {
val broadcastCoordinator = LiveBroadcastCoordinator(appContext)
suspend fun bootstrapAuth() {
authRepository.currentSession()?.let { accessToken = it.accessToken }
authRepository.currentSession()?.let {
accessToken = it.accessToken
OverlayLogoCache.configure(it.accessToken)
}
}
}

View File

@@ -41,12 +41,16 @@ data class TeamDto(
@Json(name = "youtube_connected") val youtubeConnected: Boolean? = false,
@Json(name = "youtube_selectable") val youtubeSelectable: Boolean? = false,
@Json(name = "youtube_channel_title") val youtubeChannelTitle: String? = null,
@Json(name = "youtube_uses_platform_channel") val youtubeUsesPlatformChannel: Boolean? = null,
@Json(name = "youtube_team_channel_title") val youtubeTeamChannelTitle: String? = null,
@Json(name = "plan_name") val planName: String? = null,
@Json(name = "recordings_enabled") val recordingsEnabled: Boolean? = false,
@Json(name = "phone_download_enabled") val phoneDownloadEnabled: Boolean? = false,
@Json(name = "billing_url") val billingUrl: String? = null,
@Json(name = "staff_manage_url") val staffManageUrl: String? = null,
@Json(name = "logo_url") val logoUrl: String? = null,
@Json(name = "primary_color") val primaryColor: String? = null,
@Json(name = "secondary_color") val secondaryColor: String? = null,
) {
fun toDomain() = Team(
id = id,
@@ -54,10 +58,14 @@ data class TeamDto(
sport = sport ?: "volleyball",
canStream = canStream ?: true,
clubName = clubName,
logoUrl = logoUrl,
primaryColor = primaryColor,
secondaryColor = secondaryColor,
youtubeEnabled = youtubeEnabled ?: false,
youtubeConnected = youtubeConnected ?: false,
youtubeSelectable = youtubeSelectable ?: false,
youtubeChannelTitle = youtubeChannelTitle,
youtubeUsesPlatformChannel = youtubeUsesPlatformChannel ?: false,
youtubeTeamChannelTitle = youtubeTeamChannelTitle,
planName = planName,
recordingsEnabled = recordingsEnabled ?: false,
@@ -82,6 +90,11 @@ data class MatchDto(
@Json(name = "active_session_status") val activeSessionStatus: String? = null,
@Json(name = "stream_completed") val streamCompleted: Boolean? = null,
@Json(name = "coach_hub_visible") val coachHubVisible: Boolean? = null,
@Json(name = "home_primary_color") val homePrimaryColor: String? = null,
@Json(name = "home_secondary_color") val homeSecondaryColor: String? = null,
@Json(name = "home_logo_url") val homeLogoUrl: String? = null,
@Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null,
@Json(name = "opponent_logo_url") val opponentLogoUrl: String? = null,
) {
fun toDomain() = Match(
id = id,
@@ -97,6 +110,11 @@ data class MatchDto(
activeSessionStatus = activeSessionStatus,
streamCompleted = streamCompleted ?: false,
coachHubVisible = coachHubVisible,
homePrimaryColor = homePrimaryColor,
homeSecondaryColor = homeSecondaryColor,
homeLogoUrl = homeLogoUrl,
opponentPrimaryColor = opponentPrimaryColor,
opponentLogoUrl = opponentLogoUrl,
)
}
@@ -218,14 +236,25 @@ data class UpdateMatchBodyFull(
@Json(name = "scheduled_at") val scheduledAt: String? = null,
@Json(name = "sets_to_win") val setsToWin: Int,
val category: String? = null,
@Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null,
@Json(name = "scoring_rules") val scoringRules: ScoringRulesBody? = null,
)
data class UpdateTeamRequest(val team: UpdateTeamBody)
data class UpdateTeamBody(
@Json(name = "primary_color") val primaryColor: String? = null,
@Json(name = "secondary_color") val secondaryColor: String? = null,
)
data class UpdateMatchRequestFull(val match: UpdateMatchBodyFull)
data class NetworkTestResponse(
val ready: Boolean,
@Json(name = "target_upload_mbps") val targetUploadMbps: Double? = null,
@Json(name = "quality_preset") val qualityPreset: String? = null,
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
@Json(name = "target_fps") val targetFps: Int? = null,
)
data class RecordingDto(

View File

@@ -3,9 +3,12 @@ package com.matchlivetv.match_live_tv.data.api
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import okhttp3.MultipartBody
interface MatchLiveApi {
@POST("auth/login")
@@ -23,6 +26,24 @@ interface MatchLiveApi {
@GET("teams")
suspend fun teams(): List<TeamDto>
@GET("teams/{teamId}")
suspend fun team(@Path("teamId") teamId: String): TeamDto
@PATCH("teams/{teamId}")
suspend fun updateTeam(
@Path("teamId") teamId: String,
@Body body: UpdateTeamRequest,
): TeamDto
@Multipart
@PATCH("teams/{teamId}")
suspend fun updateTeamMultipart(
@Path("teamId") teamId: String,
@Part("team[primary_color]") primaryColor: okhttp3.RequestBody?,
@Part("team[secondary_color]") secondaryColor: okhttp3.RequestBody?,
@Part logoFile: MultipartBody.Part?,
): TeamDto
@GET("teams/{teamId}/recordings")
suspend fun recordings(@Path("teamId") teamId: String): List<RecordingDto>
@@ -44,6 +65,22 @@ interface MatchLiveApi {
@Body body: UpdateMatchRequestFull,
): MatchDto
@Multipart
@PATCH("matches/{matchId}")
suspend fun updateMatchMultipart(
@Path("matchId") matchId: String,
@Part("match[opponent_name]") opponentName: okhttp3.RequestBody,
@Part("match[location]") location: okhttp3.RequestBody?,
@Part("match[scheduled_at]") scheduledAt: okhttp3.RequestBody?,
@Part("match[sets_to_win]") setsToWin: okhttp3.RequestBody,
@Part("match[category]") category: okhttp3.RequestBody?,
@Part("match[opponent_primary_color]") opponentPrimaryColor: okhttp3.RequestBody?,
@Part("match[scoring_rules][points_per_set]") pointsPerSet: okhttp3.RequestBody?,
@Part("match[scoring_rules][points_deciding_set]") pointsDecidingSet: okhttp3.RequestBody?,
@Part("match[scoring_rules][min_point_lead]") minPointLead: okhttp3.RequestBody?,
@Part opponentLogoFile: MultipartBody.Part?,
): MatchDto
@DELETE("matches/{matchId}")
suspend fun deleteMatch(@Path("matchId") matchId: String)

View File

@@ -0,0 +1,29 @@
package com.matchlivetv.match_live_tv.data.api
import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
object MultipartBodies {
fun textPart(value: String) = value.toRequestBody("text/plain".toMediaTypeOrNull())
fun logoPart(context: Context, uri: Uri, fieldName: String): MultipartBody.Part {
val contentResolver = context.applicationContext.contentResolver
val mime = contentResolver.getType(uri) ?: "image/jpeg"
val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
val temp = File.createTempFile("upload_", ".$ext", context.cacheDir)
contentResolver.openInputStream(uri)?.use { input ->
temp.outputStream().use { output -> input.copyTo(output) }
}
return MultipartBody.Part.createFormData(
fieldName,
"logo.$ext",
temp.asRequestBody(mime.toMediaTypeOrNull()),
)
}
}

View File

@@ -116,11 +116,16 @@ class ActionCableClient(
"welcome" -> Unit
else -> {
if (json.has("message")) {
@Suppress("UNCHECKED_CAST")
val message = json.opt("message")
when (message) {
is JSONObject -> listener?.onChannelMessage(messageToMap(message))
is Map<*, *> -> listener?.onChannelMessage(message as Map<String, Any?>)
is String -> runCatching { JSONObject(message) }
.getOrNull()
?.let { listener?.onChannelMessage(messageToMap(it)) }
is Map<*, *> -> {
@Suppress("UNCHECKED_CAST")
listener?.onChannelMessage(message as Map<String, Any?>)
}
}
}
}
@@ -130,11 +135,20 @@ class ActionCableClient(
private fun messageToMap(json: JSONObject): Map<String, Any?> {
val map = mutableMapOf<String, Any?>()
json.keys().forEach { key ->
map[key] = json.get(key)
map[key] = jsonValueToKotlin(json.get(key))
}
return map
}
private fun jsonValueToKotlin(value: Any?): Any? = when (value) {
is JSONObject -> messageToMap(value)
is org.json.JSONArray -> List(value.length()) { index ->
jsonValueToKotlin(value.get(index))
}
JSONObject.NULL -> null
else -> value
}
companion object {
private const val TAG = "ActionCableClient"
}

View File

@@ -1,14 +1,30 @@
package com.matchlivetv.match_live_tv.data.cable
import android.util.Log
import com.matchlivetv.match_live_tv.core.AppConfig
import com.matchlivetv.match_live_tv.domain.ScoreState
import com.squareup.moshi.Moshi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.math.min
class SessionCableService(private val moshi: Moshi) {
class SessionCableService(
private val moshi: Moshi,
private val scope: CoroutineScope,
) {
private var client: ActionCableClient? = null
private var reconnectJob: Job? = null
private var reconnectAttempt = 0
private var intentionalDisconnect = false
private var pendingSessionId: String? = null
private var pendingToken: String? = null
private var pendingDeviceRole: String = "camera"
private val _connected = MutableStateFlow(false)
val connected: StateFlow<Boolean> = _connected.asStateFlow()
@@ -16,23 +32,53 @@ class SessionCableService(private val moshi: Moshi) {
var onScoreUpdate: ((ScoreState) -> Unit)? = null
var onPauseStream: (() -> Unit)? = null
var onResumeStream: (() -> Unit)? = null
var onEndStream: (() -> Unit)? = null
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
disconnect()
client = ActionCableClient(AppConfig.cableUrl, accessToken, moshi).also { cable ->
intentionalDisconnect = false
reconnectJob?.cancel()
reconnectAttempt = 0
pendingSessionId = sessionId
pendingToken = accessToken
pendingDeviceRole = deviceRole
openSocket()
}
fun sendScoreUpdate(score: ScoreState) {
client?.performReceive(score.cablePayload())
}
fun disconnect() {
intentionalDisconnect = true
reconnectJob?.cancel()
reconnectJob = null
pendingSessionId = null
pendingToken = null
client?.disconnect()
client = null
_connected.value = false
}
private fun openSocket() {
val sessionId = pendingSessionId ?: return
val token = pendingToken ?: return
client?.disconnect()
client = ActionCableClient(AppConfig.cableUrl, token, moshi).also { cable ->
cable.connect(
channel = "SessionChannel",
params = mapOf(
"session_id" to sessionId,
"device_role" to deviceRole,
"device_role" to pendingDeviceRole,
),
listener = object : ActionCableClient.Listener {
override fun onConnected() {
reconnectAttempt = 0
_connected.value = true
}
override fun onDisconnected() {
_connected.value = false
scheduleReconnect()
}
override fun onChannelMessage(payload: Map<String, Any?>) {
@@ -43,44 +89,65 @@ class SessionCableService(private val moshi: Moshi) {
}
}
fun sendScoreUpdate(score: ScoreState) {
client?.performReceive(score.cablePayload())
}
fun disconnect() {
client?.disconnect()
client = null
_connected.value = false
private fun scheduleReconnect() {
if (intentionalDisconnect || pendingSessionId == null || pendingToken == null) return
reconnectJob?.cancel()
reconnectJob = scope.launch {
reconnectAttempt += 1
val delayMs = min(30_000L, 1_000L shl min(reconnectAttempt - 1, 4))
Log.i(TAG, "ActionCable reconnect in ${delayMs}ms (attempt $reconnectAttempt)")
delay(delayMs)
if (!intentionalDisconnect) openSocket()
}
}
private fun dispatch(data: Map<String, Any?>) {
when (data["type"] as? String) {
"score_update" -> onScoreUpdate?.invoke(parseScore(data))
"stream_event" -> when (data["event"] as? String) {
val payload = unwrapCablePayload(data)
when (payload["type"] as? String) {
"score_update" -> onScoreUpdate?.invoke(parseScore(payload))
"command" -> when (payload["action"] as? String) {
"pause_stream" -> onPauseStream?.invoke()
"resume_stream" -> onResumeStream?.invoke()
"stop_stream" -> onEndStream?.invoke()
}
"stream_event" -> when (payload["event"] as? String) {
"paused" -> onPauseStream?.invoke()
"resumed" -> onResumeStream?.invoke()
"ended" -> onEndStream?.invoke()
}
}
}
private fun parseScore(data: Map<String, Any?>): ScoreState {
/** Compat: messaggi legacy `{ method: receive_message, data: {...} }`. */
private fun unwrapCablePayload(raw: Map<String, Any?>): Map<String, Any?> {
if (raw["method"] != "receive_message") return raw
val nested = raw["data"] ?: return raw
@Suppress("UNCHECKED_CAST")
val partialsRaw = data["set_partials"] as? List<Map<String, Any?>> ?: emptyList()
return nested as? Map<String, Any?> ?: raw
}
private fun parseScore(data: Map<String, Any?>): ScoreState {
val partialsRaw = data["set_partials"] as? List<*> ?: emptyList<Any>()
return ScoreState(
homeSets = (data["home_sets"] as? Number)?.toInt() ?: 0,
awaySets = (data["away_sets"] as? Number)?.toInt() ?: 0,
homePoints = (data["home_points"] as? Number)?.toInt() ?: 0,
awayPoints = (data["away_points"] as? Number)?.toInt() ?: 0,
currentSet = (data["current_set"] as? Number)?.toInt() ?: 1,
setPartials = partialsRaw.map {
setPartials = partialsRaw.mapNotNull { item ->
val row = item as? Map<*, *> ?: return@mapNotNull null
com.matchlivetv.match_live_tv.domain.SetPartial(
set = (it["set"] as? Number)?.toInt() ?: 1,
home = (it["home"] as? Number)?.toInt() ?: 0,
away = (it["away"] as? Number)?.toInt() ?: 0,
set = (row["set"] as? Number)?.toInt() ?: 1,
home = (row["home"] as? Number)?.toInt() ?: 0,
away = (row["away"] as? Number)?.toInt() ?: 0,
)
},
timeoutHome = data["timeout_home"] as? Boolean ?: false,
timeoutAway = data["timeout_away"] as? Boolean ?: false,
)
}
companion object {
private const val TAG = "SessionCableService"
}
}

View File

@@ -1,13 +1,18 @@
package com.matchlivetv.match_live_tv.data.repository
import android.content.Context
import android.net.Uri
import com.matchlivetv.match_live_tv.core.TokenStore
import com.matchlivetv.match_live_tv.core.parseApiInstant
import com.matchlivetv.match_live_tv.data.api.CreateMatchBody
import com.matchlivetv.match_live_tv.data.api.CreateMatchRequest
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.api.MultipartBodies
import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody
import com.matchlivetv.match_live_tv.data.api.UpdateMatchBodyFull
import com.matchlivetv.match_live_tv.data.api.UpdateMatchRequestFull
import com.matchlivetv.match_live_tv.data.api.UpdateTeamBody
import com.matchlivetv.match_live_tv.data.api.UpdateTeamRequest
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.Recording
import com.matchlivetv.match_live_tv.domain.Team
@@ -18,12 +23,14 @@ import java.time.Instant
class MatchRepository(
private val api: MatchLiveApi,
private val tokenStore: TokenStore,
private val appContext: Context,
) {
suspend fun fetchTeams(): List<Team> =
api.teams().map { it.toDomain() }
suspend fun fetchTeam(teamId: String): Team? =
fetchTeams().firstOrNull { it.id == teamId }
runCatching { api.team(teamId).toDomain() }.getOrNull()
?: fetchTeams().firstOrNull { it.id == teamId }
suspend fun resolveActiveTeam(teams: List<Team>): Team? {
if (teams.isEmpty()) return null
@@ -77,6 +84,31 @@ class MatchRepository(
),
).toDomain()
suspend fun updateTeamBranding(
teamId: String,
primaryColor: String,
secondaryColor: String?,
logoUri: Uri?,
): Team {
if (logoUri != null) {
return api.updateTeamMultipart(
teamId = teamId,
primaryColor = MultipartBodies.textPart(primaryColor),
secondaryColor = secondaryColor?.let { MultipartBodies.textPart(it) },
logoFile = MultipartBodies.logoPart(appContext, logoUri, "logo_file"),
).toDomain()
}
return api.updateTeam(
teamId,
UpdateTeamRequest(
team = UpdateTeamBody(
primaryColor = primaryColor,
secondaryColor = secondaryColor,
),
),
).toDomain()
}
suspend fun updateMatch(
matchId: String,
opponentName: String,
@@ -84,20 +116,39 @@ class MatchRepository(
scheduledAt: String?,
setsToWin: Int,
category: String?,
opponentPrimaryColor: String?,
scoringRules: ScoringRulesBody?,
): Match = api.updateMatch(
matchId,
UpdateMatchRequestFull(
match = UpdateMatchBodyFull(
opponentName = opponentName,
location = location?.takeIf { it.isNotBlank() },
scheduledAt = scheduledAt,
setsToWin = setsToWin,
category = category?.takeIf { it.isNotBlank() },
scoringRules = scoringRules,
),
),
).toDomain()
opponentLogoUri: Uri? = null,
): Match {
val body = UpdateMatchBodyFull(
opponentName = opponentName,
location = location?.takeIf { it.isNotBlank() },
scheduledAt = scheduledAt,
setsToWin = setsToWin,
category = category?.takeIf { it.isNotBlank() },
opponentPrimaryColor = opponentPrimaryColor,
scoringRules = scoringRules,
)
if (opponentLogoUri != null) {
return api.updateMatchMultipart(
matchId = matchId,
opponentName = MultipartBodies.textPart(opponentName),
location = body.location?.let { MultipartBodies.textPart(it) },
scheduledAt = scheduledAt?.let { MultipartBodies.textPart(it) },
setsToWin = MultipartBodies.textPart(setsToWin.toString()),
category = body.category?.let { MultipartBodies.textPart(it) },
opponentPrimaryColor = opponentPrimaryColor?.let { MultipartBodies.textPart(it) },
pointsPerSet = scoringRules?.pointsPerSet?.let { MultipartBodies.textPart(it.toString()) },
pointsDecidingSet = scoringRules?.pointsDecidingSet?.let { MultipartBodies.textPart(it.toString()) },
minPointLead = scoringRules?.minPointLead?.let { MultipartBodies.textPart(it.toString()) },
opponentLogoFile = MultipartBodies.logoPart(appContext, opponentLogoUri, "opponent_logo_file"),
).toDomain()
}
return api.updateMatch(
matchId,
UpdateMatchRequestFull(match = body),
).toDomain()
}
suspend fun deleteMatch(matchId: String) {
api.deleteMatch(matchId)

View File

@@ -35,8 +35,9 @@ class ScoreController(
}
fun applyRemote(remote: ScoreState) {
if (remote == _score.value) return
val now = System.currentTimeMillis()
if (now < suppressRemoteUntilMs && remote.progressKey() < _score.value.progressKey()) {
if (now < suppressRemoteUntilMs && remote.progressKey() <= _score.value.progressKey()) {
return
}
_score.value = remote

View File

@@ -17,11 +17,15 @@ data class Team(
val name: String,
val sport: String,
val clubName: String? = null,
val logoUrl: String? = null,
val primaryColor: String? = null,
val secondaryColor: String? = null,
val canStream: Boolean = true,
val youtubeEnabled: Boolean = false,
val youtubeConnected: Boolean = false,
val youtubeSelectable: Boolean = false,
val youtubeChannelTitle: String? = null,
val youtubeUsesPlatformChannel: Boolean = false,
val youtubeTeamChannelTitle: String? = null,
val planName: String? = null,
val recordingsEnabled: Boolean = false,
@@ -31,6 +35,24 @@ data class Team(
) {
val canUseYoutube: Boolean get() = youtubeEnabled
val isYoutubeReady: Boolean get() = youtubeSelectable
/** Canale YouTube effettivo per la prossima diretta (`platform` | `team`). */
val effectiveYoutubeChannel: String?
get() = if (isYoutubeReady) {
if (youtubeUsesPlatformChannel) "platform" else "team"
} else {
null
}
val youtubeDestinationLabel: String
get() = if (youtubeUsesPlatformChannel) {
"Canale Match Live TV"
} else {
val name = youtubeTeamChannelTitle?.takeIf { it.isNotBlank() }
?: clubName?.takeIf { it.isNotBlank() }
?: "società"
"Canale società · $name"
}
}
data class ScoringRules(
@@ -54,6 +76,11 @@ data class Match(
val activeSessionStatus: String? = null,
val streamCompleted: Boolean = false,
val coachHubVisible: Boolean? = null,
val homePrimaryColor: String? = null,
val homeSecondaryColor: String? = null,
val homeLogoUrl: String? = null,
val opponentPrimaryColor: String? = null,
val opponentLogoUrl: String? = null,
) {
val hasActiveSession: Boolean get() = activeSessionId != null

View File

@@ -28,6 +28,10 @@ data class ScoreState(
},
)
fun progressKey(): Int =
homeSets * 1_000_000 + awaySets * 100_000 + homePoints * 1_000 + awayPoints
fun progressKey(): Long =
currentSet * 10_000_000_000L +
homeSets * 1_000_000L +
awaySets * 100_000L +
homePoints * 1_000L +
awayPoints
}

View File

@@ -0,0 +1,103 @@
package com.matchlivetv.match_live_tv.streaming.overlay
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
/** Cache bitmap loghi squadra per il tabellone overlay. */
object OverlayLogoCache {
private const val TAG = "OverlayLogoCache"
private val cache = mutableMapOf<String, Bitmap>()
private val mutex = Mutex()
@Volatile
private var authToken: String? = null
private val httpClient = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.followRedirects(true)
.build()
fun configure(authToken: String?) {
this.authToken = authToken
}
fun get(url: String?): Bitmap? {
if (url.isNullOrBlank()) return null
return cache[url]
}
suspend fun preload(context: Context, url: String?, bearerToken: String? = authToken) {
if (url.isNullOrBlank()) return
if (cache.containsKey(url)) return
mutex.withLock {
if (cache.containsKey(url)) return
val bitmap = loadBitmap(context, url, bearerToken) ?: run {
Log.w(TAG, "logo non caricato: $url")
return
}
cache[url] = bitmap
Log.i(TAG, "logo in cache: $url (${bitmap.width}x${bitmap.height})")
}
}
suspend fun preloadAll(
context: Context,
urls: List<String?>,
bearerToken: String? = authToken,
) {
urls.filterNotNull().distinct().forEach { preload(context, it, bearerToken) }
}
fun clear() {
cache.values.forEach { it.recycle() }
cache.clear()
}
private suspend fun loadBitmap(
context: Context,
url: String,
bearerToken: String?,
): Bitmap? = withContext(Dispatchers.IO) {
runCatching {
val request = Request.Builder()
.url(url)
.apply {
bearerToken?.takeIf { it.isNotBlank() }?.let {
header("Authorization", "Bearer $it")
}
}
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.w(TAG, "HTTP ${response.code} per $url")
return@withContext null
}
response.body?.byteStream()?.use { stream ->
BitmapFactory.decodeStream(stream)
}
}
}.getOrNull()?.let { scaleDown(it) }
}
private fun scaleDown(source: Bitmap, maxSide: Int = 128): Bitmap {
val largest = maxOf(source.width, source.height)
if (largest <= maxSide) return source
val scale = maxSide.toFloat() / largest
val w = (source.width * scale).toInt().coerceAtLeast(1)
val h = (source.height * scale).toInt().coerceAtLeast(1)
return Bitmap.createScaledBitmap(source, w, h, true).also {
if (it !== source) source.recycle()
}
}
}

View File

@@ -3,7 +3,14 @@ package com.matchlivetv.match_live_tv.streaming.overlay
import com.matchlivetv.match_live_tv.domain.ScoreState
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
fun ScoreState.toScoreboardState(homeTeamName: String, awayTeamName: String): ScoreboardState {
fun ScoreState.toScoreboardState(
homeTeamName: String,
awayTeamName: String,
homeAccentColor: Int = 0xFFFF2D2D.toInt(),
awayAccentColor: Int = 0xFF1E3A8A.toInt(),
homeLogoUrl: String? = null,
awayLogoUrl: String? = null,
): ScoreboardState {
val columns = buildList {
setPartials.forEach { partial ->
add(
@@ -28,6 +35,10 @@ fun ScoreState.toScoreboardState(homeTeamName: String, awayTeamName: String): Sc
homeTeamName = homeTeamName,
awayTeamName = awayTeamName,
columns = columns,
homeAccentColor = homeAccentColor,
awayAccentColor = awayAccentColor,
homeLogoUrl = homeLogoUrl,
awayLogoUrl = awayLogoUrl,
)
}

View File

@@ -17,6 +17,8 @@ data class ScoreboardState(
val columns: List<ScoreboardSetColumn>,
val homeAccentColor: Int = 0xFFFF2D2D.toInt(),
val awayAccentColor: Int = 0xFF1E3A8A.toInt(),
val homeLogoUrl: String? = null,
val awayLogoUrl: String? = null,
)
enum class BroadcastOverlayStatus {
@@ -48,6 +50,10 @@ private fun ScoreboardState.fingerprint(): Int {
result = 31 * result + awayTeamName.hashCode()
result = 31 * result + homeAccentColor
result = 31 * result + awayAccentColor
result = 31 * result + (homeLogoUrl?.hashCode() ?: 0)
result = 31 * result + (awayLogoUrl?.hashCode() ?: 0)
result = 31 * result + logoLoadedKey(homeLogoUrl)
result = 31 * result + logoLoadedKey(awayLogoUrl)
columns.forEach { col ->
result = 31 * result + col.setNumber
result = 31 * result + col.homePoints
@@ -56,3 +62,5 @@ private fun ScoreboardState.fingerprint(): Int {
}
return result
}
private fun logoLoadedKey(url: String?): Int = if (OverlayLogoCache.get(url) != null) 1 else 0

View File

@@ -33,6 +33,7 @@ class ScoreboardElement : OverlayElement {
textAlign = Paint.Align.CENTER
}
private val accentBarPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val logoPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
private val winPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FF2D2D")
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
@@ -42,7 +43,23 @@ class ScoreboardElement : OverlayElement {
color = Color.parseColor("#CCCCCC")
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL)
}
private val rect = RectF()
private val brandMatchPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
}
private val brandTvPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#9CA3AF")
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
}
private val brandLiveBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FF2D2D")
}
private val brandLiveTextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
}
private val boxRect = RectF()
private val liveRect = RectF()
override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) {
val board = state.scoreboard ?: return
@@ -51,20 +68,24 @@ class ScoreboardElement : OverlayElement {
val scale = layout.canvasHeight / 720f * SIZE_BOOST
val pad = (8f * scale).roundToInt().coerceAtLeast(6)
val colorBarW = (5f * scale).roundToInt().coerceAtLeast(4)
val teamW = (118f * scale).roundToInt()
val logoSize = (24f * scale).roundToInt().coerceAtLeast(16)
val logoGap = (4f * scale).roundToInt().coerceAtLeast(3)
val teamW = (158f * scale).roundToInt()
val colW = (28f * scale).roundToInt().coerceAtLeast(22)
val brandH = (18f * scale).roundToInt().coerceAtLeast(14)
val headH = (16f * scale).roundToInt().coerceAtLeast(12)
val rowH = (20f * scale).roundToInt().coerceAtLeast(16)
val rowH = (22f * scale).roundToInt().coerceAtLeast(18)
val footerH = (18f * scale).roundToInt().coerceAtLeast(14)
val n = board.columns.size
val boxW = pad * 2 + colorBarW + teamW + n * colW
val boxH = pad * 2 + headH + rowH * 2 + footerH
val logoSlotW = logoSlotWidth(board, logoSize, logoGap)
val boxW = pad * 2 + colorBarW + logoSlotW + teamW + n * colW
val boxH = pad * 2 + brandH + headH + rowH * 2 + footerH
val left = layout.marginPx
val top = layout.marginPx
rect.set(left.toFloat(), top.toFloat(), (left + boxW).toFloat(), (top + boxH).toFloat())
canvas.drawRoundRect(rect, 6f * scale, 6f * scale, backgroundPaint)
boxRect.set(left.toFloat(), top.toFloat(), (left + boxW).toFloat(), (top + boxH).toFloat())
canvas.drawRoundRect(boxRect, 6f * scale, 6f * scale, backgroundPaint)
val headerSize = (10f * scale).coerceAtLeast(8f)
val teamSize = (11f * scale).coerceAtLeast(9f)
@@ -76,15 +97,17 @@ class ScoreboardElement : OverlayElement {
winPaint.textSize = scoreSize
footerPaint.textSize = footerSize
val scoresLeft = left + pad + colorBarW + teamW
val headerBaseline = top + pad + headH - (2f * scale)
drawBrandHeader(canvas, left, top, pad, scale, brandH)
val scoresLeft = left + pad + colorBarW + logoSlotW + teamW
val headerBaseline = top + pad + brandH + headH - (2f * scale)
board.columns.forEachIndexed { index, column ->
val cx = scoresLeft + index * colW + colW / 2f
canvas.drawText(column.setNumber.toString(), cx, headerBaseline, headerPaint)
}
val homeRowTop = top + pad + headH
val homeRowTop = top + pad + brandH + headH
val awayRowTop = homeRowTop + rowH
drawTeamRow(
canvas = canvas,
@@ -94,6 +117,9 @@ class ScoreboardElement : OverlayElement {
left = left,
pad = pad,
colorBarW = colorBarW,
logoSize = logoSize,
logoGap = logoGap,
logoSlotW = logoSlotW,
teamW = teamW,
colW = colW,
rowH = rowH,
@@ -108,6 +134,9 @@ class ScoreboardElement : OverlayElement {
left = left,
pad = pad,
colorBarW = colorBarW,
logoSize = logoSize,
logoGap = logoGap,
logoSlotW = logoSlotW,
teamW = teamW,
colW = colW,
rowH = rowH,
@@ -117,8 +146,50 @@ class ScoreboardElement : OverlayElement {
val currentSet = board.columns.lastOrNull { it.isCurrent }?.setNumber ?: board.columns.size
val footerText = ordinalSetLabel(currentSet)
val footerBaseline = rect.bottom - pad - (2f * scale)
canvas.drawText(footerText, rect.left + pad + colorBarW + 4f * scale, footerBaseline, footerPaint)
val footerBaseline = boxRect.bottom - pad - (2f * scale)
canvas.drawText(
footerText,
boxRect.left + pad + colorBarW + 4f * scale,
footerBaseline,
footerPaint,
)
}
private fun logoSlotWidth(board: ScoreboardState, logoSize: Int, logoGap: Int): Int {
val needsSlot = !board.homeLogoUrl.isNullOrBlank() || !board.awayLogoUrl.isNullOrBlank()
return if (needsSlot) logoSize + logoGap else 0
}
private fun drawBrandHeader(
canvas: Canvas,
left: Int,
top: Int,
pad: Int,
scale: Float,
brandH: Int,
) {
val textSize = (9f * scale).coerceAtLeast(7f)
brandMatchPaint.textSize = textSize
brandTvPaint.textSize = textSize
brandLiveTextPaint.textSize = textSize * 0.88f
var x = left + pad.toFloat()
val baseline = top + pad + brandH * 0.72f
canvas.drawText("MATCH", x, baseline, brandMatchPaint)
x += brandMatchPaint.measureText("MATCH") + 4f * scale
val liveText = "LIVE"
val livePadH = 4f * scale
val livePadV = 2f * scale
val liveW = brandLiveTextPaint.measureText(liveText) + livePadH * 2
val liveH = textSize + livePadV * 2
val liveTop = baseline - textSize - livePadV * 0.5f
liveRect.set(x, liveTop, x + liveW, liveTop + liveH)
canvas.drawRoundRect(liveRect, 3f * scale, 3f * scale, brandLiveBgPaint)
canvas.drawText(liveText, x + livePadH, baseline - livePadV * 0.3f, brandLiveTextPaint)
x += liveW + 4f * scale
canvas.drawText("TV", x, baseline, brandTvPaint)
}
private fun drawTeamRow(
@@ -129,6 +200,9 @@ class ScoreboardElement : OverlayElement {
left: Int,
pad: Int,
colorBarW: Int,
logoSize: Int,
logoGap: Int,
logoSlotW: Int,
teamW: Int,
colW: Int,
rowH: Int,
@@ -141,12 +215,25 @@ class ScoreboardElement : OverlayElement {
val barBottom = rowTop + rowH - (2f * scale)
canvas.drawRect(barLeft, barTop, barLeft + colorBarW, barBottom, accentBarPaint)
val logoUrl = if (isHome) board.homeLogoUrl else board.awayLogoUrl
val logo = OverlayLogoCache.get(logoUrl)
var nameLeft = barLeft + colorBarW + logoGap
if (logoSlotW > 0) {
if (logo != null) {
val logoLeft = nameLeft
val logoTop = rowTop + (rowH - logoSize) / 2f
val dest = RectF(logoLeft, logoTop, logoLeft + logoSize, logoTop + logoSize)
canvas.drawBitmap(logo, null, dest, logoPaint)
}
nameLeft += logoSize + logoGap
}
val rawName = if (isHome) board.homeTeamName else board.awayTeamName
val name = abbreviate(rawName).uppercase()
val nameMaxW = teamW - (8f * scale)
val name = rawName.trim().uppercase()
val nameMaxW = teamW - (6f * scale)
val displayName = ellipsize(name, teamPaint, nameMaxW)
val nameBaseline = rowTop + rowH * 0.72f
canvas.drawText(displayName, barLeft + colorBarW + 6f * scale, nameBaseline, teamPaint)
canvas.drawText(displayName, nameLeft, nameBaseline, teamPaint)
board.columns.forEachIndexed { index, column ->
val cx = scoresLeft + index * colW + colW / 2f
@@ -167,14 +254,8 @@ class ScoreboardElement : OverlayElement {
}
}
private fun abbreviate(name: String): String {
val trimmed = name.trim()
if (trimmed.length <= 12) return trimmed
return trimmed.take(11) + ""
}
private fun ellipsize(text: String, paint: TextPaint, maxWidth: Float): String {
val safeMax = max(32f, maxWidth)
val safeMax = max(48f, maxWidth)
return TextUtils.ellipsize(text, paint, safeMax, TextUtils.TruncateAt.END)?.toString() ?: text
}

View File

@@ -0,0 +1,614 @@
package com.matchlivetv.match_live_tv.ui.broadcast
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
import com.matchlivetv.match_live_tv.core.resolveMediaUrl
import com.matchlivetv.match_live_tv.core.DeviceHealthSnapshot
import com.matchlivetv.match_live_tv.core.ThermalLevel
import com.matchlivetv.match_live_tv.domain.ScoreState
import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
private val SideToolbarWidth = 44.dp
private val IconButtonSize = 36.dp
private val ScoreButtonSize = 32.dp
@Composable
fun BroadcastControlsOverlay(
controlsVisible: Boolean,
onToggleControls: () -> Unit,
statusText: String,
statusColor: Color,
cableConnected: Boolean,
isPaused: Boolean,
homeName: String,
awayName: String,
homeAccentColor: Color,
awayAccentColor: Color,
homeLogoUrl: String?,
awayLogoUrl: String?,
score: ScoreState,
pointsTarget: Int,
onPointHome: () -> Unit,
onPointAway: () -> Unit,
onMinusHome: () -> Unit,
onMinusAway: () -> Unit,
onCloseSet: () -> Unit,
onPauseOrResume: () -> Unit,
onTerminate: () -> Unit,
onShareLive: () -> Unit,
onShareRegia: () -> Unit,
shareLiveEnabled: Boolean,
fps: Int,
targetFps: Int,
bitrateKbps: Long,
networkType: String,
deviceHealth: DeviceHealthSnapshot,
modifier: Modifier = Modifier,
) {
var showTerminateConfirm by remember { mutableStateOf(false) }
if (showTerminateConfirm) {
AlertDialog(
onDismissRequest = { showTerminateConfirm = false },
containerColor = MatchColors.SurfaceElevated,
title = { Text("Terminare la diretta?") },
text = { Text("Lo streaming verrà chiuso per tutti gli spettatori.") },
confirmButton = {
TextButton(
onClick = {
showTerminateConfirm = false
onTerminate()
},
) {
Text("TERMINA", color = MatchColors.PrimaryRed)
}
},
dismissButton = {
TextButton(onClick = { showTerminateConfirm = false }) {
Text("Annulla", color = MatchColors.TextSecondary)
}
},
)
}
Box(modifier.fillMaxSize()) {
MatchStatusBadge(
text = statusText,
textColor = statusColor,
backgroundColor = MatchColors.Background.copy(alpha = 0.78f),
modifier = Modifier
.align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(start = 8.dp, top = 4.dp),
)
Column(
Modifier
.align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 4.dp, end = 8.dp),
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
SideIconButton(
icon = if (controlsVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (controlsVisible) "Nascondi controlli" else "Mostra controlli",
onClick = onToggleControls,
)
BroadcastTelemetryPanel(
cableConnected = cableConnected,
fps = fps,
targetFps = targetFps,
bitrateKbps = bitrateKbps,
networkType = networkType,
deviceHealth = deviceHealth,
)
}
if (controlsVisible) {
Column(
Modifier
.align(Alignment.CenterStart)
.padding(start = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
SideIconButton(
icon = Icons.Default.Share,
contentDescription = "Condividi diretta",
onClick = onShareLive,
enabled = shareLiveEnabled,
)
SideIconButton(
icon = Icons.Default.Videocam,
contentDescription = "Condividi link regia",
onClick = onShareRegia,
)
SideIconButton(
icon = Icons.Default.Check,
contentDescription = "Chiudi set",
onClick = onCloseSet,
)
}
Column(
Modifier
.align(Alignment.CenterEnd)
.padding(end = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
SideIconButton(
icon = if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
contentDescription = if (isPaused) "Riprendi diretta" else "Pausa diretta",
onClick = onPauseOrResume,
highlighted = isPaused,
)
SideIconButton(
icon = Icons.Default.Stop,
contentDescription = "Termina diretta",
onClick = { showTerminateConfirm = true },
danger = true,
)
}
Row(
Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(start = SideToolbarWidth, end = SideToolbarWidth, bottom = 8.dp),
verticalAlignment = Alignment.Bottom,
) {
TeamScoreColumn(
teamLabel = "CASA",
teamName = homeName,
accentColor = homeAccentColor,
logoUrl = homeLogoUrl,
points = score.homePoints,
onPlus = onPointHome,
onMinus = onMinusHome,
alignEnd = false,
modifier = Modifier.weight(1f),
)
Column(
Modifier
.padding(horizontal = 12.dp)
.padding(bottom = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"${score.homeSets} - ${score.awaySets}",
style = MaterialTheme.typography.titleMedium,
color = Color.White,
fontWeight = FontWeight.Bold,
)
Text(
"Set ${score.currentSet} · $pointsTarget pt",
style = MaterialTheme.typography.labelSmall,
color = MatchColors.TextSecondary,
)
}
TeamScoreColumn(
teamLabel = "OSPITE",
teamName = awayName,
accentColor = awayAccentColor,
logoUrl = awayLogoUrl,
points = score.awayPoints,
onPlus = onPointAway,
onMinus = onMinusAway,
alignEnd = true,
modifier = Modifier.weight(1f),
)
}
}
}
}
@Composable
private fun BroadcastTelemetryPanel(
cableConnected: Boolean,
fps: Int,
targetFps: Int,
bitrateKbps: Long,
networkType: String,
deviceHealth: DeviceHealthSnapshot,
modifier: Modifier = Modifier,
) {
val labelStyle = MaterialTheme.typography.labelSmall
Column(
modifier
.clip(RoundedCornerShape(8.dp))
.background(MatchColors.Background.copy(alpha = 0.78f))
.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
if (cableConnected) "Tabellone OK" else "Tabellone offline",
style = labelStyle,
color = if (cableConnected) MatchColors.SuccessGreen else MatchColors.TextSecondary,
)
val fpsLabel = if (fps > 0) "$fps fps" else "— fps"
val targetSuffix = if (targetFps > 0) " / $targetFps" else ""
Text(
fpsLabel + targetSuffix,
style = labelStyle,
color = Color.White,
)
if (bitrateKbps > 0) {
Text(
formatBitrateKbps(bitrateKbps),
style = labelStyle,
color = MatchColors.TextSecondary,
)
}
Text(
"${networkType} · ${deviceHealth.batteryPercent}%",
style = labelStyle,
color = MatchColors.TextSecondary,
)
ThermalIndicator(
tempC = deviceHealth.batteryTempC,
level = deviceHealth.thermalLevel,
label = deviceHealth.thermalLabel,
)
}
}
@Composable
private fun ThermalIndicator(
tempC: Float?,
level: ThermalLevel,
label: String,
) {
val color = when (level) {
ThermalLevel.NORMAL -> MatchColors.SuccessGreen
ThermalLevel.WARM -> MatchColors.AccentYellow
ThermalLevel.HOT -> Color(0xFFFF9800)
ThermalLevel.CRITICAL -> MatchColors.PrimaryRed
}
val tempText = tempC?.let { "${it.toInt()}°C" } ?: "—°C"
val warningBg = when (level) {
ThermalLevel.NORMAL -> Color.Transparent
else -> color.copy(alpha = 0.18f)
}
Row(
Modifier
.clip(RoundedCornerShape(4.dp))
.background(warningBg)
.padding(horizontal = 4.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
tempText,
style = MaterialTheme.typography.labelSmall,
color = color,
fontWeight = if (level >= ThermalLevel.WARM) FontWeight.Bold else FontWeight.Normal,
)
if (level >= ThermalLevel.WARM) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = color,
fontWeight = FontWeight.Bold,
)
}
}
}
private fun formatBitrateKbps(kbps: Long): String {
if (kbps >= 1000) return "${"%.1f".format(kbps / 1000.0)} Mbps"
return "$kbps kbps"
}
@Composable
private fun TeamScoreColumn(
teamLabel: String,
teamName: String,
accentColor: Color,
logoUrl: String?,
points: Int,
onPlus: () -> Unit,
onMinus: () -> Unit,
alignEnd: Boolean,
modifier: Modifier = Modifier,
) {
Column(
modifier
.clip(RoundedCornerShape(10.dp))
.background(MatchColors.Background.copy(alpha = 0.72f))
.padding(horizontal = 10.dp, vertical = 8.dp),
horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start,
) {
TeamIdentityRow(
teamLabel = teamLabel,
teamName = teamName,
accentColor = accentColor,
logoUrl = logoUrl,
alignEnd = alignEnd,
)
Spacer(Modifier.height(6.dp))
Text(
"$points",
style = MaterialTheme.typography.headlineMedium,
color = accentColor,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 4.dp),
)
Spacer(Modifier.height(4.dp))
val teamSide = if (alignEnd) "ospite" else "casa"
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (alignEnd) Arrangement.End else Arrangement.Start,
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (alignEnd) {
ScoreIconButton(
label = "+1",
tooltip = "Aggiungi punto $teamSide",
onClick = onPlus,
primary = true,
)
ScoreIconButton(label = "", tooltip = "Togli punto $teamSide", onClick = onMinus)
} else {
ScoreIconButton(label = "", tooltip = "Togli punto $teamSide", onClick = onMinus)
ScoreIconButton(
label = "+1",
tooltip = "Aggiungi punto $teamSide",
onClick = onPlus,
primary = true,
)
}
}
}
}
}
@Composable
private fun TeamIdentityRow(
teamLabel: String,
teamName: String,
accentColor: Color,
logoUrl: String?,
alignEnd: Boolean,
) {
val resolvedLogoUrl = resolveMediaUrl(logoUrl)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (alignEnd) Arrangement.End else Arrangement.Start,
) {
if (alignEnd) {
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.End,
) {
Text(
teamLabel,
style = MaterialTheme.typography.labelSmall,
color = MatchColors.TextSecondary,
letterSpacing = 1.sp,
)
Text(
teamName,
style = MaterialTheme.typography.labelMedium,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
)
}
if (!resolvedLogoUrl.isNullOrBlank()) {
Spacer(Modifier.width(8.dp))
TeamLogoThumbnail(resolvedLogoUrl)
}
Spacer(Modifier.width(6.dp))
TeamColorBar(accentColor)
} else {
TeamColorBar(accentColor)
if (!resolvedLogoUrl.isNullOrBlank()) {
Spacer(Modifier.width(6.dp))
TeamLogoThumbnail(resolvedLogoUrl)
}
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
teamLabel,
style = MaterialTheme.typography.labelSmall,
color = MatchColors.TextSecondary,
letterSpacing = 1.sp,
)
Text(
teamName,
style = MaterialTheme.typography.labelMedium,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun TeamColorBar(color: Color) {
Box(
Modifier
.width(4.dp)
.height(36.dp)
.clip(RoundedCornerShape(2.dp))
.background(color),
)
}
@Composable
private fun TeamLogoThumbnail(url: String) {
Box(
Modifier
.size(32.dp)
.clip(RoundedCornerShape(6.dp))
.background(MatchColors.SurfaceElevated)
.border(1.dp, MatchColors.Outline, RoundedCornerShape(6.dp)),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier.size(32.dp),
contentScale = ContentScale.Crop,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SideIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
highlighted: Boolean = false,
danger: Boolean = false,
) {
val bg = when {
danger -> MatchColors.PrimaryRed.copy(alpha = 0.88f)
highlighted -> MatchColors.SuccessGreen.copy(alpha = 0.55f)
else -> MatchColors.Background.copy(alpha = 0.78f)
}
TooltipBox(
modifier = modifier,
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(contentDescription)
}
},
state = rememberTooltipState(),
) {
Box(
Modifier
.size(IconButtonSize)
.clip(RoundedCornerShape(8.dp))
.background(bg)
.clickable(enabled = enabled, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = if (enabled) Color.White else MatchColors.TextSecondary,
modifier = Modifier.size(20.dp),
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ScoreIconButton(
label: String,
tooltip: String,
onClick: () -> Unit,
primary: Boolean = false,
) {
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(tooltip)
}
},
state = rememberTooltipState(),
) {
Box(
Modifier
.size(width = if (label == "+1") 40.dp else ScoreButtonSize, height = ScoreButtonSize)
.clip(RoundedCornerShape(6.dp))
.background(if (primary) MatchColors.PrimaryRed else MatchColors.SurfaceElevated)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = Color.White,
)
}
}
}
fun shareBroadcastLink(context: Context, url: String, subject: String) {
context.startActivity(
Intent.createChooser(
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
putExtra(Intent.EXTRA_SUBJECT, subject)
},
"Condividi",
),
)
}
fun copyBroadcastLink(context: Context, url: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("link", url))
}

View File

@@ -1,25 +1,18 @@
package com.matchlivetv.match_live_tv.ui.broadcast
import android.view.ViewGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -32,11 +25,12 @@ 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.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.matchlivetv.match_live_tv.core.DeviceTelemetry
import com.matchlivetv.match_live_tv.core.parseColorHex
import com.matchlivetv.match_live_tv.core.resolveMediaUrl
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.MatchScoringRules
@@ -44,25 +38,26 @@ import com.matchlivetv.match_live_tv.domain.StreamSession
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayLogoCache
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayState
import com.matchlivetv.match_live_tv.streaming.overlay.toOverlayStatus
import com.matchlivetv.match_live_tv.streaming.overlay.toScoreboardState
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.components.MatchStatusBadge
import com.matchlivetv.match_live_tv.ui.permissions.rememberBroadcastPermissionsState
import com.matchlivetv.match_live_tv.ui.system.KeepScreenOnEffect
import com.matchlivetv.match_live_tv.ui.system.LockLandscapeOrientationEffect
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BroadcastScreen(
container: AppContainer,
sessionId: String,
onFinished: () -> Unit,
) {
LockLandscapeOrientationEffect()
KeepScreenOnEffect()
val scope = rememberCoroutineScope()
val context = LocalContext.current
val snackbar = remember { SnackbarHostState() }
@@ -72,6 +67,8 @@ fun BroadcastScreen(
var loading by remember { mutableStateOf(true) }
var pauseInFlight by remember { mutableStateOf(false) }
var resumeInFlight by remember { mutableStateOf(false) }
var controlsVisible by remember { mutableStateOf(true) }
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) }
val metrics by container.broadcastCoordinator.metrics.collectAsState()
val score by container.scoreController.score.collectAsState()
val cableConnected by container.sessionCable.connected.collectAsState()
@@ -105,9 +102,9 @@ fun BroadcastScreen(
val updated = container.sessionRepository.pauseSession(sessionId)
session = updated
container.broadcastCoordinator.engine.pauseBroadcast()
snackbar.showSnackbar("Diretta in pausa — gli spettatori vedono la copertina")
snackbar.showSnackbar("Diretta in pausa")
} catch (e: Exception) {
snackbar.showSnackbar("Pausa sessione: ${e.message ?: "errore"}")
snackbar.showSnackbar("Pausa: ${e.message ?: "errore"}")
} finally {
pauseInFlight = false
}
@@ -125,9 +122,9 @@ fun BroadcastScreen(
}
updated = container.sessionRepository.fetchSession(sessionId)
session = updated
snackbar.showSnackbar("Diretta ripresa — di nuovo in onda")
snackbar.showSnackbar("Diretta ripresa")
} catch (e: Exception) {
snackbar.showSnackbar("Ripresa diretta: ${e.message ?: "errore"}")
snackbar.showSnackbar("Ripresa: ${e.message ?: "errore"}")
} finally {
resumeInFlight = false
}
@@ -135,18 +132,21 @@ fun BroadcastScreen(
suspend fun applyRemotePause() {
container.broadcastCoordinator.engine.pauseBroadcast()
session = session?.copy(status = "paused")
snackbar.showSnackbar("Pausa dalla regia — trasmissione fermata")
session = runCatching { container.sessionRepository.fetchSession(sessionId) }
.getOrNull() ?: session?.copy(status = "paused")
snackbar.showSnackbar("Pausa dalla regia")
}
suspend fun applyRemoteResume() {
if (resumeInFlight) return
val current = session ?: return
val current = runCatching { container.sessionRepository.fetchSession(sessionId) }
.getOrNull() ?: session ?: return
val url = current.rtmpIngestUrl ?: return
resumeInFlight = true
try {
session = current
container.broadcastCoordinator.engine.resumeBroadcast(broadcastConfig(current))
snackbar.showSnackbar("Ripresa dalla regia — di nuovo in onda")
snackbar.showSnackbar("Ripresa dalla regia")
} catch (e: Exception) {
snackbar.showSnackbar("Ripresa RTMP: ${e.message ?: "errore"}")
} finally {
@@ -154,6 +154,14 @@ fun BroadcastScreen(
}
}
suspend fun applyRemoteStop() {
container.sessionCable.disconnect()
container.broadcastCoordinator.engine.stopBroadcast()
container.broadcastCoordinator.stopService()
snackbar.showSnackbar("Diretta chiusa dalla regia")
onFinished()
}
LaunchedEffect(sessionId, permissions.granted) {
if (!permissions.granted) return@LaunchedEffect
loading = true
@@ -175,6 +183,9 @@ fun BroadcastScreen(
container.sessionCable.onResumeStream = {
if (!resumeInFlight) scope.launch { applyRemoteResume() }
}
container.sessionCable.onEndStream = {
scope.launch { applyRemoteStop() }
}
container.sessionCable.connect(sessionId, token, deviceRole = "camera")
}
container.broadcastCoordinator.startService()
@@ -192,15 +203,62 @@ fun BroadcastScreen(
LaunchedEffect(score, match, metrics.phase) {
val currentMatch = match ?: return@LaunchedEffect
val authToken = container.authRepository.currentSession()?.accessToken
val homeLogoUrl = resolveMediaUrl(currentMatch.homeLogoUrl)
val awayLogoUrl = resolveMediaUrl(currentMatch.opponentLogoUrl)
OverlayLogoCache.preloadAll(
context,
listOf(homeLogoUrl, awayLogoUrl),
bearerToken = authToken,
)
container.broadcastCoordinator.updateOverlay(
OverlayState(
scoreboard = score.toScoreboardState(currentMatch.teamName, currentMatch.opponentName),
scoreboard = score.toScoreboardState(
homeTeamName = currentMatch.teamName,
awayTeamName = currentMatch.opponentName,
homeAccentColor = parseColorHex(currentMatch.homePrimaryColor, 0xFFFF2D2D.toInt()),
awayAccentColor = parseColorHex(
currentMatch.opponentPrimaryColor,
0xFF1E3A8A.toInt(),
),
homeLogoUrl = homeLogoUrl,
awayLogoUrl = awayLogoUrl,
),
watermarkVisible = true,
broadcastStatus = metrics.phase.toOverlayStatus(),
),
)
}
LaunchedEffect(Unit) {
while (true) {
deviceHealth = DeviceTelemetry.snapshot(context)
delay(2_000)
}
}
LaunchedEffect(sessionId) {
container.sessionCable.connected.collect { connected ->
if (!connected) return@collect
runCatching {
container.sessionRepository.fetchSession(sessionId).score
}.getOrNull()?.let { remote ->
container.scoreController.applyRemote(remote)
}
}
}
LaunchedEffect(sessionId) {
while (true) {
delay(4_000)
runCatching {
container.sessionRepository.fetchSession(sessionId).score
}.getOrNull()?.let { remote ->
container.scoreController.applyRemote(remote)
}
}
}
LaunchedEffect(sessionId) {
while (true) {
delay(10_000)
@@ -222,8 +280,10 @@ fun BroadcastScreen(
DisposableEffect(sessionId) {
onDispose {
container.sessionCable.onScoreUpdate = null
container.sessionCable.onPauseStream = null
container.sessionCable.onResumeStream = null
container.sessionCable.onEndStream = null
container.sessionCable.disconnect()
container.broadcastCoordinator.stopService()
container.broadcastCoordinator.engine.stopBroadcast()
@@ -246,156 +306,144 @@ fun BroadcastScreen(
else -> MatchColors.AccentYellow
}
val pointsTarget = scoringRules?.pointsTarget(score.currentSet) ?: 25
val currentSession = session
val currentMatch = match
val shareUrl = currentSession?.watchShareUrl()
val shareSubject = currentMatch?.let { "${it.teamName} vs ${it.opponentName}" } ?: "Diretta Match Live TV"
match?.let { currentMatch ->
currentMatch?.let { m ->
ScoreDialogRouter(
host = dialogHost,
homeName = currentMatch.teamName,
awayName = currentMatch.opponentName,
homeName = m.teamName,
awayName = m.opponentName,
)
}
MatchScreenScaffold(
topBar = {
Column {
TopAppBar(
title = {
Column {
Text("Diretta", style = MaterialTheme.typography.titleMedium)
Text(
if (cableConnected) "Tabellone collegato" else "Tabellone offline",
style = MaterialTheme.typography.labelSmall,
color = if (cableConnected) MatchColors.SuccessGreen else MatchColors.TextSecondary,
)
}
},
navigationIcon = {
IconButton(onClick = onFinished) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Indietro",
tint = Color.White,
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MatchColors.Background,
titleContentColor = Color.White,
),
)
SnackbarHost(hostState = snackbar)
}
},
Box(
Modifier
.fillMaxSize()
.background(Color.Black),
) {
Column(Modifier.fillMaxSize()) {
Box(
Modifier
.weight(1f)
.fillMaxWidth(),
) {
if (loading) {
CircularProgressIndicator(
color = MatchColors.PrimaryRed,
modifier = Modifier.align(Alignment.Center),
)
} else {
CameraPreviewPanel(container)
}
MatchStatusBadge(
text = statusText,
textColor = statusColor,
backgroundColor = MatchColors.Background.copy(alpha = 0.75f),
modifier = Modifier
.align(Alignment.TopStart)
.padding(16.dp),
when {
loading -> {
CircularProgressIndicator(
color = MatchColors.PrimaryRed,
modifier = Modifier.align(Alignment.Center),
)
if (isPaused && !loading) {
MatchPrimaryButton(
label = "RIPRENDI DIRETTA",
onClick = { scope.launch { resumeStream() } },
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp),
)
}
}
Column(
Modifier
.weight(1f)
.verticalScroll(rememberScrollState()),
) {
error?.let {
!permissions.granted -> {
Column(
Modifier
.align(Alignment.Center)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
it,
color = MatchColors.PrimaryRed,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
match?.let { currentMatch ->
scoreActions?.let { actions ->
LiveScoreControls(
homeName = currentMatch.teamName,
awayName = currentMatch.opponentName,
score = score,
pointsTarget = pointsTarget,
onPointHome = {
scope.launch {
container.scoreController.incrementHome()
actions.afterPointChange(
container.scoreController.score.value,
) { stopStreamPermanently() }
}
},
onPointAway = {
scope.launch {
container.scoreController.incrementAway()
actions.afterPointChange(
container.scoreController.score.value,
) { stopStreamPermanently() }
}
},
onMinusHome = { container.scoreController.decrementHome() },
onMinusAway = { container.scoreController.decrementAway() },
onCloseSet = {
scope.launch {
actions.requestCloseSet { stopStreamPermanently() }
}
},
)
}
}
if (!permissions.granted) {
Text(
"Consenti accesso a camera e microfono per andare in diretta",
"Consenti camera e microfono per andare in diretta",
color = MatchColors.AccentYellow,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
Spacer(Modifier.height(12.dp))
MatchPrimaryButton(
label = "CONCEDI PERMESSI",
onClick = permissions.request,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
if (isPaused) {
MatchPrimaryButton(
label = "RIPRENDI DIRETTA",
onClick = { scope.launch { resumeStream() } },
modifier = Modifier.padding(horizontal = 16.dp),
)
} else if (!loading && session != null) {
MatchSecondaryButton(
label = "PAUSA DIRETTA",
onClick = { scope.launch { pauseStream() } },
modifier = Modifier.padding(horizontal = 16.dp),
)
}
MatchPrimaryButton(
label = "TERMINA DIRETTA",
onClick = { scope.launch { stopStreamPermanently() } },
modifier = Modifier.padding(16.dp),
)
}
else -> {
CameraPreviewPanel(container)
}
}
error?.let {
Text(
it,
color = MatchColors.PrimaryRed,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.align(Alignment.Center)
.padding(24.dp),
)
}
if (!loading && permissions.granted && currentMatch != null && scoreActions != null) {
BroadcastControlsOverlay(
controlsVisible = controlsVisible,
onToggleControls = { controlsVisible = !controlsVisible },
statusText = statusText,
statusColor = statusColor,
cableConnected = cableConnected,
isPaused = isPaused,
homeName = currentMatch.teamName,
awayName = currentMatch.opponentName,
homeAccentColor = Color(parseColorHex(currentMatch.homePrimaryColor, 0xFFFF2D2D.toInt())),
awayAccentColor = Color(parseColorHex(currentMatch.opponentPrimaryColor, 0xFF1E3A8A.toInt())),
homeLogoUrl = currentMatch.homeLogoUrl,
awayLogoUrl = currentMatch.opponentLogoUrl,
score = score,
pointsTarget = pointsTarget,
onPointHome = {
scope.launch {
container.scoreController.incrementHome()
scoreActions.afterPointChange(container.scoreController.score.value) {
stopStreamPermanently()
}
}
},
onPointAway = {
scope.launch {
container.scoreController.incrementAway()
scoreActions.afterPointChange(container.scoreController.score.value) {
stopStreamPermanently()
}
}
},
onMinusHome = { container.scoreController.decrementHome() },
onMinusAway = { container.scoreController.decrementAway() },
onCloseSet = {
scope.launch {
scoreActions.requestCloseSet { stopStreamPermanently() }
}
},
onPauseOrResume = {
scope.launch {
if (isPaused) resumeStream() else pauseStream()
}
},
onTerminate = { scope.launch { stopStreamPermanently() } },
onShareLive = {
val url = shareUrl
if (url.isNullOrBlank()) {
scope.launch { snackbar.showSnackbar("Link diretta non ancora disponibile") }
} else {
shareBroadcastLink(context, url, shareSubject)
}
},
onShareRegia = {
scope.launch {
runCatching { container.sessionRepository.createRegiaLink(sessionId) }
.onSuccess { url ->
shareBroadcastLink(context, url, "Link regia — $shareSubject")
}
.onFailure {
snackbar.showSnackbar(it.message ?: "Errore link regia")
}
}
},
shareLiveEnabled = !shareUrl.isNullOrBlank(),
fps = metrics.fps,
targetFps = currentSession?.targetFps ?: 30,
bitrateKbps = metrics.bitrateKbps,
networkType = DeviceTelemetry.networkType(context),
deviceHealth = deviceHealth,
)
}
SnackbarHost(
hostState = snackbar,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 48.dp),
)
}
}

View File

@@ -0,0 +1,17 @@
package com.matchlivetv.match_live_tv.ui.system
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalView
/** Impedisce allo schermo di andare in standby durante la diretta. */
@Composable
fun KeepScreenOnEffect() {
val view = LocalView.current
DisposableEffect(Unit) {
view.keepScreenOn = true
onDispose {
view.keepScreenOn = false
}
}
}

View File

@@ -0,0 +1,31 @@
package com.matchlivetv.match_live_tv.ui.system
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.pm.ActivityInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
private fun Context.findActivity(): Activity? {
var ctx: Context = this
while (ctx is ContextWrapper) {
if (ctx is Activity) return ctx
ctx = ctx.baseContext
}
return null
}
/** Blocca in orizzontale (es. schermata diretta). Al termine torna al portrait. */
@Composable
fun LockLandscapeOrientationEffect() {
val context = LocalContext.current
DisposableEffect(Unit) {
val activity = context.findActivity() ?: return@DisposableEffect onDispose {}
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
onDispose {
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
}

View File

@@ -1,5 +1,9 @@
package com.matchlivetv.match_live_tv.ui.wizard
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -13,6 +17,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -22,9 +27,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.core.normalizeHexColor
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.Team
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
import com.matchlivetv.match_live_tv.ui.matches.MatchOutlinedField
@@ -36,6 +43,7 @@ private const val DEFAULT_SETS_TO_WIN = 3
private const val DEFAULT_POINTS_PER_SET = 25
private const val DEFAULT_POINTS_DECIDING_SET = 15
private const val DEFAULT_MIN_POINT_LEAD = 2
private const val DEFAULT_OPPONENT_COLOR = "#1E3A8A"
@Composable
fun StepMatchScreen(
@@ -51,6 +59,37 @@ fun StepMatchScreen(
var location by remember { mutableStateOf(match.location.orEmpty()) }
var campionato by remember { mutableStateOf(match.category.orEmpty()) }
var homeTeam by remember { mutableStateOf<Team?>(null) }
var homeLogoUrl by remember { mutableStateOf(match.homeLogoUrl) }
var opponentLogoUrl by remember { mutableStateOf(match.opponentLogoUrl) }
var homePrimaryColor by remember {
mutableStateOf(normalizeHexColor(match.homePrimaryColor))
}
var opponentPrimaryColor by remember {
mutableStateOf(normalizeHexColor(match.opponentPrimaryColor, DEFAULT_OPPONENT_COLOR))
}
var homeLogoUri by remember { mutableStateOf<Uri?>(null) }
var opponentLogoUri by remember { mutableStateOf<Uri?>(null) }
LaunchedEffect(match.teamId) {
runCatching { container.matchRepository.fetchTeam(match.teamId) }
.onSuccess { team ->
homeTeam = team
if (team != null) {
homePrimaryColor = normalizeHexColor(team.primaryColor ?: match.homePrimaryColor)
homeLogoUrl = team.logoUrl ?: match.homeLogoUrl
}
}
}
val pickHomeLogo = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
) { uri -> homeLogoUri = uri }
val pickOpponentLogo = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
) { uri -> opponentLogoUri = uri }
val existingRules = match.scoringRules
var customRules by remember {
mutableStateOf(existingRules != null || match.setsToWin != DEFAULT_SETS_TO_WIN)
@@ -74,10 +113,38 @@ fun StepMatchScreen(
Text("Dettagli partita", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(20.dp))
WizardReadOnlyField(label = "Squadra casa", value = match.teamName)
Spacer(Modifier.height(12.dp))
MatchOutlinedField(value = opponent, onValueChange = { opponent = it }, label = "Avversario")
Spacer(Modifier.height(12.dp))
TeamBrandingRow(
sectionLabel = "Squadra di casa",
teamName = match.teamName,
nameEditable = false,
onTeamNameChange = {},
remoteLogoUrl = homeLogoUrl,
localLogoUri = homeLogoUri,
primaryColorHex = homePrimaryColor,
placeholderColorSeed = "home-${match.teamId}",
onPrimaryColorChange = { homePrimaryColor = it },
onPickLogo = {
pickHomeLogo.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
},
onClearLogo = { homeLogoUri = null },
)
Spacer(Modifier.height(16.dp))
TeamBrandingRow(
sectionLabel = "Squadra avversaria",
teamName = opponent,
nameEditable = true,
onTeamNameChange = { opponent = it },
remoteLogoUrl = opponentLogoUrl,
localLogoUri = opponentLogoUri,
primaryColorHex = opponentPrimaryColor,
placeholderColorSeed = "away-${match.id}",
onPrimaryColorChange = { opponentPrimaryColor = it },
onPickLogo = {
pickOpponentLogo.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
},
onClearLogo = { opponentLogoUri = null },
)
Spacer(Modifier.height(16.dp))
MatchOutlinedField(value = location, onValueChange = { location = it }, label = "Luogo")
Spacer(Modifier.height(12.dp))
MatchOutlinedField(
@@ -202,9 +269,23 @@ fun StepMatchScreen(
)
}
val initialHomeColor = normalizeHexColor(
homeTeam?.primaryColor ?: match.homePrimaryColor,
)
val homeBrandingChanged =
homePrimaryColor != initialHomeColor || homeLogoUri != null
saving = true
scope.launch {
runCatching {
if (homeBrandingChanged) {
container.matchRepository.updateTeamBranding(
teamId = match.teamId,
primaryColor = homePrimaryColor,
secondaryColor = homeTeam?.secondaryColor,
logoUri = homeLogoUri,
)
}
container.matchRepository.updateMatch(
matchId = match.id,
opponentName = opponent.trim(),
@@ -212,7 +293,9 @@ fun StepMatchScreen(
scheduledAt = match.scheduledAt,
setsToWin = resolvedSets,
category = campionato.trim(),
opponentPrimaryColor = opponentPrimaryColor,
scoringRules = resolvedRules,
opponentLogoUri = opponentLogoUri,
)
}.onSuccess { updated ->
container.wizardSession.match = updated

View File

@@ -56,6 +56,7 @@ fun StepNetworkTestScreen(
var uploadMbps by remember { mutableStateOf(0.0) }
var latencyMs by remember { mutableStateOf(0) }
var networkType by remember { mutableStateOf("") }
var selectedQualityLabel by remember { mutableStateOf<String?>(null) }
var starting by remember { mutableStateOf(false) }
var currentSession by remember { mutableStateOf(session) }
@@ -92,6 +93,14 @@ fun StepNetworkTestScreen(
networkType = networkType,
)
}.getOrNull()
if (result != null) {
selectedQualityLabel = formatQualityLabel(result)
runCatching { container.sessionRepository.fetchSession(currentSession.id) }
.onSuccess { updated ->
currentSession = updated
container.wizardSession.setSession(updated, match)
}
}
ready = result?.ready ?: (uploadMbps >= 2.0)
testCompleted = true
testing = false
@@ -141,6 +150,13 @@ fun StepNetworkTestScreen(
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
selectedQualityLabel?.let { quality ->
Spacer(Modifier.height(12.dp))
WizardReadOnlyField(
label = "Qualità streaming (automatica)",
value = quality,
)
}
}
if (testCompleted && shareUrl != null) {
Spacer(Modifier.height(16.dp))
@@ -240,3 +256,13 @@ private fun copyToClipboard(context: Context, text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("link", text))
}
private fun formatQualityLabel(result: com.matchlivetv.match_live_tv.data.api.NetworkTestResponse): String {
val bitrateMbps = (result.targetBitrate ?: 2_500_000) / 1_000_000.0
val fps = result.targetFps ?: 30
val resolution = when {
result.qualityPreset?.startsWith("1080p") == true -> "1080p"
else -> "720p"
}
return "$resolution · ${fps}fps · ${"%.1f".format(bitrateMbps)} Mbps"
}

View File

@@ -42,7 +42,6 @@ fun StepTransmissionScreen(
var loadingTeam by remember { mutableStateOf(true) }
var platform by remember { mutableStateOf("matchlivetv") }
var privacy by remember { mutableStateOf("public") }
var youtubeChannel by remember { mutableStateOf<String?>("platform") }
var creating by remember { mutableStateOf(false) }
LaunchedEffect(match.teamId) {
@@ -56,7 +55,14 @@ fun StepTransmissionScreen(
return
}
val youtubeReady = team?.isYoutubeReady == true
val loadedTeam = team
val youtubeReady = loadedTeam?.isYoutubeReady == true
val youtubeSubtitle = when {
loadedTeam == null -> "Canale in attivazione"
!loadedTeam.canUseYoutube -> "Premium Light o Full"
!loadedTeam.isYoutubeReady -> "Canale in attivazione"
else -> loadedTeam.youtubeDestinationLabel
}
Column(
Modifier
@@ -67,6 +73,7 @@ fun StepTransmissionScreen(
Text(
"Piano $plan",
style = MaterialTheme.typography.bodyMedium,
color = MatchColors.TextSecondary,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
@@ -83,18 +90,13 @@ fun StepTransmissionScreen(
Spacer(Modifier.height(8.dp))
WizardPlatformCard(
title = "YouTube Live",
subtitle = when {
team?.canUseYoutube != true -> "Premium Light o Full"
!youtubeReady -> "Canale in attivazione"
else -> "Canale YouTube collegato"
},
subtitle = youtubeSubtitle,
selected = platform == "youtube",
enabled = youtubeReady,
badge = if (team?.canUseYoutube != true) "Premium" else null,
onClick = {
if (youtubeReady) {
platform = "youtube"
youtubeChannel = "platform"
} else {
onError("YouTube non disponibile per questa squadra")
}
@@ -104,13 +106,15 @@ fun StepTransmissionScreen(
Text("Visibilità", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
MatchSecondaryButton(
WizardChoiceButton(
label = "PUBBLICO",
selected = privacy == "public",
onClick = { privacy = "public" },
modifier = Modifier.weight(1f),
)
MatchSecondaryButton(
WizardChoiceButton(
label = "NON IN ELENCO",
selected = privacy == "unlisted",
onClick = { privacy = "unlisted" },
modifier = Modifier.weight(1f),
)
@@ -118,16 +122,19 @@ fun StepTransmissionScreen(
Spacer(Modifier.height(8.dp))
Text(
if (privacy == "public") {
"Compare nell'elenco dirette e sul canale scelto."
"Compare nell'elenco dirette su MatchLiveTV.it, nelle ricerche e sul canale YouTube (se selezionato)."
} else {
"Solo chi ha il link."
"Non compare negli elenchi pubblici né nelle ricerche. Solo chi ha il link può guardare."
},
style = MaterialTheme.typography.bodyMedium,
color = MatchColors.TextSecondary,
)
Text(
"La partita resta sempre visibile nel backend della squadra per tutta la durata dell'abbonamento.",
style = MaterialTheme.typography.bodyMedium,
color = MatchColors.TextSecondary,
modifier = Modifier.padding(top = 6.dp),
)
Spacer(Modifier.height(24.dp))
Text("Qualità", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(12.dp))
WizardReadOnlyField(label = "Preset", value = "720p · 30fps · 2.5 Mbps")
Spacer(Modifier.height(32.dp))
Row {
MatchSecondaryButton(
@@ -147,7 +154,7 @@ fun StepTransmissionScreen(
matchId = match.id,
platform = platform,
privacyStatus = privacy,
youtubeChannel = if (platform == "youtube") youtubeChannel else null,
youtubeChannel = if (platform == "youtube") loadedTeam?.effectiveYoutubeChannel else null,
)
}.onSuccess { session ->
container.wizardSession.setSession(session, match)

View File

@@ -0,0 +1,377 @@
package com.matchlivetv.match_live_tv.ui.wizard
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.matchlivetv.match_live_tv.core.parseColorHex
import com.matchlivetv.match_live_tv.core.placeholderTeamColor
import com.matchlivetv.match_live_tv.core.resolveMediaUrl
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@Composable
fun TeamBrandingRow(
sectionLabel: String,
teamName: String,
nameEditable: Boolean,
onTeamNameChange: (String) -> Unit,
remoteLogoUrl: String?,
localLogoUri: Uri?,
primaryColorHex: String,
placeholderColorSeed: String,
onPrimaryColorChange: (String) -> Unit,
onPickLogo: () -> Unit,
onClearLogo: () -> Unit,
modifier: Modifier = Modifier,
) {
val hasLogo = localLogoUri != null || !remoteLogoUrl.isNullOrBlank()
val hasColor = primaryColorHex.isNotBlank()
val isConfigured = hasLogo && hasColor
val placeholderColor = remember(placeholderColorSeed) { placeholderTeamColor(placeholderColorSeed) }
val displayColorHex = primaryColorHex.ifBlank { placeholderColor }
val displayColor = Color(parseColorHex(displayColorHex, 0xFFE53935.toInt()))
var menuExpanded by remember { mutableStateOf(false) }
var showCustomize by remember { mutableStateOf(false) }
Column(modifier.fillMaxWidth()) {
Text(
sectionLabel,
style = MaterialTheme.typography.labelLarge,
color = MatchColors.TextSecondary,
)
Spacer(Modifier.height(8.dp))
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MatchColors.Surface)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (isConfigured) {
TeamLogoImage(
remoteLogoUrl = remoteLogoUrl,
localLogoUri = localLogoUri,
size = 44.dp,
)
ColorAccentBar(color = displayColor, height = 36.dp)
Text(
text = teamName.ifBlank { "Squadra" },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
} else {
ColorAccentBar(color = displayColor, height = 32.dp)
if (nameEditable) {
InlineTeamNameField(
value = teamName,
onValueChange = onTeamNameChange,
placeholder = "Nome avversario",
modifier = Modifier.weight(1f),
)
} else {
Text(
text = teamName.ifBlank { "Squadra" },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
Box {
IconButton(onClick = { menuExpanded = true }) {
Icon(
Icons.Default.MoreVert,
contentDescription = "Modifica squadra",
tint = MatchColors.TextSecondary,
)
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
) {
DropdownMenuItem(
text = { Text("Personalizza") },
onClick = {
menuExpanded = false
showCustomize = true
},
)
}
}
}
}
if (showCustomize) {
TeamBrandingCustomizeSheet(
title = sectionLabel,
teamName = teamName,
nameEditable = nameEditable,
remoteLogoUrl = remoteLogoUrl,
localLogoUri = localLogoUri,
primaryColorHex = primaryColorHex,
fallbackColorHex = placeholderColor,
onDismiss = { showCustomize = false },
onConfirm = { name, color ->
if (nameEditable) onTeamNameChange(name)
onPrimaryColorChange(color)
showCustomize = false
},
onPickLogo = onPickLogo,
onClearLogo = onClearLogo,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TeamBrandingCustomizeSheet(
title: String,
teamName: String,
nameEditable: Boolean,
remoteLogoUrl: String?,
localLogoUri: Uri?,
primaryColorHex: String,
fallbackColorHex: String,
onDismiss: () -> Unit,
onConfirm: (name: String, colorHex: String) -> Unit,
onPickLogo: () -> Unit,
onClearLogo: () -> Unit,
) {
val initialColor = primaryColorHex.ifBlank { fallbackColorHex }
val pickerInitialColor = remember { initialColor }
var draftName by remember { mutableStateOf(teamName) }
var draftColor by remember { mutableStateOf(initialColor) }
val hasLogo = localLogoUri != null || !remoteLogoUrl.isNullOrBlank()
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = MatchColors.SurfaceElevated,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 32.dp),
) {
Text("Personalizza", style = MaterialTheme.typography.headlineSmall)
Text(
title,
style = MaterialTheme.typography.bodyMedium,
color = MatchColors.TextSecondary,
)
Spacer(Modifier.height(20.dp))
if (nameEditable) {
Text("Nome squadra", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = draftName,
onValueChange = { draftName = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Nome avversario", color = MatchColors.TextSecondary) },
colors = outlinedFieldColors(),
)
Spacer(Modifier.height(20.dp))
} else {
Text("Nome squadra", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(4.dp))
Text(teamName, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(20.dp))
}
Text("Logo", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(10.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
TeamLogoPreview(
remoteLogoUrl = remoteLogoUrl,
localLogoUri = localLogoUri,
size = 72.dp,
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
MatchSecondaryButton(label = "CARICA LOGO", onClick = onPickLogo)
if (hasLogo) {
MatchSecondaryButton(label = "RIMUOVI", onClick = onClearLogo)
}
}
}
Spacer(Modifier.height(20.dp))
Text("Colore squadra", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(12.dp))
TeamColorPickerPanel(
initialColorHex = pickerInitialColor,
onColorChange = { draftColor = it },
)
Spacer(Modifier.height(24.dp))
MatchPrimaryButton(
label = "SALVA",
onClick = { onConfirm(draftName.trim(), draftColor.trim()) },
)
Spacer(Modifier.height(8.dp))
MatchSecondaryButton(label = "ANNULLA", onClick = onDismiss)
}
}
}
@Composable
private fun TeamLogoPreview(
remoteLogoUrl: String?,
localLogoUri: Uri?,
size: androidx.compose.ui.unit.Dp,
) {
Box(
Modifier
.size(size)
.clip(RoundedCornerShape(12.dp))
.background(MatchColors.Surface)
.border(1.dp, MatchColors.Outline, RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center,
) {
when {
localLogoUri != null -> AsyncImage(
model = localLogoUri,
contentDescription = null,
modifier = Modifier.size(size),
contentScale = ContentScale.Crop,
)
!remoteLogoUrl.isNullOrBlank() -> AsyncImage(
model = resolveMediaUrl(remoteLogoUrl),
contentDescription = null,
modifier = Modifier.size(size),
contentScale = ContentScale.Crop,
)
else -> Text("Nessun logo", style = MaterialTheme.typography.labelMedium, color = MatchColors.TextSecondary)
}
}
}
@Composable
private fun TeamLogoImage(
remoteLogoUrl: String?,
localLogoUri: Uri?,
size: androidx.compose.ui.unit.Dp,
) {
Box(
Modifier
.size(size)
.clip(RoundedCornerShape(8.dp))
.background(MatchColors.SurfaceElevated)
.border(1.dp, MatchColors.Outline, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
when {
localLogoUri != null -> AsyncImage(
model = localLogoUri,
contentDescription = null,
modifier = Modifier.size(size),
contentScale = ContentScale.Crop,
)
!remoteLogoUrl.isNullOrBlank() -> AsyncImage(
model = resolveMediaUrl(remoteLogoUrl),
contentDescription = null,
modifier = Modifier.size(size),
contentScale = ContentScale.Crop,
)
}
}
}
@Composable
private fun ColorAccentBar(
color: Color,
height: androidx.compose.ui.unit.Dp,
) {
Box(
Modifier
.width(5.dp)
.height(height)
.clip(RoundedCornerShape(2.dp))
.background(color),
)
}
@Composable
private fun InlineTeamNameField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
placeholder = {
Text(placeholder, color = MatchColors.TextSecondary)
},
modifier = modifier,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge,
colors = outlinedFieldColors(),
)
}
@Composable
private fun outlinedFieldColors() = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = MatchColors.PrimaryRed,
unfocusedBorderColor = MatchColors.Outline,
focusedLabelColor = MatchColors.PrimaryRed,
unfocusedLabelColor = MatchColors.TextSecondary,
cursorColor = MatchColors.PrimaryRed,
)

View File

@@ -0,0 +1,190 @@
package com.matchlivetv.match_live_tv.ui.wizard
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.core.hsvToHex
import com.matchlivetv.match_live_tv.core.parseColorHex
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@Composable
fun TeamColorPickerPanel(
initialColorHex: String,
onColorChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val initialArgb = parseColorHex(initialColorHex, 0xFFE53935.toInt())
val initialHsv = remember(initialColorHex) {
FloatArray(3).also { android.graphics.Color.colorToHSV(initialArgb, it) }
}
var hue by remember(initialColorHex) { mutableFloatStateOf(initialHsv[0]) }
var saturation by remember(initialColorHex) { mutableFloatStateOf(initialHsv[1]) }
var brightness by remember(initialColorHex) { mutableFloatStateOf(initialHsv[2]) }
val selectedColor = remember(hue, saturation, brightness) {
Color(android.graphics.Color.HSVToColor(floatArrayOf(hue, saturation, brightness)))
}
fun emitColor(h: Float, s: Float, v: Float) {
onColorChange(hsvToHex(h, s, v))
}
Column(
modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
Modifier
.size(56.dp)
.clip(CircleShape)
.background(selectedColor)
.border(2.dp, MatchColors.Outline, CircleShape),
)
SaturationValuePicker(
hue = hue,
saturation = saturation,
brightness = brightness,
onChange = { s, v ->
saturation = s
brightness = v
emitColor(hue, s, v)
},
)
HueSlider(
hue = hue,
onHueChange = { newHue ->
hue = newHue
emitColor(newHue, saturation, brightness)
},
)
}
}
@Composable
private fun SaturationValuePicker(
hue: Float,
saturation: Float,
brightness: Float,
onChange: (saturation: Float, brightness: Float) -> Unit,
) {
val baseHue = Color(android.graphics.Color.HSVToColor(floatArrayOf(hue, 1f, 1f)))
Box(
Modifier
.fillMaxWidth()
.height(140.dp)
.clip(RoundedCornerShape(10.dp))
.border(1.dp, MatchColors.Outline, RoundedCornerShape(10.dp))
.pointerInput(hue) {
detectTapGestures { offset ->
onChange(
(offset.x / size.width).coerceIn(0f, 1f),
1f - (offset.y / size.height).coerceIn(0f, 1f),
)
}
}
.pointerInput(hue) {
detectDragGestures { change, _ ->
change.consume()
onChange(
(change.position.x / size.width).coerceIn(0f, 1f),
1f - (change.position.y / size.height).coerceIn(0f, 1f),
)
}
},
) {
Canvas(Modifier.fillMaxSize()) {
drawRect(Color.White)
drawRect(Brush.horizontalGradient(listOf(Color.White, baseHue)))
drawRect(Brush.verticalGradient(listOf(Color.Transparent, Color.Black)))
val cx = saturation * size.width
val cy = (1f - brightness) * size.height
drawCircle(
color = Color.White,
radius = 10f,
center = Offset(cx, cy),
style = Stroke(width = 3f),
)
drawCircle(
color = Color.Black.copy(alpha = 0.35f),
radius = 10f,
center = Offset(cx, cy),
style = Stroke(width = 1.5f),
)
}
}
}
@Composable
private fun HueSlider(
hue: Float,
onHueChange: (Float) -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
Text(
"Tonalità",
style = MaterialTheme.typography.labelMedium,
color = MatchColors.TextSecondary,
)
Spacer(Modifier.height(4.dp))
Box(
Modifier
.fillMaxWidth()
.height(16.dp)
.clip(RoundedCornerShape(8.dp))
.background(
Brush.horizontalGradient(
listOf(
Color.Red,
Color.Yellow,
Color.Green,
Color.Cyan,
Color.Blue,
Color.Magenta,
Color.Red,
),
),
),
)
Slider(
value = hue,
onValueChange = onHueChange,
valueRange = 0f..360f,
colors = SliderDefaults.colors(
thumbColor = Color.White,
activeTrackColor = Color.Transparent,
inactiveTrackColor = Color.Transparent,
),
)
}
}

View File

@@ -21,6 +21,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
private val stepTitles = listOf(
@@ -54,6 +56,20 @@ fun WizardStepIndicator(currentStep: Int, modifier: Modifier = Modifier) {
fun wizardStepTitle(step: Int): String =
stepTitles[(step.coerceIn(1, 3) - 1)]
@Composable
fun WizardChoiceButton(
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (selected) {
MatchPrimaryButton(label = label, onClick = onClick, modifier = modifier)
} else {
MatchSecondaryButton(label = label, onClick = onClick, modifier = modifier)
}
}
@Composable
fun WizardReadOnlyField(label: String, value: String) {
Column(