From 4ed15bc235c452efc4d6bab508c61dc11e89fe7d Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Tue, 9 Jun 2026 08:42:23 +0200 Subject: [PATCH] Rimuovi replay da app, fix live web e sync punteggi broadcast. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/helpers/public/live_helper.rb | 49 ----- .../app/views/layouts/marketing_live.html.erb | 2 +- .../public/live/_player_overlays.html.erb | 6 + .../app/views/public/live/_scorebug.html.erb | 47 ----- backend/app/views/public/live/show.html.erb | 66 +------ backend/public/live.css | 160 ---------------- docs/REPLAY_MODULE.md | 2 +- native/android/README.md | 2 +- native/android/app/build.gradle.kts | 2 +- .../match_live_tv/data/api/ApiDtos.kt | 46 +---- .../match_live_tv/data/api/MatchLiveApi.kt | 5 - .../data/cable/SessionCableService.kt | 21 ++- .../data/repository/MatchRepository.kt | 12 +- .../data/scoring/ScoreController.kt | 7 + .../match_live_tv/domain/Recording.kt | 22 --- .../match_live_tv/ui/archive/ArchiveScreen.kt | 178 ------------------ .../ui/broadcast/BroadcastControlsOverlay.kt | 11 +- .../ui/broadcast/BroadcastScreen.kt | 18 +- .../match_live_tv/ui/matches/MatchesScreen.kt | 4 - .../match_live_tv/ui/navigation/AppNavHost.kt | 15 -- .../match_live_tv/ui/navigation/Routes.kt | 1 - .../ui/wizard/StepMatchScreen.kt | 19 +- 22 files changed, 77 insertions(+), 618 deletions(-) create mode 100644 backend/app/views/public/live/_player_overlays.html.erb delete mode 100644 backend/app/views/public/live/_scorebug.html.erb delete mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Recording.kt delete mode 100644 native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/archive/ArchiveScreen.kt diff --git a/backend/app/helpers/public/live_helper.rb b/backend/app/helpers/public/live_helper.rb index b55b830..e5e50e5 100644 --- a/backend/app/helpers/public/live_helper.rb +++ b/backend/app/helpers/public/live_helper.rb @@ -61,57 +61,8 @@ module Public "#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}" 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 - 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) data = partial.respond_to?(:with_indifferent_access) ? partial.with_indifferent_access : partial set_no = data[:set] || data["set"] diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb index 50e3372..37f4f5a 100644 --- a/backend/app/views/layouts/marketing_live.html.erb +++ b/backend/app/views/layouts/marketing_live.html.erb @@ -7,7 +7,7 @@ <%= render "shared/meta_tags" %> - + <%= yield :head %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> diff --git a/backend/app/views/public/live/_player_overlays.html.erb b/backend/app/views/public/live/_player_overlays.html.erb new file mode 100644 index 0000000..e427f10 --- /dev/null +++ b/backend/app/views/public/live/_player_overlays.html.erb @@ -0,0 +1,6 @@ +
+ " + ><%= @stream_closed ? "Terminata" : (@session.paused? ? "In pausa" : (@on_air ? "LIVE" : "In attesa")) %> +
diff --git a/backend/app/views/public/live/_scorebug.html.erb b/backend/app/views/public/live/_scorebug.html.erb deleted file mode 100644 index ec430c2..0000000 --- a/backend/app/views/public/live/_scorebug.html.erb +++ /dev/null @@ -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) %> -
-
- - - - - - <% cols[:labels].each do |label| %> - - <% end %> - - - - - - <% cols[:home].each_with_index do |val, i| %> - - <% end %> - - - - <% cols[:away].each_with_index do |val, i| %> - - <% end %> - - -
Punteggio live
Squadra<%= label %>
<%= live_scorebug_team_label(team.name) %>"><%= val %>
<%= live_scorebug_team_label(@match.opponent_name) %>"><%= val %>
- -
- " - ><%= @stream_closed ? "Terminata" : (@session.paused? ? "In pausa" : (@on_air ? "LIVE" : "In attesa")) %> -
diff --git a/backend/app/views/public/live/show.html.erb b/backend/app/views/public/live/show.html.erb index c0b96cf..9a1122c 100644 --- a/backend/app/views/public/live/show.html.erb +++ b/backend/app/views/public/live/show.html.erb @@ -22,7 +22,7 @@ <% if @session.ended_at %>

Chiusa il <%= l_local(@session.ended_at) %>

<% end %> -

Il punteggio finale resta visibile sopra.

+

Il punteggio è visibile nel video se la diretta era ancora in corso.

