From 2b7e8b9e36326a2c03a43193f7919e41a52b1a8b Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Tue, 9 Jun 2026 23:31:12 +0200 Subject: [PATCH] Migliora overlay basket Android, regia e catalogo sport senza timer. Fix crash e race su +3, layout tabellone con squadre ai lati, safe area in diretta e rimozione overlay cronometro; backend con sync punteggio periodo, link diretta in regia e init score_state basket. Co-authored-by: Cursor --- .../api/v1/stream_sessions_controller.rb | 1 + .../controllers/public/regia_controller.rb | 6 +- backend/app/domain/sports/overlay_kind.rb | 2 +- backend/app/models/match.rb | 1 + backend/app/services/sessions/create.rb | 2 +- backend/app/views/public/regia/show.html.erb | 39 ++++-- backend/config/sports.yml | 50 +++---- backend/public/regia.css | 14 +- backend/public/regia.js | 48 +++++-- backend/spec/requests/api/v1/sports_spec.rb | 3 +- backend/spec/requests/public/regia_spec.rb | 2 + native/android/app/build.gradle.kts | 2 +- .../match_live_tv/data/api/ApiDtos.kt | 20 ++- .../data/repository/ScoreRepository.kt | 10 ++ .../data/scoring/ScoreController.kt | 18 ++- .../overlay/CompactScoreboardElement.kt | 123 +++++++++++++++--- .../overlay/OverlayCanvasRenderer.kt | 1 - .../streaming/overlay/OverlayRenderer.kt | 17 ++- .../streaming/overlay/OverlayState.kt | 14 +- .../streaming/overlay/TimerElement.kt | 24 ---- .../ui/broadcast/BroadcastControlsOverlay.kt | 35 +---- .../ui/broadcast/BroadcastScreen.kt | 43 ++---- .../ui/wizard/WizardComponents.kt | 33 +++++ 23 files changed, 341 insertions(+), 167 deletions(-) delete mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt diff --git a/backend/app/controllers/api/v1/stream_sessions_controller.rb b/backend/app/controllers/api/v1/stream_sessions_controller.rb index e9a55f9..4ca1845 100644 --- a/backend/app/controllers/api/v1/stream_sessions_controller.rb +++ b/backend/app/controllers/api/v1/stream_sessions_controller.rb @@ -167,6 +167,7 @@ module Api def score_sync_params params.permit( :home_sets, :away_sets, :home_points, :away_points, :current_set, + :clock_secs, :clock_running, :period, :home_score, :away_score, :overtime, set_partials: %i[set home away] ) end diff --git a/backend/app/controllers/public/regia_controller.rb b/backend/app/controllers/public/regia_controller.rb index f37b98a..3b7def0 100644 --- a/backend/app/controllers/public/regia_controller.rb +++ b/backend/app/controllers/public/regia_controller.rb @@ -18,8 +18,10 @@ module Public @rules = Scoring::Rules.from_match(@match) @stream_closed = @session.terminal? @on_air = !@stream_closed && mediamtx_stream_playable?(@session) - @share_url = request.original_url - @share_title = "Regia — #{@team.name} vs #{@match.opponent_name}" + @regia_share_url = request.original_url + @regia_share_title = "Regia — #{@team.name} vs #{@match.opponent_name}" + @live_share_url = @session.share_url + @live_share_title = "#{@team.name} vs #{@match.opponent_name} — Diretta Match Live TV" end def status diff --git a/backend/app/domain/sports/overlay_kind.rb b/backend/app/domain/sports/overlay_kind.rb index 8d6d07d..b7f3cf2 100644 --- a/backend/app/domain/sports/overlay_kind.rb +++ b/backend/app/domain/sports/overlay_kind.rb @@ -2,7 +2,7 @@ module Sports class OverlayKind - ALL = %w[none timer volley basket timed racket].freeze + ALL = %w[none volley basket timed racket].freeze def self.valid?(value) ALL.include?(value.to_s) diff --git a/backend/app/models/match.rb b/backend/app/models/match.rb index ea7c112..d56fa4d 100644 --- a/backend/app/models/match.rb +++ b/backend/app/models/match.rb @@ -99,6 +99,7 @@ class Match < ApplicationRecord def effective_overlay_kind kind = overlay_kind.presence || Sports::Catalog.overlay_for(sport_key) + kind = "none" if kind == "timer" allowed = Sports::Catalog.allowed_overlays_for(sport_key) allowed.include?(kind) ? kind : Sports::Catalog.overlay_for(sport_key) end diff --git a/backend/app/services/sessions/create.rb b/backend/app/services/sessions/create.rb index 9d9e69b..f5d2768 100644 --- a/backend/app/services/sessions/create.rb +++ b/backend/app/services/sessions/create.rb @@ -35,7 +35,7 @@ module Sessions StreamSession.transaction do session.save! - session.create_score_state! + Scoring::Engine.ensure_score_for(session) mtx = Mediamtx::Client.new mtx.create_path(session) log_event(session, "pairing", { created: true, platform: session.platform }) diff --git a/backend/app/views/public/regia/show.html.erb b/backend/app/views/public/regia/show.html.erb index 8e3dd0d..181d2e0 100644 --- a/backend/app/views/public/regia/show.html.erb +++ b/backend/app/views/public/regia/show.html.erb @@ -19,8 +19,10 @@ data-hls-url="<%= j @session.hls_playback_url %>" data-home-name="<%= j @team.name %>" data-away-name="<%= j @match.opponent_name %>" - data-share-url="<%= j @share_url %>" - data-share-title="<%= j @share_title %>" + data-regia-share-url="<%= j @regia_share_url %>" + data-regia-share-title="<%= j @regia_share_title %>" + data-live-share-url="<%= j @live_share_url %>" + data-live-share-title="<%= j @live_share_title %>" data-points-target="<%= @match.effective_board_type.in?(%w[volley racket]) ? Scoring::Rules.from_match(@match).points_target(@score.current_set) : 0 %>" data-stream-closed="<%= @stream_closed %>" data-initial-paused="<%= @session.paused? %>"> @@ -33,13 +35,30 @@ -
- - + <% if @live_share_url.present? %> +
+

