From ae36d17adbb0bfa423ef2b30a458ae3bcc275513 Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Mon, 8 Jun 2026 09:30:43 +0200 Subject: [PATCH] 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 --- .../channels/concerns/cable_broadcastable.rb | 2 +- .../controllers/admin/sessions_controller.rb | 17 + .../controllers/api/v1/matches_controller.rb | 33 +- .../api/v1/stream_sessions_controller.rb | 24 +- .../controllers/api/v1/teams_controller.rb | 20 +- .../controllers/concerns/api/url_helper.rb | 16 + .../controllers/public/regia_controller.rb | 12 +- backend/app/helpers/admin_helper.rb | 18 + backend/app/helpers/public/live_helper.rb | 30 + backend/app/models/match.rb | 21 + backend/app/models/stream_session.rb | 6 + backend/app/services/sessions/regia_access.rb | 4 +- .../app/services/sessions/select_quality.rb | 29 + .../app/views/admin/dashboard/index.html.erb | 8 +- .../app/views/admin/sessions/_links.html.erb | 41 ++ .../app/views/admin/sessions/index.html.erb | 8 +- .../app/views/admin/sessions/show.html.erb | 5 +- .../app/views/layouts/marketing_live.html.erb | 2 +- .../app/views/public/live/_scorebug.html.erb | 47 ++ backend/app/views/public/live/show.html.erb | 89 ++- backend/app/views/public/regia/show.html.erb | 12 +- backend/config/routes.rb | 6 +- ...200000_add_opponent_branding_to_matches.rb | 5 + backend/public/admin.css | 84 +++ backend/public/regia.js | 37 +- backend/spec/requests/public/regia_spec.rb | 20 + native/android/app/build.gradle.kts | 4 +- .../android/app/src/main/AndroidManifest.xml | 1 + .../match_live_tv/core/ColorHex.kt | 40 ++ .../match_live_tv/core/DeviceTelemetry.kt | 88 ++- .../match_live_tv/core/MediaUrl.kt | 11 + .../match_live_tv/data/AppContainer.kt | 13 +- .../match_live_tv/data/api/ApiDtos.kt | 29 + .../match_live_tv/data/api/MatchLiveApi.kt | 37 ++ .../match_live_tv/data/api/MultipartBodies.kt | 29 + .../data/cable/ActionCableClient.kt | 20 +- .../data/cable/SessionCableService.kt | 109 +++- .../data/repository/MatchRepository.kt | 79 ++- .../data/scoring/ScoreController.kt | 3 +- .../match_live_tv/domain/Models.kt | 27 + .../match_live_tv/domain/ScoreState.kt | 8 +- .../streaming/overlay/OverlayLogoCache.kt | 103 +++ .../streaming/overlay/OverlayMappings.kt | 13 +- .../streaming/overlay/OverlayState.kt | 8 + .../streaming/overlay/ScoreboardElement.kt | 125 +++- .../ui/broadcast/BroadcastControlsOverlay.kt | 614 ++++++++++++++++++ .../ui/broadcast/BroadcastScreen.kt | 358 +++++----- .../match_live_tv/ui/system/KeepScreenOn.kt | 17 + .../ui/system/ScreenOrientation.kt | 31 + .../ui/wizard/StepMatchScreen.kt | 91 ++- .../ui/wizard/StepNetworkTestScreen.kt | 26 + .../ui/wizard/StepTransmissionScreen.kt | 41 +- .../ui/wizard/TeamBrandingEditor.kt | 377 +++++++++++ .../ui/wizard/TeamColorPicker.kt | 190 ++++++ .../ui/wizard/WizardComponents.kt | 16 + 55 files changed, 2819 insertions(+), 285 deletions(-) create mode 100644 backend/app/controllers/concerns/api/url_helper.rb create mode 100644 backend/app/services/sessions/select_quality.rb create mode 100644 backend/app/views/admin/sessions/_links.html.erb create mode 100644 backend/app/views/public/live/_scorebug.html.erb create mode 100644 backend/db/migrate/20260606200000_add_opponent_branding_to_matches.rb create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ColorHex.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/MediaUrl.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MultipartBodies.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayLogoCache.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/system/KeepScreenOn.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/system/ScreenOrientation.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/TeamBrandingEditor.kt create mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/TeamColorPicker.kt diff --git a/backend/app/channels/concerns/cable_broadcastable.rb b/backend/app/channels/concerns/cable_broadcastable.rb index cf89fbb..2850629 100644 --- a/backend/app/channels/concerns/cable_broadcastable.rb +++ b/backend/app/channels/concerns/cable_broadcastable.rb @@ -3,7 +3,7 @@ module CableBroadcastable class_methods do def broadcast_message(session, data) - broadcast_to(session, method: "receive_message", data: data) + broadcast_to(session, data) end end end diff --git a/backend/app/controllers/admin/sessions_controller.rb b/backend/app/controllers/admin/sessions_controller.rb index 6445242..2f1704f 100644 --- a/backend/app/controllers/admin/sessions_controller.rb +++ b/backend/app/controllers/admin/sessions_controller.rb @@ -22,5 +22,22 @@ module Admin Rails.logger.error("[admin] stop session #{@session.id}: #{e.class} #{e.message}") redirect_to admin_session_path(@session), alert: "Errore durante la chiusura: #{e.message}" end + + def regia_link + @session = StreamSession.find(params[:id]) + if @session.terminal? + redirect_to admin_session_path(@session), alert: "Sessione terminata: impossibile generare il link regia." + return + end + + access = Sessions::RegiaAccess.new(@session) + token = access.issue_token! + redirect_to admin_session_path(@session), + notice: "Link regia generato.", + flash: { + regia_url: access.url(token), + regia_expires_at: @session.regia_token_expires_at&.iso8601 + } + end end end diff --git a/backend/app/controllers/api/v1/matches_controller.rb b/backend/app/controllers/api/v1/matches_controller.rb index ab9dabc..0de3457 100644 --- a/backend/app/controllers/api/v1/matches_controller.rb +++ b/backend/app/controllers/api/v1/matches_controller.rb @@ -1,6 +1,9 @@ module Api module V1 class MatchesController < BaseController + include Api::UrlHelper + include BrandingAttachments + before_action :set_team, only: %i[index create] before_action :set_match, only: %i[show update destroy] @@ -14,6 +17,7 @@ module Api def create match = @team.matches.create!(match_params) + attach_opponent_logo(match) render json: match_json(match), status: :created end @@ -24,7 +28,9 @@ module Api def update attrs = match_params.to_h normalize_scoring_rules!(attrs) + normalize_opponent_color!(attrs) @match.update!(attrs) + attach_opponent_logo(@match) render json: match_json(@match) end @@ -49,18 +55,17 @@ module Api def set_match team_ids = current_user.streamable_teams.map(&:id) - @match = Match.where(team_id: team_ids).find(params[:id]) + @match = Match.includes(:team).where(team_id: team_ids).find(params[:id]) end def match_params params.require(:match).permit( :opponent_name, :location, :scheduled_at, :sport, :sets_to_win, - :category, + :category, :opponent_primary_color, scoring_rules: %i[points_per_set points_deciding_set min_point_lead] ) end - # Regolamento standard: scoring_rules assente o {} → default FIPAV lato Scoring::Rules. def normalize_scoring_rules!(attrs) return unless attrs.key?("scoring_rules") @@ -68,12 +73,27 @@ module Api attrs["scoring_rules"] = {} if rules.blank? end + def normalize_opponent_color!(attrs) + return unless attrs.key?("opponent_primary_color") + + attrs["opponent_primary_color"] = normalize_hex_color( + attrs["opponent_primary_color"], + Match::DEFAULT_OPPONENT_COLOR + ) + end + + def attach_opponent_logo(match) + file = params[:opponent_logo_file] + match.opponent_logo_file.attach(file) if file.present? + end + def match_json(match, detail: false) active = active_session_for(match) + team = match.team { id: match.id, team_id: match.team_id, - team_name: match.team.name, + team_name: team.name, opponent_name: match.opponent_name, location: match.location, scheduled_at: match.scheduled_at, @@ -81,6 +101,11 @@ module Api sets_to_win: match.sets_to_win, scoring_rules: match.scoring_rules.presence, category: match.category, + home_primary_color: team.effective_primary_color, + home_secondary_color: team.effective_secondary_color, + home_logo_url: api_absolute_url(team.effective_logo_url), + opponent_primary_color: match.effective_opponent_primary_color, + opponent_logo_url: api_absolute_url(match.opponent_logo_url), active_session_id: active&.id, active_session_status: active&.status, stream_completed: match.stream_completed?, diff --git a/backend/app/controllers/api/v1/stream_sessions_controller.rb b/backend/app/controllers/api/v1/stream_sessions_controller.rb index b5d7266..25f1399 100644 --- a/backend/app/controllers/api/v1/stream_sessions_controller.rb +++ b/backend/app/controllers/api/v1/stream_sessions_controller.rb @@ -100,13 +100,31 @@ module Api end def network_test + upload_mbps = params[:upload_mbps].to_f + preset = Sessions::SelectQuality.call(upload_mbps: upload_mbps) + @session.update!( + quality_preset: preset[:id], + target_bitrate: preset[:target_bitrate], + target_fps: preset[:target_fps] + ) @session.stream_events.create!( event_type: "network_test", - metadata: params.permit(:download_mbps, :upload_mbps, :latency_ms, :network_type).to_h, + metadata: params.permit(:download_mbps, :upload_mbps, :latency_ms, :network_type) + .to_h.merge( + "quality_preset" => preset[:id], + "target_bitrate" => preset[:target_bitrate] + ), occurred_at: Time.current ) - ready = params[:upload_mbps].to_f >= (@session.target_bitrate / 1_000_000.0 * 0.8) - render json: { ready: ready, target_upload_mbps: @session.target_bitrate / 1_000_000.0 } + target_upload = preset[:target_bitrate] / 1_000_000.0 + ready = upload_mbps >= (target_upload * Sessions::SelectQuality::UPLOAD_HEADROOM) + render json: { + ready: ready, + target_upload_mbps: target_upload, + quality_preset: preset[:id], + target_bitrate: preset[:target_bitrate], + target_fps: preset[:target_fps] + } end def youtube_stats diff --git a/backend/app/controllers/api/v1/teams_controller.rb b/backend/app/controllers/api/v1/teams_controller.rb index e4d0f41..f1fab7d 100644 --- a/backend/app/controllers/api/v1/teams_controller.rb +++ b/backend/app/controllers/api/v1/teams_controller.rb @@ -1,6 +1,9 @@ module Api module V1 class TeamsController < BaseController + include Api::UrlHelper + include BrandingAttachments + def index teams = current_user.streamable_teams render json: teams.map { |t| team_json(t) } @@ -48,6 +51,7 @@ module Api def update team = current_user.manageable_teams.find(params[:id]) team.update!(team_params) + attach_team_logo(team) render json: team_json(team) end @@ -69,7 +73,19 @@ module Api private def team_params - params.require(:team).permit(:name, :sport, :logo_url) + p = params.require(:team).permit(:name, :sport, :logo_url, :primary_color, :secondary_color) + if p[:primary_color].present? + p[:primary_color] = normalize_hex_color(p[:primary_color], p[:primary_color]) + end + if p[:secondary_color].present? + p[:secondary_color] = normalize_hex_color(p[:secondary_color], p[:secondary_color]) + end + p + end + + def attach_team_logo(team) + file = params[:logo_file] + team.logo_file.attach(file) if file.present? end def team_json(team, detail: false) @@ -80,7 +96,7 @@ module Api id: team.id, name: team.name, sport: team.sport, - logo_url: team.effective_logo_url, + logo_url: api_absolute_url(team.effective_logo_url), primary_color: team.effective_primary_color, secondary_color: team.effective_secondary_color, club_id: team.club_id, diff --git a/backend/app/controllers/concerns/api/url_helper.rb b/backend/app/controllers/concerns/api/url_helper.rb new file mode 100644 index 0000000..9a62232 --- /dev/null +++ b/backend/app/controllers/concerns/api/url_helper.rb @@ -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 diff --git a/backend/app/controllers/public/regia_controller.rb b/backend/app/controllers/public/regia_controller.rb index 2bd4e23..40bf960 100644 --- a/backend/app/controllers/public/regia_controller.rb +++ b/backend/app/controllers/public/regia_controller.rb @@ -6,7 +6,7 @@ module Public layout "regia" protect_from_forgery with: :null_session - skip_before_action :verify_authenticity_token, only: %i[score pause stop] + skip_before_action :verify_authenticity_token, only: %i[score pause resume stop] before_action :set_session_from_token @@ -44,6 +44,13 @@ module Public render json: status_payload end + def resume + Sessions::Resume.new(@session).call + render json: status_payload + rescue Teams::EntitlementError => e + render json: status_payload.merge(error: e.message), status: :unprocessable_entity + end + def stop Sessions::Stop.new(@session).call render json: status_payload @@ -55,8 +62,7 @@ module Public @session = Sessions::RegiaAccess.find_session_by_token(params[:token]) return if @session - render file: Rails.root.join("public/404.html"), status: :not_found, layout: false - throw :abort + head :not_found end def status_payload diff --git a/backend/app/helpers/admin_helper.rb b/backend/app/helpers/admin_helper.rb index 5b715f1..e0a799a 100644 --- a/backend/app/helpers/admin_helper.rb +++ b/backend/app/helpers/admin_helper.rb @@ -19,4 +19,22 @@ module AdminHelper mins = minutes % 60 hours.positive? ? "#{hours}h #{mins}m" : "#{mins} min" end + + def admin_session_watch_links(session) + links = [] + if session.matchlivetv_platform? + links << { label: "Pagina live", url: session.watch_page_url } + end + youtube = session.youtube_watch_url + links << { label: "YouTube", url: youtube } if youtube.present? + links + end + + def admin_regia_expires_label(iso_time) + return if iso_time.blank? + + Time.zone.parse(iso_time).strftime("%d/%m/%Y %H:%M") + rescue ArgumentError, TypeError + nil + end end diff --git a/backend/app/helpers/public/live_helper.rb b/backend/app/helpers/public/live_helper.rb index fac9d41..b55b830 100644 --- a/backend/app/helpers/public/live_helper.rb +++ b/backend/app/helpers/public/live_helper.rb @@ -61,6 +61,36 @@ module Public "#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}" end + def live_scorebug_columns(score_state) + unless score_state + return { + labels: ["1"], + home: ["0"], + away: ["0"], + live_index: 0 + } + end + + partials = Array(score_state.set_partials) + labels = partials.map { |p| (p["set"] || p[:set]).to_s } + home = partials.map { |p| (p["home"] || p[:home]).to_s } + away = partials.map { |p| (p["away"] || p[:away]).to_s } + labels << score_state.current_set.to_s + home << score_state.home_points.to_s + away << score_state.away_points.to_s + { + labels: labels, + home: home, + away: away, + live_index: labels.length - 1 + } + end + + def live_scorebug_team_label(name, max: 16) + n = name.to_s + n.length <= max ? n : "#{n[0, max - 1]}…" + end + # Colore ospite sul tabellone: secondario società se leggibile su bianco, altrimenti blu visitatore. def live_scorebug_away_color(team) club = team.club diff --git a/backend/app/models/match.rb b/backend/app/models/match.rb index 4b9dd0f..854482b 100644 --- a/backend/app/models/match.rb +++ b/backend/app/models/match.rb @@ -2,11 +2,16 @@ class Match < ApplicationRecord belongs_to :team has_many :stream_sessions, dependent: :destroy + has_one_attached :opponent_logo_file + ACTIVE_SESSION_STATUSES = %w[live connecting reconnecting paused].freeze + DEFAULT_OPPONENT_COLOR = "#1E3A8A" validates :opponent_name, presence: true validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 } + validates :opponent_primary_color, format: { with: Brandable::HEX_COLOR }, allow_blank: true validate :scoring_rules_shape, if: -> { scoring_rules.present? } + validate :opponent_logo_file_type, if: -> { opponent_logo_file.attached? } # category: campionato / descrizione torneo (facoltativo, es. "Serie C"). @@ -63,6 +68,16 @@ class Match < ApplicationRecord stream_sessions.where(status: %w[ended error]).exists? end + def opponent_logo_url + return unless opponent_logo_file.attached? + + Rails.application.routes.url_helpers.rails_blob_path(opponent_logo_file, only_path: true) + end + + def effective_opponent_primary_color + opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR + end + # Hub app coach: dirette da riprendere o partite programmate future. # Le bozze «Avversario» abbandonate (senza sessione) non compaiono. def coach_hub_visible? @@ -77,6 +92,12 @@ class Match < ApplicationRecord private + def opponent_logo_file_type + return if opponent_logo_file.content_type.in?(%w[image/png image/jpeg image/webp]) + + errors.add(:opponent_logo_file, "deve essere PNG, JPEG o WebP") + end + def scoring_rules_shape rules = scoring_rules.is_a?(Hash) ? scoring_rules : {} %w[points_per_set points_deciding_set min_point_lead].each do |key| diff --git a/backend/app/models/stream_session.rb b/backend/app/models/stream_session.rb index 425a1fd..e8ea493 100644 --- a/backend/app/models/stream_session.rb +++ b/backend/app/models/stream_session.rb @@ -131,6 +131,12 @@ class StreamSession < ApplicationRecord status.in?(%w[ended error]) end + def regia_token_active? + regia_token_digest.present? && + regia_token_expires_at&.future? && + !terminal? + end + def resumable? status.in?(%w[connecting live reconnecting paused]) end diff --git a/backend/app/services/sessions/regia_access.rb b/backend/app/services/sessions/regia_access.rb index 81a425f..cfd0465 100644 --- a/backend/app/services/sessions/regia_access.rb +++ b/backend/app/services/sessions/regia_access.rb @@ -13,7 +13,9 @@ module Sessions def rotate! token = SecureRandom.urlsafe_base64(24) - expires = [DEFAULT_TTL.from_now, session_end_cap].compact.min + expires = DEFAULT_TTL.from_now + cap = session_end_cap + expires = [expires, cap].min if cap&.future? @session.update!( regia_token_digest: digest(token), regia_token_expires_at: expires diff --git a/backend/app/services/sessions/select_quality.rb b/backend/app/services/sessions/select_quality.rb new file mode 100644 index 0000000..69d22a5 --- /dev/null +++ b/backend/app/services/sessions/select_quality.rb @@ -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 diff --git a/backend/app/views/admin/dashboard/index.html.erb b/backend/app/views/admin/dashboard/index.html.erb index 4d3f993..89bacfd 100644 --- a/backend/app/views/admin/dashboard/index.html.erb +++ b/backend/app/views/admin/dashboard/index.html.erb @@ -91,7 +91,7 @@ <% if @active_sessions.any? %> - + <% @active_sessions.each do |s| %> @@ -99,6 +99,12 @@ + `) + .join(""); + const homeCells = cols.home + .map((val, i) => { + const live = i === cols.liveIndex ? " live-scorebug__pts--live-home" : ""; + return ``; + }) + .join(""); + const awayCells = cols.away + .map((val, i) => { + const live = i === cols.liveIndex ? " live-scorebug__pts--live-away" : ""; + return ``; + }) + .join(""); + scorebug.innerHTML = ` +
PartitaStatoInizio
PartitaStatoInizioLink
<%= s.match.team.name %> vs <%= s.match.opponent_name %> <%= s.status %> <%= s.started_at&.strftime("%d/%m %H:%M") || "—" %> <%= 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?" } } %> diff --git a/backend/app/views/admin/sessions/_links.html.erb b/backend/app/views/admin/sessions/_links.html.erb new file mode 100644 index 0000000..baee4cc --- /dev/null +++ b/backend/app/views/admin/sessions/_links.html.erb @@ -0,0 +1,41 @@ +<% watch_links = admin_session_watch_links(session) %> + diff --git a/backend/app/views/admin/sessions/index.html.erb b/backend/app/views/admin/sessions/index.html.erb index 20b7fca..9c48299 100644 --- a/backend/app/views/admin/sessions/index.html.erb +++ b/backend/app/views/admin/sessions/index.html.erb @@ -1,12 +1,18 @@

