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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: {}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ class OverlayCanvasRenderer(context: Context) {
|
|||||||
WatermarkElement(context),
|
WatermarkElement(context),
|
||||||
ScoreboardElement(),
|
ScoreboardElement(),
|
||||||
CompactScoreboardElement(),
|
CompactScoreboardElement(),
|
||||||
TimerElement(),
|
|
||||||
SponsorElement(),
|
SponsorElement(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 -> {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user