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

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

View File

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

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.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<String, Int> = 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<String, Int>? = 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,
)

View File

@@ -44,9 +44,6 @@ interface MatchLiveApi {
@Part logoFile: MultipartBody.Part?,
): TeamDto
@GET("teams/{teamId}/recordings")
suspend fun recordings(@Path("teamId") teamId: String): List<RecordingDto>
@GET("teams/{teamId}/matches")
suspend fun matches(@Path("teamId") teamId: String): List<MatchDto>
@@ -126,6 +123,4 @@ interface MatchLiveApi {
@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 {
val partialsRaw = data["set_partials"] as? List<*> ?: emptyList<Any>()
val partialsRaw = data["set_partials"] as? List<*> ?: data["setPartials"] as? List<*> ?: emptyList<Any>()
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<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 {
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.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<Recording> =
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<Match, Instant?>(nullsLast()) { match ->
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 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()) {

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,
) {
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",

View File

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

View File

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

View File

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

View File

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

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