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 <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 23:31:12 +02:00
parent 34c97f4447
commit 2b7e8b9e36
23 changed files with 341 additions and 167 deletions

View File

@@ -167,6 +167,7 @@ module Api
def score_sync_params def score_sync_params
params.permit( params.permit(
:home_sets, :away_sets, :home_points, :away_points, :current_set, :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] set_partials: %i[set home away]
) )
end end

View File

@@ -18,8 +18,10 @@ module Public
@rules = Scoring::Rules.from_match(@match) @rules = Scoring::Rules.from_match(@match)
@stream_closed = @session.terminal? @stream_closed = @session.terminal?
@on_air = !@stream_closed && mediamtx_stream_playable?(@session) @on_air = !@stream_closed && mediamtx_stream_playable?(@session)
@share_url = request.original_url @regia_share_url = request.original_url
@share_title = "Regia — #{@team.name} vs #{@match.opponent_name}" @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 end
def status def status

View File

@@ -2,7 +2,7 @@
module Sports module Sports
class OverlayKind class OverlayKind
ALL = %w[none timer volley basket timed racket].freeze ALL = %w[none volley basket timed racket].freeze
def self.valid?(value) def self.valid?(value)
ALL.include?(value.to_s) ALL.include?(value.to_s)

View File

@@ -99,6 +99,7 @@ class Match < ApplicationRecord
def effective_overlay_kind def effective_overlay_kind
kind = overlay_kind.presence || Sports::Catalog.overlay_for(sport_key) kind = overlay_kind.presence || Sports::Catalog.overlay_for(sport_key)
kind = "none" if kind == "timer"
allowed = Sports::Catalog.allowed_overlays_for(sport_key) allowed = Sports::Catalog.allowed_overlays_for(sport_key)
allowed.include?(kind) ? kind : Sports::Catalog.overlay_for(sport_key) allowed.include?(kind) ? kind : Sports::Catalog.overlay_for(sport_key)
end end

View File

@@ -35,7 +35,7 @@ module Sessions
StreamSession.transaction do StreamSession.transaction do
session.save! session.save!
session.create_score_state! Scoring::Engine.ensure_score_for(session)
mtx = Mediamtx::Client.new mtx = Mediamtx::Client.new
mtx.create_path(session) mtx.create_path(session)
log_event(session, "pairing", { created: true, platform: session.platform }) log_event(session, "pairing", { created: true, platform: session.platform })

View File

@@ -19,8 +19,10 @@
data-hls-url="<%= j @session.hls_playback_url %>" data-hls-url="<%= j @session.hls_playback_url %>"
data-home-name="<%= j @team.name %>" data-home-name="<%= j @team.name %>"
data-away-name="<%= j @match.opponent_name %>" data-away-name="<%= j @match.opponent_name %>"
data-share-url="<%= j @share_url %>" data-regia-share-url="<%= j @regia_share_url %>"
data-share-title="<%= j @share_title %>" 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-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-stream-closed="<%= @stream_closed %>"
data-initial-paused="<%= @session.paused? %>"> data-initial-paused="<%= @session.paused? %>">
@@ -33,13 +35,30 @@
</span> </span>
</header> </header>
<div class="regia-share"> <% if @live_share_url.present? %>
<button type="button" class="regia-btn regia-btn--outline" id="btn-share" style="margin:0"> <div class="regia-share-group">
Condividi link regia <p class="regia-share-label">Link diretta (spettatori)</p>
</button> <div class="regia-share">
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy" style="margin:0"> <button type="button" class="regia-btn regia-btn--outline" id="btn-share-live" style="margin:0">
Copia link Condividi diretta
</button> </button>
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-live" style="margin:0">
Copia link
</button>
</div>
</div>
<% end %>
<div class="regia-share-group">
<p class="regia-share-label">Link regia (punteggio)</p>
<div class="regia-share">
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-regia" style="margin:0">
Condividi link regia
</button>
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-regia" style="margin:0">
Copia link
</button>
</div>
</div> </div>
<div class="regia-preview"> <div class="regia-preview">
@@ -74,4 +93,4 @@
</div> </div>
</div> </div>
<script src="/regia.js?v=5"></script> <script src="/regia.js?v=6"></script>