Link diretta (spettatori)

+
+ + +
+
+ <% end %> + +
+

Link regia (punteggio)

+
+ + +
@@ -74,4 +93,4 @@
- + diff --git a/backend/config/sports.yml b/backend/config/sports.yml index b934261..73b2e86 100644 --- a/backend/config/sports.yml +++ b/backend/config/sports.yml @@ -1,12 +1,12 @@ # Catalogo sport Match Live TV — board e overlay impliciti, regole standard. # board: volley | basket | timed | racket | timer | generic -# overlay: none | timer | volley | basket | timed | racket +# overlay: none | volley | basket | timed | racket pallavolo: label: Pallavolo board: volley overlay: volley - allowed_overlays: [volley, timer, none] + allowed_overlays: [volley, none] default_rules: sets_to_win: 3 points_per_set: 25 @@ -17,7 +17,7 @@ beach_volley: label: Beach Volley board: volley overlay: volley - allowed_overlays: [volley, timer, none] + allowed_overlays: [volley, none] default_rules: sets_to_win: 2 points_per_set: 21 @@ -28,7 +28,7 @@ sitting_volley: label: Sitting Volley board: volley overlay: volley - allowed_overlays: [volley, timer, none] + allowed_overlays: [volley, none] default_rules: sets_to_win: 3 points_per_set: 25 @@ -39,7 +39,7 @@ basket: label: Basket board: basket overlay: basket - allowed_overlays: [basket, timer, none] + allowed_overlays: [basket, none] default_rules: periods: 4 period_duration_secs: 600 @@ -49,7 +49,7 @@ calcio_a_5: label: Calcio a 5 board: timed overlay: timed - allowed_overlays: [timed, timer, none] + allowed_overlays: [timed, none] default_rules: periods: 2 period_duration_secs: 1200 @@ -59,7 +59,7 @@ pallamano: label: Pallamano board: timed overlay: timed - allowed_overlays: [timed, timer, none] + allowed_overlays: [timed, none] default_rules: periods: 2 period_duration_secs: 1800 @@ -69,7 +69,7 @@ hockey_indoor: label: Hockey Indoor board: timed overlay: timed - allowed_overlays: [timed, timer, none] + allowed_overlays: [timed, none] default_rules: periods: 2 period_duration_secs: 1200 @@ -79,7 +79,7 @@ floorball: label: Floorball board: timed overlay: timed - allowed_overlays: [timed, timer, none] + allowed_overlays: [timed, none] default_rules: periods: 3 period_duration_secs: 1200 @@ -89,7 +89,7 @@ tennis: label: Tennis board: racket overlay: racket - allowed_overlays: [racket, timer, none] + allowed_overlays: [racket, none] default_rules: sets_to_win: 2 points_per_set: 6 @@ -100,7 +100,7 @@ tennis_tavolo: label: Tennis Tavolo board: racket overlay: racket - allowed_overlays: [racket, timer, none] + allowed_overlays: [racket, none] default_rules: sets_to_win: 3 points_per_set: 11 @@ -111,7 +111,7 @@ badminton: label: Badminton board: racket overlay: racket - allowed_overlays: [racket, timer, none] + allowed_overlays: [racket, none] default_rules: sets_to_win: 2 points_per_set: 21 @@ -122,7 +122,7 @@ padel: label: Padel board: racket overlay: racket - allowed_overlays: [racket, timer, none] + allowed_overlays: [racket, none] default_rules: sets_to_win: 2 points_per_set: 6 @@ -133,7 +133,7 @@ pickleball: label: Pickleball board: racket overlay: racket - allowed_overlays: [racket, timer, none] + allowed_overlays: [racket, none] default_rules: sets_to_win: 2 points_per_set: 11 @@ -143,41 +143,41 @@ pickleball: ginnastica_artistica: label: Ginnastica Artistica board: timer - overlay: timer - allowed_overlays: [timer, none] + overlay: none + allowed_overlays: [none] default_rules: {} ginnastica_ritmica: label: Ginnastica Ritmica board: timer - overlay: timer - allowed_overlays: [timer, none] + overlay: none + allowed_overlays: [none] default_rules: {} danza_sportiva: label: Danza Sportiva board: timer - overlay: timer - allowed_overlays: [timer, none] + overlay: none + allowed_overlays: [none] default_rules: {} cheerleading: label: Cheerleading board: timer - overlay: timer - allowed_overlays: [timer, none] + overlay: none + allowed_overlays: [none] default_rules: {} pattinaggio: label: Pattinaggio board: timer - overlay: timer - allowed_overlays: [timer, none] + overlay: none + allowed_overlays: [none] default_rules: {} altro: label: Altro board: generic overlay: none - allowed_overlays: [none, timer] + allowed_overlays: [none] default_rules: {} diff --git a/backend/public/regia.css b/backend/public/regia.css index e07fdc7..1b4ff89 100644 --- a/backend/public/regia.css +++ b/backend/public/regia.css @@ -173,10 +173,22 @@ margin-top: 8px; } +.regia-share-group { + margin: 12px 0; +} + +.regia-share-label { + margin: 0 0 6px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--regia-muted); +} + .regia-share { display: flex; gap: 8px; - margin: 12px 0; } .regia-share .regia-btn { flex: 1; font-size: 0.9rem; padding: 12px; } diff --git a/backend/public/regia.js b/backend/public/regia.js index d8e3d37..b8939b7 100644 --- a/backend/public/regia.js +++ b/backend/public/regia.js @@ -14,8 +14,10 @@ hlsUrl: root.dataset.hlsUrl, homeName: root.dataset.homeName, awayName: root.dataset.awayName, - shareUrl: root.dataset.shareUrl, - shareTitle: root.dataset.shareTitle, + regiaShareUrl: root.dataset.regiaShareUrl, + regiaShareTitle: root.dataset.regiaShareTitle, + liveShareUrl: root.dataset.liveShareUrl, + liveShareTitle: root.dataset.liveShareTitle, streamClosed: root.dataset.streamClosed === "true" }; @@ -360,12 +362,12 @@ 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() { - const payload = { - title: cfg.shareTitle, - text: "Apri questo link per gestire il punteggio della diretta:", - url: cfg.shareUrl - }; + async function shareLink(url, title, text) { + if (!url) { + toast("Link non disponibile"); + return; + } + const payload = { title, text, url }; if (navigator.share) { try { await navigator.share(payload); @@ -374,20 +376,38 @@ if (err.name === "AbortError") return; } } - await copyLink(); + await copyLink(url); } - async function copyLink() { + async function copyLink(url) { + if (!url) { + toast("Link non disponibile"); + return; + } try { - await navigator.clipboard.writeText(cfg.shareUrl); + await navigator.clipboard.writeText(url); toast("Link copiato"); } catch (_) { - prompt("Copia il link:", cfg.shareUrl); + prompt("Copia il link:", url); } } - document.getElementById("btn-share")?.addEventListener("click", () => shareLink()); - document.getElementById("btn-copy")?.addEventListener("click", () => copyLink()); + document.getElementById("btn-share-regia")?.addEventListener("click", () => + shareLink( + cfg.regiaShareUrl, + cfg.regiaShareTitle, + "Apri questo link per gestire il punteggio della diretta:" + ) + ); + document.getElementById("btn-copy-regia")?.addEventListener("click", () => copyLink(cfg.regiaShareUrl)); + document.getElementById("btn-share-live")?.addEventListener("click", () => + shareLink( + cfg.liveShareUrl, + cfg.liveShareTitle, + "Guarda la diretta su Match Live TV:" + ) + ); + document.getElementById("btn-copy-live")?.addEventListener("click", () => copyLink(cfg.liveShareUrl)); function bindPreviewStallRecovery() { if (!els.preview) return; diff --git a/backend/spec/requests/api/v1/sports_spec.rb b/backend/spec/requests/api/v1/sports_spec.rb index 1597d14..46bd964 100644 --- a/backend/spec/requests/api/v1/sports_spec.rb +++ b/backend/spec/requests/api/v1/sports_spec.rb @@ -14,6 +14,7 @@ RSpec.describe "Api::V1::Sports", type: :request do expect(body).to be_an(Array) pallavolo = body.find { |s| s["key"] == "pallavolo" } expect(pallavolo["board"]).to eq("volley") - expect(pallavolo["allowed_overlays"]).to include("volley", "timer", "none") + expect(pallavolo["allowed_overlays"]).to include("volley", "none") + expect(pallavolo["allowed_overlays"]).not_to include("timer") end end diff --git a/backend/spec/requests/public/regia_spec.rb b/backend/spec/requests/public/regia_spec.rb index cdf0823..0a160e6 100644 --- a/backend/spec/requests/public/regia_spec.rb +++ b/backend/spec/requests/public/regia_spec.rb @@ -11,7 +11,9 @@ RSpec.describe "Public regia", type: :request do token = Sessions::RegiaAccess.new(session).issue_token! get public_regia_path(token) expect(response).to have_http_status(:ok) + expect(response.body).to include("Condividi diretta") expect(response.body).to include("Condividi link regia") + expect(response.body).to include("/live/#{session.id}") expect(response.body).not_to include("Aruba") end diff --git a/native/android/app/build.gradle.kts b/native/android/app/build.gradle.kts index b52e663..1519c6f 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 = 17 + versionCode = 21 versionName = "2.0.0-native" val apiBaseUrl = project.findProperty("API_BASE_URL") as String? 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 f35c77e..3cc5cec 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 @@ -220,11 +220,24 @@ data class ScoreStateDto( timeoutHome = timeoutHome ?: false, timeoutAway = timeoutAway ?: false, boardType = boardType ?: "volley", - period = currentSet ?: 1, + period = parsePeriodNumber(period, currentSet), periodLabel = period, clockSecs = clockSecs ?: 0, clockRunning = clockRunning ?: false, ) + + private fun parsePeriodNumber(label: String?, currentSet: Int?): Int { + label?.let { text -> + Regex("""Q(\d+)""", RegexOption.IGNORE_CASE).find(text)?.groupValues?.get(1)?.toIntOrNull() + ?.let { return it } + Regex("""(\d+)\s*°""").find(text)?.groupValues?.get(1)?.toIntOrNull() + ?.let { return it } + Regex("""OT(\d+)""", RegexOption.IGNORE_CASE).find(text)?.groupValues?.get(1)?.toIntOrNull() + ?.let { return it } + text.toIntOrNull()?.takeIf { it > 0 }?.let { return it } + } + return currentSet?.coerceAtLeast(1) ?: 1 + } } data class ScoreActionRequest( @@ -238,6 +251,11 @@ data class ScoreSyncRequest( @Json(name = "away_points") val awayPoints: Int, @Json(name = "current_set") val currentSet: Int, @Json(name = "set_partials") val setPartials: List, + @Json(name = "clock_secs") val clockSecs: Int? = null, + @Json(name = "clock_running") val clockRunning: Boolean? = null, + val period: Int? = null, + @Json(name = "home_score") val homeScore: Int? = null, + @Json(name = "away_score") val awayScore: Int? = null, ) data class CreateSessionRequest( diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/ScoreRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/ScoreRepository.kt index e1eb2a3..151ec08 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/ScoreRepository.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/ScoreRepository.kt @@ -10,6 +10,7 @@ class ScoreRepository( private val api: MatchLiveApi, ) { suspend fun syncScore(sessionId: String, score: ScoreState): ScoreState? { + val periodBoard = score.boardType in PERIOD_BOARDS val response = api.syncScore( sessionId, ScoreSyncRequest( @@ -21,6 +22,11 @@ class ScoreRepository( setPartials = score.setPartials.map { SetPartialDto(set = it.set, home = it.home, away = it.away) }, + clockSecs = score.clockSecs.takeIf { periodBoard }, + clockRunning = score.clockRunning.takeIf { periodBoard }, + period = score.period.takeIf { periodBoard }, + homeScore = score.homePoints.takeIf { periodBoard }, + awayScore = score.awayPoints.takeIf { periodBoard }, ), ) return response.score?.toDomain() @@ -30,4 +36,8 @@ class ScoreRepository( val response = api.applyScoreAction(sessionId, ScoreActionRequest(action)) return response.score?.toDomain() } + + companion object { + private val PERIOD_BOARDS = setOf("basket", "timed") + } } 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 d118c71..7f864f9 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 @@ -11,7 +11,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -75,9 +74,26 @@ class ScoreController( } fun applyAction(action: String) { + applyOptimistic(action) scheduleAction(action) } + private fun applyOptimistic(action: String) { + _score.update { current -> + when (action) { + "home_point" -> current.copy(homePoints = current.homePoints + 1) + "away_point" -> current.copy(awayPoints = current.awayPoints + 1) + "home_point_2" -> current.copy(homePoints = current.homePoints + 2) + "away_point_2" -> current.copy(awayPoints = current.awayPoints + 2) + "home_point_3" -> current.copy(homePoints = current.homePoints + 3) + "away_point_3" -> current.copy(awayPoints = current.awayPoints + 3) + "home_undo" -> current.copy(homePoints = (current.homePoints - 1).coerceAtLeast(0)) + "away_undo" -> current.copy(awayPoints = (current.awayPoints - 1).coerceAtLeast(0)) + else -> current + } + } + } + private fun scheduleAction(action: String) { syncJob?.cancel() syncJob = scope.launch { diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/CompactScoreboardElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/CompactScoreboardElement.kt index 4cd8089..8de8e32 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/CompactScoreboardElement.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/CompactScoreboardElement.kt @@ -6,14 +6,17 @@ import android.graphics.Paint import android.graphics.RectF import android.graphics.Typeface import android.text.TextPaint +import android.text.TextUtils import kotlin.math.roundToInt -/** Tabellone compatto per basket, timed e sport a punteggio semplice. */ +/** Tabellone compatto basket/timed: squadre ai lati, punteggio al centro. */ class CompactScoreboardElement : OverlayElement { private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(210, 12, 12, 12) } + private val accentBarPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val logoPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) private val labelPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) @@ -21,6 +24,7 @@ class CompactScoreboardElement : OverlayElement { private val metaPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#AAAAAA") typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + textAlign = Paint.Align.CENTER } private val scorePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE @@ -33,29 +37,116 @@ class CompactScoreboardElement : OverlayElement { val kind = state.overlayKind if (kind !in setOf(OverlayKind.BASKET, OverlayKind.TIMED, OverlayKind.RACKET, OverlayKind.VOLLEY)) return - val w = (layout.canvasWidth * 0.42f).roundToInt().coerceAtLeast(220) - val h = (layout.canvasHeight * 0.11f).roundToInt().coerceAtLeast(72) + val scale = layout.canvasHeight / 720f + val pad = (10f * scale).roundToInt().coerceAtLeast(8) + val barW = (4f * scale).roundToInt().coerceAtLeast(3) + val logoSize = (28f * scale).roundToInt().coerceAtLeast(20) + val gap = (6f * scale).roundToInt().coerceAtLeast(4) + + val totalW = (layout.canvasWidth * 0.62f).roundToInt().coerceAtLeast(320) + val mainH = (layout.canvasHeight * 0.09f).roundToInt().coerceAtLeast(56) + val metaH = (layout.canvasHeight * 0.028f).roundToInt().coerceAtLeast(16) + val totalH = mainH + metaH + pad + val left = layout.marginPx.toFloat() val top = layout.marginPx.toFloat() - val rect = RectF(left, top, left + w, top + h) - canvas.drawRoundRect(rect, 12f, 12f, backgroundPaint) + val rect = RectF(left, top, left + totalW, top + totalH) + canvas.drawRoundRect(rect, 10f * scale, 10f * scale, backgroundPaint) - labelPaint.textSize = (h * 0.18f).coerceAtLeast(14f) - scorePaint.textSize = (h * 0.34f).coerceAtLeast(22f) - metaPaint.textSize = (h * 0.16f).coerceAtLeast(12f) + val centerX = rect.centerX() + val rowCenterY = top + pad + mainH * 0.58f + val sideMaxW = totalW * 0.34f + val scoreZoneHalf = totalW * 0.11f + + labelPaint.textSize = (mainH * 0.28f).coerceAtLeast(13f) + scorePaint.textSize = (mainH * 0.46f).coerceAtLeast(24f) + + drawTeamSide( + canvas = canvas, + compact = compact, + isHome = true, + rectLeft = rect.left + pad, + rectRight = centerX - scoreZoneHalf, + centerY = rowCenterY, + barW = barW, + logoSize = logoSize, + gap = gap, + maxWidth = sideMaxW, + ) + drawTeamSide( + canvas = canvas, + compact = compact, + isHome = false, + rectLeft = centerX + scoreZoneHalf, + rectRight = rect.right - pad, + centerY = rowCenterY, + barW = barW, + logoSize = logoSize, + gap = gap, + maxWidth = sideMaxW, + ) - val midY = rect.centerY() - canvas.drawText(compact.homeTeamName, rect.left + 16f, midY - 6f, labelPaint) - canvas.drawText(compact.awayTeamName, rect.left + 16f, midY + labelPaint.textSize + 4f, labelPaint) canvas.drawText( "${compact.homeScore} - ${compact.awayScore}", - rect.right - 56f, - midY + scorePaint.textSize * 0.35f, + centerX, + rowCenterY + scorePaint.textSize * 0.32f, scorePaint, ) - val meta = listOfNotNull(compact.periodLabel, compact.clockText).joinToString(" · ") - if (meta.isNotBlank()) { - canvas.drawText(meta, rect.left + 16f, rect.bottom - 10f, metaPaint) + + compact.periodLabel?.takeIf { it.isNotBlank() }?.let { period -> + metaPaint.textSize = (metaH * 0.72f).coerceAtLeast(11f) + canvas.drawText(period, centerX, rect.bottom - pad * 0.45f, metaPaint) } } + + private fun drawTeamSide( + canvas: Canvas, + compact: CompactScoreboardState, + isHome: Boolean, + rectLeft: Float, + rectRight: Float, + centerY: Float, + barW: Int, + logoSize: Int, + gap: Int, + maxWidth: Float, + ) { + val accentColor = if (isHome) compact.homeAccentColor else compact.awayAccentColor + val logoUrl = if (isHome) compact.homeLogoUrl else compact.awayLogoUrl + val logo = OverlayLogoCache.get(logoUrl) + val rawName = if (isHome) compact.homeTeamName else compact.awayTeamName + val name = ellipsize(rawName.trim(), labelPaint, maxWidth * 0.55f) + + if (isHome) { + var x = rectLeft + accentBarPaint.color = accentColor + canvas.drawRect(x, centerY - logoSize * 0.45f, x + barW, centerY + logoSize * 0.45f, accentBarPaint) + x += barW + gap + if (logo != null) { + val dest = RectF(x, centerY - logoSize / 2f, x + logoSize, centerY + logoSize / 2f) + canvas.drawBitmap(logo, null, dest, logoPaint) + x += logoSize + gap + } + canvas.drawText(name, x, centerY + labelPaint.textSize * 0.35f, labelPaint) + } else { + var x = rectRight + accentBarPaint.color = accentColor + canvas.drawRect(x - barW, centerY - logoSize * 0.45f, x, centerY + logoSize * 0.45f, accentBarPaint) + x -= barW + gap + labelPaint.textAlign = Paint.Align.RIGHT + canvas.drawText(name, x, centerY + labelPaint.textSize * 0.35f, labelPaint) + labelPaint.textAlign = Paint.Align.LEFT + x -= gap + if (logo != null) { + x -= logoSize + val dest = RectF(x, centerY - logoSize / 2f, x + logoSize, centerY + logoSize / 2f) + canvas.drawBitmap(logo, null, dest, logoPaint) + } + } + } + + private fun ellipsize(text: String, paint: TextPaint, maxWidth: Float): String { + val safeMax = maxWidth.coerceAtLeast(48f) + 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/streaming/overlay/OverlayCanvasRenderer.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt index fa4d41e..28ce5dc 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt @@ -16,7 +16,6 @@ class OverlayCanvasRenderer(context: Context) { WatermarkElement(context), ScoreboardElement(), CompactScoreboardElement(), - TimerElement(), SponsorElement(), ) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayRenderer.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayRenderer.kt index a5226cd..b01f070 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayRenderer.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayRenderer.kt @@ -1,6 +1,7 @@ package com.matchlivetv.match_live_tv.streaming.overlay import android.content.Context +import android.graphics.Bitmap import android.util.Log import com.pedro.encoder.input.gl.render.filters.`object`.ImageObjectFilterRender import com.pedro.encoder.utils.gl.TranslateTo @@ -21,6 +22,7 @@ class OverlayRenderer(context: Context) { private var attached = false private var lastFingerprint = Int.MIN_VALUE private var pendingState: OverlayState? = null + private var uploadedBitmap: Bitmap? = null fun attach(gl: GlInterface, width: Int, height: Int, isPortrait: Boolean = false) { if (attached && gl === glInterface && width == streamWidth && height == streamHeight) { @@ -72,6 +74,7 @@ class OverlayRenderer(context: Context) { glInterface = null attached = false lastFingerprint = Int.MIN_VALUE + recycleUploadedBitmap() canvasRenderer.release() } @@ -80,14 +83,24 @@ class OverlayRenderer(context: Context) { if (fp == lastFingerprint) return lastFingerprint = fp - val bmp = canvasRenderer.render(state, isPortraitContent) + val rendered = canvasRenderer.render(state, isPortraitContent) + val upload = rendered.copy(rendered.config ?: Bitmap.Config.ARGB_8888, false) filter?.let { overlayFilter -> overlayFilter.setDefaultScale(streamWidth, streamHeight) overlayFilter.setPosition(TranslateTo.TOP_LEFT) - overlayFilter.setImage(bmp) + overlayFilter.setImage(upload) + recycleUploadedBitmap() + uploadedBitmap = upload + } ?: run { + upload.recycle() } } + private fun recycleUploadedBitmap() { + uploadedBitmap?.recycle() + uploadedBitmap = null + } + companion object { private const val TAG = "OverlayRenderer" } 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 c7c1bbe..ff97334 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 @@ -27,14 +27,14 @@ data class CompactScoreboardState( val homeScore: Int, val awayScore: Int, val periodLabel: String? = null, - val clockText: String? = null, val homeAccentColor: Int = 0xFFFF2D2D.toInt(), val awayAccentColor: Int = 0xFF1E3A8A.toInt(), + val homeLogoUrl: String? = null, + val awayLogoUrl: String? = null, ) enum class OverlayKind { NONE, - TIMER, VOLLEY, BASKET, TIMED, @@ -43,8 +43,7 @@ enum class OverlayKind { companion object { fun fromApi(value: String?): OverlayKind = when (value?.lowercase()) { - "none" -> NONE - "timer" -> TIMER + "none", "timer" -> NONE "basket" -> BASKET "timed" -> TIMED "racket" -> RACKET @@ -66,14 +65,12 @@ data class OverlayState( val compactScoreboard: CompactScoreboardState? = null, val watermarkVisible: Boolean = true, val broadcastStatus: BroadcastOverlayStatus = BroadcastOverlayStatus.LIVE, - val timerText: String? = null, val sponsorText: String? = null, ) { fun fingerprint(): Int { var result = overlayKind.hashCode() result = 31 * result + watermarkVisible.hashCode() result = 31 * result + broadcastStatus.hashCode() - result = 31 * result + (timerText?.hashCode() ?: 0) result = 31 * result + (sponsorText?.hashCode() ?: 0) result = 31 * result + (scoreboard?.fingerprint() ?: 0) result = 31 * result + (compactScoreboard?.fingerprint() ?: 0) @@ -104,8 +101,11 @@ private fun CompactScoreboardState.fingerprint(): Int { result = 31 * result + awayTeamName.hashCode() result = 31 * result + homeScore result = 31 * result + awayScore + result = 31 * result + homeAccentColor + result = 31 * result + awayAccentColor result = 31 * result + (periodLabel?.hashCode() ?: 0) - result = 31 * result + (clockText?.hashCode() ?: 0) + result = 31 * result + logoLoadedKey(homeLogoUrl) + result = 31 * result + logoLoadedKey(awayLogoUrl) return result } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt deleted file mode 100644 index 294cc28..0000000 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.matchlivetv.match_live_tv.streaming.overlay - -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.Typeface - -/** Placeholder per cronometro / timeout (MVP: disegna solo se [OverlayState.timerText] è valorizzato). */ -class TimerElement : OverlayElement { - - private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.WHITE - typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) - } - - override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) { - if (state.overlayKind != OverlayKind.TIMER && state.timerText.isNullOrBlank()) return - val text = state.timerText?.takeIf { it.isNotBlank() } ?: return - paint.textSize = layout.canvasHeight * 0.05f - val x = layout.marginPx.toFloat() - val y = layout.canvasHeight - layout.marginPx.toFloat() - canvas.drawText(text, x, y, paint) - } -} 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 index 53a5b36..bd52792 100644 --- 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 @@ -16,10 +16,9 @@ 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.safeDrawing 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 @@ -28,7 +27,6 @@ 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.SkipNext -import androidx.compose.material.icons.filled.Timer import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Visibility @@ -65,7 +63,6 @@ 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.streaming.overlay.formatOverlayClock import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge import com.matchlivetv.match_live_tv.ui.theme.MatchColors @@ -100,7 +97,6 @@ fun BroadcastControlsOverlay( onPoint3Away: (() -> Unit)? = null, onCloseSet: (() -> Unit)? = null, onAdvancePeriod: (() -> Unit)? = null, - onClockToggle: (() -> Unit)? = null, onPauseOrResume: () -> Unit, onTerminate: () -> Unit, onShareLive: () -> Unit, @@ -139,21 +135,23 @@ fun BroadcastControlsOverlay( ) } - Box(modifier.fillMaxSize()) { + Box( + modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing), + ) { 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), @@ -205,14 +203,6 @@ fun BroadcastControlsOverlay( onClick = onAdvancePeriod, ) } - if (onClockToggle != null) { - SideIconButton( - icon = Icons.Default.Timer, - contentDescription = if (score.clockRunning) "Ferma cronometro" else "Avvia cronometro", - onClick = onClockToggle, - highlighted = score.clockRunning, - ) - } } Column( @@ -238,7 +228,6 @@ fun BroadcastControlsOverlay( Row( Modifier .align(Alignment.BottomCenter) - .windowInsetsPadding(WindowInsets.navigationBars) .padding(start = SideToolbarWidth, end = SideToolbarWidth, bottom = 8.dp), verticalAlignment = Alignment.Bottom, ) { @@ -403,18 +392,6 @@ private fun ScoreCenterPanel( style = MaterialTheme.typography.labelSmall, color = MatchColors.TextSecondary, ) - Text( - formatOverlayClock(score.clockSecs), - style = MaterialTheme.typography.labelMedium, - color = if (score.clockRunning) MatchColors.SuccessGreen else MatchColors.TextSecondary, - ) - } - "timer" -> { - Text( - formatOverlayClock(score.clockSecs, countUp = true), - style = MaterialTheme.typography.labelMedium, - color = if (score.clockRunning) MatchColors.SuccessGreen else MatchColors.TextSecondary, - ) } "generic" -> Unit else -> { 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 a7e78b6..52c970e 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 @@ -43,7 +43,6 @@ import com.matchlivetv.match_live_tv.streaming.overlay.CompactScoreboardState import com.matchlivetv.match_live_tv.streaming.overlay.OverlayKind 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.formatOverlayClock 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 @@ -72,6 +71,7 @@ fun BroadcastScreen( var pauseInFlight by remember { mutableStateOf(false) } var resumeInFlight by remember { mutableStateOf(false) } var controlsVisible by remember { mutableStateOf(true) } + var logoReady by remember { mutableStateOf(0) } var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) } val metrics by container.broadcastCoordinator.metrics.collectAsState() val score by container.scoreController.score.collectAsState() @@ -216,9 +216,10 @@ fun BroadcastScreen( ), bearerToken = authToken, ) + logoReady++ } - LaunchedEffect(score, match, metrics.phase) { + LaunchedEffect(score, match, metrics.phase, logoReady) { val currentMatch = match ?: return@LaunchedEffect val homeLogoUrl = resolveMediaUrl(currentMatch.homeLogoUrl) val awayLogoUrl = resolveMediaUrl(currentMatch.opponentLogoUrl) @@ -231,15 +232,6 @@ fun BroadcastScreen( watermarkVisible = false, broadcastStatus = metrics.phase.toOverlayStatus(), ) - OverlayKind.TIMER -> OverlayState( - overlayKind = overlayKind, - timerText = formatOverlayClock( - if (score.boardType == "timer") score.clockSecs else score.homePoints, - countUp = true, - ), - watermarkVisible = true, - broadcastStatus = metrics.phase.toOverlayStatus(), - ) OverlayKind.BASKET, OverlayKind.TIMED -> OverlayState( overlayKind = overlayKind, compactScoreboard = CompactScoreboardState( @@ -249,11 +241,12 @@ fun BroadcastScreen( awayScore = score.awayPoints, periodLabel = score.periodLabel ?: when (overlayKind) { OverlayKind.BASKET -> "Q${score.period}" - else -> "${score.period}°" + else -> "${score.period}° tempo" }, - clockText = formatOverlayClock(score.clockSecs), homeAccentColor = homeColor, awayAccentColor = awayColor, + homeLogoUrl = homeLogoUrl, + awayLogoUrl = awayLogoUrl, ), watermarkVisible = true, broadcastStatus = metrics.phase.toOverlayStatus(), @@ -293,16 +286,6 @@ fun BroadcastScreen( } } - LaunchedEffect(sessionId) { - while (true) { - delay(1_000) - val current = container.scoreController.score.value - if (current.clockRunning && current.boardType in setOf("basket", "timed", "timer")) { - container.scoreController.applyAction("clock_tick") - } - } - } - LaunchedEffect(sessionId) { while (true) { delay(4_000) @@ -364,7 +347,7 @@ fun BroadcastScreen( val currentSession = session val currentMatch = match val boardType = currentMatch?.boardType?.ifBlank { score.boardType } ?: score.boardType - val usesActionScoring = boardType in setOf("basket", "timed", "timer") + val usesActionScoring = boardType in setOf("basket", "timed") fun applyScoreAction(action: String) { container.scoreController.applyAction(action) @@ -427,7 +410,12 @@ fun BroadcastScreen( } if (!loading && permissions.granted && currentMatch != null && scoreActions != null) { - key(score.progressKey()) { + val controlsKey = if (boardType in setOf("basket", "timed")) { + currentMatch.id + } else { + score.progressKey() + } + key(controlsKey) { BroadcastControlsOverlay( controlsVisible = controlsVisible, onToggleControls = { controlsVisible = !controlsVisible }, @@ -508,11 +496,6 @@ fun BroadcastScreen( } else { null }, - onClockToggle = if (boardType in setOf("basket", "timed", "timer")) { - { applyScoreAction("clock_toggle") } - } else { - null - }, onPauseOrResume = { scope.launch { if (isPaused) resumeStream() else pauseStream() 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 44294bc..84fddd0 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 @@ -11,8 +11,16 @@ 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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Sports +import androidx.compose.material.icons.filled.SportsBasketball +import androidx.compose.material.icons.filled.SportsSoccer +import androidx.compose.material.icons.filled.SportsTennis +import androidx.compose.material.icons.filled.SportsVolleyball +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -20,6 +28,7 @@ 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.unit.dp import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton @@ -56,6 +65,30 @@ fun WizardStepIndicator(currentStep: Int, modifier: Modifier = Modifier) { fun wizardStepTitle(step: Int): String = stepTitles[(step.coerceIn(1, 3) - 1)] +private fun sportWizardIcon(sportKey: String, boardType: String): ImageVector = when { + sportKey.contains("volley") || boardType == "volley" || boardType == "racket" -> + Icons.Default.SportsVolleyball + sportKey == "basket" || boardType == "basket" -> Icons.Default.SportsBasketball + sportKey.contains("calcio") || boardType == "timed" -> Icons.Default.SportsSoccer + sportKey.contains("tennis") || sportKey.contains("padel") -> Icons.Default.SportsTennis + else -> Icons.Default.Sports +} + +@Composable +fun SportWizardBadge( + sportKey: String, + boardType: String, + sportLabel: String?, + modifier: Modifier = Modifier, +) { + Icon( + imageVector = sportWizardIcon(sportKey, boardType), + contentDescription = sportLabel ?: sportKey, + tint = MatchColors.TextSecondary, + modifier = modifier.size(18.dp), + ) +} + @Composable fun WizardChoiceButton( label: String,