<%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %> <% elsif @session.platform == "youtube" %> @@ -41,7 +41,7 @@ <% else %>
- <%= render "public/live/scorebug", match: @match, session: @session %> + <%= render "public/live/player_overlays", match: @match, session: @session %> @@ -58,68 +58,7 @@ const pausedMsg = document.getElementById("paused-msg"); const awaitingMsg = document.getElementById("awaiting-msg"); const playHint = document.getElementById("play-hint"); - const scorebug = document.getElementById("live-scorebug"); 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) => `${label}`) - .join(""); - const homeCells = cols.home - .map((val, i) => { - const live = i === cols.liveIndex ? " live-scorebug__pts--live-home" : ""; - return `${val}`; - }) - .join(""); - const awayCells = cols.away - .map((val, i) => { - const live = i === cols.liveIndex ? " live-scorebug__pts--live-away" : ""; - return `${val}`; - }) - .join(""); - scorebug.innerHTML = ` - - - - ${headCells} - - - - ${homeCells} - - - ${awayCells} - - -
Punteggio live
Squadra
${homeLabel}
${awayLabel}
- `; - } function syncStreamBadge(data) { if (!streamBadge) return; @@ -329,7 +268,6 @@ return; } - renderLiveScorebug(data.score); syncStreamBadge(data); const onAir = !!data.on_air; diff --git a/backend/public/live.css b/backend/public/live.css index a9512b7..8e84d5f 100644 --- a/backend/public/live.css +++ b/backend/public/live.css @@ -48,166 +48,6 @@ 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 { margin: 12px 0 0; color: #f5f5f5; diff --git a/docs/REPLAY_MODULE.md b/docs/REPLAY_MODULE.md index 7b6f4cb..f25172f 100644 --- a/docs/REPLAY_MODULE.md +++ b/docs/REPLAY_MODULE.md @@ -25,7 +25,7 @@ MediaMTX registra **solo mentre il telefono pubblica** (RTMP connesso). In pausa ### 3. Archivio Replay - Web: `/clubs/:id/replays` (gestione società) - Pubblico: `/replay` con filtro società/squadra -- App: schermata Replay +- App mobile: **nessuna** UI replay (gestione solo da sito web) ### 4. Riproduzione - Player MP4: `/replay/:id` + stream `/replay/:id/stream` diff --git a/native/android/README.md b/native/android/README.md index 5a80dc0..8813ccc 100644 --- a/native/android/README.md +++ b/native/android/README.md @@ -34,7 +34,7 @@ API default: `https://www.matchlivetv.it` (override con `-PAPI_BASE_URL=...`). - [x] Login, lista partite, avvio diretta, preview + RTMP - [ ] Wizard completo (rete, piattaforma YouTube, tabellone) - [ ] 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 (prossimo passo su Mac) diff --git a/native/android/app/build.gradle.kts b/native/android/app/build.gradle.kts index 77b4df1..f44fa99 100644 --- a/native/android/app/build.gradle.kts +++ b/native/android/app/build.gradle.kts @@ -12,7 +12,7 @@ android { applicationId = "com.matchlivetv.match_live_tv" minSdk = 24 targetSdk = 35 - versionCode = 15 + versionCode = 16 versionName = "2.0.0-native" val apiBaseUrl = project.findProperty("API_BASE_URL") as String? diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt index 9454405..5753145 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt @@ -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.Match -import com.matchlivetv.match_live_tv.domain.Recording import com.matchlivetv.match_live_tv.domain.ScoringRules import com.matchlivetv.match_live_tv.domain.StreamSession import com.matchlivetv.match_live_tv.domain.Team @@ -230,6 +229,12 @@ fun ScoringRules.toBody() = ScoringRulesBody( minPointLead = minPointLead, ) +fun ScoringRulesBody.toMap(): Map = mapOf( + "points_per_set" to pointsPerSet, + "points_deciding_set" to pointsDecidingSet, + "min_point_lead" to minPointLead, +) + data class UpdateMatchBodyFull( @Json(name = "opponent_name") val opponentName: String, val location: String? = null, @@ -237,7 +242,7 @@ data class UpdateMatchBodyFull( @Json(name = "sets_to_win") val setsToWin: Int, val category: 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? = null, ) data class UpdateTeamRequest(val team: UpdateTeamBody) @@ -257,43 +262,6 @@ data class NetworkTestResponse( @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( @Json(name = "regia_url") val regiaUrl: String, ) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt index 8a68079..dfaee82 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt @@ -44,9 +44,6 @@ interface MatchLiveApi { @Part logoFile: MultipartBody.Part?, ): TeamDto - @GET("teams/{teamId}/recordings") - suspend fun recordings(@Path("teamId") teamId: String): List - @GET("teams/{teamId}/matches") suspend fun matches(@Path("teamId") teamId: String): List @@ -126,6 +123,4 @@ interface MatchLiveApi { @Body body: TelemetryRequest, ) - @GET("recordings/{recordingId}/download") - suspend fun recordingDownload(@Path("recordingId") recordingId: String): Map } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt index 6474ad3..90bd151 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt @@ -127,13 +127,13 @@ class SessionCableService( } private fun parseScore(data: Map): ScoreState { - val partialsRaw = data["set_partials"] as? List<*> ?: emptyList() + val partialsRaw = data["set_partials"] as? List<*> ?: data["setPartials"] as? List<*> ?: emptyList() return ScoreState( - homeSets = (data["home_sets"] as? Number)?.toInt() ?: 0, - awaySets = (data["away_sets"] as? Number)?.toInt() ?: 0, - homePoints = (data["home_points"] as? Number)?.toInt() ?: 0, - awayPoints = (data["away_points"] as? Number)?.toInt() ?: 0, - currentSet = (data["current_set"] as? Number)?.toInt() ?: 1, + homeSets = intField(data, "home_sets", "homeSets"), + awaySets = intField(data, "away_sets", "awaySets"), + homePoints = intField(data, "home_points", "homePoints"), + awayPoints = intField(data, "away_points", "awayPoints"), + currentSet = intField(data, "current_set", "currentSet").coerceAtLeast(1), setPartials = partialsRaw.mapNotNull { item -> val row = item as? Map<*, *> ?: return@mapNotNull null com.matchlivetv.match_live_tv.domain.SetPartial( @@ -147,6 +147,15 @@ class SessionCableService( ) } + private fun intField(data: Map, 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 { private const val TAG = "SessionCableService" } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt index 7542357..030305c 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt @@ -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.MultipartBodies 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.UpdateMatchRequestFull import com.matchlivetv.match_live_tv.data.api.UpdateTeamBody import com.matchlivetv.match_live_tv.data.api.UpdateTeamRequest 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.coachHubVisible import kotlinx.coroutines.flow.first @@ -55,9 +55,6 @@ class MatchRepository( return fetchMatchesForTeam(team.id) } - suspend fun fetchRecordings(teamId: String): List = - api.recordings(teamId).map { it.toDomain() } - suspend fun selectTeam(teamId: String) { tokenStore.saveActiveTeamId(teamId) } @@ -127,7 +124,7 @@ class MatchRepository( setsToWin = setsToWin, category = category?.takeIf { it.isNotBlank() }, opponentPrimaryColor = opponentPrimaryColor, - scoringRules = scoringRules, + scoringRules = scoringRules?.toMap() ?: emptyMap(), ) if (opponentLogoUri != null) { return api.updateMatchMultipart( @@ -154,11 +151,6 @@ class MatchRepository( api.deleteMatch(matchId) } - suspend fun recordingDownloadUrl(recordingId: String): String { - val body = api.recordingDownload(recordingId) - return body["download_url"] ?: error("URL download mancante") - } - companion object { private val matchComparator = compareBy(nullsLast()) { match -> parseApiInstant(match.scheduledAt) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/scoring/ScoreController.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/scoring/ScoreController.kt index 4ef9acb..875ac52 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/scoring/ScoreController.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/scoring/ScoreController.kt @@ -6,6 +6,7 @@ import com.matchlivetv.match_live_tv.domain.ScoreState import com.matchlivetv.match_live_tv.domain.SetPartial import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -35,6 +36,12 @@ class ScoreController( } fun applyRemote(remote: ScoreState) { + scope.launch { + applyRemoteOnMain(remote) + } + } + + private fun applyRemoteOnMain(remote: ScoreState) { if (remote == _score.value) return val now = System.currentTimeMillis() if (now < suppressRemoteUntilMs && remote.progressKey() <= _score.value.progressKey()) { diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Recording.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Recording.kt deleted file mode 100644 index 7a09989..0000000 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Recording.kt +++ /dev/null @@ -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" -} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/archive/ArchiveScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/archive/ArchiveScreen.kt deleted file mode 100644 index 2e2b56f..0000000 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/archive/ArchiveScreen.kt +++ /dev/null @@ -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(null) } - var recordings by remember { mutableStateOf>(emptyList()) } - var error by remember { mutableStateOf(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), - ) - } - } - } - } - } - } - } -} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt index 35f030b..c6086f4 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt @@ -233,8 +233,8 @@ fun BroadcastControlsOverlay( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - "${score.homeSets} - ${score.awaySets}", - style = MaterialTheme.typography.titleMedium, + "${score.homePoints} - ${score.awayPoints}", + style = MaterialTheme.typography.titleLarge, color = Color.White, fontWeight = FontWeight.Bold, ) @@ -243,6 +243,13 @@ fun BroadcastControlsOverlay( style = MaterialTheme.typography.labelSmall, 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( teamLabel = "OSPITE", diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt index c6ccd50..572ba7a 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.key import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -201,16 +202,23 @@ fun BroadcastScreen( loading = false } - LaunchedEffect(score, match, metrics.phase) { + LaunchedEffect(match?.id) { val currentMatch = match ?: return@LaunchedEffect val authToken = container.authRepository.currentSession()?.accessToken - val homeLogoUrl = resolveMediaUrl(currentMatch.homeLogoUrl) - val awayLogoUrl = resolveMediaUrl(currentMatch.opponentLogoUrl) OverlayLogoCache.preloadAll( context, - listOf(homeLogoUrl, awayLogoUrl), + listOf( + resolveMediaUrl(currentMatch.homeLogoUrl), + resolveMediaUrl(currentMatch.opponentLogoUrl), + ), 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( OverlayState( scoreboard = score.toScoreboardState( @@ -366,6 +374,7 @@ fun BroadcastScreen( } if (!loading && permissions.granted && currentMatch != null && scoreActions != null) { + key(score.progressKey()) { BroadcastControlsOverlay( controlsVisible = controlsVisible, onToggleControls = { controlsVisible = !controlsVisible }, @@ -436,6 +445,7 @@ fun BroadcastScreen( networkType = DeviceTelemetry.networkType(context), deviceHealth = deviceHealth, ) + } } SnackbarHost( diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt index d77adae..6652d96 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt @@ -62,7 +62,6 @@ fun MatchesScreen( container: AppContainer, onOpenSetup: (matchId: String) -> Unit, onOpenBroadcast: (sessionId: String) -> Unit, - onOpenArchive: () -> Unit, onLogout: () -> Unit, ) { val scope = rememberCoroutineScope() @@ -157,9 +156,6 @@ fun MatchesScreen( titleContentColor = Color.White, ), actions = { - IconButton(onClick = onOpenArchive) { - Text("Archivio", color = MatchColors.TextSecondary) - } IconButton(onClick = { scope.launch { container.authRepository.logout() diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt index ebe2382..4d387b7 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt @@ -7,7 +7,6 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument 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.login.LoginScreen import com.matchlivetv.match_live_tv.ui.matches.MatchesScreen @@ -50,9 +49,6 @@ fun AppNavHost(container: AppContainer) { onOpenBroadcast = { sessionId -> navController.navigate(Routes.broadcast(sessionId)) }, - onOpenArchive = { - navController.navigate(Routes.Archive) - }, onLogout = { navController.navigate(Routes.Login) { 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( route = Routes.Setup, arguments = listOf( diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt index 764e282..e18ae6c 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt @@ -4,7 +4,6 @@ object Routes { const val Splash = "splash" const val Login = "login" const val Matches = "matches" - const val Archive = "archive" const val Setup = "setup/{matchId}/{step}" const val Broadcast = "broadcast/{sessionId}" diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt index 3d98ba7..cb95bba 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt @@ -45,6 +45,15 @@ private const val DEFAULT_POINTS_DECIDING_SET = 15 private const val DEFAULT_MIN_POINT_LEAD = 2 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 fun StepMatchScreen( container: AppContainer, @@ -91,9 +100,7 @@ fun StepMatchScreen( ) { uri -> opponentLogoUri = uri } val existingRules = match.scoringRules - var customRules by remember { - mutableStateOf(existingRules != null || match.setsToWin != DEFAULT_SETS_TO_WIN) - } + var customRules by remember { mutableStateOf(matchHasCustomScoring(match)) } var setsToWin by remember { mutableIntStateOf(match.setsToWin.coerceIn(2, 3)) } var pointsPerSet by remember { mutableIntStateOf(existingRules?.pointsPerSet ?: DEFAULT_POINTS_PER_SET) @@ -262,11 +269,7 @@ fun StepMatchScreen( minPointLead = existingRules?.minPointLead ?: DEFAULT_MIN_POINT_LEAD, ) } else { - ScoringRulesBody( - pointsPerSet = DEFAULT_POINTS_PER_SET, - pointsDecidingSet = DEFAULT_POINTS_DECIDING_SET, - minPointLead = DEFAULT_MIN_POINT_LEAD, - ) + null } val initialHomeColor = normalizeHexColor(