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:
2026-06-09 08:42:23 +02:00
parent 74eee24293
commit 4ed15bc235
22 changed files with 77 additions and 618 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ android {
applicationId = "com.matchlivetv.match_live_tv" applicationId = "com.matchlivetv.match_live_tv"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 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?

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 },
@@ -436,6 +445,7 @@ fun BroadcastScreen(
networkType = DeviceTelemetry.networkType(context), networkType = DeviceTelemetry.networkType(context),
deviceHealth = deviceHealth, deviceHealth = deviceHealth,
) )
}
} }
SnackbarHost( SnackbarHost(

View File

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

View File

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

View File

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

View File

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