View File

@@ -1,12 +1,12 @@
# Catalogo sport Match Live TV — board e overlay impliciti, regole standard. # Catalogo sport Match Live TV — board e overlay impliciti, regole standard.
# board: volley | basket | timed | racket | timer | generic # board: volley | basket | timed | racket | timer | generic
# overlay: none | timer | volley | basket | timed | racket # overlay: none | volley | basket | timed | racket
pallavolo: pallavolo:
label: Pallavolo label: Pallavolo
board: volley board: volley
overlay: volley overlay: volley
allowed_overlays: [volley, timer, none] allowed_overlays: [volley, none]
default_rules: default_rules:
sets_to_win: 3 sets_to_win: 3
points_per_set: 25 points_per_set: 25
@@ -17,7 +17,7 @@ beach_volley:
label: Beach Volley label: Beach Volley
board: volley board: volley
overlay: volley overlay: volley
allowed_overlays: [volley, timer, none] allowed_overlays: [volley, none]
default_rules: default_rules:
sets_to_win: 2 sets_to_win: 2
points_per_set: 21 points_per_set: 21
@@ -28,7 +28,7 @@ sitting_volley:
label: Sitting Volley label: Sitting Volley
board: volley board: volley
overlay: volley overlay: volley
allowed_overlays: [volley, timer, none] allowed_overlays: [volley, none]
default_rules: default_rules:
sets_to_win: 3 sets_to_win: 3
points_per_set: 25 points_per_set: 25
@@ -39,7 +39,7 @@ basket:
label: Basket label: Basket
board: basket board: basket
overlay: basket overlay: basket
allowed_overlays: [basket, timer, none] allowed_overlays: [basket, none]
default_rules: default_rules:
periods: 4 periods: 4
period_duration_secs: 600 period_duration_secs: 600
@@ -49,7 +49,7 @@ calcio_a_5:
label: Calcio a 5 label: Calcio a 5
board: timed board: timed
overlay: timed overlay: timed
allowed_overlays: [timed, timer, none] allowed_overlays: [timed, none]
default_rules: default_rules:
periods: 2 periods: 2
period_duration_secs: 1200 period_duration_secs: 1200
@@ -59,7 +59,7 @@ pallamano:
label: Pallamano label: Pallamano
board: timed board: timed
overlay: timed overlay: timed
allowed_overlays: [timed, timer, none] allowed_overlays: [timed, none]
default_rules: default_rules:
periods: 2 periods: 2
period_duration_secs: 1800 period_duration_secs: 1800
@@ -69,7 +69,7 @@ hockey_indoor:
label: Hockey Indoor label: Hockey Indoor
board: timed board: timed
overlay: timed overlay: timed
allowed_overlays: [timed, timer, none] allowed_overlays: [timed, none]
default_rules: default_rules:
periods: 2 periods: 2
period_duration_secs: 1200 period_duration_secs: 1200
@@ -79,7 +79,7 @@ floorball:
label: Floorball label: Floorball
board: timed board: timed
overlay: timed overlay: timed
allowed_overlays: [timed, timer, none] allowed_overlays: [timed, none]
default_rules: default_rules:
periods: 3 periods: 3
period_duration_secs: 1200 period_duration_secs: 1200
@@ -89,7 +89,7 @@ tennis:
label: Tennis label: Tennis
board: racket board: racket
overlay: racket overlay: racket
allowed_overlays: [racket, timer, none] allowed_overlays: [racket, none]
default_rules: default_rules:
sets_to_win: 2 sets_to_win: 2
points_per_set: 6 points_per_set: 6
@@ -100,7 +100,7 @@ tennis_tavolo:
label: Tennis Tavolo label: Tennis Tavolo
board: racket board: racket
overlay: racket overlay: racket
allowed_overlays: [racket, timer, none] allowed_overlays: [racket, none]
default_rules: default_rules:
sets_to_win: 3 sets_to_win: 3
points_per_set: 11 points_per_set: 11
@@ -111,7 +111,7 @@ badminton:
label: Badminton label: Badminton
board: racket board: racket
overlay: racket overlay: racket
allowed_overlays: [racket, timer, none] allowed_overlays: [racket, none]
default_rules: default_rules:
sets_to_win: 2 sets_to_win: 2
points_per_set: 21 points_per_set: 21
@@ -122,7 +122,7 @@ padel:
label: Padel label: Padel
board: racket board: racket
overlay: racket overlay: racket
allowed_overlays: [racket, timer, none] allowed_overlays: [racket, none]
default_rules: default_rules:
sets_to_win: 2 sets_to_win: 2
points_per_set: 6 points_per_set: 6
@@ -133,7 +133,7 @@ pickleball:
label: Pickleball label: Pickleball
board: racket board: racket
overlay: racket overlay: racket
allowed_overlays: [racket, timer, none] allowed_overlays: [racket, none]
default_rules: default_rules:
sets_to_win: 2 sets_to_win: 2
points_per_set: 11 points_per_set: 11
@@ -143,41 +143,41 @@ pickleball:
ginnastica_artistica: ginnastica_artistica:
label: Ginnastica Artistica label: Ginnastica Artistica
board: timer board: timer
overlay: timer overlay: none
allowed_overlays: [timer, none] allowed_overlays: [none]
default_rules: {} default_rules: {}
ginnastica_ritmica: ginnastica_ritmica:
label: Ginnastica Ritmica label: Ginnastica Ritmica
board: timer board: timer
overlay: timer overlay: none
allowed_overlays: [timer, none] allowed_overlays: [none]
default_rules: {} default_rules: {}
danza_sportiva: danza_sportiva:
label: Danza Sportiva label: Danza Sportiva
board: timer board: timer
overlay: timer overlay: none
allowed_overlays: [timer, none] allowed_overlays: [none]
default_rules: {} default_rules: {}
cheerleading: cheerleading:
label: Cheerleading label: Cheerleading
board: timer board: timer
overlay: timer overlay: none
allowed_overlays: [timer, none] allowed_overlays: [none]
default_rules: {} default_rules: {}
pattinaggio: pattinaggio:
label: Pattinaggio label: Pattinaggio
board: timer board: timer
overlay: timer overlay: none
allowed_overlays: [timer, none] allowed_overlays: [none]
default_rules: {} default_rules: {}
altro: altro:
label: Altro label: Altro
board: generic board: generic
overlay: none overlay: none
allowed_overlays: [none, timer] allowed_overlays: [none]
default_rules: {} default_rules: {}

