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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
16
backend/app/controllers/concerns/api/url_helper.rb
Normal file
16
backend/app/controllers/concerns/api/url_helper.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
29
backend/app/services/sessions/select_quality.rb
Normal file
29
backend/app/services/sessions/select_quality.rb
Normal 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
|
||||
@@ -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?" } } %>
|
||||
|
||||
41
backend/app/views/admin/sessions/_links.html.erb
Normal file
41
backend/app/views/admin/sessions/_links.html.erb
Normal 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 (l’URL 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>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %>>
|
||||
|
||||
47
backend/app/views/public/live/_scorebug.html.erb
Normal file
47
backend/app/views/public/live/_scorebug.html.erb
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddOpponentBrandingToMatches < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :matches, :opponent_primary_color, :string
|
||||
end
|
||||
end
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user