Rimuovi replay da app, fix live web e sync punteggi broadcast.
La pagina live mostra solo l'overlay nel video (niente tabellone HTML); l'app nativa non espone più l'archivio replay, il toggle regole punteggio parte spento e i controlli in diretta seguono il punteggio dalla regia. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -61,57 +61,8 @@ module Public
|
|||||||
"#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}"
|
"#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def live_scorebug_columns(score_state)
|
|
||||||
unless score_state
|
|
||||||
return {
|
|
||||||
labels: ["1"],
|
|
||||||
home: ["0"],
|
|
||||||
away: ["0"],
|
|
||||||
live_index: 0
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
partials = Array(score_state.set_partials)
|
|
||||||
labels = partials.map { |p| (p["set"] || p[:set]).to_s }
|
|
||||||
home = partials.map { |p| (p["home"] || p[:home]).to_s }
|
|
||||||
away = partials.map { |p| (p["away"] || p[:away]).to_s }
|
|
||||||
labels << score_state.current_set.to_s
|
|
||||||
home << score_state.home_points.to_s
|
|
||||||
away << score_state.away_points.to_s
|
|
||||||
{
|
|
||||||
labels: labels,
|
|
||||||
home: home,
|
|
||||||
away: away,
|
|
||||||
live_index: labels.length - 1
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def live_scorebug_team_label(name, max: 16)
|
|
||||||
n = name.to_s
|
|
||||||
n.length <= max ? n : "#{n[0, max - 1]}…"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Colore ospite sul tabellone: secondario società se leggibile su bianco, altrimenti blu visitatore.
|
|
||||||
def live_scorebug_away_color(team)
|
|
||||||
club = team.club
|
|
||||||
candidate = club&.effective_secondary_color.presence || "#1565c0"
|
|
||||||
return candidate unless light_hex_color?(candidate)
|
|
||||||
|
|
||||||
"#1565c0"
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def light_hex_color?(hex)
|
|
||||||
h = hex.to_s.delete_prefix("#")
|
|
||||||
return false unless h.match?(/\A\h{6}\z/i)
|
|
||||||
|
|
||||||
r = h[0..1].to_i(16)
|
|
||||||
g = h[2..3].to_i(16)
|
|
||||||
b = h[4..5].to_i(16)
|
|
||||||
(0.299 * r + 0.587 * g + 0.114 * b) > 200
|
|
||||||
end
|
|
||||||
|
|
||||||
def format_set_partial_entry(partial)
|
def format_set_partial_entry(partial)
|
||||||
data = partial.respond_to?(:with_indifferent_access) ? partial.with_indifferent_access : partial
|
data = partial.respond_to?(:with_indifferent_access) ? partial.with_indifferent_access : partial
|
||||||
set_no = data[:set] || data["set"]
|
set_no = data[:set] || data["set"]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||||
<link rel="stylesheet" href="/marketing.css?v=36">
|
<link rel="stylesheet" href="/marketing.css?v=36">
|
||||||
<link rel="stylesheet" href="/live.css?v=23">
|
<link rel="stylesheet" href="/live.css?v=24">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
<body<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
<body<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||||
|
|||||||
6
backend/app/views/public/live/_player_overlays.html.erb
Normal file
6
backend/app/views/public/live/_player_overlays.html.erb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<div class="live-player-overlays" id="live-overlays">
|
||||||
|
<span
|
||||||
|
id="live-stream-badge"
|
||||||
|
class="live-ovl-badge live-ovl-badge--right <%= @stream_closed ? "badge-ended" : (@session.paused? ? "badge-wait" : (@on_air ? "badge-live" : "badge-wait")) %>"
|
||||||
|
><%= @stream_closed ? "Terminata" : (@session.paused? ? "In pausa" : (@on_air ? "LIVE" : "In attesa")) %></span>
|
||||||
|
</div>
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<% score = @session.score_state %>
|
|
||||||
<% team = @match.team %>
|
|
||||||
<% cols = live_scorebug_columns(score) %>
|
|
||||||
<% home_color = team.effective_primary_color %>
|
|
||||||
<% away_color = live_scorebug_away_color(team) %>
|
|
||||||
<div class="live-player-overlays" id="live-overlays">
|
|
||||||
<div
|
|
||||||
id="live-scorebug"
|
|
||||||
class="live-scorebug"
|
|
||||||
style="--sb-home-primary: <%= home_color %>; --sb-away-primary: <%= away_color %>;"
|
|
||||||
data-home-name="<%= j live_scorebug_team_label(team.name) %>"
|
|
||||||
data-away-name="<%= j live_scorebug_team_label(@match.opponent_name) %>"
|
|
||||||
>
|
|
||||||
<table class="live-scorebug__table" aria-label="Tabellone">
|
|
||||||
<caption class="visually-hidden">Punteggio live</caption>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="live-scorebug__corner" scope="col">Squadra</th>
|
|
||||||
<% cols[:labels].each do |label| %>
|
|
||||||
<th class="live-scorebug__col-set live-scorebug__col-h" scope="col"><%= label %></th>
|
|
||||||
<% end %>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr class="live-scorebug__row--home">
|
|
||||||
<th class="live-scorebug__team" scope="row"><%= live_scorebug_team_label(team.name) %></th>
|
|
||||||
<% cols[:home].each_with_index do |val, i| %>
|
|
||||||
<td class="live-scorebug__pts<%= " live-scorebug__pts--live-home" if i == cols[:live_index] %>"><%= val %></td>
|
|
||||||
<% end %>
|
|
||||||
</tr>
|
|
||||||
<tr class="live-scorebug__row--away">
|
|
||||||
<th class="live-scorebug__team" scope="row"><%= live_scorebug_team_label(@match.opponent_name) %></th>
|
|
||||||
<% cols[:away].each_with_index do |val, i| %>
|
|
||||||
<td class="live-scorebug__pts<%= " live-scorebug__pts--live-away" if i == cols[:live_index] %>"><%= val %></td>
|
|
||||||
<% end %>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div class="live-scorebug__brand" aria-hidden="true">
|
|
||||||
<span class="live-scorebug__brand-text">MATCH <span class="live-scorebug__brand-accent">LIVE TV</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
id="live-stream-badge"
|
|
||||||
class="live-ovl-badge live-ovl-badge--right <%= @stream_closed ? "badge-ended" : (@session.paused? ? "badge-wait" : (@on_air ? "badge-live" : "badge-wait")) %>"
|
|
||||||
><%= @stream_closed ? "Terminata" : (@session.paused? ? "In pausa" : (@on_air ? "LIVE" : "In attesa")) %></span>
|
|
||||||
</div>
|
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<% if @session.ended_at %>
|
<% if @session.ended_at %>
|
||||||
<p class="stream-ended-meta">Chiusa il <%= l_local(@session.ended_at) %></p>
|
<p class="stream-ended-meta">Chiusa il <%= l_local(@session.ended_at) %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<p class="stream-ended-hint">Il punteggio finale resta visibile sopra.</p>
|
<p class="stream-ended-hint">Il punteggio è visibile nel video se la diretta era ancora in corso.</p>
|
||||||
<%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %>
|
<%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %>
|
||||||
</div>
|
</div>
|
||||||
<% elsif @session.platform == "youtube" %>
|
<% elsif @session.platform == "youtube" %>
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<% else %>
|
<% else %>
|
||||||
<div class="live-player-wrap">
|
<div class="live-player-wrap">
|
||||||
<video id="player" controls playsinline muted autoplay poster="/images/copertina-canale.png"></video>
|
<video id="player" controls playsinline muted autoplay poster="/images/copertina-canale.png"></video>
|
||||||
<%= render "public/live/scorebug", match: @match, session: @session %>
|
<%= render "public/live/player_overlays", match: @match, session: @session %>
|
||||||
<button type="button" id="play-hint" class="live-play-hint" hidden>
|
<button type="button" id="play-hint" class="live-play-hint" hidden>
|
||||||
▶ Avvia la diretta
|
▶ Avvia la diretta
|
||||||
</button>
|
</button>
|
||||||
@@ -58,68 +58,7 @@
|
|||||||
const pausedMsg = document.getElementById("paused-msg");
|
const pausedMsg = document.getElementById("paused-msg");
|
||||||
const awaitingMsg = document.getElementById("awaiting-msg");
|
const awaitingMsg = document.getElementById("awaiting-msg");
|
||||||
const playHint = document.getElementById("play-hint");
|
const playHint = document.getElementById("play-hint");
|
||||||
const scorebug = document.getElementById("live-scorebug");
|
|
||||||
const streamBadge = document.getElementById("live-stream-badge");
|
const streamBadge = document.getElementById("live-stream-badge");
|
||||||
const homeName = "<%= j @match.team.name %>";
|
|
||||||
const awayName = "<%= j @match.opponent_name %>";
|
|
||||||
|
|
||||||
function abbrevName(name, max = 16) {
|
|
||||||
const n = String(name || "");
|
|
||||||
return n.length <= max ? n : `${n.slice(0, max - 1)}…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreColumnsFromPayload(score) {
|
|
||||||
if (!score) return { labels: ["1"], home: ["0"], away: ["0"], liveIndex: 0 };
|
|
||||||
const partials = score.set_partials || score.setPartials || [];
|
|
||||||
const labels = partials.map((p) => String(p.set ?? p["set"] ?? ""));
|
|
||||||
const home = partials.map((p) => String(p.home ?? p["home"] ?? "0"));
|
|
||||||
const away = partials.map((p) => String(p.away ?? p["away"] ?? "0"));
|
|
||||||
const currentSet = score.current_set ?? score.currentSet ?? 1;
|
|
||||||
labels.push(String(currentSet));
|
|
||||||
home.push(String(score.home_points ?? score.homePoints ?? 0));
|
|
||||||
away.push(String(score.away_points ?? score.awayPoints ?? 0));
|
|
||||||
return { labels, home, away, liveIndex: labels.length - 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLiveScorebug(score) {
|
|
||||||
if (!scorebug) return;
|
|
||||||
const cols = scoreColumnsFromPayload(score);
|
|
||||||
const homeLabel = abbrevName(scorebug.dataset.homeName || homeName);
|
|
||||||
const awayLabel = abbrevName(scorebug.dataset.awayName || awayName);
|
|
||||||
const headCells = cols.labels
|
|
||||||
.map((label) => `<th class="live-scorebug__col-set live-scorebug__col-h" scope="col">${label}</th>`)
|
|
||||||
.join("");
|
|
||||||
const homeCells = cols.home
|
|
||||||
.map((val, i) => {
|
|
||||||
const live = i === cols.liveIndex ? " live-scorebug__pts--live-home" : "";
|
|
||||||
return `<td class="live-scorebug__pts${live}">${val}</td>`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
const awayCells = cols.away
|
|
||||||
.map((val, i) => {
|
|
||||||
const live = i === cols.liveIndex ? " live-scorebug__pts--live-away" : "";
|
|
||||||
return `<td class="live-scorebug__pts${live}">${val}</td>`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
scorebug.innerHTML = `
|
|
||||||
<table class="live-scorebug__table" aria-label="Tabellone">
|
|
||||||
<caption class="visually-hidden">Punteggio live</caption>
|
|
||||||
<thead><tr>
|
|
||||||
<th class="live-scorebug__corner" scope="col">Squadra</th>${headCells}
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr class="live-scorebug__row--home">
|
|
||||||
<th class="live-scorebug__team" scope="row">${homeLabel}</th>${homeCells}
|
|
||||||
</tr>
|
|
||||||
<tr class="live-scorebug__row--away">
|
|
||||||
<th class="live-scorebug__team" scope="row">${awayLabel}</th>${awayCells}
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div class="live-scorebug__brand" aria-hidden="true">
|
|
||||||
<span class="live-scorebug__brand-text">MATCH <span class="live-scorebug__brand-accent">LIVE TV</span></span>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncStreamBadge(data) {
|
function syncStreamBadge(data) {
|
||||||
if (!streamBadge) return;
|
if (!streamBadge) return;
|
||||||
@@ -329,7 +268,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderLiveScorebug(data.score);
|
|
||||||
syncStreamBadge(data);
|
syncStreamBadge(data);
|
||||||
|
|
||||||
const onAir = !!data.on_air;
|
const onAir = !!data.on_air;
|
||||||
|
|||||||
@@ -48,166 +48,6 @@
|
|||||||
left: auto;
|
left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tabellone broadcast: tabella per colonne allineate (set / punteggi). */
|
|
||||||
.live-main .live-scorebug {
|
|
||||||
position: absolute;
|
|
||||||
top: 12px;
|
|
||||||
left: 12px;
|
|
||||||
width: max-content;
|
|
||||||
max-width: min(92%, 320px);
|
|
||||||
padding: 6px 8px 5px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgba(255, 255, 255, 0.94);
|
|
||||||
color: #111;
|
|
||||||
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.45);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug__table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
table-layout: fixed;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug__col-team {
|
|
||||||
width: 7.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug__col-set {
|
|
||||||
width: 1.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug__corner {
|
|
||||||
width: 7.25rem;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
font-size: 0;
|
|
||||||
line-height: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug thead th {
|
|
||||||
padding: 0 0 4px;
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
|
||||||
font-size: 0.68rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #666;
|
|
||||||
text-align: center;
|
|
||||||
vertical-align: bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug__col-h {
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug tbody th,
|
|
||||||
.live-main .live-scorebug tbody td {
|
|
||||||
padding: 3px 0;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug tbody tr + tr th,
|
|
||||||
.live-main .live-scorebug tbody tr + tr td {
|
|
||||||
padding-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug__team {
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 7.25rem;
|
|
||||||
padding-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug__row--home .live-scorebug__team {
|
|
||||||
color: var(--sb-home-primary, #111);
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug__row--away .live-scorebug__team {
|
|
||||||
color: var(--sb-away-primary, #1565c0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug__pts {
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 800;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: #111;
|
|
||||||
padding-left: 0;
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug__pts--live-home {
|
|
||||||
background: color-mix(in srgb, var(--sb-home-primary, #e53935) 16%, #fff);
|
|
||||||
border-radius: 3px;
|
|
||||||
color: var(--sb-home-primary, #111);
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug__pts--live-away {
|
|
||||||
background: color-mix(in srgb, var(--sb-away-primary, #1565c0) 16%, #fff);
|
|
||||||
border-radius: 3px;
|
|
||||||
color: var(--sb-away-primary, #111);
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug__brand {
|
|
||||||
margin-top: 5px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: linear-gradient(135deg, #14141c 0%, #2a1214 55%, #1a1a24 100%);
|
|
||||||
border: 1px solid rgba(229, 57, 53, 0.35);
|
|
||||||
text-align: center;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug__brand-text {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug__brand-accent {
|
|
||||||
color: #e53935;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug .visually-hidden {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
|
||||||
.live-main .live-scorebug {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
max-width: 88%;
|
|
||||||
padding: 5px 6px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug__col-team,
|
|
||||||
.live-main .live-scorebug__corner {
|
|
||||||
width: 5.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug__team {
|
|
||||||
max-width: 5.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-scorebug__col-set {
|
|
||||||
width: 1.55rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-main .live-status-msg {
|
.live-main .live-status-msg {
|
||||||
margin: 12px 0 0;
|
margin: 12px 0 0;
|
||||||
color: #f5f5f5;
|
color: #f5f5f5;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ MediaMTX registra **solo mentre il telefono pubblica** (RTMP connesso). In pausa
|
|||||||
### 3. Archivio Replay
|
### 3. Archivio Replay
|
||||||
- Web: `/clubs/:id/replays` (gestione società)
|
- Web: `/clubs/:id/replays` (gestione società)
|
||||||
- Pubblico: `/replay` con filtro società/squadra
|
- Pubblico: `/replay` con filtro società/squadra
|
||||||
- App: schermata Replay
|
- App mobile: **nessuna** UI replay (gestione solo da sito web)
|
||||||
|
|
||||||
### 4. Riproduzione
|
### 4. Riproduzione
|
||||||
- Player MP4: `/replay/:id` + stream `/replay/:id/stream`
|
- Player MP4: `/replay/:id` + stream `/replay/:id/stream`
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ API default: `https://www.matchlivetv.it` (override con `-PAPI_BASE_URL=...`).
|
|||||||
- [x] Login, lista partite, avvio diretta, preview + RTMP
|
- [x] Login, lista partite, avvio diretta, preview + RTMP
|
||||||
- [ ] Wizard completo (rete, piattaforma YouTube, tabellone)
|
- [ ] Wizard completo (rete, piattaforma YouTube, tabellone)
|
||||||
- [ ] WebSocket tabellone / regia
|
- [ ] WebSocket tabellone / regia
|
||||||
- [ ] Archivio registrazioni
|
- [x] Replay solo da sito web (non in app)
|
||||||
- [ ] **iOS nativo** — da sviluppare su Mac in `native/ios/` (SwiftUI)
|
- [ ] **iOS nativo** — da sviluppare su Mac in `native/ios/` (SwiftUI)
|
||||||
|
|
||||||
## iOS (prossimo passo su Mac)
|
## iOS (prossimo passo su Mac)
|
||||||
|
|||||||
@@ -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 = 15
|
versionCode = 16
|
||||||
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?
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.matchlivetv.match_live_tv.data.api
|
|||||||
|
|
||||||
import com.matchlivetv.match_live_tv.domain.AuthTokens
|
import com.matchlivetv.match_live_tv.domain.AuthTokens
|
||||||
import com.matchlivetv.match_live_tv.domain.Match
|
import com.matchlivetv.match_live_tv.domain.Match
|
||||||
import com.matchlivetv.match_live_tv.domain.Recording
|
|
||||||
import com.matchlivetv.match_live_tv.domain.ScoringRules
|
import com.matchlivetv.match_live_tv.domain.ScoringRules
|
||||||
import com.matchlivetv.match_live_tv.domain.StreamSession
|
import com.matchlivetv.match_live_tv.domain.StreamSession
|
||||||
import com.matchlivetv.match_live_tv.domain.Team
|
import com.matchlivetv.match_live_tv.domain.Team
|
||||||
@@ -230,6 +229,12 @@ fun ScoringRules.toBody() = ScoringRulesBody(
|
|||||||
minPointLead = minPointLead,
|
minPointLead = minPointLead,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun ScoringRulesBody.toMap(): Map<String, Int> = mapOf(
|
||||||
|
"points_per_set" to pointsPerSet,
|
||||||
|
"points_deciding_set" to pointsDecidingSet,
|
||||||
|
"min_point_lead" to minPointLead,
|
||||||
|
)
|
||||||
|
|
||||||
data class UpdateMatchBodyFull(
|
data class UpdateMatchBodyFull(
|
||||||
@Json(name = "opponent_name") val opponentName: String,
|
@Json(name = "opponent_name") val opponentName: String,
|
||||||
val location: String? = null,
|
val location: String? = null,
|
||||||
@@ -237,7 +242,7 @@ data class UpdateMatchBodyFull(
|
|||||||
@Json(name = "sets_to_win") val setsToWin: Int,
|
@Json(name = "sets_to_win") val setsToWin: Int,
|
||||||
val category: String? = null,
|
val category: String? = null,
|
||||||
@Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null,
|
@Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null,
|
||||||
@Json(name = "scoring_rules") val scoringRules: ScoringRulesBody? = null,
|
@Json(name = "scoring_rules") val scoringRules: Map<String, Int>? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class UpdateTeamRequest(val team: UpdateTeamBody)
|
data class UpdateTeamRequest(val team: UpdateTeamBody)
|
||||||
@@ -257,43 +262,6 @@ data class NetworkTestResponse(
|
|||||||
@Json(name = "target_fps") val targetFps: Int? = null,
|
@Json(name = "target_fps") val targetFps: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RecordingDto(
|
|
||||||
val id: String,
|
|
||||||
val title: String? = null,
|
|
||||||
@Json(name = "opponent_name") val opponentName: String? = null,
|
|
||||||
@Json(name = "team_name") val teamName: String? = null,
|
|
||||||
val status: String? = null,
|
|
||||||
@Json(name = "status_label") val statusLabel: String? = null,
|
|
||||||
@Json(name = "recorded_at") val recordedAt: String? = null,
|
|
||||||
@Json(name = "ended_at") val endedAt: String? = null,
|
|
||||||
@Json(name = "duration_label") val durationLabel: String? = null,
|
|
||||||
@Json(name = "views_label") val viewsLabel: String? = null,
|
|
||||||
@Json(name = "replay_url") val replayUrl: String? = null,
|
|
||||||
@Json(name = "thumbnail_url") val thumbnailUrl: String? = null,
|
|
||||||
@Json(name = "download_enabled") val downloadEnabled: Boolean? = null,
|
|
||||||
@Json(name = "youtube_watch_url") val youtubeWatchUrl: String? = null,
|
|
||||||
@Json(name = "expires_at") val expiresAt: String? = null,
|
|
||||||
@Json(name = "title_or_default") val titleOrDefault: String? = null,
|
|
||||||
) {
|
|
||||||
fun toDomain() = Recording(
|
|
||||||
id = id,
|
|
||||||
title = title ?: titleOrDefault ?: "Replay",
|
|
||||||
opponentName = opponentName.orEmpty(),
|
|
||||||
teamName = teamName.orEmpty(),
|
|
||||||
status = status ?: "processing",
|
|
||||||
statusLabel = statusLabel ?: status.orEmpty(),
|
|
||||||
recordedAt = recordedAt,
|
|
||||||
endedAt = endedAt,
|
|
||||||
durationLabel = durationLabel,
|
|
||||||
viewsLabel = viewsLabel,
|
|
||||||
replayUrl = replayUrl,
|
|
||||||
thumbnailUrl = thumbnailUrl,
|
|
||||||
downloadEnabled = downloadEnabled ?: false,
|
|
||||||
youtubeWatchUrl = youtubeWatchUrl,
|
|
||||||
expiresAt = expiresAt,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class RegiaLinkResponse(
|
data class RegiaLinkResponse(
|
||||||
@Json(name = "regia_url") val regiaUrl: String,
|
@Json(name = "regia_url") val regiaUrl: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,9 +44,6 @@ interface MatchLiveApi {
|
|||||||
@Part logoFile: MultipartBody.Part?,
|
@Part logoFile: MultipartBody.Part?,
|
||||||
): TeamDto
|
): TeamDto
|
||||||
|
|
||||||
@GET("teams/{teamId}/recordings")
|
|
||||||
suspend fun recordings(@Path("teamId") teamId: String): List<RecordingDto>
|
|
||||||
|
|
||||||
@GET("teams/{teamId}/matches")
|
@GET("teams/{teamId}/matches")
|
||||||
suspend fun matches(@Path("teamId") teamId: String): List<MatchDto>
|
suspend fun matches(@Path("teamId") teamId: String): List<MatchDto>
|
||||||
|
|
||||||
@@ -126,6 +123,4 @@ interface MatchLiveApi {
|
|||||||
@Body body: TelemetryRequest,
|
@Body body: TelemetryRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
@GET("recordings/{recordingId}/download")
|
|
||||||
suspend fun recordingDownload(@Path("recordingId") recordingId: String): Map<String, String>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,13 +127,13 @@ class SessionCableService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun parseScore(data: Map<String, Any?>): ScoreState {
|
private fun parseScore(data: Map<String, Any?>): ScoreState {
|
||||||
val partialsRaw = data["set_partials"] as? List<*> ?: emptyList<Any>()
|
val partialsRaw = data["set_partials"] as? List<*> ?: data["setPartials"] as? List<*> ?: emptyList<Any>()
|
||||||
return ScoreState(
|
return ScoreState(
|
||||||
homeSets = (data["home_sets"] as? Number)?.toInt() ?: 0,
|
homeSets = intField(data, "home_sets", "homeSets"),
|
||||||
awaySets = (data["away_sets"] as? Number)?.toInt() ?: 0,
|
awaySets = intField(data, "away_sets", "awaySets"),
|
||||||
homePoints = (data["home_points"] as? Number)?.toInt() ?: 0,
|
homePoints = intField(data, "home_points", "homePoints"),
|
||||||
awayPoints = (data["away_points"] as? Number)?.toInt() ?: 0,
|
awayPoints = intField(data, "away_points", "awayPoints"),
|
||||||
currentSet = (data["current_set"] as? Number)?.toInt() ?: 1,
|
currentSet = intField(data, "current_set", "currentSet").coerceAtLeast(1),
|
||||||
setPartials = partialsRaw.mapNotNull { item ->
|
setPartials = partialsRaw.mapNotNull { item ->
|
||||||
val row = item as? Map<*, *> ?: return@mapNotNull null
|
val row = item as? Map<*, *> ?: return@mapNotNull null
|
||||||
com.matchlivetv.match_live_tv.domain.SetPartial(
|
com.matchlivetv.match_live_tv.domain.SetPartial(
|
||||||
@@ -147,6 +147,15 @@ class SessionCableService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun intField(data: Map<String, Any?>, snake: String, camel: String): Int {
|
||||||
|
val raw = data[snake] ?: data[camel]
|
||||||
|
return when (raw) {
|
||||||
|
is Number -> raw.toInt()
|
||||||
|
is String -> raw.toIntOrNull() ?: 0
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "SessionCableService"
|
private const val TAG = "SessionCableService"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import com.matchlivetv.match_live_tv.data.api.CreateMatchRequest
|
|||||||
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
||||||
import com.matchlivetv.match_live_tv.data.api.MultipartBodies
|
import com.matchlivetv.match_live_tv.data.api.MultipartBodies
|
||||||
import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody
|
import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody
|
||||||
|
import com.matchlivetv.match_live_tv.data.api.toMap
|
||||||
import com.matchlivetv.match_live_tv.data.api.UpdateMatchBodyFull
|
import com.matchlivetv.match_live_tv.data.api.UpdateMatchBodyFull
|
||||||
import com.matchlivetv.match_live_tv.data.api.UpdateMatchRequestFull
|
import com.matchlivetv.match_live_tv.data.api.UpdateMatchRequestFull
|
||||||
import com.matchlivetv.match_live_tv.data.api.UpdateTeamBody
|
import com.matchlivetv.match_live_tv.data.api.UpdateTeamBody
|
||||||
import com.matchlivetv.match_live_tv.data.api.UpdateTeamRequest
|
import com.matchlivetv.match_live_tv.data.api.UpdateTeamRequest
|
||||||
import com.matchlivetv.match_live_tv.domain.Match
|
import com.matchlivetv.match_live_tv.domain.Match
|
||||||
import com.matchlivetv.match_live_tv.domain.Recording
|
|
||||||
import com.matchlivetv.match_live_tv.domain.Team
|
import com.matchlivetv.match_live_tv.domain.Team
|
||||||
import com.matchlivetv.match_live_tv.domain.coachHubVisible
|
import com.matchlivetv.match_live_tv.domain.coachHubVisible
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -55,9 +55,6 @@ class MatchRepository(
|
|||||||
return fetchMatchesForTeam(team.id)
|
return fetchMatchesForTeam(team.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchRecordings(teamId: String): List<Recording> =
|
|
||||||
api.recordings(teamId).map { it.toDomain() }
|
|
||||||
|
|
||||||
suspend fun selectTeam(teamId: String) {
|
suspend fun selectTeam(teamId: String) {
|
||||||
tokenStore.saveActiveTeamId(teamId)
|
tokenStore.saveActiveTeamId(teamId)
|
||||||
}
|
}
|
||||||
@@ -127,7 +124,7 @@ class MatchRepository(
|
|||||||
setsToWin = setsToWin,
|
setsToWin = setsToWin,
|
||||||
category = category?.takeIf { it.isNotBlank() },
|
category = category?.takeIf { it.isNotBlank() },
|
||||||
opponentPrimaryColor = opponentPrimaryColor,
|
opponentPrimaryColor = opponentPrimaryColor,
|
||||||
scoringRules = scoringRules,
|
scoringRules = scoringRules?.toMap() ?: emptyMap(),
|
||||||
)
|
)
|
||||||
if (opponentLogoUri != null) {
|
if (opponentLogoUri != null) {
|
||||||
return api.updateMatchMultipart(
|
return api.updateMatchMultipart(
|
||||||
@@ -154,11 +151,6 @@ class MatchRepository(
|
|||||||
api.deleteMatch(matchId)
|
api.deleteMatch(matchId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun recordingDownloadUrl(recordingId: String): String {
|
|
||||||
val body = api.recordingDownload(recordingId)
|
|
||||||
return body["download_url"] ?: error("URL download mancante")
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val matchComparator = compareBy<Match, Instant?>(nullsLast()) { match ->
|
private val matchComparator = compareBy<Match, Instant?>(nullsLast()) { match ->
|
||||||
parseApiInstant(match.scheduledAt)
|
parseApiInstant(match.scheduledAt)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.matchlivetv.match_live_tv.domain.ScoreState
|
|||||||
import com.matchlivetv.match_live_tv.domain.SetPartial
|
import com.matchlivetv.match_live_tv.domain.SetPartial
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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
|
||||||
@@ -35,6 +36,12 @@ class ScoreController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun applyRemote(remote: ScoreState) {
|
fun applyRemote(remote: ScoreState) {
|
||||||
|
scope.launch {
|
||||||
|
applyRemoteOnMain(remote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyRemoteOnMain(remote: ScoreState) {
|
||||||
if (remote == _score.value) return
|
if (remote == _score.value) return
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if (now < suppressRemoteUntilMs && remote.progressKey() <= _score.value.progressKey()) {
|
if (now < suppressRemoteUntilMs && remote.progressKey() <= _score.value.progressKey()) {
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
package com.matchlivetv.match_live_tv.domain
|
|
||||||
|
|
||||||
data class Recording(
|
|
||||||
val id: String,
|
|
||||||
val title: String,
|
|
||||||
val opponentName: String,
|
|
||||||
val teamName: String,
|
|
||||||
val status: String,
|
|
||||||
val statusLabel: String,
|
|
||||||
val recordedAt: String? = null,
|
|
||||||
val endedAt: String? = null,
|
|
||||||
val durationLabel: String? = null,
|
|
||||||
val viewsLabel: String? = null,
|
|
||||||
val replayUrl: String? = null,
|
|
||||||
val thumbnailUrl: String? = null,
|
|
||||||
val downloadEnabled: Boolean = false,
|
|
||||||
val youtubeWatchUrl: String? = null,
|
|
||||||
val expiresAt: String? = null,
|
|
||||||
) {
|
|
||||||
val isReady: Boolean get() = status == "ready"
|
|
||||||
val isProcessing: Boolean get() = status == "processing"
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
package com.matchlivetv.match_live_tv.ui.archive
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
|
||||||
import com.matchlivetv.match_live_tv.domain.Recording
|
|
||||||
import com.matchlivetv.match_live_tv.domain.Team
|
|
||||||
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
|
||||||
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
|
|
||||||
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun ArchiveScreen(
|
|
||||||
container: AppContainer,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var loading by remember { mutableStateOf(true) }
|
|
||||||
var team by remember { mutableStateOf<Team?>(null) }
|
|
||||||
var recordings by remember { mutableStateOf<List<Recording>>(emptyList()) }
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
loading = true
|
|
||||||
runCatching {
|
|
||||||
val teams = container.matchRepository.fetchTeams()
|
|
||||||
val active = container.matchRepository.resolveActiveTeam(teams)
|
|
||||||
team = active
|
|
||||||
recordings = active?.let { container.matchRepository.fetchRecordings(it.id) }.orEmpty()
|
|
||||||
}.onFailure { error = it.message }
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
MatchScreenScaffold(
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text("Replay") },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MatchColors.Background,
|
|
||||||
titleContentColor = Color.White,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
when {
|
|
||||||
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
CircularProgressIndicator(color = MatchColors.PrimaryRed)
|
|
||||||
}
|
|
||||||
error != null -> Column(
|
|
||||||
Modifier.fillMaxSize().padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(error!!, color = MatchColors.PrimaryRed, textAlign = TextAlign.Center)
|
|
||||||
}
|
|
||||||
team != null && !team!!.recordingsEnabled -> Column(
|
|
||||||
Modifier.fillMaxSize().padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Replay disponibili con Premium Light o Full",
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Light: 30 giorni · Full: 90 giorni",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
|
||||||
)
|
|
||||||
team?.billingUrl?.let { url ->
|
|
||||||
MatchPrimaryButton(
|
|
||||||
label = "SCOPRI PREMIUM",
|
|
||||||
onClick = {
|
|
||||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(top = 16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
recordings.isEmpty() -> Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
|
||||||
Text(
|
|
||||||
"Nessun replay ancora.\nAl termine delle dirette Premium le registrazioni compaiono qui.",
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> LazyColumn(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
items(recordings, key = { it.id }) { recording ->
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MatchColors.Surface),
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
onClick = {
|
|
||||||
if (recording.isReady) {
|
|
||||||
recording.replayUrl?.let { url ->
|
|
||||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(16.dp)) {
|
|
||||||
Text(recording.title, style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
listOfNotNull(
|
|
||||||
recording.durationLabel,
|
|
||||||
recording.viewsLabel,
|
|
||||||
recording.statusLabel,
|
|
||||||
).joinToString(" · "),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
if (recording.downloadEnabled && team?.phoneDownloadEnabled == true) {
|
|
||||||
MatchPrimaryButton(
|
|
||||||
label = "SCARICA MP4",
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
runCatching {
|
|
||||||
container.matchRepository.recordingDownloadUrl(recording.id)
|
|
||||||
}.onSuccess { url ->
|
|
||||||
context.startActivity(
|
|
||||||
Intent(Intent.ACTION_VIEW, Uri.parse(url)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -233,8 +233,8 @@ fun BroadcastControlsOverlay(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"${score.homeSets} - ${score.awaySets}",
|
"${score.homePoints} - ${score.awayPoints}",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
@@ -243,6 +243,13 @@ fun BroadcastControlsOverlay(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MatchColors.TextSecondary,
|
color = MatchColors.TextSecondary,
|
||||||
)
|
)
|
||||||
|
if (score.homeSets > 0 || score.awaySets > 0) {
|
||||||
|
Text(
|
||||||
|
"Set vinti ${score.homeSets}-${score.awaySets}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MatchColors.TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
TeamScoreColumn(
|
TeamScoreColumn(
|
||||||
teamLabel = "OSPITE",
|
teamLabel = "OSPITE",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -201,16 +202,23 @@ fun BroadcastScreen(
|
|||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(score, match, metrics.phase) {
|
LaunchedEffect(match?.id) {
|
||||||
val currentMatch = match ?: return@LaunchedEffect
|
val currentMatch = match ?: return@LaunchedEffect
|
||||||
val authToken = container.authRepository.currentSession()?.accessToken
|
val authToken = container.authRepository.currentSession()?.accessToken
|
||||||
val homeLogoUrl = resolveMediaUrl(currentMatch.homeLogoUrl)
|
|
||||||
val awayLogoUrl = resolveMediaUrl(currentMatch.opponentLogoUrl)
|
|
||||||
OverlayLogoCache.preloadAll(
|
OverlayLogoCache.preloadAll(
|
||||||
context,
|
context,
|
||||||
listOf(homeLogoUrl, awayLogoUrl),
|
listOf(
|
||||||
|
resolveMediaUrl(currentMatch.homeLogoUrl),
|
||||||
|
resolveMediaUrl(currentMatch.opponentLogoUrl),
|
||||||
|
),
|
||||||
bearerToken = authToken,
|
bearerToken = authToken,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(score, match, metrics.phase) {
|
||||||
|
val currentMatch = match ?: return@LaunchedEffect
|
||||||
|
val homeLogoUrl = resolveMediaUrl(currentMatch.homeLogoUrl)
|
||||||
|
val awayLogoUrl = resolveMediaUrl(currentMatch.opponentLogoUrl)
|
||||||
container.broadcastCoordinator.updateOverlay(
|
container.broadcastCoordinator.updateOverlay(
|
||||||
OverlayState(
|
OverlayState(
|
||||||
scoreboard = score.toScoreboardState(
|
scoreboard = score.toScoreboardState(
|
||||||
@@ -366,6 +374,7 @@ fun BroadcastScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!loading && permissions.granted && currentMatch != null && scoreActions != null) {
|
if (!loading && permissions.granted && currentMatch != null && scoreActions != null) {
|
||||||
|
key(score.progressKey()) {
|
||||||
BroadcastControlsOverlay(
|
BroadcastControlsOverlay(
|
||||||
controlsVisible = controlsVisible,
|
controlsVisible = controlsVisible,
|
||||||
onToggleControls = { controlsVisible = !controlsVisible },
|
onToggleControls = { controlsVisible = !controlsVisible },
|
||||||
@@ -437,6 +446,7 @@ fun BroadcastScreen(
|
|||||||
deviceHealth = deviceHealth,
|
deviceHealth = deviceHealth,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SnackbarHost(
|
SnackbarHost(
|
||||||
hostState = snackbar,
|
hostState = snackbar,
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ fun MatchesScreen(
|
|||||||
container: AppContainer,
|
container: AppContainer,
|
||||||
onOpenSetup: (matchId: String) -> Unit,
|
onOpenSetup: (matchId: String) -> Unit,
|
||||||
onOpenBroadcast: (sessionId: String) -> Unit,
|
onOpenBroadcast: (sessionId: String) -> Unit,
|
||||||
onOpenArchive: () -> Unit,
|
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -157,9 +156,6 @@ fun MatchesScreen(
|
|||||||
titleContentColor = Color.White,
|
titleContentColor = Color.White,
|
||||||
),
|
),
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = onOpenArchive) {
|
|
||||||
Text("Archivio", color = MatchColors.TextSecondary)
|
|
||||||
}
|
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
container.authRepository.logout()
|
container.authRepository.logout()
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import androidx.navigation.compose.composable
|
|||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||||
import com.matchlivetv.match_live_tv.ui.archive.ArchiveScreen
|
|
||||||
import com.matchlivetv.match_live_tv.ui.broadcast.BroadcastScreen
|
import com.matchlivetv.match_live_tv.ui.broadcast.BroadcastScreen
|
||||||
import com.matchlivetv.match_live_tv.ui.login.LoginScreen
|
import com.matchlivetv.match_live_tv.ui.login.LoginScreen
|
||||||
import com.matchlivetv.match_live_tv.ui.matches.MatchesScreen
|
import com.matchlivetv.match_live_tv.ui.matches.MatchesScreen
|
||||||
@@ -50,9 +49,6 @@ fun AppNavHost(container: AppContainer) {
|
|||||||
onOpenBroadcast = { sessionId ->
|
onOpenBroadcast = { sessionId ->
|
||||||
navController.navigate(Routes.broadcast(sessionId))
|
navController.navigate(Routes.broadcast(sessionId))
|
||||||
},
|
},
|
||||||
onOpenArchive = {
|
|
||||||
navController.navigate(Routes.Archive)
|
|
||||||
},
|
|
||||||
onLogout = {
|
onLogout = {
|
||||||
navController.navigate(Routes.Login) {
|
navController.navigate(Routes.Login) {
|
||||||
popUpTo(Routes.Matches) { inclusive = true }
|
popUpTo(Routes.Matches) { inclusive = true }
|
||||||
@@ -60,17 +56,6 @@ fun AppNavHost(container: AppContainer) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.Archive) {
|
|
||||||
ArchiveScreen(
|
|
||||||
container = container,
|
|
||||||
onBack = {
|
|
||||||
navController.navigate(Routes.Matches) {
|
|
||||||
popUpTo(Routes.Matches) { inclusive = false }
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(
|
composable(
|
||||||
route = Routes.Setup,
|
route = Routes.Setup,
|
||||||
arguments = listOf(
|
arguments = listOf(
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ object Routes {
|
|||||||
const val Splash = "splash"
|
const val Splash = "splash"
|
||||||
const val Login = "login"
|
const val Login = "login"
|
||||||
const val Matches = "matches"
|
const val Matches = "matches"
|
||||||
const val Archive = "archive"
|
|
||||||
const val Setup = "setup/{matchId}/{step}"
|
const val Setup = "setup/{matchId}/{step}"
|
||||||
const val Broadcast = "broadcast/{sessionId}"
|
const val Broadcast = "broadcast/{sessionId}"
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ private const val DEFAULT_POINTS_DECIDING_SET = 15
|
|||||||
private const val DEFAULT_MIN_POINT_LEAD = 2
|
private const val DEFAULT_MIN_POINT_LEAD = 2
|
||||||
private const val DEFAULT_OPPONENT_COLOR = "#1E3A8A"
|
private const val DEFAULT_OPPONENT_COLOR = "#1E3A8A"
|
||||||
|
|
||||||
|
/** True solo se la partita ha regole non standard (non i default FIPAV). */
|
||||||
|
private fun matchHasCustomScoring(match: Match): Boolean {
|
||||||
|
if (match.setsToWin != DEFAULT_SETS_TO_WIN) return true
|
||||||
|
val rules = match.scoringRules ?: return false
|
||||||
|
return rules.pointsPerSet != DEFAULT_POINTS_PER_SET ||
|
||||||
|
rules.pointsDecidingSet != DEFAULT_POINTS_DECIDING_SET ||
|
||||||
|
rules.minPointLead != DEFAULT_MIN_POINT_LEAD
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StepMatchScreen(
|
fun StepMatchScreen(
|
||||||
container: AppContainer,
|
container: AppContainer,
|
||||||
@@ -91,9 +100,7 @@ fun StepMatchScreen(
|
|||||||
) { uri -> opponentLogoUri = uri }
|
) { uri -> opponentLogoUri = uri }
|
||||||
|
|
||||||
val existingRules = match.scoringRules
|
val existingRules = match.scoringRules
|
||||||
var customRules by remember {
|
var customRules by remember { mutableStateOf(matchHasCustomScoring(match)) }
|
||||||
mutableStateOf(existingRules != null || match.setsToWin != DEFAULT_SETS_TO_WIN)
|
|
||||||
}
|
|
||||||
var setsToWin by remember { mutableIntStateOf(match.setsToWin.coerceIn(2, 3)) }
|
var setsToWin by remember { mutableIntStateOf(match.setsToWin.coerceIn(2, 3)) }
|
||||||
var pointsPerSet by remember {
|
var pointsPerSet by remember {
|
||||||
mutableIntStateOf(existingRules?.pointsPerSet ?: DEFAULT_POINTS_PER_SET)
|
mutableIntStateOf(existingRules?.pointsPerSet ?: DEFAULT_POINTS_PER_SET)
|
||||||
@@ -262,11 +269,7 @@ fun StepMatchScreen(
|
|||||||
minPointLead = existingRules?.minPointLead ?: DEFAULT_MIN_POINT_LEAD,
|
minPointLead = existingRules?.minPointLead ?: DEFAULT_MIN_POINT_LEAD,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ScoringRulesBody(
|
null
|
||||||
pointsPerSet = DEFAULT_POINTS_PER_SET,
|
|
||||||
pointsDecidingSet = DEFAULT_POINTS_DECIDING_SET,
|
|
||||||
minPointLead = DEFAULT_MIN_POINT_LEAD,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val initialHomeColor = normalizeHexColor(
|
val initialHomeColor = normalizeHexColor(
|
||||||
|
|||||||
Reference in New Issue
Block a user