View File

@@ -173,10 +173,22 @@
margin-top: 8px; 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 { .regia-share {
display: flex; display: flex;
gap: 8px; gap: 8px;
margin: 12px 0;
} }
.regia-share .regia-btn { flex: 1; font-size: 0.9rem; padding: 12px; } .regia-share .regia-btn { flex: 1; font-size: 0.9rem; padding: 12px; }

View File

@@ -14,8 +14,10 @@
hlsUrl: root.dataset.hlsUrl, hlsUrl: root.dataset.hlsUrl,
homeName: root.dataset.homeName, homeName: root.dataset.homeName,
awayName: root.dataset.awayName, awayName: root.dataset.awayName,
shareUrl: root.dataset.shareUrl, regiaShareUrl: root.dataset.regiaShareUrl,
shareTitle: root.dataset.shareTitle, regiaShareTitle: root.dataset.regiaShareTitle,
liveShareUrl: root.dataset.liveShareUrl,
liveShareTitle: root.dataset.liveShareTitle,
streamClosed: root.dataset.streamClosed === "true" streamClosed: root.dataset.streamClosed === "true"
}; };
@@ -360,12 +362,12 @@
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message))); els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message))); document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
async function shareLink() { async function shareLink(url, title, text) {
const payload = { if (!url) {
title: cfg.shareTitle, toast("Link non disponibile");
text: "Apri questo link per gestire il punteggio della diretta:", return;
url: cfg.shareUrl }
}; const payload = { title, text, url };
if (navigator.share) { if (navigator.share) {
try { try {
await navigator.share(payload); await navigator.share(payload);
@@ -374,20 +376,38 @@
if (err.name === "AbortError") return; 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 { try {
await navigator.clipboard.writeText(cfg.shareUrl); await navigator.clipboard.writeText(url);
toast("Link copiato"); toast("Link copiato");
} catch (_) { } catch (_) {
prompt("Copia il link:", cfg.shareUrl); prompt("Copia il link:", url);
} }
} }
document.getElementById("btn-share")?.addEventListener("click", () => shareLink()); document.getElementById("btn-share-regia")?.addEventListener("click", () =>
document.getElementById("btn-copy")?.addEventListener("click", () => copyLink()); 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() { function bindPreviewStallRecovery() {
if (!els.preview) return; if (!els.preview) return;

View File

@@ -14,6 +14,7 @@ RSpec.describe "Api::V1::Sports", type: :request do
expect(body).to be_an(Array) expect(body).to be_an(Array)
pallavolo = body.find { |s| s["key"] == "pallavolo" } pallavolo = body.find { |s| s["key"] == "pallavolo" }
expect(pallavolo["board"]).to eq("volley") 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
end end

View File

@@ -11,7 +11,9 @@ RSpec.describe "Public regia", type: :request do
token = Sessions::RegiaAccess.new(session).issue_token! token = Sessions::RegiaAccess.new(session).issue_token!
get public_regia_path(token) get public_regia_path(token)
expect(response).to have_http_status(:ok) 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("Condividi link regia")
expect(response.body).to include("/live/#{session.id}")
expect(response.body).not_to include("Aruba") expect(response.body).not_to include("Aruba")
end end

View File

@@ -12,7 +12,7 @@ android {
applicationId = "com.matchlivetv.match_live_tv" applicationId = "com.matchlivetv.match_live_tv"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 17 versionCode = 21
versionName = "2.0.0-native" versionName = "2.0.0-native"
val apiBaseUrl = project.findProperty("API_BASE_URL") as String? val apiBaseUrl = project.findProperty("API_BASE_URL") as String?

View File

@@ -220,11 +220,24 @@ data class ScoreStateDto(
timeoutHome = timeoutHome ?: false, timeoutHome = timeoutHome ?: false,
timeoutAway = timeoutAway ?: false, timeoutAway = timeoutAway ?: false,
boardType = boardType ?: "volley", boardType = boardType ?: "volley",
period = currentSet ?: 1, period = parsePeriodNumber(period, currentSet),
periodLabel = period, periodLabel = period,
clockSecs = clockSecs ?: 0, clockSecs = clockSecs ?: 0,
clockRunning = clockRunning ?: false, 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( data class ScoreActionRequest(
@@ -238,6 +251,11 @@ data class ScoreSyncRequest(
@Json(name = "away_points") val awayPoints: Int, @Json(name = "away_points") val awayPoints: Int,
@Json(name = "current_set") val currentSet: Int, @Json(name = "current_set") val currentSet: Int,
@Json(name = "set_partials") val setPartials: List<SetPartialDto>, @Json(name = "set_partials") val setPartials: List<SetPartialDto>,
@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( data class CreateSessionRequest(

View File

@@ -10,6 +10,7 @@ class ScoreRepository(
private val api: MatchLiveApi, private val api: MatchLiveApi,
) { ) {
suspend fun syncScore(sessionId: String, score: ScoreState): ScoreState? { suspend fun syncScore(sessionId: String, score: ScoreState): ScoreState? {
val periodBoard = score.boardType in PERIOD_BOARDS
val response = api.syncScore( val response = api.syncScore(
sessionId, sessionId,
ScoreSyncRequest( ScoreSyncRequest(
@@ -21,6 +22,11 @@ class ScoreRepository(
setPartials = score.setPartials.map { setPartials = score.setPartials.map {
SetPartialDto(set = it.set, home = it.home, away = it.away) 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() return response.score?.toDomain()
@@ -30,4 +36,8 @@ class ScoreRepository(
val response = api.applyScoreAction(sessionId, ScoreActionRequest(action)) val response = api.applyScoreAction(sessionId, ScoreActionRequest(action))
return response.score?.toDomain() return response.score?.toDomain()
} }
companion object {
private val PERIOD_BOARDS = setOf("basket", "timed")
}
} }

View File

@@ -11,7 +11,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -75,9 +74,26 @@ class ScoreController(
} }
fun applyAction(action: String) { fun applyAction(action: String) {
applyOptimistic(action)
scheduleAction(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) { private fun scheduleAction(action: String) {
syncJob?.cancel() syncJob?.cancel()
syncJob = scope.launch { syncJob = scope.launch {

View File

@@ -6,14 +6,17 @@ import android.graphics.Paint
import android.graphics.RectF import android.graphics.RectF
import android.graphics.Typeface import android.graphics.Typeface
import android.text.TextPaint import android.text.TextPaint
import android.text.TextUtils
import kotlin.math.roundToInt 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 { class CompactScoreboardElement : OverlayElement {
private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(210, 12, 12, 12) 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 { private val labelPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE color = Color.WHITE
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
@@ -21,6 +24,7 @@ class CompactScoreboardElement : OverlayElement {
private val metaPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { private val metaPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#AAAAAA") color = Color.parseColor("#AAAAAA")
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL)
textAlign = Paint.Align.CENTER
} }
private val scorePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { private val scorePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE color = Color.WHITE
@@ -33,29 +37,116 @@ class CompactScoreboardElement : OverlayElement {
val kind = state.overlayKind val kind = state.overlayKind
if (kind !in setOf(OverlayKind.BASKET, OverlayKind.TIMED, OverlayKind.RACKET, OverlayKind.VOLLEY)) return if (kind !in setOf(OverlayKind.BASKET, OverlayKind.TIMED, OverlayKind.RACKET, OverlayKind.VOLLEY)) return
val w = (layout.canvasWidth * 0.42f).roundToInt().coerceAtLeast(220) val scale = layout.canvasHeight / 720f
val h = (layout.canvasHeight * 0.11f).roundToInt().coerceAtLeast(72) 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 left = layout.marginPx.toFloat()
val top = layout.marginPx.toFloat() val top = layout.marginPx.toFloat()
val rect = RectF(left, top, left + w, top + h) val rect = RectF(left, top, left + totalW, top + totalH)
canvas.drawRoundRect(rect, 12f, 12f, backgroundPaint) canvas.drawRoundRect(rect, 10f * scale, 10f * scale, backgroundPaint)
labelPaint.textSize = (h * 0.18f).coerceAtLeast(14f) val centerX = rect.centerX()
scorePaint.textSize = (h * 0.34f).coerceAtLeast(22f) val rowCenterY = top + pad + mainH * 0.58f
metaPaint.textSize = (h * 0.16f).coerceAtLeast(12f) 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( canvas.drawText(
"${compact.homeScore} - ${compact.awayScore}", "${compact.homeScore} - ${compact.awayScore}",
rect.right - 56f, centerX,
midY + scorePaint.textSize * 0.35f, rowCenterY + scorePaint.textSize * 0.32f,
scorePaint, scorePaint,
) )
val meta = listOfNotNull(compact.periodLabel, compact.clockText).joinToString(" · ")
if (meta.isNotBlank()) { compact.periodLabel?.takeIf { it.isNotBlank() }?.let { period ->
canvas.drawText(meta, rect.left + 16f, rect.bottom - 10f, metaPaint) 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
}
} }

View File

@@ -16,7 +16,6 @@ class OverlayCanvasRenderer(context: Context) {
WatermarkElement(context), WatermarkElement(context),
ScoreboardElement(), ScoreboardElement(),
CompactScoreboardElement(), CompactScoreboardElement(),
TimerElement(),
SponsorElement(), SponsorElement(),
) )

View File

@@ -1,6 +1,7 @@
package com.matchlivetv.match_live_tv.streaming.overlay package com.matchlivetv.match_live_tv.streaming.overlay
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.util.Log import android.util.Log
import com.pedro.encoder.input.gl.render.filters.`object`.ImageObjectFilterRender import com.pedro.encoder.input.gl.render.filters.`object`.ImageObjectFilterRender
import com.pedro.encoder.utils.gl.TranslateTo import com.pedro.encoder.utils.gl.TranslateTo
@@ -21,6 +22,7 @@ class OverlayRenderer(context: Context) {
private var attached = false private var attached = false
private var lastFingerprint = Int.MIN_VALUE private var lastFingerprint = Int.MIN_VALUE
private var pendingState: OverlayState? = null private var pendingState: OverlayState? = null
private var uploadedBitmap: Bitmap? = null
fun attach(gl: GlInterface, width: Int, height: Int, isPortrait: Boolean = false) { fun attach(gl: GlInterface, width: Int, height: Int, isPortrait: Boolean = false) {
if (attached && gl === glInterface && width == streamWidth && height == streamHeight) { if (attached && gl === glInterface && width == streamWidth && height == streamHeight) {
@@ -72,6 +74,7 @@ class OverlayRenderer(context: Context) {
glInterface = null glInterface = null
attached = false attached = false
lastFingerprint = Int.MIN_VALUE lastFingerprint = Int.MIN_VALUE
recycleUploadedBitmap()
canvasRenderer.release() canvasRenderer.release()
} }
@@ -80,14 +83,24 @@ class OverlayRenderer(context: Context) {
if (fp == lastFingerprint) return if (fp == lastFingerprint) return
lastFingerprint = fp 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 -> filter?.let { overlayFilter ->
overlayFilter.setDefaultScale(streamWidth, streamHeight) overlayFilter.setDefaultScale(streamWidth, streamHeight)
overlayFilter.setPosition(TranslateTo.TOP_LEFT) 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 { companion object {
private const val TAG = "OverlayRenderer" private const val TAG = "OverlayRenderer"
} }

View File

@@ -27,14 +27,14 @@ data class CompactScoreboardState(
val homeScore: Int, val homeScore: Int,
val awayScore: Int, val awayScore: Int,
val periodLabel: String? = null, val periodLabel: String? = null,
val clockText: String? = null,
val homeAccentColor: Int = 0xFFFF2D2D.toInt(), val homeAccentColor: Int = 0xFFFF2D2D.toInt(),
val awayAccentColor: Int = 0xFF1E3A8A.toInt(), val awayAccentColor: Int = 0xFF1E3A8A.toInt(),
val homeLogoUrl: String? = null,
val awayLogoUrl: String? = null,
) )
enum class OverlayKind { enum class OverlayKind {
NONE, NONE,
TIMER,
VOLLEY, VOLLEY,
BASKET, BASKET,
TIMED, TIMED,
@@ -43,8 +43,7 @@ enum class OverlayKind {
companion object { companion object {
fun fromApi(value: String?): OverlayKind = when (value?.lowercase()) { fun fromApi(value: String?): OverlayKind = when (value?.lowercase()) {
"none" -> NONE "none", "timer" -> NONE
"timer" -> TIMER
"basket" -> BASKET "basket" -> BASKET
"timed" -> TIMED "timed" -> TIMED
"racket" -> RACKET "racket" -> RACKET
@@ -66,14 +65,12 @@ data class OverlayState(
val compactScoreboard: CompactScoreboardState? = null, val compactScoreboard: CompactScoreboardState? = null,
val watermarkVisible: Boolean = true, val watermarkVisible: Boolean = true,
val broadcastStatus: BroadcastOverlayStatus = BroadcastOverlayStatus.LIVE, val broadcastStatus: BroadcastOverlayStatus = BroadcastOverlayStatus.LIVE,
val timerText: String? = null,
val sponsorText: String? = null, val sponsorText: String? = null,
) { ) {
fun fingerprint(): Int { fun fingerprint(): Int {
var result = overlayKind.hashCode() var result = overlayKind.hashCode()
result = 31 * result + watermarkVisible.hashCode() result = 31 * result + watermarkVisible.hashCode()
result = 31 * result + broadcastStatus.hashCode() result = 31 * result + broadcastStatus.hashCode()
result = 31 * result + (timerText?.hashCode() ?: 0)
result = 31 * result + (sponsorText?.hashCode() ?: 0) result = 31 * result + (sponsorText?.hashCode() ?: 0)
result = 31 * result + (scoreboard?.fingerprint() ?: 0) result = 31 * result + (scoreboard?.fingerprint() ?: 0)
result = 31 * result + (compactScoreboard?.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 + awayTeamName.hashCode()
result = 31 * result + homeScore result = 31 * result + homeScore
result = 31 * result + awayScore result = 31 * result + awayScore
result = 31 * result + homeAccentColor
result = 31 * result + awayAccentColor
result = 31 * result + (periodLabel?.hashCode() ?: 0) result = 31 * result + (periodLabel?.hashCode() ?: 0)
result = 31 * result + (clockText?.hashCode() ?: 0) result = 31 * result + logoLoadedKey(homeLogoUrl)
result = 31 * result + logoLoadedKey(awayLogoUrl)
return result return result
} }

View File

@@ -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)
}
}

View File

@@ -16,10 +16,9 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape 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.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext 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.Share
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Visibility 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.DeviceHealthSnapshot
import com.matchlivetv.match_live_tv.core.ThermalLevel import com.matchlivetv.match_live_tv.core.ThermalLevel
import com.matchlivetv.match_live_tv.domain.ScoreState 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.components.MatchStatusBadge
import com.matchlivetv.match_live_tv.ui.theme.MatchColors import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@@ -100,7 +97,6 @@ fun BroadcastControlsOverlay(
onPoint3Away: (() -> Unit)? = null, onPoint3Away: (() -> Unit)? = null,
onCloseSet: (() -> Unit)? = null, onCloseSet: (() -> Unit)? = null,
onAdvancePeriod: (() -> Unit)? = null, onAdvancePeriod: (() -> Unit)? = null,
onClockToggle: (() -> Unit)? = null,
onPauseOrResume: () -> Unit, onPauseOrResume: () -> Unit,
onTerminate: () -> Unit, onTerminate: () -> Unit,
onShareLive: () -> Unit, onShareLive: () -> Unit,
@@ -139,21 +135,23 @@ fun BroadcastControlsOverlay(
) )
} }
Box(modifier.fillMaxSize()) { Box(
modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
MatchStatusBadge( MatchStatusBadge(
text = statusText, text = statusText,
textColor = statusColor, textColor = statusColor,
backgroundColor = MatchColors.Background.copy(alpha = 0.78f), backgroundColor = MatchColors.Background.copy(alpha = 0.78f),
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(start = 8.dp, top = 4.dp), .padding(start = 8.dp, top = 4.dp),
) )
Column( Column(
Modifier Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 4.dp, end = 8.dp), .padding(top = 4.dp, end = 8.dp),
horizontalAlignment = Alignment.End, horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
@@ -205,14 +203,6 @@ fun BroadcastControlsOverlay(
onClick = onAdvancePeriod, 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( Column(
@@ -238,7 +228,6 @@ fun BroadcastControlsOverlay(
Row( Row(
Modifier Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(start = SideToolbarWidth, end = SideToolbarWidth, bottom = 8.dp), .padding(start = SideToolbarWidth, end = SideToolbarWidth, bottom = 8.dp),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
) { ) {
@@ -403,18 +392,6 @@ private fun ScoreCenterPanel(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MatchColors.TextSecondary, 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 "generic" -> Unit
else -> { else -> {

View File

@@ -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.OverlayKind
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayLogoCache import com.matchlivetv.match_live_tv.streaming.overlay.OverlayLogoCache
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayState import com.matchlivetv.match_live_tv.streaming.overlay.OverlayState
import com.matchlivetv.match_live_tv.streaming.overlay.formatOverlayClock
import com.matchlivetv.match_live_tv.streaming.overlay.toOverlayStatus import com.matchlivetv.match_live_tv.streaming.overlay.toOverlayStatus
import com.matchlivetv.match_live_tv.streaming.overlay.toScoreboardState import com.matchlivetv.match_live_tv.streaming.overlay.toScoreboardState
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
@@ -72,6 +71,7 @@ fun BroadcastScreen(
var pauseInFlight by remember { mutableStateOf(false) } var pauseInFlight by remember { mutableStateOf(false) }
var resumeInFlight by remember { mutableStateOf(false) } var resumeInFlight by remember { mutableStateOf(false) }
var controlsVisible by remember { mutableStateOf(true) } var controlsVisible by remember { mutableStateOf(true) }
var logoReady by remember { mutableStateOf(0) }
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) } var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) }
val metrics by container.broadcastCoordinator.metrics.collectAsState() val metrics by container.broadcastCoordinator.metrics.collectAsState()
val score by container.scoreController.score.collectAsState() val score by container.scoreController.score.collectAsState()
@@ -216,9 +216,10 @@ fun BroadcastScreen(
), ),
bearerToken = authToken, bearerToken = authToken,
) )
logoReady++
} }
LaunchedEffect(score, match, metrics.phase) { LaunchedEffect(score, match, metrics.phase, logoReady) {
val currentMatch = match ?: return@LaunchedEffect val currentMatch = match ?: return@LaunchedEffect
val homeLogoUrl = resolveMediaUrl(currentMatch.homeLogoUrl) val homeLogoUrl = resolveMediaUrl(currentMatch.homeLogoUrl)
val awayLogoUrl = resolveMediaUrl(currentMatch.opponentLogoUrl) val awayLogoUrl = resolveMediaUrl(currentMatch.opponentLogoUrl)
@@ -231,15 +232,6 @@ fun BroadcastScreen(
watermarkVisible = false, watermarkVisible = false,
broadcastStatus = metrics.phase.toOverlayStatus(), 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.BASKET, OverlayKind.TIMED -> OverlayState(
overlayKind = overlayKind, overlayKind = overlayKind,
compactScoreboard = CompactScoreboardState( compactScoreboard = CompactScoreboardState(
@@ -249,11 +241,12 @@ fun BroadcastScreen(
awayScore = score.awayPoints, awayScore = score.awayPoints,
periodLabel = score.periodLabel ?: when (overlayKind) { periodLabel = score.periodLabel ?: when (overlayKind) {
OverlayKind.BASKET -> "Q${score.period}" OverlayKind.BASKET -> "Q${score.period}"
else -> "${score.period}°" else -> "${score.period}° tempo"
}, },
clockText = formatOverlayClock(score.clockSecs),
homeAccentColor = homeColor, homeAccentColor = homeColor,
awayAccentColor = awayColor, awayAccentColor = awayColor,
homeLogoUrl = homeLogoUrl,
awayLogoUrl = awayLogoUrl,
), ),
watermarkVisible = true, watermarkVisible = true,
broadcastStatus = metrics.phase.toOverlayStatus(), 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) { LaunchedEffect(sessionId) {
while (true) { while (true) {
delay(4_000) delay(4_000)
@@ -364,7 +347,7 @@ fun BroadcastScreen(
val currentSession = session val currentSession = session
val currentMatch = match val currentMatch = match
val boardType = currentMatch?.boardType?.ifBlank { score.boardType } ?: score.boardType 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) { fun applyScoreAction(action: String) {
container.scoreController.applyAction(action) container.scoreController.applyAction(action)
@@ -427,7 +410,12 @@ fun BroadcastScreen(
} }
if (!loading && permissions.granted && currentMatch != null && scoreActions != null) { 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( BroadcastControlsOverlay(
controlsVisible = controlsVisible, controlsVisible = controlsVisible,
onToggleControls = { controlsVisible = !controlsVisible }, onToggleControls = { controlsVisible = !controlsVisible },
@@ -508,11 +496,6 @@ fun BroadcastScreen(
} else { } else {
null null
}, },
onClockToggle = if (boardType in setOf("basket", "timed", "timer")) {
{ applyScoreAction("clock_toggle") }
} else {
null
},
onPauseOrResume = { onPauseOrResume = {
scope.launch { scope.launch {
if (isPaused) resumeStream() else pauseStream() if (isPaused) resumeStream() else pauseStream()

View File

@@ -11,8 +11,16 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -20,6 +28,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton 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 = fun wizardStepTitle(step: Int): String =
stepTitles[(step.coerceIn(1, 3) - 1)] 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 @Composable
fun WizardChoiceButton( fun WizardChoiceButton(
label: String, label: String,