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,