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
|
class_methods do
|
||||||
def broadcast_message(session, data)
|
def broadcast_message(session, data)
|
||||||
broadcast_to(session, method: "receive_message", data: data)
|
broadcast_to(session, data)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,5 +22,22 @@ module Admin
|
|||||||
Rails.logger.error("[admin] stop session #{@session.id}: #{e.class} #{e.message}")
|
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}"
|
redirect_to admin_session_path(@session), alert: "Errore durante la chiusura: #{e.message}"
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
module Api
|
module Api
|
||||||
module V1
|
module V1
|
||||||
class MatchesController < BaseController
|
class MatchesController < BaseController
|
||||||
|
include Api::UrlHelper
|
||||||
|
include BrandingAttachments
|
||||||
|
|
||||||
before_action :set_team, only: %i[index create]
|
before_action :set_team, only: %i[index create]
|
||||||
before_action :set_match, only: %i[show update destroy]
|
before_action :set_match, only: %i[show update destroy]
|
||||||
|
|
||||||
@@ -14,6 +17,7 @@ module Api
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
match = @team.matches.create!(match_params)
|
match = @team.matches.create!(match_params)
|
||||||
|
attach_opponent_logo(match)
|
||||||
render json: match_json(match), status: :created
|
render json: match_json(match), status: :created
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -24,7 +28,9 @@ module Api
|
|||||||
def update
|
def update
|
||||||
attrs = match_params.to_h
|
attrs = match_params.to_h
|
||||||
normalize_scoring_rules!(attrs)
|
normalize_scoring_rules!(attrs)
|
||||||
|
normalize_opponent_color!(attrs)
|
||||||
@match.update!(attrs)
|
@match.update!(attrs)
|
||||||
|
attach_opponent_logo(@match)
|
||||||
render json: match_json(@match)
|
render json: match_json(@match)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -49,18 +55,17 @@ module Api
|
|||||||
|
|
||||||
def set_match
|
def set_match
|
||||||
team_ids = current_user.streamable_teams.map(&:id)
|
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
|
end
|
||||||
|
|
||||||
def match_params
|
def match_params
|
||||||
params.require(:match).permit(
|
params.require(:match).permit(
|
||||||
:opponent_name, :location, :scheduled_at, :sport, :sets_to_win,
|
: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]
|
scoring_rules: %i[points_per_set points_deciding_set min_point_lead]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Regolamento standard: scoring_rules assente o {} → default FIPAV lato Scoring::Rules.
|
|
||||||
def normalize_scoring_rules!(attrs)
|
def normalize_scoring_rules!(attrs)
|
||||||
return unless attrs.key?("scoring_rules")
|
return unless attrs.key?("scoring_rules")
|
||||||
|
|
||||||
@@ -68,12 +73,27 @@ module Api
|
|||||||
attrs["scoring_rules"] = {} if rules.blank?
|
attrs["scoring_rules"] = {} if rules.blank?
|
||||||
end
|
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)
|
def match_json(match, detail: false)
|
||||||
active = active_session_for(match)
|
active = active_session_for(match)
|
||||||
|
team = match.team
|
||||||
{
|
{
|
||||||
id: match.id,
|
id: match.id,
|
||||||
team_id: match.team_id,
|
team_id: match.team_id,
|
||||||
team_name: match.team.name,
|
team_name: team.name,
|
||||||
opponent_name: match.opponent_name,
|
opponent_name: match.opponent_name,
|
||||||
location: match.location,
|
location: match.location,
|
||||||
scheduled_at: match.scheduled_at,
|
scheduled_at: match.scheduled_at,
|
||||||
@@ -81,6 +101,11 @@ module Api
|
|||||||
sets_to_win: match.sets_to_win,
|
sets_to_win: match.sets_to_win,
|
||||||
scoring_rules: match.scoring_rules.presence,
|
scoring_rules: match.scoring_rules.presence,
|
||||||
category: match.category,
|
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_id: active&.id,
|
||||||
active_session_status: active&.status,
|
active_session_status: active&.status,
|
||||||
stream_completed: match.stream_completed?,
|
stream_completed: match.stream_completed?,
|
||||||
|
|||||||
@@ -100,13 +100,31 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
def network_test
|
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!(
|
@session.stream_events.create!(
|
||||||
event_type: "network_test",
|
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
|
occurred_at: Time.current
|
||||||
)
|
)
|
||||||
ready = params[:upload_mbps].to_f >= (@session.target_bitrate / 1_000_000.0 * 0.8)
|
target_upload = preset[:target_bitrate] / 1_000_000.0
|
||||||
render json: { ready: ready, target_upload_mbps: @session.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
|
end
|
||||||
|
|
||||||
def youtube_stats
|
def youtube_stats
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
module Api
|
module Api
|
||||||
module V1
|
module V1
|
||||||
class TeamsController < BaseController
|
class TeamsController < BaseController
|
||||||
|
include Api::UrlHelper
|
||||||
|
include BrandingAttachments
|
||||||
|
|
||||||
def index
|
def index
|
||||||
teams = current_user.streamable_teams
|
teams = current_user.streamable_teams
|
||||||
render json: teams.map { |t| team_json(t) }
|
render json: teams.map { |t| team_json(t) }
|
||||||
@@ -48,6 +51,7 @@ module Api
|
|||||||
def update
|
def update
|
||||||
team = current_user.manageable_teams.find(params[:id])
|
team = current_user.manageable_teams.find(params[:id])
|
||||||
team.update!(team_params)
|
team.update!(team_params)
|
||||||
|
attach_team_logo(team)
|
||||||
render json: team_json(team)
|
render json: team_json(team)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -69,7 +73,19 @@ module Api
|
|||||||
private
|
private
|
||||||
|
|
||||||
def team_params
|
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
|
end
|
||||||
|
|
||||||
def team_json(team, detail: false)
|
def team_json(team, detail: false)
|
||||||
@@ -80,7 +96,7 @@ module Api
|
|||||||
id: team.id,
|
id: team.id,
|
||||||
name: team.name,
|
name: team.name,
|
||||||
sport: team.sport,
|
sport: team.sport,
|
||||||
logo_url: team.effective_logo_url,
|
logo_url: api_absolute_url(team.effective_logo_url),
|
||||||
primary_color: team.effective_primary_color,
|
primary_color: team.effective_primary_color,
|
||||||
secondary_color: team.effective_secondary_color,
|
secondary_color: team.effective_secondary_color,
|
||||||
club_id: team.club_id,
|
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"
|
layout "regia"
|
||||||
|
|
||||||
protect_from_forgery with: :null_session
|
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
|
before_action :set_session_from_token
|
||||||
|
|
||||||
@@ -44,6 +44,13 @@ module Public
|
|||||||
render json: status_payload
|
render json: status_payload
|
||||||
end
|
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
|
def stop
|
||||||
Sessions::Stop.new(@session).call
|
Sessions::Stop.new(@session).call
|
||||||
render json: status_payload
|
render json: status_payload
|
||||||
@@ -55,8 +62,7 @@ module Public
|
|||||||
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
|
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
|
||||||
return if @session
|
return if @session
|
||||||
|
|
||||||
render file: Rails.root.join("public/404.html"), status: :not_found, layout: false
|
head :not_found
|
||||||
throw :abort
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def status_payload
|
def status_payload
|
||||||
|
|||||||
@@ -19,4 +19,22 @@ module AdminHelper
|
|||||||
mins = minutes % 60
|
mins = minutes % 60
|
||||||
hours.positive? ? "#{hours}h #{mins}m" : "#{mins} min"
|
hours.positive? ? "#{hours}h #{mins}m" : "#{mins} min"
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -61,6 +61,36 @@ module Public
|
|||||||
"#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}"
|
"#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}"
|
||||||
end
|
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.
|
# Colore ospite sul tabellone: secondario società se leggibile su bianco, altrimenti blu visitatore.
|
||||||
def live_scorebug_away_color(team)
|
def live_scorebug_away_color(team)
|
||||||
club = team.club
|
club = team.club
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ class Match < ApplicationRecord
|
|||||||
belongs_to :team
|
belongs_to :team
|
||||||
has_many :stream_sessions, dependent: :destroy
|
has_many :stream_sessions, dependent: :destroy
|
||||||
|
|
||||||
|
has_one_attached :opponent_logo_file
|
||||||
|
|
||||||
ACTIVE_SESSION_STATUSES = %w[live connecting reconnecting paused].freeze
|
ACTIVE_SESSION_STATUSES = %w[live connecting reconnecting paused].freeze
|
||||||
|
DEFAULT_OPPONENT_COLOR = "#1E3A8A"
|
||||||
|
|
||||||
validates :opponent_name, presence: true
|
validates :opponent_name, presence: true
|
||||||
validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 }
|
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 :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").
|
# category: campionato / descrizione torneo (facoltativo, es. "Serie C").
|
||||||
|
|
||||||
@@ -63,6 +68,16 @@ class Match < ApplicationRecord
|
|||||||
stream_sessions.where(status: %w[ended error]).exists?
|
stream_sessions.where(status: %w[ended error]).exists?
|
||||||
end
|
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.
|
# Hub app coach: dirette da riprendere o partite programmate future.
|
||||||
# Le bozze «Avversario» abbandonate (senza sessione) non compaiono.
|
# Le bozze «Avversario» abbandonate (senza sessione) non compaiono.
|
||||||
def coach_hub_visible?
|
def coach_hub_visible?
|
||||||
@@ -77,6 +92,12 @@ class Match < ApplicationRecord
|
|||||||
|
|
||||||
private
|
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
|
def scoring_rules_shape
|
||||||
rules = scoring_rules.is_a?(Hash) ? scoring_rules : {}
|
rules = scoring_rules.is_a?(Hash) ? scoring_rules : {}
|
||||||
%w[points_per_set points_deciding_set min_point_lead].each do |key|
|
%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])
|
status.in?(%w[ended error])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def regia_token_active?
|
||||||
|
regia_token_digest.present? &&
|
||||||
|
regia_token_expires_at&.future? &&
|
||||||
|
!terminal?
|
||||||
|
end
|
||||||
|
|
||||||
def resumable?
|
def resumable?
|
||||||
status.in?(%w[connecting live reconnecting paused])
|
status.in?(%w[connecting live reconnecting paused])
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ module Sessions
|
|||||||
|
|
||||||
def rotate!
|
def rotate!
|
||||||
token = SecureRandom.urlsafe_base64(24)
|
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!(
|
@session.update!(
|
||||||
regia_token_digest: digest(token),
|
regia_token_digest: digest(token),
|
||||||
regia_token_expires_at: expires
|
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? %>
|
<% if @active_sessions.any? %>
|
||||||
<table class="admin-table">
|
<table class="admin-table">
|
||||||
<thead>
|
<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>
|
</thead>
|
||||||
<tbody id="active-sessions-body">
|
<tbody id="active-sessions-body">
|
||||||
<% @active_sessions.each do |s| %>
|
<% @active_sessions.each do |s| %>
|
||||||
@@ -99,6 +99,12 @@
|
|||||||
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
|
<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><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="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">
|
<td class="admin-actions">
|
||||||
<%= link_to "Dettaglio", admin_session_path(s) %>
|
<%= 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?" } } %>
|
<%= 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>
|
<h2>Stream Sessions</h2>
|
||||||
<table class="admin-table">
|
<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>
|
<tbody>
|
||||||
<% @sessions.each do |s| %>
|
<% @sessions.each do |s| %>
|
||||||
<tr>
|
<tr>
|
||||||
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
|
<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><span class="badge <%= s.status == 'live' ? 'badge--live' : 'badge--paused' %>"><%= s.status %></span></td>
|
||||||
<td><%= s.disconnection_count %></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>
|
<td><%= link_to "Dettaglio", admin_session_path(s) %></td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -10,8 +10,11 @@
|
|||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<p>Match: <%= @session.match.team.name %> vs <%= @session.match.opponent_name %></p>
|
<p>Match: <%= @session.match.team.name %> vs <%= @session.match.opponent_name %></p>
|
||||||
|
|
||||||
|
<%= render "admin/sessions/links", session: @session %>
|
||||||
|
|
||||||
<% if @session.youtube_broadcast_id %>
|
<% 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 %>
|
<% end %>
|
||||||
<p>RTMP ingest: <code><%= @session.rtmp_ingest_url %></code></p>
|
<p>RTMP ingest: <code><%= @session.rtmp_ingest_url %></code></p>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<%= render "shared/meta_tags" %>
|
<%= 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="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="/marketing.css?v=36">
|
||||||
<link rel="stylesheet" href="/live.css?v=22">
|
<link rel="stylesheet" href="/live.css?v=23">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
<body<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
<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 %>
|
<% club_name = @match.team.club&.name %>
|
||||||
<% content_for :title do %><%= club_name %> · <%= @match.team.name %> vs <%= @match.opponent_name %> — Diretta live<% end %>
|
<% 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 :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 %>
|
<% content_for :head do %>
|
||||||
<% unless @stream_closed || @session.platform == "youtube" %>
|
<% unless @stream_closed || @session.platform == "youtube" %>
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<% else %>
|
<% else %>
|
||||||
<div class="live-player-wrap">
|
<div class="live-player-wrap">
|
||||||
<video id="player" controls playsinline muted autoplay poster="/images/copertina-canale.png"></video>
|
<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>
|
<button type="button" id="play-hint" class="live-play-hint" hidden>
|
||||||
▶ Avvia la diretta
|
▶ Avvia la diretta
|
||||||
</button>
|
</button>
|
||||||
@@ -58,6 +58,88 @@
|
|||||||
const pausedMsg = document.getElementById("paused-msg");
|
const pausedMsg = document.getElementById("paused-msg");
|
||||||
const awaitingMsg = document.getElementById("awaiting-msg");
|
const awaitingMsg = document.getElementById("awaiting-msg");
|
||||||
const playHint = document.getElementById("play-hint");
|
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 hls = null;
|
||||||
let playerStarted = false;
|
let playerStarted = false;
|
||||||
@@ -247,6 +329,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderLiveScorebug(data.score);
|
||||||
|
syncStreamBadge(data);
|
||||||
|
|
||||||
const onAir = !!data.on_air;
|
const onAir = !!data.on_air;
|
||||||
const sessionLive = !!data.live;
|
const sessionLive = !!data.live;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
data-status-url="<%= j public_regia_status_path(params[:token]) %>"
|
data-status-url="<%= j public_regia_status_path(params[:token]) %>"
|
||||||
data-score-url="<%= j public_regia_score_path(params[:token]) %>"
|
data-score-url="<%= j public_regia_score_path(params[:token]) %>"
|
||||||
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
||||||
|
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
|
||||||
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
|
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
|
||||||
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
||||||
data-hls-url="<%= j @session.hls_playback_url %>"
|
data-hls-url="<%= j @session.hls_playback_url %>"
|
||||||
@@ -20,13 +21,14 @@
|
|||||||
data-share-url="<%= j @share_url %>"
|
data-share-url="<%= j @share_url %>"
|
||||||
data-share-title="<%= j @share_title %>"
|
data-share-title="<%= j @share_title %>"
|
||||||
data-points-target="<%= Scoring::Rules.from_match(@match).points_target(@score.current_set) %>"
|
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">
|
<header class="regia-header">
|
||||||
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
||||||
<p>Regia · Set <%= @score.current_set %></p>
|
<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') %>">
|
<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" : (@on_air ? "In onda" : "In attesa") %>
|
<%= @stream_closed ? "Terminata" : (@session.paused? ? "In pausa" : (@on_air ? "In onda" : "In attesa")) %>
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<% unless @stream_closed %>
|
<% 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>
|
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop">Chiudi diretta</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,4 +96,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</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"
|
resources :billing_invoices, only: %i[index new create edit update], controller: "billing_invoices"
|
||||||
end
|
end
|
||||||
resources :sessions, only: %i[index show] do
|
resources :sessions, only: %i[index show] do
|
||||||
member { post :stop }
|
member do
|
||||||
|
post :stop
|
||||||
|
post :regia_link
|
||||||
|
end
|
||||||
end
|
end
|
||||||
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
|
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
|
||||||
end
|
end
|
||||||
@@ -105,6 +108,7 @@ Rails.application.routes.draw do
|
|||||||
get "regia/:token/status.json", to: "public/regia#status", as: :public_regia_status
|
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
|
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/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
|
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
|
||||||
|
|
||||||
root to: "public/pages#home"
|
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;
|
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 {
|
.admin-input {
|
||||||
padding: 0.45rem 0.6rem;
|
padding: 0.45rem 0.6rem;
|
||||||
border: 1px solid #444;
|
border: 1px solid #444;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
statusUrl: root.dataset.statusUrl,
|
statusUrl: root.dataset.statusUrl,
|
||||||
scoreUrl: root.dataset.scoreUrl,
|
scoreUrl: root.dataset.scoreUrl,
|
||||||
pauseUrl: root.dataset.pauseUrl,
|
pauseUrl: root.dataset.pauseUrl,
|
||||||
|
resumeUrl: root.dataset.resumeUrl,
|
||||||
stopUrl: root.dataset.stopUrl,
|
stopUrl: root.dataset.stopUrl,
|
||||||
cableUrl: root.dataset.cableUrl,
|
cableUrl: root.dataset.cableUrl,
|
||||||
hlsUrl: root.dataset.hlsUrl,
|
hlsUrl: root.dataset.hlsUrl,
|
||||||
@@ -30,13 +31,15 @@
|
|||||||
modalConfirm: document.getElementById("modal-confirm"),
|
modalConfirm: document.getElementById("modal-confirm"),
|
||||||
modalCancel: document.getElementById("modal-cancel"),
|
modalCancel: document.getElementById("modal-cancel"),
|
||||||
preview: document.getElementById("regia-preview"),
|
preview: document.getElementById("regia-preview"),
|
||||||
previewPlaceholder: document.getElementById("regia-preview-placeholder")
|
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
||||||
|
btnPause: document.getElementById("btn-pause")
|
||||||
};
|
};
|
||||||
|
|
||||||
let pendingAction = null;
|
let pendingAction = null;
|
||||||
let hls = null;
|
let hls = null;
|
||||||
let previewStarted = false;
|
let previewStarted = false;
|
||||||
let wasPausedPreview = false;
|
let wasPausedPreview = false;
|
||||||
|
let streamPaused = root.dataset.initialPaused === "true";
|
||||||
|
|
||||||
function hlsUrlFresh() {
|
function hlsUrlFresh() {
|
||||||
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
|
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) {
|
function setBadge(data) {
|
||||||
|
syncPauseButton(data);
|
||||||
if (data.stream_closed) {
|
if (data.stream_closed) {
|
||||||
els.badge.textContent = "Terminata";
|
els.badge.textContent = "Terminata";
|
||||||
els.badge.className = "regia-badge regia-badge--ended";
|
els.badge.className = "regia-badge regia-badge--ended";
|
||||||
@@ -139,6 +150,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleAction(action) {
|
async function handleAction(action) {
|
||||||
|
const prevHome = els.homePoints?.textContent;
|
||||||
|
const prevAway = document.getElementById("away-points")?.textContent;
|
||||||
try {
|
try {
|
||||||
const data = await postScore(action);
|
const data = await postScore(action);
|
||||||
applyScorePayload(data);
|
applyScorePayload(data);
|
||||||
@@ -152,6 +165,9 @@
|
|||||||
openModal("Partita vinta", `${winner} ha vinto la partita. Chiudere la diretta?`, () => stopStream());
|
openModal("Partita vinta", `${winner} ha vinto la partita. Chiudere la diretta?`, () => stopStream());
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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");
|
toast(e.message || "Errore");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,10 +300,19 @@
|
|||||||
showPreviewPlaceholder("Diretta terminata");
|
showPreviewPlaceholder("Diretta terminata");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pauseStream() {
|
async function togglePauseStream() {
|
||||||
await fetch(cfg.pauseUrl, { method: "POST", headers: { Accept: "application/json" } });
|
const url = streamPaused ? cfg.resumeUrl : cfg.pauseUrl;
|
||||||
toast("Diretta in pausa — copertina in onda");
|
const res = await fetch(url, { method: "POST", headers: { Accept: "application/json" } });
|
||||||
pollStatus();
|
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() {
|
async function stopStream() {
|
||||||
@@ -302,7 +327,7 @@
|
|||||||
return data;
|
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)));
|
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
|
||||||
|
|
||||||
async function shareLink() {
|
async function shareLink() {
|
||||||
|
|||||||
@@ -22,4 +22,24 @@ RSpec.describe "Public regia", type: :request do
|
|||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(session.score_state.reload.home_points).to eq(1)
|
expect(session.score_state.reload.home_points).to eq(1)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ android {
|
|||||||
applicationId = "com.matchlivetv.match_live_tv"
|
applicationId = "com.matchlivetv.match_live_tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 14
|
versionCode = 15
|
||||||
versionName = "2.0.0-native"
|
versionName = "2.0.0-native"
|
||||||
|
|
||||||
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
||||||
@@ -74,6 +74,8 @@ dependencies {
|
|||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
|
||||||
|
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||||
|
|
||||||
implementation("com.github.pedroSG94.RootEncoder:library:2.5.5")
|
implementation("com.github.pedroSG94.RootEncoder:library:2.5.5")
|
||||||
|
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:windowSoftInputMode="adjustResize">
|
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.ConnectivityManager
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.os.BatteryManager
|
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 {
|
object DeviceTelemetry {
|
||||||
fun batteryLevel(context: Context): Int {
|
fun batteryLevel(context: Context): Int = snapshot(context).batteryPercent
|
||||||
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 networkType(context: Context): String = runCatching {
|
fun networkType(context: Context): String = runCatching {
|
||||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
@@ -28,4 +37,67 @@ object DeviceTelemetry {
|
|||||||
else -> "Sconosciuto"
|
else -> "Sconosciuto"
|
||||||
}
|
}
|
||||||
}.getOrDefault("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.data.scoring.ScoreController
|
||||||
import com.matchlivetv.match_live_tv.BuildConfig
|
import com.matchlivetv.match_live_tv.BuildConfig
|
||||||
import com.matchlivetv.match_live_tv.streaming.LiveBroadcastCoordinator
|
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.Moshi
|
||||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -69,18 +70,19 @@ class AppContainer(context: Context) {
|
|||||||
|
|
||||||
val authRepository = AuthRepository(api, tokenStore) { token ->
|
val authRepository = AuthRepository(api, tokenStore) { token ->
|
||||||
accessToken = token
|
accessToken = token
|
||||||
|
OverlayLogoCache.configure(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
val matchRepository = MatchRepository(api, tokenStore)
|
val matchRepository = MatchRepository(api, tokenStore, appContext)
|
||||||
|
|
||||||
val sessionRepository = SessionRepository(api)
|
val sessionRepository = SessionRepository(api)
|
||||||
|
|
||||||
val scoreRepository = ScoreRepository(api)
|
val scoreRepository = ScoreRepository(api)
|
||||||
|
|
||||||
val sessionCable = SessionCableService(moshi)
|
|
||||||
|
|
||||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
|
||||||
|
val sessionCable = SessionCableService(moshi, appScope)
|
||||||
|
|
||||||
val scoreController = ScoreController(scoreRepository, sessionCable, appScope)
|
val scoreController = ScoreController(scoreRepository, sessionCable, appScope)
|
||||||
|
|
||||||
val wizardSession = WizardSessionHolder()
|
val wizardSession = WizardSessionHolder()
|
||||||
@@ -88,6 +90,9 @@ class AppContainer(context: Context) {
|
|||||||
val broadcastCoordinator = LiveBroadcastCoordinator(appContext)
|
val broadcastCoordinator = LiveBroadcastCoordinator(appContext)
|
||||||
|
|
||||||
suspend fun bootstrapAuth() {
|
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_connected") val youtubeConnected: Boolean? = false,
|
||||||
@Json(name = "youtube_selectable") val youtubeSelectable: Boolean? = false,
|
@Json(name = "youtube_selectable") val youtubeSelectable: Boolean? = false,
|
||||||
@Json(name = "youtube_channel_title") val youtubeChannelTitle: String? = null,
|
@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 = "youtube_team_channel_title") val youtubeTeamChannelTitle: String? = null,
|
||||||
@Json(name = "plan_name") val planName: String? = null,
|
@Json(name = "plan_name") val planName: String? = null,
|
||||||
@Json(name = "recordings_enabled") val recordingsEnabled: Boolean? = false,
|
@Json(name = "recordings_enabled") val recordingsEnabled: Boolean? = false,
|
||||||
@Json(name = "phone_download_enabled") val phoneDownloadEnabled: Boolean? = false,
|
@Json(name = "phone_download_enabled") val phoneDownloadEnabled: Boolean? = false,
|
||||||
@Json(name = "billing_url") val billingUrl: String? = null,
|
@Json(name = "billing_url") val billingUrl: String? = null,
|
||||||
@Json(name = "staff_manage_url") val staffManageUrl: 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(
|
fun toDomain() = Team(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -54,10 +58,14 @@ data class TeamDto(
|
|||||||
sport = sport ?: "volleyball",
|
sport = sport ?: "volleyball",
|
||||||
canStream = canStream ?: true,
|
canStream = canStream ?: true,
|
||||||
clubName = clubName,
|
clubName = clubName,
|
||||||
|
logoUrl = logoUrl,
|
||||||
|
primaryColor = primaryColor,
|
||||||
|
secondaryColor = secondaryColor,
|
||||||
youtubeEnabled = youtubeEnabled ?: false,
|
youtubeEnabled = youtubeEnabled ?: false,
|
||||||
youtubeConnected = youtubeConnected ?: false,
|
youtubeConnected = youtubeConnected ?: false,
|
||||||
youtubeSelectable = youtubeSelectable ?: false,
|
youtubeSelectable = youtubeSelectable ?: false,
|
||||||
youtubeChannelTitle = youtubeChannelTitle,
|
youtubeChannelTitle = youtubeChannelTitle,
|
||||||
|
youtubeUsesPlatformChannel = youtubeUsesPlatformChannel ?: false,
|
||||||
youtubeTeamChannelTitle = youtubeTeamChannelTitle,
|
youtubeTeamChannelTitle = youtubeTeamChannelTitle,
|
||||||
planName = planName,
|
planName = planName,
|
||||||
recordingsEnabled = recordingsEnabled ?: false,
|
recordingsEnabled = recordingsEnabled ?: false,
|
||||||
@@ -82,6 +90,11 @@ data class MatchDto(
|
|||||||
@Json(name = "active_session_status") val activeSessionStatus: String? = null,
|
@Json(name = "active_session_status") val activeSessionStatus: String? = null,
|
||||||
@Json(name = "stream_completed") val streamCompleted: Boolean? = null,
|
@Json(name = "stream_completed") val streamCompleted: Boolean? = null,
|
||||||
@Json(name = "coach_hub_visible") val coachHubVisible: 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(
|
fun toDomain() = Match(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -97,6 +110,11 @@ data class MatchDto(
|
|||||||
activeSessionStatus = activeSessionStatus,
|
activeSessionStatus = activeSessionStatus,
|
||||||
streamCompleted = streamCompleted ?: false,
|
streamCompleted = streamCompleted ?: false,
|
||||||
coachHubVisible = coachHubVisible,
|
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 = "scheduled_at") val scheduledAt: String? = null,
|
||||||
@Json(name = "sets_to_win") val setsToWin: Int,
|
@Json(name = "sets_to_win") val setsToWin: Int,
|
||||||
val category: String? = null,
|
val category: String? = null,
|
||||||
|
@Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null,
|
||||||
@Json(name = "scoring_rules") val scoringRules: ScoringRulesBody? = 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 UpdateMatchRequestFull(val match: UpdateMatchBodyFull)
|
||||||
|
|
||||||
data class NetworkTestResponse(
|
data class NetworkTestResponse(
|
||||||
val ready: Boolean,
|
val ready: Boolean,
|
||||||
@Json(name = "target_upload_mbps") val targetUploadMbps: Double? = null,
|
@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(
|
data class RecordingDto(
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ package com.matchlivetv.match_live_tv.data.api
|
|||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.DELETE
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Multipart
|
||||||
import retrofit2.http.PATCH
|
import retrofit2.http.PATCH
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Part
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
|
||||||
interface MatchLiveApi {
|
interface MatchLiveApi {
|
||||||
@POST("auth/login")
|
@POST("auth/login")
|
||||||
@@ -23,6 +26,24 @@ interface MatchLiveApi {
|
|||||||
@GET("teams")
|
@GET("teams")
|
||||||
suspend fun teams(): List<TeamDto>
|
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")
|
@GET("teams/{teamId}/recordings")
|
||||||
suspend fun recordings(@Path("teamId") teamId: String): List<RecordingDto>
|
suspend fun recordings(@Path("teamId") teamId: String): List<RecordingDto>
|
||||||
|
|
||||||
@@ -44,6 +65,22 @@ interface MatchLiveApi {
|
|||||||
@Body body: UpdateMatchRequestFull,
|
@Body body: UpdateMatchRequestFull,
|
||||||
): MatchDto
|
): 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}")
|
@DELETE("matches/{matchId}")
|
||||||
suspend fun deleteMatch(@Path("matchId") matchId: String)
|
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
|
"welcome" -> Unit
|
||||||
else -> {
|
else -> {
|
||||||
if (json.has("message")) {
|
if (json.has("message")) {
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val message = json.opt("message")
|
val message = json.opt("message")
|
||||||
when (message) {
|
when (message) {
|
||||||
is JSONObject -> listener?.onChannelMessage(messageToMap(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?> {
|
private fun messageToMap(json: JSONObject): Map<String, Any?> {
|
||||||
val map = mutableMapOf<String, Any?>()
|
val map = mutableMapOf<String, Any?>()
|
||||||
json.keys().forEach { key ->
|
json.keys().forEach { key ->
|
||||||
map[key] = json.get(key)
|
map[key] = jsonValueToKotlin(json.get(key))
|
||||||
}
|
}
|
||||||
return map
|
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 {
|
companion object {
|
||||||
private const val TAG = "ActionCableClient"
|
private const val TAG = "ActionCableClient"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
package com.matchlivetv.match_live_tv.data.cable
|
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.core.AppConfig
|
||||||
import com.matchlivetv.match_live_tv.domain.ScoreState
|
import com.matchlivetv.match_live_tv.domain.ScoreState
|
||||||
import com.squareup.moshi.Moshi
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
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 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)
|
private val _connected = MutableStateFlow(false)
|
||||||
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
||||||
@@ -16,23 +32,53 @@ class SessionCableService(private val moshi: Moshi) {
|
|||||||
var onScoreUpdate: ((ScoreState) -> Unit)? = null
|
var onScoreUpdate: ((ScoreState) -> Unit)? = null
|
||||||
var onPauseStream: (() -> Unit)? = null
|
var onPauseStream: (() -> Unit)? = null
|
||||||
var onResumeStream: (() -> Unit)? = null
|
var onResumeStream: (() -> Unit)? = null
|
||||||
|
var onEndStream: (() -> Unit)? = null
|
||||||
|
|
||||||
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
|
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
|
||||||
disconnect()
|
intentionalDisconnect = false
|
||||||
client = ActionCableClient(AppConfig.cableUrl, accessToken, moshi).also { cable ->
|
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(
|
cable.connect(
|
||||||
channel = "SessionChannel",
|
channel = "SessionChannel",
|
||||||
params = mapOf(
|
params = mapOf(
|
||||||
"session_id" to sessionId,
|
"session_id" to sessionId,
|
||||||
"device_role" to deviceRole,
|
"device_role" to pendingDeviceRole,
|
||||||
),
|
),
|
||||||
listener = object : ActionCableClient.Listener {
|
listener = object : ActionCableClient.Listener {
|
||||||
override fun onConnected() {
|
override fun onConnected() {
|
||||||
|
reconnectAttempt = 0
|
||||||
_connected.value = true
|
_connected.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDisconnected() {
|
override fun onDisconnected() {
|
||||||
_connected.value = false
|
_connected.value = false
|
||||||
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onChannelMessage(payload: Map<String, Any?>) {
|
override fun onChannelMessage(payload: Map<String, Any?>) {
|
||||||
@@ -43,44 +89,65 @@ class SessionCableService(private val moshi: Moshi) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendScoreUpdate(score: ScoreState) {
|
private fun scheduleReconnect() {
|
||||||
client?.performReceive(score.cablePayload())
|
if (intentionalDisconnect || pendingSessionId == null || pendingToken == null) return
|
||||||
}
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = scope.launch {
|
||||||
fun disconnect() {
|
reconnectAttempt += 1
|
||||||
client?.disconnect()
|
val delayMs = min(30_000L, 1_000L shl min(reconnectAttempt - 1, 4))
|
||||||
client = null
|
Log.i(TAG, "ActionCable reconnect in ${delayMs}ms (attempt $reconnectAttempt)")
|
||||||
_connected.value = false
|
delay(delayMs)
|
||||||
|
if (!intentionalDisconnect) openSocket()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun dispatch(data: Map<String, Any?>) {
|
private fun dispatch(data: Map<String, Any?>) {
|
||||||
when (data["type"] as? String) {
|
val payload = unwrapCablePayload(data)
|
||||||
"score_update" -> onScoreUpdate?.invoke(parseScore(data))
|
when (payload["type"] as? String) {
|
||||||
"stream_event" -> when (data["event"] 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()
|
"paused" -> onPauseStream?.invoke()
|
||||||
"resumed" -> onResumeStream?.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")
|
@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(
|
return ScoreState(
|
||||||
homeSets = (data["home_sets"] as? Number)?.toInt() ?: 0,
|
homeSets = (data["home_sets"] as? Number)?.toInt() ?: 0,
|
||||||
awaySets = (data["away_sets"] as? Number)?.toInt() ?: 0,
|
awaySets = (data["away_sets"] as? Number)?.toInt() ?: 0,
|
||||||
homePoints = (data["home_points"] as? Number)?.toInt() ?: 0,
|
homePoints = (data["home_points"] as? Number)?.toInt() ?: 0,
|
||||||
awayPoints = (data["away_points"] as? Number)?.toInt() ?: 0,
|
awayPoints = (data["away_points"] as? Number)?.toInt() ?: 0,
|
||||||
currentSet = (data["current_set"] as? Number)?.toInt() ?: 1,
|
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(
|
com.matchlivetv.match_live_tv.domain.SetPartial(
|
||||||
set = (it["set"] as? Number)?.toInt() ?: 1,
|
set = (row["set"] as? Number)?.toInt() ?: 1,
|
||||||
home = (it["home"] as? Number)?.toInt() ?: 0,
|
home = (row["home"] as? Number)?.toInt() ?: 0,
|
||||||
away = (it["away"] as? Number)?.toInt() ?: 0,
|
away = (row["away"] as? Number)?.toInt() ?: 0,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
timeoutHome = data["timeout_home"] as? Boolean ?: false,
|
timeoutHome = data["timeout_home"] as? Boolean ?: false,
|
||||||
timeoutAway = data["timeout_away"] 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
|
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.TokenStore
|
||||||
import com.matchlivetv.match_live_tv.core.parseApiInstant
|
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.CreateMatchBody
|
||||||
import com.matchlivetv.match_live_tv.data.api.CreateMatchRequest
|
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.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.ScoringRulesBody
|
||||||
import com.matchlivetv.match_live_tv.data.api.UpdateMatchBodyFull
|
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.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.Match
|
||||||
import com.matchlivetv.match_live_tv.domain.Recording
|
import com.matchlivetv.match_live_tv.domain.Recording
|
||||||
import com.matchlivetv.match_live_tv.domain.Team
|
import com.matchlivetv.match_live_tv.domain.Team
|
||||||
@@ -18,12 +23,14 @@ import java.time.Instant
|
|||||||
class MatchRepository(
|
class MatchRepository(
|
||||||
private val api: MatchLiveApi,
|
private val api: MatchLiveApi,
|
||||||
private val tokenStore: TokenStore,
|
private val tokenStore: TokenStore,
|
||||||
|
private val appContext: Context,
|
||||||
) {
|
) {
|
||||||
suspend fun fetchTeams(): List<Team> =
|
suspend fun fetchTeams(): List<Team> =
|
||||||
api.teams().map { it.toDomain() }
|
api.teams().map { it.toDomain() }
|
||||||
|
|
||||||
suspend fun fetchTeam(teamId: String): Team? =
|
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? {
|
suspend fun resolveActiveTeam(teams: List<Team>): Team? {
|
||||||
if (teams.isEmpty()) return null
|
if (teams.isEmpty()) return null
|
||||||
@@ -77,6 +84,31 @@ class MatchRepository(
|
|||||||
),
|
),
|
||||||
).toDomain()
|
).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(
|
suspend fun updateMatch(
|
||||||
matchId: String,
|
matchId: String,
|
||||||
opponentName: String,
|
opponentName: String,
|
||||||
@@ -84,20 +116,39 @@ class MatchRepository(
|
|||||||
scheduledAt: String?,
|
scheduledAt: String?,
|
||||||
setsToWin: Int,
|
setsToWin: Int,
|
||||||
category: String?,
|
category: String?,
|
||||||
|
opponentPrimaryColor: String?,
|
||||||
scoringRules: ScoringRulesBody?,
|
scoringRules: ScoringRulesBody?,
|
||||||
): Match = api.updateMatch(
|
opponentLogoUri: Uri? = null,
|
||||||
matchId,
|
): Match {
|
||||||
UpdateMatchRequestFull(
|
val body = UpdateMatchBodyFull(
|
||||||
match = UpdateMatchBodyFull(
|
opponentName = opponentName,
|
||||||
opponentName = opponentName,
|
location = location?.takeIf { it.isNotBlank() },
|
||||||
location = location?.takeIf { it.isNotBlank() },
|
scheduledAt = scheduledAt,
|
||||||
scheduledAt = scheduledAt,
|
setsToWin = setsToWin,
|
||||||
setsToWin = setsToWin,
|
category = category?.takeIf { it.isNotBlank() },
|
||||||
category = category?.takeIf { it.isNotBlank() },
|
opponentPrimaryColor = opponentPrimaryColor,
|
||||||
scoringRules = scoringRules,
|
scoringRules = scoringRules,
|
||||||
),
|
)
|
||||||
),
|
if (opponentLogoUri != null) {
|
||||||
).toDomain()
|
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) {
|
suspend fun deleteMatch(matchId: String) {
|
||||||
api.deleteMatch(matchId)
|
api.deleteMatch(matchId)
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ class ScoreController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun applyRemote(remote: ScoreState) {
|
fun applyRemote(remote: ScoreState) {
|
||||||
|
if (remote == _score.value) return
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if (now < suppressRemoteUntilMs && remote.progressKey() < _score.value.progressKey()) {
|
if (now < suppressRemoteUntilMs && remote.progressKey() <= _score.value.progressKey()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_score.value = remote
|
_score.value = remote
|
||||||
|
|||||||
@@ -17,11 +17,15 @@ data class Team(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val sport: String,
|
val sport: String,
|
||||||
val clubName: String? = null,
|
val clubName: String? = null,
|
||||||
|
val logoUrl: String? = null,
|
||||||
|
val primaryColor: String? = null,
|
||||||
|
val secondaryColor: String? = null,
|
||||||
val canStream: Boolean = true,
|
val canStream: Boolean = true,
|
||||||
val youtubeEnabled: Boolean = false,
|
val youtubeEnabled: Boolean = false,
|
||||||
val youtubeConnected: Boolean = false,
|
val youtubeConnected: Boolean = false,
|
||||||
val youtubeSelectable: Boolean = false,
|
val youtubeSelectable: Boolean = false,
|
||||||
val youtubeChannelTitle: String? = null,
|
val youtubeChannelTitle: String? = null,
|
||||||
|
val youtubeUsesPlatformChannel: Boolean = false,
|
||||||
val youtubeTeamChannelTitle: String? = null,
|
val youtubeTeamChannelTitle: String? = null,
|
||||||
val planName: String? = null,
|
val planName: String? = null,
|
||||||
val recordingsEnabled: Boolean = false,
|
val recordingsEnabled: Boolean = false,
|
||||||
@@ -31,6 +35,24 @@ data class Team(
|
|||||||
) {
|
) {
|
||||||
val canUseYoutube: Boolean get() = youtubeEnabled
|
val canUseYoutube: Boolean get() = youtubeEnabled
|
||||||
val isYoutubeReady: Boolean get() = youtubeSelectable
|
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(
|
data class ScoringRules(
|
||||||
@@ -54,6 +76,11 @@ data class Match(
|
|||||||
val activeSessionStatus: String? = null,
|
val activeSessionStatus: String? = null,
|
||||||
val streamCompleted: Boolean = false,
|
val streamCompleted: Boolean = false,
|
||||||
val coachHubVisible: Boolean? = null,
|
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
|
val hasActiveSession: Boolean get() = activeSessionId != null
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ data class ScoreState(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
fun progressKey(): Int =
|
fun progressKey(): Long =
|
||||||
homeSets * 1_000_000 + awaySets * 100_000 + homePoints * 1_000 + awayPoints
|
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.domain.ScoreState
|
||||||
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
|
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 {
|
val columns = buildList {
|
||||||
setPartials.forEach { partial ->
|
setPartials.forEach { partial ->
|
||||||
add(
|
add(
|
||||||
@@ -28,6 +35,10 @@ fun ScoreState.toScoreboardState(homeTeamName: String, awayTeamName: String): Sc
|
|||||||
homeTeamName = homeTeamName,
|
homeTeamName = homeTeamName,
|
||||||
awayTeamName = awayTeamName,
|
awayTeamName = awayTeamName,
|
||||||
columns = columns,
|
columns = columns,
|
||||||
|
homeAccentColor = homeAccentColor,
|
||||||
|
awayAccentColor = awayAccentColor,
|
||||||
|
homeLogoUrl = homeLogoUrl,
|
||||||
|
awayLogoUrl = awayLogoUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ data class ScoreboardState(
|
|||||||
val columns: List<ScoreboardSetColumn>,
|
val columns: List<ScoreboardSetColumn>,
|
||||||
val homeAccentColor: Int = 0xFFFF2D2D.toInt(),
|
val homeAccentColor: Int = 0xFFFF2D2D.toInt(),
|
||||||
val awayAccentColor: Int = 0xFF1E3A8A.toInt(),
|
val awayAccentColor: Int = 0xFF1E3A8A.toInt(),
|
||||||
|
val homeLogoUrl: String? = null,
|
||||||
|
val awayLogoUrl: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class BroadcastOverlayStatus {
|
enum class BroadcastOverlayStatus {
|
||||||
@@ -48,6 +50,10 @@ private fun ScoreboardState.fingerprint(): Int {
|
|||||||
result = 31 * result + awayTeamName.hashCode()
|
result = 31 * result + awayTeamName.hashCode()
|
||||||
result = 31 * result + homeAccentColor
|
result = 31 * result + homeAccentColor
|
||||||
result = 31 * result + awayAccentColor
|
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 ->
|
columns.forEach { col ->
|
||||||
result = 31 * result + col.setNumber
|
result = 31 * result + col.setNumber
|
||||||
result = 31 * result + col.homePoints
|
result = 31 * result + col.homePoints
|
||||||
@@ -56,3 +62,5 @@ private fun ScoreboardState.fingerprint(): Int {
|
|||||||
}
|
}
|
||||||
return result
|
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
|
textAlign = Paint.Align.CENTER
|
||||||
}
|
}
|
||||||
private val accentBarPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
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 {
|
private val winPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
color = Color.parseColor("#FF2D2D")
|
color = Color.parseColor("#FF2D2D")
|
||||||
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
|
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
|
||||||
@@ -42,7 +43,23 @@ class ScoreboardElement : OverlayElement {
|
|||||||
color = Color.parseColor("#CCCCCC")
|
color = Color.parseColor("#CCCCCC")
|
||||||
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL)
|
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) {
|
override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) {
|
||||||
val board = state.scoreboard ?: return
|
val board = state.scoreboard ?: return
|
||||||
@@ -51,20 +68,24 @@ class ScoreboardElement : OverlayElement {
|
|||||||
val scale = layout.canvasHeight / 720f * SIZE_BOOST
|
val scale = layout.canvasHeight / 720f * SIZE_BOOST
|
||||||
val pad = (8f * scale).roundToInt().coerceAtLeast(6)
|
val pad = (8f * scale).roundToInt().coerceAtLeast(6)
|
||||||
val colorBarW = (5f * scale).roundToInt().coerceAtLeast(4)
|
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 colW = (28f * scale).roundToInt().coerceAtLeast(22)
|
||||||
|
val brandH = (18f * scale).roundToInt().coerceAtLeast(14)
|
||||||
val headH = (16f * scale).roundToInt().coerceAtLeast(12)
|
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 footerH = (18f * scale).roundToInt().coerceAtLeast(14)
|
||||||
val n = board.columns.size
|
val n = board.columns.size
|
||||||
|
|
||||||
val boxW = pad * 2 + colorBarW + teamW + n * colW
|
val logoSlotW = logoSlotWidth(board, logoSize, logoGap)
|
||||||
val boxH = pad * 2 + headH + rowH * 2 + footerH
|
val boxW = pad * 2 + colorBarW + logoSlotW + teamW + n * colW
|
||||||
|
val boxH = pad * 2 + brandH + headH + rowH * 2 + footerH
|
||||||
val left = layout.marginPx
|
val left = layout.marginPx
|
||||||
val top = layout.marginPx
|
val top = layout.marginPx
|
||||||
|
|
||||||
rect.set(left.toFloat(), top.toFloat(), (left + boxW).toFloat(), (top + boxH).toFloat())
|
boxRect.set(left.toFloat(), top.toFloat(), (left + boxW).toFloat(), (top + boxH).toFloat())
|
||||||
canvas.drawRoundRect(rect, 6f * scale, 6f * scale, backgroundPaint)
|
canvas.drawRoundRect(boxRect, 6f * scale, 6f * scale, backgroundPaint)
|
||||||
|
|
||||||
val headerSize = (10f * scale).coerceAtLeast(8f)
|
val headerSize = (10f * scale).coerceAtLeast(8f)
|
||||||
val teamSize = (11f * scale).coerceAtLeast(9f)
|
val teamSize = (11f * scale).coerceAtLeast(9f)
|
||||||
@@ -76,15 +97,17 @@ class ScoreboardElement : OverlayElement {
|
|||||||
winPaint.textSize = scoreSize
|
winPaint.textSize = scoreSize
|
||||||
footerPaint.textSize = footerSize
|
footerPaint.textSize = footerSize
|
||||||
|
|
||||||
val scoresLeft = left + pad + colorBarW + teamW
|
drawBrandHeader(canvas, left, top, pad, scale, brandH)
|
||||||
val headerBaseline = top + pad + headH - (2f * scale)
|
|
||||||
|
val scoresLeft = left + pad + colorBarW + logoSlotW + teamW
|
||||||
|
val headerBaseline = top + pad + brandH + headH - (2f * scale)
|
||||||
|
|
||||||
board.columns.forEachIndexed { index, column ->
|
board.columns.forEachIndexed { index, column ->
|
||||||
val cx = scoresLeft + index * colW + colW / 2f
|
val cx = scoresLeft + index * colW + colW / 2f
|
||||||
canvas.drawText(column.setNumber.toString(), cx, headerBaseline, headerPaint)
|
canvas.drawText(column.setNumber.toString(), cx, headerBaseline, headerPaint)
|
||||||
}
|
}
|
||||||
|
|
||||||
val homeRowTop = top + pad + headH
|
val homeRowTop = top + pad + brandH + headH
|
||||||
val awayRowTop = homeRowTop + rowH
|
val awayRowTop = homeRowTop + rowH
|
||||||
drawTeamRow(
|
drawTeamRow(
|
||||||
canvas = canvas,
|
canvas = canvas,
|
||||||
@@ -94,6 +117,9 @@ class ScoreboardElement : OverlayElement {
|
|||||||
left = left,
|
left = left,
|
||||||
pad = pad,
|
pad = pad,
|
||||||
colorBarW = colorBarW,
|
colorBarW = colorBarW,
|
||||||
|
logoSize = logoSize,
|
||||||
|
logoGap = logoGap,
|
||||||
|
logoSlotW = logoSlotW,
|
||||||
teamW = teamW,
|
teamW = teamW,
|
||||||
colW = colW,
|
colW = colW,
|
||||||
rowH = rowH,
|
rowH = rowH,
|
||||||
@@ -108,6 +134,9 @@ class ScoreboardElement : OverlayElement {
|
|||||||
left = left,
|
left = left,
|
||||||
pad = pad,
|
pad = pad,
|
||||||
colorBarW = colorBarW,
|
colorBarW = colorBarW,
|
||||||
|
logoSize = logoSize,
|
||||||
|
logoGap = logoGap,
|
||||||
|
logoSlotW = logoSlotW,
|
||||||
teamW = teamW,
|
teamW = teamW,
|
||||||
colW = colW,
|
colW = colW,
|
||||||
rowH = rowH,
|
rowH = rowH,
|
||||||
@@ -117,8 +146,50 @@ class ScoreboardElement : OverlayElement {
|
|||||||
|
|
||||||
val currentSet = board.columns.lastOrNull { it.isCurrent }?.setNumber ?: board.columns.size
|
val currentSet = board.columns.lastOrNull { it.isCurrent }?.setNumber ?: board.columns.size
|
||||||
val footerText = ordinalSetLabel(currentSet)
|
val footerText = ordinalSetLabel(currentSet)
|
||||||
val footerBaseline = rect.bottom - pad - (2f * scale)
|
val footerBaseline = boxRect.bottom - pad - (2f * scale)
|
||||||
canvas.drawText(footerText, rect.left + pad + colorBarW + 4f * scale, footerBaseline, footerPaint)
|
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(
|
private fun drawTeamRow(
|
||||||
@@ -129,6 +200,9 @@ class ScoreboardElement : OverlayElement {
|
|||||||
left: Int,
|
left: Int,
|
||||||
pad: Int,
|
pad: Int,
|
||||||
colorBarW: Int,
|
colorBarW: Int,
|
||||||
|
logoSize: Int,
|
||||||
|
logoGap: Int,
|
||||||
|
logoSlotW: Int,
|
||||||
teamW: Int,
|
teamW: Int,
|
||||||
colW: Int,
|
colW: Int,
|
||||||
rowH: Int,
|
rowH: Int,
|
||||||
@@ -141,12 +215,25 @@ class ScoreboardElement : OverlayElement {
|
|||||||
val barBottom = rowTop + rowH - (2f * scale)
|
val barBottom = rowTop + rowH - (2f * scale)
|
||||||
canvas.drawRect(barLeft, barTop, barLeft + colorBarW, barBottom, accentBarPaint)
|
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 rawName = if (isHome) board.homeTeamName else board.awayTeamName
|
||||||
val name = abbreviate(rawName).uppercase()
|
val name = rawName.trim().uppercase()
|
||||||
val nameMaxW = teamW - (8f * scale)
|
val nameMaxW = teamW - (6f * scale)
|
||||||
val displayName = ellipsize(name, teamPaint, nameMaxW)
|
val displayName = ellipsize(name, teamPaint, nameMaxW)
|
||||||
val nameBaseline = rowTop + rowH * 0.72f
|
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 ->
|
board.columns.forEachIndexed { index, column ->
|
||||||
val cx = scoresLeft + index * colW + colW / 2f
|
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 {
|
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
|
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
|
package com.matchlivetv.match_live_tv.ui.broadcast
|
||||||
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.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.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.MaterialTheme
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -32,11 +25,12 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import com.matchlivetv.match_live_tv.core.DeviceTelemetry
|
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.data.AppContainer
|
||||||
import com.matchlivetv.match_live_tv.domain.Match
|
import com.matchlivetv.match_live_tv.domain.Match
|
||||||
import com.matchlivetv.match_live_tv.domain.MatchScoringRules
|
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.BroadcastConfig
|
||||||
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
|
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
|
||||||
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
|
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
|
||||||
|
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.OverlayState
|
||||||
import com.matchlivetv.match_live_tv.streaming.overlay.toOverlayStatus
|
import com.matchlivetv.match_live_tv.streaming.overlay.toOverlayStatus
|
||||||
import com.matchlivetv.match_live_tv.streaming.overlay.toScoreboardState
|
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.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.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 com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BroadcastScreen(
|
fun BroadcastScreen(
|
||||||
container: AppContainer,
|
container: AppContainer,
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
onFinished: () -> Unit,
|
onFinished: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
LockLandscapeOrientationEffect()
|
||||||
|
KeepScreenOnEffect()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbar = remember { SnackbarHostState() }
|
val snackbar = remember { SnackbarHostState() }
|
||||||
@@ -72,6 +67,8 @@ fun BroadcastScreen(
|
|||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var pauseInFlight by remember { mutableStateOf(false) }
|
var pauseInFlight by remember { mutableStateOf(false) }
|
||||||
var resumeInFlight by remember { mutableStateOf(false) }
|
var resumeInFlight by remember { mutableStateOf(false) }
|
||||||
|
var controlsVisible by remember { mutableStateOf(true) }
|
||||||
|
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) }
|
||||||
val metrics by container.broadcastCoordinator.metrics.collectAsState()
|
val metrics by container.broadcastCoordinator.metrics.collectAsState()
|
||||||
val score by container.scoreController.score.collectAsState()
|
val score by container.scoreController.score.collectAsState()
|
||||||
val cableConnected by container.sessionCable.connected.collectAsState()
|
val cableConnected by container.sessionCable.connected.collectAsState()
|
||||||
@@ -105,9 +102,9 @@ fun BroadcastScreen(
|
|||||||
val updated = container.sessionRepository.pauseSession(sessionId)
|
val updated = container.sessionRepository.pauseSession(sessionId)
|
||||||
session = updated
|
session = updated
|
||||||
container.broadcastCoordinator.engine.pauseBroadcast()
|
container.broadcastCoordinator.engine.pauseBroadcast()
|
||||||
snackbar.showSnackbar("Diretta in pausa — gli spettatori vedono la copertina")
|
snackbar.showSnackbar("Diretta in pausa")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
snackbar.showSnackbar("Pausa sessione: ${e.message ?: "errore"}")
|
snackbar.showSnackbar("Pausa: ${e.message ?: "errore"}")
|
||||||
} finally {
|
} finally {
|
||||||
pauseInFlight = false
|
pauseInFlight = false
|
||||||
}
|
}
|
||||||
@@ -125,9 +122,9 @@ fun BroadcastScreen(
|
|||||||
}
|
}
|
||||||
updated = container.sessionRepository.fetchSession(sessionId)
|
updated = container.sessionRepository.fetchSession(sessionId)
|
||||||
session = updated
|
session = updated
|
||||||
snackbar.showSnackbar("Diretta ripresa — di nuovo in onda")
|
snackbar.showSnackbar("Diretta ripresa")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
snackbar.showSnackbar("Ripresa diretta: ${e.message ?: "errore"}")
|
snackbar.showSnackbar("Ripresa: ${e.message ?: "errore"}")
|
||||||
} finally {
|
} finally {
|
||||||
resumeInFlight = false
|
resumeInFlight = false
|
||||||
}
|
}
|
||||||
@@ -135,18 +132,21 @@ fun BroadcastScreen(
|
|||||||
|
|
||||||
suspend fun applyRemotePause() {
|
suspend fun applyRemotePause() {
|
||||||
container.broadcastCoordinator.engine.pauseBroadcast()
|
container.broadcastCoordinator.engine.pauseBroadcast()
|
||||||
session = session?.copy(status = "paused")
|
session = runCatching { container.sessionRepository.fetchSession(sessionId) }
|
||||||
snackbar.showSnackbar("Pausa dalla regia — trasmissione fermata")
|
.getOrNull() ?: session?.copy(status = "paused")
|
||||||
|
snackbar.showSnackbar("Pausa dalla regia")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun applyRemoteResume() {
|
suspend fun applyRemoteResume() {
|
||||||
if (resumeInFlight) return
|
if (resumeInFlight) return
|
||||||
val current = session ?: return
|
val current = runCatching { container.sessionRepository.fetchSession(sessionId) }
|
||||||
|
.getOrNull() ?: session ?: return
|
||||||
val url = current.rtmpIngestUrl ?: return
|
val url = current.rtmpIngestUrl ?: return
|
||||||
resumeInFlight = true
|
resumeInFlight = true
|
||||||
try {
|
try {
|
||||||
|
session = current
|
||||||
container.broadcastCoordinator.engine.resumeBroadcast(broadcastConfig(current))
|
container.broadcastCoordinator.engine.resumeBroadcast(broadcastConfig(current))
|
||||||
snackbar.showSnackbar("Ripresa dalla regia — di nuovo in onda")
|
snackbar.showSnackbar("Ripresa dalla regia")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
snackbar.showSnackbar("Ripresa RTMP: ${e.message ?: "errore"}")
|
snackbar.showSnackbar("Ripresa RTMP: ${e.message ?: "errore"}")
|
||||||
} finally {
|
} 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) {
|
LaunchedEffect(sessionId, permissions.granted) {
|
||||||
if (!permissions.granted) return@LaunchedEffect
|
if (!permissions.granted) return@LaunchedEffect
|
||||||
loading = true
|
loading = true
|
||||||
@@ -175,6 +183,9 @@ fun BroadcastScreen(
|
|||||||
container.sessionCable.onResumeStream = {
|
container.sessionCable.onResumeStream = {
|
||||||
if (!resumeInFlight) scope.launch { applyRemoteResume() }
|
if (!resumeInFlight) scope.launch { applyRemoteResume() }
|
||||||
}
|
}
|
||||||
|
container.sessionCable.onEndStream = {
|
||||||
|
scope.launch { applyRemoteStop() }
|
||||||
|
}
|
||||||
container.sessionCable.connect(sessionId, token, deviceRole = "camera")
|
container.sessionCable.connect(sessionId, token, deviceRole = "camera")
|
||||||
}
|
}
|
||||||
container.broadcastCoordinator.startService()
|
container.broadcastCoordinator.startService()
|
||||||
@@ -192,15 +203,62 @@ fun BroadcastScreen(
|
|||||||
|
|
||||||
LaunchedEffect(score, match, metrics.phase) {
|
LaunchedEffect(score, match, metrics.phase) {
|
||||||
val currentMatch = match ?: return@LaunchedEffect
|
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(
|
container.broadcastCoordinator.updateOverlay(
|
||||||
OverlayState(
|
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,
|
watermarkVisible = true,
|
||||||
broadcastStatus = metrics.phase.toOverlayStatus(),
|
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) {
|
LaunchedEffect(sessionId) {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(10_000)
|
delay(10_000)
|
||||||
@@ -222,8 +280,10 @@ fun BroadcastScreen(
|
|||||||
|
|
||||||
DisposableEffect(sessionId) {
|
DisposableEffect(sessionId) {
|
||||||
onDispose {
|
onDispose {
|
||||||
|
container.sessionCable.onScoreUpdate = null
|
||||||
container.sessionCable.onPauseStream = null
|
container.sessionCable.onPauseStream = null
|
||||||
container.sessionCable.onResumeStream = null
|
container.sessionCable.onResumeStream = null
|
||||||
|
container.sessionCable.onEndStream = null
|
||||||
container.sessionCable.disconnect()
|
container.sessionCable.disconnect()
|
||||||
container.broadcastCoordinator.stopService()
|
container.broadcastCoordinator.stopService()
|
||||||
container.broadcastCoordinator.engine.stopBroadcast()
|
container.broadcastCoordinator.engine.stopBroadcast()
|
||||||
@@ -246,156 +306,144 @@ fun BroadcastScreen(
|
|||||||
else -> MatchColors.AccentYellow
|
else -> MatchColors.AccentYellow
|
||||||
}
|
}
|
||||||
val pointsTarget = scoringRules?.pointsTarget(score.currentSet) ?: 25
|
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(
|
ScoreDialogRouter(
|
||||||
host = dialogHost,
|
host = dialogHost,
|
||||||
homeName = currentMatch.teamName,
|
homeName = m.teamName,
|
||||||
awayName = currentMatch.opponentName,
|
awayName = m.opponentName,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
MatchScreenScaffold(
|
Box(
|
||||||
topBar = {
|
Modifier
|
||||||
Column {
|
.fillMaxSize()
|
||||||
TopAppBar(
|
.background(Color.Black),
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
when {
|
||||||
Box(
|
loading -> {
|
||||||
Modifier
|
CircularProgressIndicator(
|
||||||
.weight(1f)
|
color = MatchColors.PrimaryRed,
|
||||||
.fillMaxWidth(),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
) {
|
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
if (isPaused && !loading) {
|
|
||||||
MatchPrimaryButton(
|
|
||||||
label = "RIPRENDI DIRETTA",
|
|
||||||
onClick = { scope.launch { resumeStream() } },
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Column(
|
!permissions.granted -> {
|
||||||
Modifier
|
Column(
|
||||||
.weight(1f)
|
Modifier
|
||||||
.verticalScroll(rememberScrollState()),
|
.align(Alignment.Center)
|
||||||
) {
|
.padding(24.dp),
|
||||||
error?.let {
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
it,
|
"Consenti camera e microfono per andare in diretta",
|
||||||
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",
|
|
||||||
color = MatchColors.AccentYellow,
|
color = MatchColors.AccentYellow,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
MatchPrimaryButton(
|
MatchPrimaryButton(
|
||||||
label = "CONCEDI PERMESSI",
|
label = "CONCEDI PERMESSI",
|
||||||
onClick = permissions.request,
|
onClick = permissions.request,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (isPaused) {
|
}
|
||||||
MatchPrimaryButton(
|
else -> {
|
||||||
label = "RIPRENDI DIRETTA",
|
CameraPreviewPanel(container)
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -13,6 +17,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -22,9 +27,11 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
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.AppContainer
|
||||||
import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody
|
import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody
|
||||||
import com.matchlivetv.match_live_tv.domain.Match
|
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.MatchPrimaryButton
|
||||||
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
|
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
|
||||||
import com.matchlivetv.match_live_tv.ui.matches.MatchOutlinedField
|
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_PER_SET = 25
|
||||||
private const val DEFAULT_POINTS_DECIDING_SET = 15
|
private const val DEFAULT_POINTS_DECIDING_SET = 15
|
||||||
private const val DEFAULT_MIN_POINT_LEAD = 2
|
private const val DEFAULT_MIN_POINT_LEAD = 2
|
||||||
|
private const val DEFAULT_OPPONENT_COLOR = "#1E3A8A"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StepMatchScreen(
|
fun StepMatchScreen(
|
||||||
@@ -51,6 +59,37 @@ fun StepMatchScreen(
|
|||||||
var location by remember { mutableStateOf(match.location.orEmpty()) }
|
var location by remember { mutableStateOf(match.location.orEmpty()) }
|
||||||
var campionato by remember { mutableStateOf(match.category.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
|
val existingRules = match.scoringRules
|
||||||
var customRules by remember {
|
var customRules by remember {
|
||||||
mutableStateOf(existingRules != null || match.setsToWin != DEFAULT_SETS_TO_WIN)
|
mutableStateOf(existingRules != null || match.setsToWin != DEFAULT_SETS_TO_WIN)
|
||||||
@@ -74,10 +113,38 @@ fun StepMatchScreen(
|
|||||||
Text("Dettagli partita", style = MaterialTheme.typography.headlineMedium)
|
Text("Dettagli partita", style = MaterialTheme.typography.headlineMedium)
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
WizardReadOnlyField(label = "Squadra casa", value = match.teamName)
|
TeamBrandingRow(
|
||||||
Spacer(Modifier.height(12.dp))
|
sectionLabel = "Squadra di casa",
|
||||||
MatchOutlinedField(value = opponent, onValueChange = { opponent = it }, label = "Avversario")
|
teamName = match.teamName,
|
||||||
Spacer(Modifier.height(12.dp))
|
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")
|
MatchOutlinedField(value = location, onValueChange = { location = it }, label = "Luogo")
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
MatchOutlinedField(
|
MatchOutlinedField(
|
||||||
@@ -202,9 +269,23 @@ fun StepMatchScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val initialHomeColor = normalizeHexColor(
|
||||||
|
homeTeam?.primaryColor ?: match.homePrimaryColor,
|
||||||
|
)
|
||||||
|
val homeBrandingChanged =
|
||||||
|
homePrimaryColor != initialHomeColor || homeLogoUri != null
|
||||||
|
|
||||||
saving = true
|
saving = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
if (homeBrandingChanged) {
|
||||||
|
container.matchRepository.updateTeamBranding(
|
||||||
|
teamId = match.teamId,
|
||||||
|
primaryColor = homePrimaryColor,
|
||||||
|
secondaryColor = homeTeam?.secondaryColor,
|
||||||
|
logoUri = homeLogoUri,
|
||||||
|
)
|
||||||
|
}
|
||||||
container.matchRepository.updateMatch(
|
container.matchRepository.updateMatch(
|
||||||
matchId = match.id,
|
matchId = match.id,
|
||||||
opponentName = opponent.trim(),
|
opponentName = opponent.trim(),
|
||||||
@@ -212,7 +293,9 @@ fun StepMatchScreen(
|
|||||||
scheduledAt = match.scheduledAt,
|
scheduledAt = match.scheduledAt,
|
||||||
setsToWin = resolvedSets,
|
setsToWin = resolvedSets,
|
||||||
category = campionato.trim(),
|
category = campionato.trim(),
|
||||||
|
opponentPrimaryColor = opponentPrimaryColor,
|
||||||
scoringRules = resolvedRules,
|
scoringRules = resolvedRules,
|
||||||
|
opponentLogoUri = opponentLogoUri,
|
||||||
)
|
)
|
||||||
}.onSuccess { updated ->
|
}.onSuccess { updated ->
|
||||||
container.wizardSession.match = updated
|
container.wizardSession.match = updated
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ fun StepNetworkTestScreen(
|
|||||||
var uploadMbps by remember { mutableStateOf(0.0) }
|
var uploadMbps by remember { mutableStateOf(0.0) }
|
||||||
var latencyMs by remember { mutableStateOf(0) }
|
var latencyMs by remember { mutableStateOf(0) }
|
||||||
var networkType by remember { mutableStateOf("—") }
|
var networkType by remember { mutableStateOf("—") }
|
||||||
|
var selectedQualityLabel by remember { mutableStateOf<String?>(null) }
|
||||||
var starting by remember { mutableStateOf(false) }
|
var starting by remember { mutableStateOf(false) }
|
||||||
var currentSession by remember { mutableStateOf(session) }
|
var currentSession by remember { mutableStateOf(session) }
|
||||||
|
|
||||||
@@ -92,6 +93,14 @@ fun StepNetworkTestScreen(
|
|||||||
networkType = networkType,
|
networkType = networkType,
|
||||||
)
|
)
|
||||||
}.getOrNull()
|
}.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)
|
ready = result?.ready ?: (uploadMbps >= 2.0)
|
||||||
testCompleted = true
|
testCompleted = true
|
||||||
testing = false
|
testing = false
|
||||||
@@ -141,6 +150,13 @@ fun StepNetworkTestScreen(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
selectedQualityLabel?.let { quality ->
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
WizardReadOnlyField(
|
||||||
|
label = "Qualità streaming (automatica)",
|
||||||
|
value = quality,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (testCompleted && shareUrl != null) {
|
if (testCompleted && shareUrl != null) {
|
||||||
Spacer(Modifier.height(16.dp))
|
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
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("link", text))
|
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 loadingTeam by remember { mutableStateOf(true) }
|
||||||
var platform by remember { mutableStateOf("matchlivetv") }
|
var platform by remember { mutableStateOf("matchlivetv") }
|
||||||
var privacy by remember { mutableStateOf("public") }
|
var privacy by remember { mutableStateOf("public") }
|
||||||
var youtubeChannel by remember { mutableStateOf<String?>("platform") }
|
|
||||||
var creating by remember { mutableStateOf(false) }
|
var creating by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(match.teamId) {
|
LaunchedEffect(match.teamId) {
|
||||||
@@ -56,7 +55,14 @@ fun StepTransmissionScreen(
|
|||||||
return
|
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(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -67,6 +73,7 @@ fun StepTransmissionScreen(
|
|||||||
Text(
|
Text(
|
||||||
"Piano $plan",
|
"Piano $plan",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MatchColors.TextSecondary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(bottom = 12.dp),
|
.padding(bottom = 12.dp),
|
||||||
@@ -83,18 +90,13 @@ fun StepTransmissionScreen(
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
WizardPlatformCard(
|
WizardPlatformCard(
|
||||||
title = "YouTube Live",
|
title = "YouTube Live",
|
||||||
subtitle = when {
|
subtitle = youtubeSubtitle,
|
||||||
team?.canUseYoutube != true -> "Premium Light o Full"
|
|
||||||
!youtubeReady -> "Canale in attivazione"
|
|
||||||
else -> "Canale YouTube collegato"
|
|
||||||
},
|
|
||||||
selected = platform == "youtube",
|
selected = platform == "youtube",
|
||||||
enabled = youtubeReady,
|
enabled = youtubeReady,
|
||||||
badge = if (team?.canUseYoutube != true) "Premium" else null,
|
badge = if (team?.canUseYoutube != true) "Premium" else null,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (youtubeReady) {
|
if (youtubeReady) {
|
||||||
platform = "youtube"
|
platform = "youtube"
|
||||||
youtubeChannel = "platform"
|
|
||||||
} else {
|
} else {
|
||||||
onError("YouTube non disponibile per questa squadra")
|
onError("YouTube non disponibile per questa squadra")
|
||||||
}
|
}
|
||||||
@@ -104,13 +106,15 @@ fun StepTransmissionScreen(
|
|||||||
Text("Visibilità", style = MaterialTheme.typography.headlineMedium)
|
Text("Visibilità", style = MaterialTheme.typography.headlineMedium)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
|
||||||
MatchSecondaryButton(
|
WizardChoiceButton(
|
||||||
label = "PUBBLICO",
|
label = "PUBBLICO",
|
||||||
|
selected = privacy == "public",
|
||||||
onClick = { privacy = "public" },
|
onClick = { privacy = "public" },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
MatchSecondaryButton(
|
WizardChoiceButton(
|
||||||
label = "NON IN ELENCO",
|
label = "NON IN ELENCO",
|
||||||
|
selected = privacy == "unlisted",
|
||||||
onClick = { privacy = "unlisted" },
|
onClick = { privacy = "unlisted" },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -118,16 +122,19 @@ fun StepTransmissionScreen(
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
if (privacy == "public") {
|
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 {
|
} 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,
|
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))
|
Spacer(Modifier.height(32.dp))
|
||||||
Row {
|
Row {
|
||||||
MatchSecondaryButton(
|
MatchSecondaryButton(
|
||||||
@@ -147,7 +154,7 @@ fun StepTransmissionScreen(
|
|||||||
matchId = match.id,
|
matchId = match.id,
|
||||||
platform = platform,
|
platform = platform,
|
||||||
privacyStatus = privacy,
|
privacyStatus = privacy,
|
||||||
youtubeChannel = if (platform == "youtube") youtubeChannel else null,
|
youtubeChannel = if (platform == "youtube") loadedTeam?.effectiveYoutubeChannel else null,
|
||||||
)
|
)
|
||||||
}.onSuccess { session ->
|
}.onSuccess { session ->
|
||||||
container.wizardSession.setSession(session, match)
|
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.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
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
|
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||||
|
|
||||||
private val stepTitles = listOf(
|
private val stepTitles = listOf(
|
||||||
@@ -54,6 +56,20 @@ fun WizardStepIndicator(currentStep: Int, modifier: Modifier = Modifier) {
|
|||||||
fun wizardStepTitle(step: Int): String =
|
fun wizardStepTitle(step: Int): String =
|
||||||
stepTitles[(step.coerceIn(1, 3) - 1)]
|
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
|
@Composable
|
||||||
fun WizardReadOnlyField(label: String, value: String) {
|
fun WizardReadOnlyField(label: String, value: String) {
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
Reference in New Issue
Block a user