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
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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 @@
</span>
</header>
<div class="regia-share">
<button type="button" class="regia-btn regia-btn--outline" id="btn-share" style="margin:0">
Condividi link regia
</button>
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy" style="margin:0">
Copia link
</button>
<% if @live_share_url.present? %>
<div class="regia-share-group">
<p class="regia-share-label">Link diretta (spettatori)</p>
<div class="regia-share">
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-live" style="margin:0">
Condividi diretta
</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 class="regia-preview">
@@ -74,4 +93,4 @@
</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.
# 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: {}

View File

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

View File

@@ -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;

View File

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

View File

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

View File

@@ -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?

View File

@@ -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<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(

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

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.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 -> {

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.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()

View File

@@ -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,