Stream Sessions

- + <% @sessions.each do |s| %> + <% end %> diff --git a/backend/app/views/admin/sessions/show.html.erb b/backend/app/views/admin/sessions/show.html.erb index ff039ee..964b70a 100644 --- a/backend/app/views/admin/sessions/show.html.erb +++ b/backend/app/views/admin/sessions/show.html.erb @@ -10,8 +10,11 @@

<% end %>

Match: <%= @session.match.team.name %> vs <%= @session.match.opponent_name %>

+ +<%= render "admin/sessions/links", session: @session %> + <% if @session.youtube_broadcast_id %> -

YouTube: Broadcast

+

YouTube Studio: Broadcast

<% end %>

RTMP ingest: <%= @session.rtmp_ingest_url %>

diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb index c403298..50e3372 100644 --- a/backend/app/views/layouts/marketing_live.html.erb +++ b/backend/app/views/layouts/marketing_live.html.erb @@ -7,7 +7,7 @@ <%= render "shared/meta_tags" %> - + <%= yield :head %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> diff --git a/backend/app/views/public/live/_scorebug.html.erb b/backend/app/views/public/live/_scorebug.html.erb new file mode 100644 index 0000000..ec430c2 --- /dev/null +++ b/backend/app/views/public/live/_scorebug.html.erb @@ -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) %> +
+
+
MatchStatusDisconnects
MatchStatusDisconnectsLink
<%= s.match.team.name %> vs <%= s.match.opponent_name %> <%= s.status %> <%= s.disconnection_count %> <%= link_to "Dettaglio", admin_session_path(s) %>
+ + + + + <% cols[:labels].each do |label| %> + + <% end %> + + + + + + <% cols[:home].each_with_index do |val, i| %> + + <% end %> + + + + <% cols[:away].each_with_index do |val, i| %> + + <% end %> + + +
Punteggio live
Squadra<%= label %>
<%= live_scorebug_team_label(team.name) %>"><%= val %>
<%= live_scorebug_team_label(@match.opponent_name) %>"><%= val %>
+ + + " + ><%= @stream_closed ? "Terminata" : (@session.paused? ? "In pausa" : (@on_air ? "LIVE" : "In attesa")) %> + diff --git a/backend/app/views/public/live/show.html.erb b/backend/app/views/public/live/show.html.erb index 65209ca..c0b96cf 100644 --- a/backend/app/views/public/live/show.html.erb +++ b/backend/app/views/public/live/show.html.erb @@ -1,7 +1,7 @@ <% club_name = @match.team.club&.name %> <% content_for :title do %><%= club_name %> · <%= @match.team.name %> vs <%= @match.opponent_name %> — Diretta live<% end %> <% content_for :meta_description do %>Segui in diretta live <%= club_name %> — <%= @match.team.name %> vs <%= @match.opponent_name %><% if @match.location.present? %> — <%= @match.location %><% end %>. Guarda dal browser senza installare app.<% end %> -<% content_for :robots, "noindex, follow" %> +<% content_for :robots, @session.publicly_listed? ? "index, follow" : "noindex, nofollow" %> <% content_for :head do %> <% unless @stream_closed || @session.platform == "youtube" %> @@ -41,7 +41,7 @@ <% else %>
- <%# Tabellone e watermark sono bruciati nel video dall'app mobile (overlay client-side). %> + <%= render "public/live/scorebug", match: @match, session: @session %> @@ -58,6 +58,88 @@ const pausedMsg = document.getElementById("paused-msg"); const awaitingMsg = document.getElementById("awaiting-msg"); const playHint = document.getElementById("play-hint"); + const scorebug = document.getElementById("live-scorebug"); + const streamBadge = document.getElementById("live-stream-badge"); + const homeName = "<%= j @match.team.name %>"; + const awayName = "<%= j @match.opponent_name %>"; + + function abbrevName(name, max = 16) { + const n = String(name || ""); + return n.length <= max ? n : `${n.slice(0, max - 1)}…`; + } + + function scoreColumnsFromPayload(score) { + if (!score) return { labels: ["1"], home: ["0"], away: ["0"], liveIndex: 0 }; + const partials = score.set_partials || score.setPartials || []; + const labels = partials.map((p) => String(p.set ?? p["set"] ?? "")); + const home = partials.map((p) => String(p.home ?? p["home"] ?? "0")); + const away = partials.map((p) => String(p.away ?? p["away"] ?? "0")); + const currentSet = score.current_set ?? score.currentSet ?? 1; + labels.push(String(currentSet)); + home.push(String(score.home_points ?? score.homePoints ?? 0)); + away.push(String(score.away_points ?? score.awayPoints ?? 0)); + return { labels, home, away, liveIndex: labels.length - 1 }; + } + + function renderLiveScorebug(score) { + if (!scorebug) return; + const cols = scoreColumnsFromPayload(score); + const homeLabel = abbrevName(scorebug.dataset.homeName || homeName); + const awayLabel = abbrevName(scorebug.dataset.awayName || awayName); + const headCells = cols.labels + .map((label) => `
${label}${val}${val}
+ + + ${headCells} + + + + ${homeCells} + + + ${awayCells} + + +
Punteggio live
Squadra
${homeLabel}
${awayLabel}
+ `; + } + + function syncStreamBadge(data) { + if (!streamBadge) return; + if (data.stream_closed) { + streamBadge.textContent = "Terminata"; + streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-ended"; + return; + } + const paused = !!(data.paused || data.status === "paused"); + if (paused) { + streamBadge.textContent = "In pausa"; + streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait"; + } else if (data.on_air) { + streamBadge.textContent = "LIVE"; + streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-live"; + } else { + streamBadge.textContent = "In attesa"; + streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait"; + } + } let hls = null; let playerStarted = false; @@ -247,6 +329,9 @@ return; } + renderLiveScorebug(data.score); + syncStreamBadge(data); + const onAir = !!data.on_air; const sessionLive = !!data.live; diff --git a/backend/app/views/public/regia/show.html.erb b/backend/app/views/public/regia/show.html.erb index 966add4..42ef9a3 100644 --- a/backend/app/views/public/regia/show.html.erb +++ b/backend/app/views/public/regia/show.html.erb @@ -12,6 +12,7 @@ data-status-url="<%= j public_regia_status_path(params[:token]) %>" data-score-url="<%= j public_regia_score_path(params[:token]) %>" data-pause-url="<%= j public_regia_pause_path(params[:token]) %>" + data-resume-url="<%= j public_regia_resume_path(params[:token]) %>" data-stop-url="<%= j public_regia_stop_path(params[:token]) %>" data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>" data-hls-url="<%= j @session.hls_playback_url %>" @@ -20,13 +21,14 @@ data-share-url="<%= j @share_url %>" data-share-title="<%= j @share_title %>" data-points-target="<%= Scoring::Rules.from_match(@match).points_target(@score.current_set) %>" - data-stream-closed="<%= @stream_closed %>"> + data-stream-closed="<%= @stream_closed %>" + data-initial-paused="<%= @session.paused? %>">

<%= @team.name %> vs <%= @match.opponent_name %>

Regia · Set <%= @score.current_set %>

- - <%= @stream_closed ? "Terminata" : (@on_air ? "In onda" : "In attesa") %> + + <%= @stream_closed ? "Terminata" : (@session.paused? ? "In pausa" : (@on_air ? "In onda" : "In attesa")) %>
@@ -76,7 +78,7 @@ <% unless @stream_closed %> - + <% end %> @@ -94,4 +96,4 @@ - + diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 4bf7ca4..7f4e58d 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -85,7 +85,10 @@ Rails.application.routes.draw do resources :billing_invoices, only: %i[index new create edit update], controller: "billing_invoices" end resources :sessions, only: %i[index show] do - member { post :stop } + member do + post :stop + post :regia_link + end end get "youtube/platform", to: "youtube#platform", as: :youtube_platform end @@ -105,6 +108,7 @@ Rails.application.routes.draw do get "regia/:token/status.json", to: "public/regia#status", as: :public_regia_status patch "regia/:token/score", to: "public/regia#score", as: :public_regia_score post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause + post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop root to: "public/pages#home" diff --git a/backend/db/migrate/20260606200000_add_opponent_branding_to_matches.rb b/backend/db/migrate/20260606200000_add_opponent_branding_to_matches.rb new file mode 100644 index 0000000..2bfc7fc --- /dev/null +++ b/backend/db/migrate/20260606200000_add_opponent_branding_to_matches.rb @@ -0,0 +1,5 @@ +class AddOpponentBrandingToMatches < ActiveRecord::Migration[7.2] + def change + add_column :matches, :opponent_primary_color, :string + end +end diff --git a/backend/public/admin.css b/backend/public/admin.css index fc59c29..d235823 100644 --- a/backend/public/admin.css +++ b/backend/public/admin.css @@ -233,6 +233,90 @@ body.admin-body { align-items: center; } +.admin-links-panel { + margin: 1.25rem 0 1.5rem; + padding: 1rem 1.1rem; + background: var(--card-bg, #14141c); + border: 1px solid var(--card-border, #2a2a36); + border-radius: 8px; +} + +.admin-links-panel h3 { + margin: 0 0 0.6rem; + font-size: 0.95rem; +} + +.admin-links-panel h3 + h3 { + margin-top: 1.1rem; +} + +.admin-link-list { + list-style: none; + margin: 0; + padding: 0; +} + +.admin-link-list li, +.admin-link-generated { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.75rem; + align-items: baseline; + margin-bottom: 0.45rem; +} + +.admin-link-label { + min-width: 6.5rem; + color: var(--muted, #9ca3af); + font-size: 0.85rem; +} + +.admin-link-url { + color: var(--red, #e53935); + word-break: break-all; +} + +.admin-link-code { + display: block; + margin: 0.35rem 0 0.5rem; + padding: 0.45rem 0.6rem; + background: #0a0a0e; + border-radius: 6px; + font-size: 0.78rem; + word-break: break-all; +} + +.admin-link-compact { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.admin-link-chip { + display: inline-block; + padding: 0.15rem 0.45rem; + border-radius: 999px; + background: #2a2a36; + color: #eee; + font-size: 0.72rem; + text-decoration: none; +} + +.admin-link-chip:hover { + background: #3a3a48; + color: #fff; +} + +.admin-link-chip--regia { + background: #3d2a00; + color: #f9a825; +} + +.admin-inline-form { + display: inline-block; + margin-top: 0.35rem; +} + .admin-input { padding: 0.45rem 0.6rem; border: 1px solid #444; diff --git a/backend/public/regia.js b/backend/public/regia.js index c2d11da..e8aba6a 100644 --- a/backend/public/regia.js +++ b/backend/public/regia.js @@ -7,6 +7,7 @@ statusUrl: root.dataset.statusUrl, scoreUrl: root.dataset.scoreUrl, pauseUrl: root.dataset.pauseUrl, + resumeUrl: root.dataset.resumeUrl, stopUrl: root.dataset.stopUrl, cableUrl: root.dataset.cableUrl, hlsUrl: root.dataset.hlsUrl, @@ -30,13 +31,15 @@ modalConfirm: document.getElementById("modal-confirm"), modalCancel: document.getElementById("modal-cancel"), preview: document.getElementById("regia-preview"), - previewPlaceholder: document.getElementById("regia-preview-placeholder") + previewPlaceholder: document.getElementById("regia-preview-placeholder"), + btnPause: document.getElementById("btn-pause") }; let pendingAction = null; let hls = null; let previewStarted = false; let wasPausedPreview = false; + let streamPaused = root.dataset.initialPaused === "true"; function hlsUrlFresh() { const sep = cfg.hlsUrl.includes("?") ? "&" : "?"; @@ -108,7 +111,15 @@ } } + function syncPauseButton(data) { + if (!els.btnPause) return; + const paused = !!(data.paused || data.status === "paused"); + streamPaused = paused; + els.btnPause.textContent = paused ? "Riprendi diretta" : "Metti in pausa"; + } + function setBadge(data) { + syncPauseButton(data); if (data.stream_closed) { els.badge.textContent = "Terminata"; els.badge.className = "regia-badge regia-badge--ended"; @@ -139,6 +150,8 @@ } async function handleAction(action) { + const prevHome = els.homePoints?.textContent; + const prevAway = document.getElementById("away-points")?.textContent; try { const data = await postScore(action); applyScorePayload(data); @@ -152,6 +165,9 @@ openModal("Partita vinta", `${winner} ha vinto la partita. Chiudere la diretta?`, () => stopStream()); } } catch (e) { + if (prevHome != null && els.homePoints) els.homePoints.textContent = prevHome; + const awayEl = document.getElementById("away-points"); + if (prevAway != null && awayEl) awayEl.textContent = prevAway; toast(e.message || "Errore"); } } @@ -284,10 +300,19 @@ showPreviewPlaceholder("Diretta terminata"); } - async function pauseStream() { - await fetch(cfg.pauseUrl, { method: "POST", headers: { Accept: "application/json" } }); - toast("Diretta in pausa — copertina in onda"); - pollStatus(); + async function togglePauseStream() { + const url = streamPaused ? cfg.resumeUrl : cfg.pauseUrl; + const res = await fetch(url, { method: "POST", headers: { Accept: "application/json" } }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || (streamPaused ? "Errore ripresa diretta" : "Errore pausa diretta")); + } + const data = await res.json(); + const wasPaused = streamPaused; + applyScorePayload(data); + setBadge(data); + syncPreviewFromStatus(data); + toast(wasPaused ? "Diretta ripresa" : "Diretta in pausa — copertina in onda"); } async function stopStream() { @@ -302,7 +327,7 @@ return data; } - document.getElementById("btn-pause")?.addEventListener("click", () => pauseStream().catch((e) => toast(e.message))); + els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message))); document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message))); async function shareLink() { diff --git a/backend/spec/requests/public/regia_spec.rb b/backend/spec/requests/public/regia_spec.rb index 3213853..cdf0823 100644 --- a/backend/spec/requests/public/regia_spec.rb +++ b/backend/spec/requests/public/regia_spec.rb @@ -22,4 +22,24 @@ RSpec.describe "Public regia", type: :request do expect(response).to have_http_status(:ok) expect(session.score_state.reload.home_points).to eq(1) end + + it "mette in pausa e riprende la diretta" do + token = Sessions::RegiaAccess.new(session).issue_token! + post public_regia_pause_path(token) + expect(response).to have_http_status(:ok) + expect(session.reload.status).to eq("paused") + + post public_regia_resume_path(token) + expect(response).to have_http_status(:ok) + expect(session.reload.status).to eq("connecting") + end + + it "emette token valido anche oltre le 8 ore dalla partenza" do + session.update!(started_at: 9.hours.ago) + token = Sessions::RegiaAccess.new(session).issue_token! + session.reload + expect(session.regia_token_expires_at).to be > Time.current + get public_regia_path(token) + expect(response).to have_http_status(:ok) + end end diff --git a/native/android/app/build.gradle.kts b/native/android/app/build.gradle.kts index 25eac58..77b4df1 100644 --- a/native/android/app/build.gradle.kts +++ b/native/android/app/build.gradle.kts @@ -12,7 +12,7 @@ android { applicationId = "com.matchlivetv.match_live_tv" minSdk = 24 targetSdk = 35 - versionCode = 14 + versionCode = 15 versionName = "2.0.0-native" val apiBaseUrl = project.findProperty("API_BASE_URL") as String? @@ -74,6 +74,8 @@ dependencies { testImplementation("junit:junit:4.13.2") + implementation("io.coil-kt:coil-compose:2.7.0") + implementation("com.github.pedroSG94.RootEncoder:library:2.5.5") androidTestImplementation("androidx.test.ext:junit:1.2.1") diff --git a/native/android/app/src/main/AndroidManifest.xml b/native/android/app/src/main/AndroidManifest.xml index 4545d0b..272221b 100644 --- a/native/android/app/src/main/AndroidManifest.xml +++ b/native/android/app/src/main/AndroidManifest.xml @@ -28,6 +28,7 @@ android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" + android:screenOrientation="portrait" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize"> diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ColorHex.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ColorHex.kt new file mode 100644 index 0000000..7c2b39e --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ColorHex.kt @@ -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] +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/DeviceTelemetry.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/DeviceTelemetry.kt index 8d4fa78..5c07461 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/DeviceTelemetry.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/DeviceTelemetry.kt @@ -6,16 +6,25 @@ import android.content.IntentFilter import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.BatteryManager +import android.os.Build +import android.os.PowerManager + +enum class ThermalLevel { + NORMAL, + WARM, + HOT, + CRITICAL, +} + +data class DeviceHealthSnapshot( + val batteryPercent: Int, + val batteryTempC: Float?, + val thermalLevel: ThermalLevel, + val thermalLabel: String, +) object DeviceTelemetry { - fun batteryLevel(context: Context): Int { - val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) - ?: return 100 - val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) - val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) - if (level < 0 || scale <= 0) return 100 - return ((level * 100f) / scale).toInt().coerceIn(0, 100) - } + fun batteryLevel(context: Context): Int = snapshot(context).batteryPercent fun networkType(context: Context): String = runCatching { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager @@ -28,4 +37,67 @@ object DeviceTelemetry { else -> "Sconosciuto" } }.getOrDefault("Sconosciuto") + + fun snapshot(context: Context): DeviceHealthSnapshot { + val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + val batteryPercent = readBatteryPercent(batteryIntent) + val batteryTempC = readBatteryTemperatureC(batteryIntent) + val thermalLevel = resolveThermalLevel(context, batteryTempC) + return DeviceHealthSnapshot( + batteryPercent = batteryPercent, + batteryTempC = batteryTempC, + thermalLevel = thermalLevel, + thermalLabel = thermalLabel(thermalLevel), + ) + } + + private fun readBatteryPercent(intent: Intent?): Int { + if (intent == null) return 100 + val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) + if (level < 0 || scale <= 0) return 100 + return ((level * 100f) / scale).toInt().coerceIn(0, 100) + } + + private fun readBatteryTemperatureC(intent: Intent?): Float? { + val raw = intent?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, -1) ?: -1 + if (raw <= 0) return null + return raw / 10f + } + + private fun resolveThermalLevel(context: Context, batteryTempC: Float?): ThermalLevel { + val fromStatus = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager + when (pm.currentThermalStatus) { + PowerManager.THERMAL_STATUS_NONE, + PowerManager.THERMAL_STATUS_LIGHT, + -> ThermalLevel.NORMAL + PowerManager.THERMAL_STATUS_MODERATE -> ThermalLevel.WARM + PowerManager.THERMAL_STATUS_SEVERE -> ThermalLevel.HOT + PowerManager.THERMAL_STATUS_CRITICAL, + PowerManager.THERMAL_STATUS_EMERGENCY, + PowerManager.THERMAL_STATUS_SHUTDOWN, + -> ThermalLevel.CRITICAL + else -> null + } + } else { + null + } + val fromTemp = batteryTempC?.let { tempFromBattery(it) } + return listOfNotNull(fromStatus, fromTemp).maxByOrNull { it.ordinal } ?: ThermalLevel.NORMAL + } + + private fun tempFromBattery(tempC: Float): ThermalLevel = when { + tempC >= 45f -> ThermalLevel.CRITICAL + tempC >= 42f -> ThermalLevel.HOT + tempC >= 38f -> ThermalLevel.WARM + else -> ThermalLevel.NORMAL + } + + private fun thermalLabel(level: ThermalLevel): String = when (level) { + ThermalLevel.NORMAL -> "OK" + ThermalLevel.WARM -> "Caldo" + ThermalLevel.HOT -> "Surriscaldamento" + ThermalLevel.CRITICAL -> "Troppo caldo" + } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/MediaUrl.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/MediaUrl.kt new file mode 100644 index 0000000..57314cc --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/MediaUrl.kt @@ -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" +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt index 351fb14..e1450b1 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt @@ -12,6 +12,7 @@ import com.matchlivetv.match_live_tv.data.repository.SessionRepository import com.matchlivetv.match_live_tv.data.scoring.ScoreController import com.matchlivetv.match_live_tv.BuildConfig import com.matchlivetv.match_live_tv.streaming.LiveBroadcastCoordinator +import com.matchlivetv.match_live_tv.streaming.overlay.OverlayLogoCache import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import kotlinx.coroutines.CoroutineScope @@ -69,18 +70,19 @@ class AppContainer(context: Context) { val authRepository = AuthRepository(api, tokenStore) { token -> accessToken = token + OverlayLogoCache.configure(token) } - val matchRepository = MatchRepository(api, tokenStore) + val matchRepository = MatchRepository(api, tokenStore, appContext) val sessionRepository = SessionRepository(api) val scoreRepository = ScoreRepository(api) - val sessionCable = SessionCableService(moshi) - private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + val sessionCable = SessionCableService(moshi, appScope) + val scoreController = ScoreController(scoreRepository, sessionCable, appScope) val wizardSession = WizardSessionHolder() @@ -88,6 +90,9 @@ class AppContainer(context: Context) { val broadcastCoordinator = LiveBroadcastCoordinator(appContext) suspend fun bootstrapAuth() { - authRepository.currentSession()?.let { accessToken = it.accessToken } + authRepository.currentSession()?.let { + accessToken = it.accessToken + OverlayLogoCache.configure(it.accessToken) + } } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt index cb940b5..9454405 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt @@ -41,12 +41,16 @@ data class TeamDto( @Json(name = "youtube_connected") val youtubeConnected: Boolean? = false, @Json(name = "youtube_selectable") val youtubeSelectable: Boolean? = false, @Json(name = "youtube_channel_title") val youtubeChannelTitle: String? = null, + @Json(name = "youtube_uses_platform_channel") val youtubeUsesPlatformChannel: Boolean? = null, @Json(name = "youtube_team_channel_title") val youtubeTeamChannelTitle: String? = null, @Json(name = "plan_name") val planName: String? = null, @Json(name = "recordings_enabled") val recordingsEnabled: Boolean? = false, @Json(name = "phone_download_enabled") val phoneDownloadEnabled: Boolean? = false, @Json(name = "billing_url") val billingUrl: String? = null, @Json(name = "staff_manage_url") val staffManageUrl: String? = null, + @Json(name = "logo_url") val logoUrl: String? = null, + @Json(name = "primary_color") val primaryColor: String? = null, + @Json(name = "secondary_color") val secondaryColor: String? = null, ) { fun toDomain() = Team( id = id, @@ -54,10 +58,14 @@ data class TeamDto( sport = sport ?: "volleyball", canStream = canStream ?: true, clubName = clubName, + logoUrl = logoUrl, + primaryColor = primaryColor, + secondaryColor = secondaryColor, youtubeEnabled = youtubeEnabled ?: false, youtubeConnected = youtubeConnected ?: false, youtubeSelectable = youtubeSelectable ?: false, youtubeChannelTitle = youtubeChannelTitle, + youtubeUsesPlatformChannel = youtubeUsesPlatformChannel ?: false, youtubeTeamChannelTitle = youtubeTeamChannelTitle, planName = planName, recordingsEnabled = recordingsEnabled ?: false, @@ -82,6 +90,11 @@ data class MatchDto( @Json(name = "active_session_status") val activeSessionStatus: String? = null, @Json(name = "stream_completed") val streamCompleted: Boolean? = null, @Json(name = "coach_hub_visible") val coachHubVisible: Boolean? = null, + @Json(name = "home_primary_color") val homePrimaryColor: String? = null, + @Json(name = "home_secondary_color") val homeSecondaryColor: String? = null, + @Json(name = "home_logo_url") val homeLogoUrl: String? = null, + @Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null, + @Json(name = "opponent_logo_url") val opponentLogoUrl: String? = null, ) { fun toDomain() = Match( id = id, @@ -97,6 +110,11 @@ data class MatchDto( activeSessionStatus = activeSessionStatus, streamCompleted = streamCompleted ?: false, coachHubVisible = coachHubVisible, + homePrimaryColor = homePrimaryColor, + homeSecondaryColor = homeSecondaryColor, + homeLogoUrl = homeLogoUrl, + opponentPrimaryColor = opponentPrimaryColor, + opponentLogoUrl = opponentLogoUrl, ) } @@ -218,14 +236,25 @@ data class UpdateMatchBodyFull( @Json(name = "scheduled_at") val scheduledAt: String? = null, @Json(name = "sets_to_win") val setsToWin: Int, val category: String? = null, + @Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null, @Json(name = "scoring_rules") val scoringRules: ScoringRulesBody? = null, ) +data class UpdateTeamRequest(val team: UpdateTeamBody) + +data class UpdateTeamBody( + @Json(name = "primary_color") val primaryColor: String? = null, + @Json(name = "secondary_color") val secondaryColor: String? = null, +) + data class UpdateMatchRequestFull(val match: UpdateMatchBodyFull) data class NetworkTestResponse( val ready: Boolean, @Json(name = "target_upload_mbps") val targetUploadMbps: Double? = null, + @Json(name = "quality_preset") val qualityPreset: String? = null, + @Json(name = "target_bitrate") val targetBitrate: Int? = null, + @Json(name = "target_fps") val targetFps: Int? = null, ) data class RecordingDto( diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt index fe2479d..8a68079 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt @@ -3,9 +3,12 @@ package com.matchlivetv.match_live_tv.data.api import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.Multipart import retrofit2.http.PATCH import retrofit2.http.POST +import retrofit2.http.Part import retrofit2.http.Path +import okhttp3.MultipartBody interface MatchLiveApi { @POST("auth/login") @@ -23,6 +26,24 @@ interface MatchLiveApi { @GET("teams") suspend fun teams(): List + @GET("teams/{teamId}") + suspend fun team(@Path("teamId") teamId: String): TeamDto + + @PATCH("teams/{teamId}") + suspend fun updateTeam( + @Path("teamId") teamId: String, + @Body body: UpdateTeamRequest, + ): TeamDto + + @Multipart + @PATCH("teams/{teamId}") + suspend fun updateTeamMultipart( + @Path("teamId") teamId: String, + @Part("team[primary_color]") primaryColor: okhttp3.RequestBody?, + @Part("team[secondary_color]") secondaryColor: okhttp3.RequestBody?, + @Part logoFile: MultipartBody.Part?, + ): TeamDto + @GET("teams/{teamId}/recordings") suspend fun recordings(@Path("teamId") teamId: String): List @@ -44,6 +65,22 @@ interface MatchLiveApi { @Body body: UpdateMatchRequestFull, ): MatchDto + @Multipart + @PATCH("matches/{matchId}") + suspend fun updateMatchMultipart( + @Path("matchId") matchId: String, + @Part("match[opponent_name]") opponentName: okhttp3.RequestBody, + @Part("match[location]") location: okhttp3.RequestBody?, + @Part("match[scheduled_at]") scheduledAt: okhttp3.RequestBody?, + @Part("match[sets_to_win]") setsToWin: okhttp3.RequestBody, + @Part("match[category]") category: okhttp3.RequestBody?, + @Part("match[opponent_primary_color]") opponentPrimaryColor: okhttp3.RequestBody?, + @Part("match[scoring_rules][points_per_set]") pointsPerSet: okhttp3.RequestBody?, + @Part("match[scoring_rules][points_deciding_set]") pointsDecidingSet: okhttp3.RequestBody?, + @Part("match[scoring_rules][min_point_lead]") minPointLead: okhttp3.RequestBody?, + @Part opponentLogoFile: MultipartBody.Part?, + ): MatchDto + @DELETE("matches/{matchId}") suspend fun deleteMatch(@Path("matchId") matchId: String) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MultipartBodies.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MultipartBodies.kt new file mode 100644 index 0000000..dfa885b --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MultipartBodies.kt @@ -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()), + ) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/ActionCableClient.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/ActionCableClient.kt index 5ffc9c2..e073f77 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/ActionCableClient.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/ActionCableClient.kt @@ -116,11 +116,16 @@ class ActionCableClient( "welcome" -> Unit else -> { if (json.has("message")) { - @Suppress("UNCHECKED_CAST") val message = json.opt("message") when (message) { is JSONObject -> listener?.onChannelMessage(messageToMap(message)) - is Map<*, *> -> listener?.onChannelMessage(message as Map) + is String -> runCatching { JSONObject(message) } + .getOrNull() + ?.let { listener?.onChannelMessage(messageToMap(it)) } + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + listener?.onChannelMessage(message as Map) + } } } } @@ -130,11 +135,20 @@ class ActionCableClient( private fun messageToMap(json: JSONObject): Map { val map = mutableMapOf() json.keys().forEach { key -> - map[key] = json.get(key) + map[key] = jsonValueToKotlin(json.get(key)) } return map } + private fun jsonValueToKotlin(value: Any?): Any? = when (value) { + is JSONObject -> messageToMap(value) + is org.json.JSONArray -> List(value.length()) { index -> + jsonValueToKotlin(value.get(index)) + } + JSONObject.NULL -> null + else -> value + } + companion object { private const val TAG = "ActionCableClient" } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt index ab10c52..6474ad3 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt @@ -1,14 +1,30 @@ package com.matchlivetv.match_live_tv.data.cable +import android.util.Log import com.matchlivetv.match_live_tv.core.AppConfig import com.matchlivetv.match_live_tv.domain.ScoreState import com.squareup.moshi.Moshi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlin.math.min -class SessionCableService(private val moshi: Moshi) { +class SessionCableService( + private val moshi: Moshi, + private val scope: CoroutineScope, +) { private var client: ActionCableClient? = null + private var reconnectJob: Job? = null + private var reconnectAttempt = 0 + private var intentionalDisconnect = false + + private var pendingSessionId: String? = null + private var pendingToken: String? = null + private var pendingDeviceRole: String = "camera" private val _connected = MutableStateFlow(false) val connected: StateFlow = _connected.asStateFlow() @@ -16,23 +32,53 @@ class SessionCableService(private val moshi: Moshi) { var onScoreUpdate: ((ScoreState) -> Unit)? = null var onPauseStream: (() -> Unit)? = null var onResumeStream: (() -> Unit)? = null + var onEndStream: (() -> Unit)? = null fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") { - disconnect() - client = ActionCableClient(AppConfig.cableUrl, accessToken, moshi).also { cable -> + intentionalDisconnect = false + reconnectJob?.cancel() + reconnectAttempt = 0 + pendingSessionId = sessionId + pendingToken = accessToken + pendingDeviceRole = deviceRole + openSocket() + } + + fun sendScoreUpdate(score: ScoreState) { + client?.performReceive(score.cablePayload()) + } + + fun disconnect() { + intentionalDisconnect = true + reconnectJob?.cancel() + reconnectJob = null + pendingSessionId = null + pendingToken = null + client?.disconnect() + client = null + _connected.value = false + } + + private fun openSocket() { + val sessionId = pendingSessionId ?: return + val token = pendingToken ?: return + client?.disconnect() + client = ActionCableClient(AppConfig.cableUrl, token, moshi).also { cable -> cable.connect( channel = "SessionChannel", params = mapOf( "session_id" to sessionId, - "device_role" to deviceRole, + "device_role" to pendingDeviceRole, ), listener = object : ActionCableClient.Listener { override fun onConnected() { + reconnectAttempt = 0 _connected.value = true } override fun onDisconnected() { _connected.value = false + scheduleReconnect() } override fun onChannelMessage(payload: Map) { @@ -43,44 +89,65 @@ class SessionCableService(private val moshi: Moshi) { } } - fun sendScoreUpdate(score: ScoreState) { - client?.performReceive(score.cablePayload()) - } - - fun disconnect() { - client?.disconnect() - client = null - _connected.value = false + private fun scheduleReconnect() { + if (intentionalDisconnect || pendingSessionId == null || pendingToken == null) return + reconnectJob?.cancel() + reconnectJob = scope.launch { + reconnectAttempt += 1 + val delayMs = min(30_000L, 1_000L shl min(reconnectAttempt - 1, 4)) + Log.i(TAG, "ActionCable reconnect in ${delayMs}ms (attempt $reconnectAttempt)") + delay(delayMs) + if (!intentionalDisconnect) openSocket() + } } private fun dispatch(data: Map) { - when (data["type"] as? String) { - "score_update" -> onScoreUpdate?.invoke(parseScore(data)) - "stream_event" -> when (data["event"] as? String) { + val payload = unwrapCablePayload(data) + when (payload["type"] as? String) { + "score_update" -> onScoreUpdate?.invoke(parseScore(payload)) + "command" -> when (payload["action"] as? String) { + "pause_stream" -> onPauseStream?.invoke() + "resume_stream" -> onResumeStream?.invoke() + "stop_stream" -> onEndStream?.invoke() + } + "stream_event" -> when (payload["event"] as? String) { "paused" -> onPauseStream?.invoke() "resumed" -> onResumeStream?.invoke() + "ended" -> onEndStream?.invoke() } } } - private fun parseScore(data: Map): ScoreState { + /** Compat: messaggi legacy `{ method: receive_message, data: {...} }`. */ + private fun unwrapCablePayload(raw: Map): Map { + if (raw["method"] != "receive_message") return raw + val nested = raw["data"] ?: return raw @Suppress("UNCHECKED_CAST") - val partialsRaw = data["set_partials"] as? List> ?: emptyList() + return nested as? Map ?: raw + } + + private fun parseScore(data: Map): ScoreState { + val partialsRaw = data["set_partials"] as? List<*> ?: emptyList() return ScoreState( homeSets = (data["home_sets"] as? Number)?.toInt() ?: 0, awaySets = (data["away_sets"] as? Number)?.toInt() ?: 0, homePoints = (data["home_points"] as? Number)?.toInt() ?: 0, awayPoints = (data["away_points"] as? Number)?.toInt() ?: 0, currentSet = (data["current_set"] as? Number)?.toInt() ?: 1, - setPartials = partialsRaw.map { + setPartials = partialsRaw.mapNotNull { item -> + val row = item as? Map<*, *> ?: return@mapNotNull null com.matchlivetv.match_live_tv.domain.SetPartial( - set = (it["set"] as? Number)?.toInt() ?: 1, - home = (it["home"] as? Number)?.toInt() ?: 0, - away = (it["away"] as? Number)?.toInt() ?: 0, + set = (row["set"] as? Number)?.toInt() ?: 1, + home = (row["home"] as? Number)?.toInt() ?: 0, + away = (row["away"] as? Number)?.toInt() ?: 0, ) }, timeoutHome = data["timeout_home"] as? Boolean ?: false, timeoutAway = data["timeout_away"] as? Boolean ?: false, ) } + + companion object { + private const val TAG = "SessionCableService" + } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt index ae4800a..7542357 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt @@ -1,13 +1,18 @@ package com.matchlivetv.match_live_tv.data.repository +import android.content.Context +import android.net.Uri import com.matchlivetv.match_live_tv.core.TokenStore import com.matchlivetv.match_live_tv.core.parseApiInstant import com.matchlivetv.match_live_tv.data.api.CreateMatchBody import com.matchlivetv.match_live_tv.data.api.CreateMatchRequest import com.matchlivetv.match_live_tv.data.api.MatchLiveApi +import com.matchlivetv.match_live_tv.data.api.MultipartBodies import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody import com.matchlivetv.match_live_tv.data.api.UpdateMatchBodyFull import com.matchlivetv.match_live_tv.data.api.UpdateMatchRequestFull +import com.matchlivetv.match_live_tv.data.api.UpdateTeamBody +import com.matchlivetv.match_live_tv.data.api.UpdateTeamRequest import com.matchlivetv.match_live_tv.domain.Match import com.matchlivetv.match_live_tv.domain.Recording import com.matchlivetv.match_live_tv.domain.Team @@ -18,12 +23,14 @@ import java.time.Instant class MatchRepository( private val api: MatchLiveApi, private val tokenStore: TokenStore, + private val appContext: Context, ) { suspend fun fetchTeams(): List = api.teams().map { it.toDomain() } suspend fun fetchTeam(teamId: String): Team? = - fetchTeams().firstOrNull { it.id == teamId } + runCatching { api.team(teamId).toDomain() }.getOrNull() + ?: fetchTeams().firstOrNull { it.id == teamId } suspend fun resolveActiveTeam(teams: List): Team? { if (teams.isEmpty()) return null @@ -77,6 +84,31 @@ class MatchRepository( ), ).toDomain() + suspend fun updateTeamBranding( + teamId: String, + primaryColor: String, + secondaryColor: String?, + logoUri: Uri?, + ): Team { + if (logoUri != null) { + return api.updateTeamMultipart( + teamId = teamId, + primaryColor = MultipartBodies.textPart(primaryColor), + secondaryColor = secondaryColor?.let { MultipartBodies.textPart(it) }, + logoFile = MultipartBodies.logoPart(appContext, logoUri, "logo_file"), + ).toDomain() + } + return api.updateTeam( + teamId, + UpdateTeamRequest( + team = UpdateTeamBody( + primaryColor = primaryColor, + secondaryColor = secondaryColor, + ), + ), + ).toDomain() + } + suspend fun updateMatch( matchId: String, opponentName: String, @@ -84,20 +116,39 @@ class MatchRepository( scheduledAt: String?, setsToWin: Int, category: String?, + opponentPrimaryColor: String?, scoringRules: ScoringRulesBody?, - ): Match = api.updateMatch( - matchId, - UpdateMatchRequestFull( - match = UpdateMatchBodyFull( - opponentName = opponentName, - location = location?.takeIf { it.isNotBlank() }, - scheduledAt = scheduledAt, - setsToWin = setsToWin, - category = category?.takeIf { it.isNotBlank() }, - scoringRules = scoringRules, - ), - ), - ).toDomain() + opponentLogoUri: Uri? = null, + ): Match { + val body = UpdateMatchBodyFull( + opponentName = opponentName, + location = location?.takeIf { it.isNotBlank() }, + scheduledAt = scheduledAt, + setsToWin = setsToWin, + category = category?.takeIf { it.isNotBlank() }, + opponentPrimaryColor = opponentPrimaryColor, + scoringRules = scoringRules, + ) + if (opponentLogoUri != null) { + return api.updateMatchMultipart( + matchId = matchId, + opponentName = MultipartBodies.textPart(opponentName), + location = body.location?.let { MultipartBodies.textPart(it) }, + scheduledAt = scheduledAt?.let { MultipartBodies.textPart(it) }, + setsToWin = MultipartBodies.textPart(setsToWin.toString()), + category = body.category?.let { MultipartBodies.textPart(it) }, + opponentPrimaryColor = opponentPrimaryColor?.let { MultipartBodies.textPart(it) }, + pointsPerSet = scoringRules?.pointsPerSet?.let { MultipartBodies.textPart(it.toString()) }, + pointsDecidingSet = scoringRules?.pointsDecidingSet?.let { MultipartBodies.textPart(it.toString()) }, + minPointLead = scoringRules?.minPointLead?.let { MultipartBodies.textPart(it.toString()) }, + opponentLogoFile = MultipartBodies.logoPart(appContext, opponentLogoUri, "opponent_logo_file"), + ).toDomain() + } + return api.updateMatch( + matchId, + UpdateMatchRequestFull(match = body), + ).toDomain() + } suspend fun deleteMatch(matchId: String) { api.deleteMatch(matchId) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/scoring/ScoreController.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/scoring/ScoreController.kt index 6602945..4ef9acb 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/scoring/ScoreController.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/scoring/ScoreController.kt @@ -35,8 +35,9 @@ class ScoreController( } fun applyRemote(remote: ScoreState) { + if (remote == _score.value) return val now = System.currentTimeMillis() - if (now < suppressRemoteUntilMs && remote.progressKey() < _score.value.progressKey()) { + if (now < suppressRemoteUntilMs && remote.progressKey() <= _score.value.progressKey()) { return } _score.value = remote diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt index 04ed9c4..81ddcda 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt @@ -17,11 +17,15 @@ data class Team( val name: String, val sport: String, val clubName: String? = null, + val logoUrl: String? = null, + val primaryColor: String? = null, + val secondaryColor: String? = null, val canStream: Boolean = true, val youtubeEnabled: Boolean = false, val youtubeConnected: Boolean = false, val youtubeSelectable: Boolean = false, val youtubeChannelTitle: String? = null, + val youtubeUsesPlatformChannel: Boolean = false, val youtubeTeamChannelTitle: String? = null, val planName: String? = null, val recordingsEnabled: Boolean = false, @@ -31,6 +35,24 @@ data class Team( ) { val canUseYoutube: Boolean get() = youtubeEnabled val isYoutubeReady: Boolean get() = youtubeSelectable + + /** Canale YouTube effettivo per la prossima diretta (`platform` | `team`). */ + val effectiveYoutubeChannel: String? + get() = if (isYoutubeReady) { + if (youtubeUsesPlatformChannel) "platform" else "team" + } else { + null + } + + val youtubeDestinationLabel: String + get() = if (youtubeUsesPlatformChannel) { + "Canale Match Live TV" + } else { + val name = youtubeTeamChannelTitle?.takeIf { it.isNotBlank() } + ?: clubName?.takeIf { it.isNotBlank() } + ?: "società" + "Canale società · $name" + } } data class ScoringRules( @@ -54,6 +76,11 @@ data class Match( val activeSessionStatus: String? = null, val streamCompleted: Boolean = false, val coachHubVisible: Boolean? = null, + val homePrimaryColor: String? = null, + val homeSecondaryColor: String? = null, + val homeLogoUrl: String? = null, + val opponentPrimaryColor: String? = null, + val opponentLogoUrl: String? = null, ) { val hasActiveSession: Boolean get() = activeSessionId != null diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/ScoreState.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/ScoreState.kt index 18821c7..9f07ede 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/ScoreState.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/ScoreState.kt @@ -28,6 +28,10 @@ data class ScoreState( }, ) - fun progressKey(): Int = - homeSets * 1_000_000 + awaySets * 100_000 + homePoints * 1_000 + awayPoints + fun progressKey(): Long = + currentSet * 10_000_000_000L + + homeSets * 1_000_000L + + awaySets * 100_000L + + homePoints * 1_000L + + awayPoints } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayLogoCache.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayLogoCache.kt new file mode 100644 index 0000000..5beb399 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayLogoCache.kt @@ -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() + 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, + 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() + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt index ca22854..9032ff6 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt @@ -3,7 +3,14 @@ package com.matchlivetv.match_live_tv.streaming.overlay import com.matchlivetv.match_live_tv.domain.ScoreState import com.matchlivetv.match_live_tv.streaming.BroadcastPhase -fun ScoreState.toScoreboardState(homeTeamName: String, awayTeamName: String): ScoreboardState { +fun ScoreState.toScoreboardState( + homeTeamName: String, + awayTeamName: String, + homeAccentColor: Int = 0xFFFF2D2D.toInt(), + awayAccentColor: Int = 0xFF1E3A8A.toInt(), + homeLogoUrl: String? = null, + awayLogoUrl: String? = null, +): ScoreboardState { val columns = buildList { setPartials.forEach { partial -> add( @@ -28,6 +35,10 @@ fun ScoreState.toScoreboardState(homeTeamName: String, awayTeamName: String): Sc homeTeamName = homeTeamName, awayTeamName = awayTeamName, columns = columns, + homeAccentColor = homeAccentColor, + awayAccentColor = awayAccentColor, + homeLogoUrl = homeLogoUrl, + awayLogoUrl = awayLogoUrl, ) } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt index c3605ac..3bdeb28 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt @@ -17,6 +17,8 @@ data class ScoreboardState( val columns: List, val homeAccentColor: Int = 0xFFFF2D2D.toInt(), val awayAccentColor: Int = 0xFF1E3A8A.toInt(), + val homeLogoUrl: String? = null, + val awayLogoUrl: String? = null, ) enum class BroadcastOverlayStatus { @@ -48,6 +50,10 @@ private fun ScoreboardState.fingerprint(): Int { result = 31 * result + awayTeamName.hashCode() result = 31 * result + homeAccentColor result = 31 * result + awayAccentColor + result = 31 * result + (homeLogoUrl?.hashCode() ?: 0) + result = 31 * result + (awayLogoUrl?.hashCode() ?: 0) + result = 31 * result + logoLoadedKey(homeLogoUrl) + result = 31 * result + logoLoadedKey(awayLogoUrl) columns.forEach { col -> result = 31 * result + col.setNumber result = 31 * result + col.homePoints @@ -56,3 +62,5 @@ private fun ScoreboardState.fingerprint(): Int { } return result } + +private fun logoLoadedKey(url: String?): Int = if (OverlayLogoCache.get(url) != null) 1 else 0 diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt index 15cb625..20d78f8 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt @@ -33,6 +33,7 @@ class ScoreboardElement : OverlayElement { textAlign = Paint.Align.CENTER } private val accentBarPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val logoPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) private val winPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#FF2D2D") typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) @@ -42,7 +43,23 @@ class ScoreboardElement : OverlayElement { color = Color.parseColor("#CCCCCC") typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) } - private val rect = RectF() + private val brandMatchPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + } + private val brandTvPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#9CA3AF") + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + } + private val brandLiveBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#FF2D2D") + } + private val brandLiveTextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + } + private val boxRect = RectF() + private val liveRect = RectF() override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) { val board = state.scoreboard ?: return @@ -51,20 +68,24 @@ class ScoreboardElement : OverlayElement { val scale = layout.canvasHeight / 720f * SIZE_BOOST val pad = (8f * scale).roundToInt().coerceAtLeast(6) val colorBarW = (5f * scale).roundToInt().coerceAtLeast(4) - val teamW = (118f * scale).roundToInt() + val logoSize = (24f * scale).roundToInt().coerceAtLeast(16) + val logoGap = (4f * scale).roundToInt().coerceAtLeast(3) + val teamW = (158f * scale).roundToInt() val colW = (28f * scale).roundToInt().coerceAtLeast(22) + val brandH = (18f * scale).roundToInt().coerceAtLeast(14) val headH = (16f * scale).roundToInt().coerceAtLeast(12) - val rowH = (20f * scale).roundToInt().coerceAtLeast(16) + val rowH = (22f * scale).roundToInt().coerceAtLeast(18) val footerH = (18f * scale).roundToInt().coerceAtLeast(14) val n = board.columns.size - val boxW = pad * 2 + colorBarW + teamW + n * colW - val boxH = pad * 2 + headH + rowH * 2 + footerH + val logoSlotW = logoSlotWidth(board, logoSize, logoGap) + val boxW = pad * 2 + colorBarW + logoSlotW + teamW + n * colW + val boxH = pad * 2 + brandH + headH + rowH * 2 + footerH val left = layout.marginPx val top = layout.marginPx - rect.set(left.toFloat(), top.toFloat(), (left + boxW).toFloat(), (top + boxH).toFloat()) - canvas.drawRoundRect(rect, 6f * scale, 6f * scale, backgroundPaint) + boxRect.set(left.toFloat(), top.toFloat(), (left + boxW).toFloat(), (top + boxH).toFloat()) + canvas.drawRoundRect(boxRect, 6f * scale, 6f * scale, backgroundPaint) val headerSize = (10f * scale).coerceAtLeast(8f) val teamSize = (11f * scale).coerceAtLeast(9f) @@ -76,15 +97,17 @@ class ScoreboardElement : OverlayElement { winPaint.textSize = scoreSize footerPaint.textSize = footerSize - val scoresLeft = left + pad + colorBarW + teamW - val headerBaseline = top + pad + headH - (2f * scale) + drawBrandHeader(canvas, left, top, pad, scale, brandH) + + val scoresLeft = left + pad + colorBarW + logoSlotW + teamW + val headerBaseline = top + pad + brandH + headH - (2f * scale) board.columns.forEachIndexed { index, column -> val cx = scoresLeft + index * colW + colW / 2f canvas.drawText(column.setNumber.toString(), cx, headerBaseline, headerPaint) } - val homeRowTop = top + pad + headH + val homeRowTop = top + pad + brandH + headH val awayRowTop = homeRowTop + rowH drawTeamRow( canvas = canvas, @@ -94,6 +117,9 @@ class ScoreboardElement : OverlayElement { left = left, pad = pad, colorBarW = colorBarW, + logoSize = logoSize, + logoGap = logoGap, + logoSlotW = logoSlotW, teamW = teamW, colW = colW, rowH = rowH, @@ -108,6 +134,9 @@ class ScoreboardElement : OverlayElement { left = left, pad = pad, colorBarW = colorBarW, + logoSize = logoSize, + logoGap = logoGap, + logoSlotW = logoSlotW, teamW = teamW, colW = colW, rowH = rowH, @@ -117,8 +146,50 @@ class ScoreboardElement : OverlayElement { val currentSet = board.columns.lastOrNull { it.isCurrent }?.setNumber ?: board.columns.size val footerText = ordinalSetLabel(currentSet) - val footerBaseline = rect.bottom - pad - (2f * scale) - canvas.drawText(footerText, rect.left + pad + colorBarW + 4f * scale, footerBaseline, footerPaint) + val footerBaseline = boxRect.bottom - pad - (2f * scale) + canvas.drawText( + footerText, + boxRect.left + pad + colorBarW + 4f * scale, + footerBaseline, + footerPaint, + ) + } + + private fun logoSlotWidth(board: ScoreboardState, logoSize: Int, logoGap: Int): Int { + val needsSlot = !board.homeLogoUrl.isNullOrBlank() || !board.awayLogoUrl.isNullOrBlank() + return if (needsSlot) logoSize + logoGap else 0 + } + + private fun drawBrandHeader( + canvas: Canvas, + left: Int, + top: Int, + pad: Int, + scale: Float, + brandH: Int, + ) { + val textSize = (9f * scale).coerceAtLeast(7f) + brandMatchPaint.textSize = textSize + brandTvPaint.textSize = textSize + brandLiveTextPaint.textSize = textSize * 0.88f + + var x = left + pad.toFloat() + val baseline = top + pad + brandH * 0.72f + + canvas.drawText("MATCH", x, baseline, brandMatchPaint) + x += brandMatchPaint.measureText("MATCH") + 4f * scale + + val liveText = "LIVE" + val livePadH = 4f * scale + val livePadV = 2f * scale + val liveW = brandLiveTextPaint.measureText(liveText) + livePadH * 2 + val liveH = textSize + livePadV * 2 + val liveTop = baseline - textSize - livePadV * 0.5f + liveRect.set(x, liveTop, x + liveW, liveTop + liveH) + canvas.drawRoundRect(liveRect, 3f * scale, 3f * scale, brandLiveBgPaint) + canvas.drawText(liveText, x + livePadH, baseline - livePadV * 0.3f, brandLiveTextPaint) + x += liveW + 4f * scale + canvas.drawText("TV", x, baseline, brandTvPaint) } private fun drawTeamRow( @@ -129,6 +200,9 @@ class ScoreboardElement : OverlayElement { left: Int, pad: Int, colorBarW: Int, + logoSize: Int, + logoGap: Int, + logoSlotW: Int, teamW: Int, colW: Int, rowH: Int, @@ -141,12 +215,25 @@ class ScoreboardElement : OverlayElement { val barBottom = rowTop + rowH - (2f * scale) canvas.drawRect(barLeft, barTop, barLeft + colorBarW, barBottom, accentBarPaint) + val logoUrl = if (isHome) board.homeLogoUrl else board.awayLogoUrl + val logo = OverlayLogoCache.get(logoUrl) + var nameLeft = barLeft + colorBarW + logoGap + if (logoSlotW > 0) { + if (logo != null) { + val logoLeft = nameLeft + val logoTop = rowTop + (rowH - logoSize) / 2f + val dest = RectF(logoLeft, logoTop, logoLeft + logoSize, logoTop + logoSize) + canvas.drawBitmap(logo, null, dest, logoPaint) + } + nameLeft += logoSize + logoGap + } + val rawName = if (isHome) board.homeTeamName else board.awayTeamName - val name = abbreviate(rawName).uppercase() - val nameMaxW = teamW - (8f * scale) + val name = rawName.trim().uppercase() + val nameMaxW = teamW - (6f * scale) val displayName = ellipsize(name, teamPaint, nameMaxW) val nameBaseline = rowTop + rowH * 0.72f - canvas.drawText(displayName, barLeft + colorBarW + 6f * scale, nameBaseline, teamPaint) + canvas.drawText(displayName, nameLeft, nameBaseline, teamPaint) board.columns.forEachIndexed { index, column -> val cx = scoresLeft + index * colW + colW / 2f @@ -167,14 +254,8 @@ class ScoreboardElement : OverlayElement { } } - private fun abbreviate(name: String): String { - val trimmed = name.trim() - if (trimmed.length <= 12) return trimmed - return trimmed.take(11) + "…" - } - private fun ellipsize(text: String, paint: TextPaint, maxWidth: Float): String { - val safeMax = max(32f, maxWidth) + val safeMax = max(48f, maxWidth) return TextUtils.ellipsize(text, paint, safeMax, TextUtils.TruncateAt.END)?.toString() ?: text } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt new file mode 100644 index 0000000..35f030b --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt @@ -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)) +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt index 26ebdf7..c6ccd50 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt @@ -1,25 +1,18 @@ package com.matchlivetv.match_live_tv.ui.broadcast import android.view.ViewGroup +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -32,11 +25,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.matchlivetv.match_live_tv.core.DeviceTelemetry +import com.matchlivetv.match_live_tv.core.parseColorHex +import com.matchlivetv.match_live_tv.core.resolveMediaUrl import com.matchlivetv.match_live_tv.data.AppContainer import com.matchlivetv.match_live_tv.domain.Match import com.matchlivetv.match_live_tv.domain.MatchScoringRules @@ -44,25 +38,26 @@ import com.matchlivetv.match_live_tv.domain.StreamSession import com.matchlivetv.match_live_tv.streaming.BroadcastConfig import com.matchlivetv.match_live_tv.streaming.BroadcastPhase import com.matchlivetv.match_live_tv.streaming.LivePreviewView +import com.matchlivetv.match_live_tv.streaming.overlay.OverlayLogoCache import com.matchlivetv.match_live_tv.streaming.overlay.OverlayState import com.matchlivetv.match_live_tv.streaming.overlay.toOverlayStatus import com.matchlivetv.match_live_tv.streaming.overlay.toScoreboardState import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton -import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold -import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton -import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge import com.matchlivetv.match_live_tv.ui.permissions.rememberBroadcastPermissionsState +import com.matchlivetv.match_live_tv.ui.system.KeepScreenOnEffect +import com.matchlivetv.match_live_tv.ui.system.LockLandscapeOrientationEffect import com.matchlivetv.match_live_tv.ui.theme.MatchColors import kotlinx.coroutines.delay import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) @Composable fun BroadcastScreen( container: AppContainer, sessionId: String, onFinished: () -> Unit, ) { + LockLandscapeOrientationEffect() + KeepScreenOnEffect() val scope = rememberCoroutineScope() val context = LocalContext.current val snackbar = remember { SnackbarHostState() } @@ -72,6 +67,8 @@ fun BroadcastScreen( var loading by remember { mutableStateOf(true) } var pauseInFlight by remember { mutableStateOf(false) } var resumeInFlight by remember { mutableStateOf(false) } + var controlsVisible by remember { mutableStateOf(true) } + var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) } val metrics by container.broadcastCoordinator.metrics.collectAsState() val score by container.scoreController.score.collectAsState() val cableConnected by container.sessionCable.connected.collectAsState() @@ -105,9 +102,9 @@ fun BroadcastScreen( val updated = container.sessionRepository.pauseSession(sessionId) session = updated container.broadcastCoordinator.engine.pauseBroadcast() - snackbar.showSnackbar("Diretta in pausa — gli spettatori vedono la copertina") + snackbar.showSnackbar("Diretta in pausa") } catch (e: Exception) { - snackbar.showSnackbar("Pausa sessione: ${e.message ?: "errore"}") + snackbar.showSnackbar("Pausa: ${e.message ?: "errore"}") } finally { pauseInFlight = false } @@ -125,9 +122,9 @@ fun BroadcastScreen( } updated = container.sessionRepository.fetchSession(sessionId) session = updated - snackbar.showSnackbar("Diretta ripresa — di nuovo in onda") + snackbar.showSnackbar("Diretta ripresa") } catch (e: Exception) { - snackbar.showSnackbar("Ripresa diretta: ${e.message ?: "errore"}") + snackbar.showSnackbar("Ripresa: ${e.message ?: "errore"}") } finally { resumeInFlight = false } @@ -135,18 +132,21 @@ fun BroadcastScreen( suspend fun applyRemotePause() { container.broadcastCoordinator.engine.pauseBroadcast() - session = session?.copy(status = "paused") - snackbar.showSnackbar("Pausa dalla regia — trasmissione fermata") + session = runCatching { container.sessionRepository.fetchSession(sessionId) } + .getOrNull() ?: session?.copy(status = "paused") + snackbar.showSnackbar("Pausa dalla regia") } suspend fun applyRemoteResume() { if (resumeInFlight) return - val current = session ?: return + val current = runCatching { container.sessionRepository.fetchSession(sessionId) } + .getOrNull() ?: session ?: return val url = current.rtmpIngestUrl ?: return resumeInFlight = true try { + session = current container.broadcastCoordinator.engine.resumeBroadcast(broadcastConfig(current)) - snackbar.showSnackbar("Ripresa dalla regia — di nuovo in onda") + snackbar.showSnackbar("Ripresa dalla regia") } catch (e: Exception) { snackbar.showSnackbar("Ripresa RTMP: ${e.message ?: "errore"}") } finally { @@ -154,6 +154,14 @@ fun BroadcastScreen( } } + suspend fun applyRemoteStop() { + container.sessionCable.disconnect() + container.broadcastCoordinator.engine.stopBroadcast() + container.broadcastCoordinator.stopService() + snackbar.showSnackbar("Diretta chiusa dalla regia") + onFinished() + } + LaunchedEffect(sessionId, permissions.granted) { if (!permissions.granted) return@LaunchedEffect loading = true @@ -175,6 +183,9 @@ fun BroadcastScreen( container.sessionCable.onResumeStream = { if (!resumeInFlight) scope.launch { applyRemoteResume() } } + container.sessionCable.onEndStream = { + scope.launch { applyRemoteStop() } + } container.sessionCable.connect(sessionId, token, deviceRole = "camera") } container.broadcastCoordinator.startService() @@ -192,15 +203,62 @@ fun BroadcastScreen( LaunchedEffect(score, match, metrics.phase) { val currentMatch = match ?: return@LaunchedEffect + val authToken = container.authRepository.currentSession()?.accessToken + val homeLogoUrl = resolveMediaUrl(currentMatch.homeLogoUrl) + val awayLogoUrl = resolveMediaUrl(currentMatch.opponentLogoUrl) + OverlayLogoCache.preloadAll( + context, + listOf(homeLogoUrl, awayLogoUrl), + bearerToken = authToken, + ) container.broadcastCoordinator.updateOverlay( OverlayState( - scoreboard = score.toScoreboardState(currentMatch.teamName, currentMatch.opponentName), + scoreboard = score.toScoreboardState( + homeTeamName = currentMatch.teamName, + awayTeamName = currentMatch.opponentName, + homeAccentColor = parseColorHex(currentMatch.homePrimaryColor, 0xFFFF2D2D.toInt()), + awayAccentColor = parseColorHex( + currentMatch.opponentPrimaryColor, + 0xFF1E3A8A.toInt(), + ), + homeLogoUrl = homeLogoUrl, + awayLogoUrl = awayLogoUrl, + ), watermarkVisible = true, broadcastStatus = metrics.phase.toOverlayStatus(), ), ) } + LaunchedEffect(Unit) { + while (true) { + deviceHealth = DeviceTelemetry.snapshot(context) + delay(2_000) + } + } + + LaunchedEffect(sessionId) { + container.sessionCable.connected.collect { connected -> + if (!connected) return@collect + runCatching { + container.sessionRepository.fetchSession(sessionId).score + }.getOrNull()?.let { remote -> + container.scoreController.applyRemote(remote) + } + } + } + + LaunchedEffect(sessionId) { + while (true) { + delay(4_000) + runCatching { + container.sessionRepository.fetchSession(sessionId).score + }.getOrNull()?.let { remote -> + container.scoreController.applyRemote(remote) + } + } + } + LaunchedEffect(sessionId) { while (true) { delay(10_000) @@ -222,8 +280,10 @@ fun BroadcastScreen( DisposableEffect(sessionId) { onDispose { + container.sessionCable.onScoreUpdate = null container.sessionCable.onPauseStream = null container.sessionCable.onResumeStream = null + container.sessionCable.onEndStream = null container.sessionCable.disconnect() container.broadcastCoordinator.stopService() container.broadcastCoordinator.engine.stopBroadcast() @@ -246,156 +306,144 @@ fun BroadcastScreen( else -> MatchColors.AccentYellow } val pointsTarget = scoringRules?.pointsTarget(score.currentSet) ?: 25 + val currentSession = session + val currentMatch = match + val shareUrl = currentSession?.watchShareUrl() + val shareSubject = currentMatch?.let { "${it.teamName} vs ${it.opponentName}" } ?: "Diretta Match Live TV" - match?.let { currentMatch -> + currentMatch?.let { m -> ScoreDialogRouter( host = dialogHost, - homeName = currentMatch.teamName, - awayName = currentMatch.opponentName, + homeName = m.teamName, + awayName = m.opponentName, ) } - MatchScreenScaffold( - topBar = { - Column { - TopAppBar( - title = { - Column { - Text("Diretta", style = MaterialTheme.typography.titleMedium) - Text( - if (cableConnected) "Tabellone collegato" else "Tabellone offline", - style = MaterialTheme.typography.labelSmall, - color = if (cableConnected) MatchColors.SuccessGreen else MatchColors.TextSecondary, - ) - } - }, - navigationIcon = { - IconButton(onClick = onFinished) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Indietro", - tint = Color.White, - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MatchColors.Background, - titleContentColor = Color.White, - ), - ) - SnackbarHost(hostState = snackbar) - } - }, + Box( + Modifier + .fillMaxSize() + .background(Color.Black), ) { - Column(Modifier.fillMaxSize()) { - Box( - Modifier - .weight(1f) - .fillMaxWidth(), - ) { - if (loading) { - CircularProgressIndicator( - color = MatchColors.PrimaryRed, - modifier = Modifier.align(Alignment.Center), - ) - } else { - CameraPreviewPanel(container) - } - MatchStatusBadge( - text = statusText, - textColor = statusColor, - backgroundColor = MatchColors.Background.copy(alpha = 0.75f), - modifier = Modifier - .align(Alignment.TopStart) - .padding(16.dp), + when { + loading -> { + CircularProgressIndicator( + color = MatchColors.PrimaryRed, + modifier = Modifier.align(Alignment.Center), ) - if (isPaused && !loading) { - MatchPrimaryButton( - label = "RIPRENDI DIRETTA", - onClick = { scope.launch { resumeStream() } }, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(16.dp), - ) - } } - Column( - Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - ) { - error?.let { + !permissions.granted -> { + Column( + Modifier + .align(Alignment.Center) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { Text( - it, - color = MatchColors.PrimaryRed, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) - } - match?.let { currentMatch -> - scoreActions?.let { actions -> - LiveScoreControls( - homeName = currentMatch.teamName, - awayName = currentMatch.opponentName, - score = score, - pointsTarget = pointsTarget, - onPointHome = { - scope.launch { - container.scoreController.incrementHome() - actions.afterPointChange( - container.scoreController.score.value, - ) { stopStreamPermanently() } - } - }, - onPointAway = { - scope.launch { - container.scoreController.incrementAway() - actions.afterPointChange( - container.scoreController.score.value, - ) { stopStreamPermanently() } - } - }, - onMinusHome = { container.scoreController.decrementHome() }, - onMinusAway = { container.scoreController.decrementAway() }, - onCloseSet = { - scope.launch { - actions.requestCloseSet { stopStreamPermanently() } - } - }, - ) - } - } - if (!permissions.granted) { - Text( - "Consenti accesso a camera e microfono per andare in diretta", + "Consenti camera e microfono per andare in diretta", color = MatchColors.AccentYellow, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) + Spacer(Modifier.height(12.dp)) MatchPrimaryButton( label = "CONCEDI PERMESSI", onClick = permissions.request, - modifier = Modifier.padding(horizontal = 16.dp), ) } - if (isPaused) { - MatchPrimaryButton( - label = "RIPRENDI DIRETTA", - onClick = { scope.launch { resumeStream() } }, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } else if (!loading && session != null) { - MatchSecondaryButton( - label = "PAUSA DIRETTA", - onClick = { scope.launch { pauseStream() } }, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } - MatchPrimaryButton( - label = "TERMINA DIRETTA", - onClick = { scope.launch { stopStreamPermanently() } }, - modifier = Modifier.padding(16.dp), - ) + } + else -> { + CameraPreviewPanel(container) } } + + error?.let { + Text( + it, + color = MatchColors.PrimaryRed, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .align(Alignment.Center) + .padding(24.dp), + ) + } + + if (!loading && permissions.granted && currentMatch != null && scoreActions != null) { + BroadcastControlsOverlay( + controlsVisible = controlsVisible, + onToggleControls = { controlsVisible = !controlsVisible }, + statusText = statusText, + statusColor = statusColor, + cableConnected = cableConnected, + isPaused = isPaused, + homeName = currentMatch.teamName, + awayName = currentMatch.opponentName, + homeAccentColor = Color(parseColorHex(currentMatch.homePrimaryColor, 0xFFFF2D2D.toInt())), + awayAccentColor = Color(parseColorHex(currentMatch.opponentPrimaryColor, 0xFF1E3A8A.toInt())), + homeLogoUrl = currentMatch.homeLogoUrl, + awayLogoUrl = currentMatch.opponentLogoUrl, + score = score, + pointsTarget = pointsTarget, + onPointHome = { + scope.launch { + container.scoreController.incrementHome() + scoreActions.afterPointChange(container.scoreController.score.value) { + stopStreamPermanently() + } + } + }, + onPointAway = { + scope.launch { + container.scoreController.incrementAway() + scoreActions.afterPointChange(container.scoreController.score.value) { + stopStreamPermanently() + } + } + }, + onMinusHome = { container.scoreController.decrementHome() }, + onMinusAway = { container.scoreController.decrementAway() }, + onCloseSet = { + scope.launch { + scoreActions.requestCloseSet { stopStreamPermanently() } + } + }, + onPauseOrResume = { + scope.launch { + if (isPaused) resumeStream() else pauseStream() + } + }, + onTerminate = { scope.launch { stopStreamPermanently() } }, + onShareLive = { + val url = shareUrl + if (url.isNullOrBlank()) { + scope.launch { snackbar.showSnackbar("Link diretta non ancora disponibile") } + } else { + shareBroadcastLink(context, url, shareSubject) + } + }, + onShareRegia = { + scope.launch { + runCatching { container.sessionRepository.createRegiaLink(sessionId) } + .onSuccess { url -> + shareBroadcastLink(context, url, "Link regia — $shareSubject") + } + .onFailure { + snackbar.showSnackbar(it.message ?: "Errore link regia") + } + } + }, + shareLiveEnabled = !shareUrl.isNullOrBlank(), + fps = metrics.fps, + targetFps = currentSession?.targetFps ?: 30, + bitrateKbps = metrics.bitrateKbps, + networkType = DeviceTelemetry.networkType(context), + deviceHealth = deviceHealth, + ) + } + + SnackbarHost( + hostState = snackbar, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 48.dp), + ) } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/system/KeepScreenOn.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/system/KeepScreenOn.kt new file mode 100644 index 0000000..78a24ed --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/system/KeepScreenOn.kt @@ -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 + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/system/ScreenOrientation.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/system/ScreenOrientation.kt new file mode 100644 index 0000000..033598f --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/system/ScreenOrientation.kt @@ -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 + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt index 95f44d5..3d98ba7 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt @@ -1,5 +1,9 @@ package com.matchlivetv.match_live_tv.ui.wizard +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,6 +17,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -22,9 +27,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.matchlivetv.match_live_tv.core.normalizeHexColor import com.matchlivetv.match_live_tv.data.AppContainer import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody import com.matchlivetv.match_live_tv.domain.Match +import com.matchlivetv.match_live_tv.domain.Team import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton import com.matchlivetv.match_live_tv.ui.matches.MatchOutlinedField @@ -36,6 +43,7 @@ private const val DEFAULT_SETS_TO_WIN = 3 private const val DEFAULT_POINTS_PER_SET = 25 private const val DEFAULT_POINTS_DECIDING_SET = 15 private const val DEFAULT_MIN_POINT_LEAD = 2 +private const val DEFAULT_OPPONENT_COLOR = "#1E3A8A" @Composable fun StepMatchScreen( @@ -51,6 +59,37 @@ fun StepMatchScreen( var location by remember { mutableStateOf(match.location.orEmpty()) } var campionato by remember { mutableStateOf(match.category.orEmpty()) } + var homeTeam by remember { mutableStateOf(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(null) } + var opponentLogoUri by remember { mutableStateOf(null) } + + LaunchedEffect(match.teamId) { + runCatching { container.matchRepository.fetchTeam(match.teamId) } + .onSuccess { team -> + homeTeam = team + if (team != null) { + homePrimaryColor = normalizeHexColor(team.primaryColor ?: match.homePrimaryColor) + homeLogoUrl = team.logoUrl ?: match.homeLogoUrl + } + } + } + + val pickHomeLogo = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + ) { uri -> homeLogoUri = uri } + + val pickOpponentLogo = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + ) { uri -> opponentLogoUri = uri } + val existingRules = match.scoringRules var customRules by remember { mutableStateOf(existingRules != null || match.setsToWin != DEFAULT_SETS_TO_WIN) @@ -74,10 +113,38 @@ fun StepMatchScreen( Text("Dettagli partita", style = MaterialTheme.typography.headlineMedium) Spacer(Modifier.height(20.dp)) - WizardReadOnlyField(label = "Squadra casa", value = match.teamName) - Spacer(Modifier.height(12.dp)) - MatchOutlinedField(value = opponent, onValueChange = { opponent = it }, label = "Avversario") - Spacer(Modifier.height(12.dp)) + TeamBrandingRow( + sectionLabel = "Squadra di casa", + teamName = match.teamName, + nameEditable = false, + onTeamNameChange = {}, + remoteLogoUrl = homeLogoUrl, + localLogoUri = homeLogoUri, + primaryColorHex = homePrimaryColor, + placeholderColorSeed = "home-${match.teamId}", + onPrimaryColorChange = { homePrimaryColor = it }, + onPickLogo = { + pickHomeLogo.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }, + onClearLogo = { homeLogoUri = null }, + ) + Spacer(Modifier.height(16.dp)) + TeamBrandingRow( + sectionLabel = "Squadra avversaria", + teamName = opponent, + nameEditable = true, + onTeamNameChange = { opponent = it }, + remoteLogoUrl = opponentLogoUrl, + localLogoUri = opponentLogoUri, + primaryColorHex = opponentPrimaryColor, + placeholderColorSeed = "away-${match.id}", + onPrimaryColorChange = { opponentPrimaryColor = it }, + onPickLogo = { + pickOpponentLogo.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }, + onClearLogo = { opponentLogoUri = null }, + ) + Spacer(Modifier.height(16.dp)) MatchOutlinedField(value = location, onValueChange = { location = it }, label = "Luogo") Spacer(Modifier.height(12.dp)) MatchOutlinedField( @@ -202,9 +269,23 @@ fun StepMatchScreen( ) } + val initialHomeColor = normalizeHexColor( + homeTeam?.primaryColor ?: match.homePrimaryColor, + ) + val homeBrandingChanged = + homePrimaryColor != initialHomeColor || homeLogoUri != null + saving = true scope.launch { runCatching { + if (homeBrandingChanged) { + container.matchRepository.updateTeamBranding( + teamId = match.teamId, + primaryColor = homePrimaryColor, + secondaryColor = homeTeam?.secondaryColor, + logoUri = homeLogoUri, + ) + } container.matchRepository.updateMatch( matchId = match.id, opponentName = opponent.trim(), @@ -212,7 +293,9 @@ fun StepMatchScreen( scheduledAt = match.scheduledAt, setsToWin = resolvedSets, category = campionato.trim(), + opponentPrimaryColor = opponentPrimaryColor, scoringRules = resolvedRules, + opponentLogoUri = opponentLogoUri, ) }.onSuccess { updated -> container.wizardSession.match = updated diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepNetworkTestScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepNetworkTestScreen.kt index 3464cb1..a63d5e8 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepNetworkTestScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepNetworkTestScreen.kt @@ -56,6 +56,7 @@ fun StepNetworkTestScreen( var uploadMbps by remember { mutableStateOf(0.0) } var latencyMs by remember { mutableStateOf(0) } var networkType by remember { mutableStateOf("—") } + var selectedQualityLabel by remember { mutableStateOf(null) } var starting by remember { mutableStateOf(false) } var currentSession by remember { mutableStateOf(session) } @@ -92,6 +93,14 @@ fun StepNetworkTestScreen( networkType = networkType, ) }.getOrNull() + if (result != null) { + selectedQualityLabel = formatQualityLabel(result) + runCatching { container.sessionRepository.fetchSession(currentSession.id) } + .onSuccess { updated -> + currentSession = updated + container.wizardSession.setSession(updated, match) + } + } ready = result?.ready ?: (uploadMbps >= 2.0) testCompleted = true testing = false @@ -141,6 +150,13 @@ fun StepNetworkTestScreen( textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) + selectedQualityLabel?.let { quality -> + Spacer(Modifier.height(12.dp)) + WizardReadOnlyField( + label = "Qualità streaming (automatica)", + value = quality, + ) + } } if (testCompleted && shareUrl != null) { Spacer(Modifier.height(16.dp)) @@ -240,3 +256,13 @@ private fun copyToClipboard(context: Context, text: String) { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.setPrimaryClip(ClipData.newPlainText("link", text)) } + +private fun formatQualityLabel(result: com.matchlivetv.match_live_tv.data.api.NetworkTestResponse): String { + val bitrateMbps = (result.targetBitrate ?: 2_500_000) / 1_000_000.0 + val fps = result.targetFps ?: 30 + val resolution = when { + result.qualityPreset?.startsWith("1080p") == true -> "1080p" + else -> "720p" + } + return "$resolution · ${fps}fps · ${"%.1f".format(bitrateMbps)} Mbps" +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepTransmissionScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepTransmissionScreen.kt index 72f1083..e5e5122 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepTransmissionScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepTransmissionScreen.kt @@ -42,7 +42,6 @@ fun StepTransmissionScreen( var loadingTeam by remember { mutableStateOf(true) } var platform by remember { mutableStateOf("matchlivetv") } var privacy by remember { mutableStateOf("public") } - var youtubeChannel by remember { mutableStateOf("platform") } var creating by remember { mutableStateOf(false) } LaunchedEffect(match.teamId) { @@ -56,7 +55,14 @@ fun StepTransmissionScreen( return } - val youtubeReady = team?.isYoutubeReady == true + val loadedTeam = team + val youtubeReady = loadedTeam?.isYoutubeReady == true + val youtubeSubtitle = when { + loadedTeam == null -> "Canale in attivazione" + !loadedTeam.canUseYoutube -> "Premium Light o Full" + !loadedTeam.isYoutubeReady -> "Canale in attivazione" + else -> loadedTeam.youtubeDestinationLabel + } Column( Modifier @@ -67,6 +73,7 @@ fun StepTransmissionScreen( Text( "Piano $plan", style = MaterialTheme.typography.bodyMedium, + color = MatchColors.TextSecondary, modifier = Modifier .fillMaxWidth() .padding(bottom = 12.dp), @@ -83,18 +90,13 @@ fun StepTransmissionScreen( Spacer(Modifier.height(8.dp)) WizardPlatformCard( title = "YouTube Live", - subtitle = when { - team?.canUseYoutube != true -> "Premium Light o Full" - !youtubeReady -> "Canale in attivazione" - else -> "Canale YouTube collegato" - }, + subtitle = youtubeSubtitle, selected = platform == "youtube", enabled = youtubeReady, badge = if (team?.canUseYoutube != true) "Premium" else null, onClick = { if (youtubeReady) { platform = "youtube" - youtubeChannel = "platform" } else { onError("YouTube non disponibile per questa squadra") } @@ -104,13 +106,15 @@ fun StepTransmissionScreen( Text("Visibilità", style = MaterialTheme.typography.headlineMedium) Spacer(Modifier.height(12.dp)) Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) { - MatchSecondaryButton( + WizardChoiceButton( label = "PUBBLICO", + selected = privacy == "public", onClick = { privacy = "public" }, modifier = Modifier.weight(1f), ) - MatchSecondaryButton( + WizardChoiceButton( label = "NON IN ELENCO", + selected = privacy == "unlisted", onClick = { privacy = "unlisted" }, modifier = Modifier.weight(1f), ) @@ -118,16 +122,19 @@ fun StepTransmissionScreen( Spacer(Modifier.height(8.dp)) Text( if (privacy == "public") { - "Compare nell'elenco dirette e sul canale scelto." + "Compare nell'elenco dirette su MatchLiveTV.it, nelle ricerche e sul canale YouTube (se selezionato)." } else { - "Solo chi ha il link." + "Non compare negli elenchi pubblici né nelle ricerche. Solo chi ha il link può guardare." }, style = MaterialTheme.typography.bodyMedium, + color = MatchColors.TextSecondary, + ) + Text( + "La partita resta sempre visibile nel backend della squadra per tutta la durata dell'abbonamento.", + style = MaterialTheme.typography.bodyMedium, + color = MatchColors.TextSecondary, + modifier = Modifier.padding(top = 6.dp), ) - Spacer(Modifier.height(24.dp)) - Text("Qualità", style = MaterialTheme.typography.headlineMedium) - Spacer(Modifier.height(12.dp)) - WizardReadOnlyField(label = "Preset", value = "720p · 30fps · 2.5 Mbps") Spacer(Modifier.height(32.dp)) Row { MatchSecondaryButton( @@ -147,7 +154,7 @@ fun StepTransmissionScreen( matchId = match.id, platform = platform, privacyStatus = privacy, - youtubeChannel = if (platform == "youtube") youtubeChannel else null, + youtubeChannel = if (platform == "youtube") loadedTeam?.effectiveYoutubeChannel else null, ) }.onSuccess { session -> container.wizardSession.setSession(session, match) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/TeamBrandingEditor.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/TeamBrandingEditor.kt new file mode 100644 index 0000000..57c6233 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/TeamBrandingEditor.kt @@ -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, +) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/TeamColorPicker.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/TeamColorPicker.kt new file mode 100644 index 0000000..39becea --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/TeamColorPicker.kt @@ -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, + ), + ) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/WizardComponents.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/WizardComponents.kt index b4b549c..44294bc 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/WizardComponents.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/WizardComponents.kt @@ -21,6 +21,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton +import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton import com.matchlivetv.match_live_tv.ui.theme.MatchColors private val stepTitles = listOf( @@ -54,6 +56,20 @@ fun WizardStepIndicator(currentStep: Int, modifier: Modifier = Modifier) { fun wizardStepTitle(step: Int): String = stepTitles[(step.coerceIn(1, 3) - 1)] +@Composable +fun WizardChoiceButton( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (selected) { + MatchPrimaryButton(label = label, onClick = onClick, modifier = modifier) + } else { + MatchSecondaryButton(label = label, onClick = onClick, modifier = modifier) + } +} + @Composable fun WizardReadOnlyField(label: String, value: String) { Column(