Aggiunge copertina sponsor custom solo Premium Full, con override in wizard e slate sui nodi di streaming.

Permette upload da portale e app con ereditarietà partita→squadra→società, generazione MP4 via ffmpeg e distribuzione su home lab e nodi CPX per pause e assenza segnale.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-19 23:47:17 +02:00
co-authored by Cursor
parent a51d5e8da5
commit 41d4235258
62 changed files with 1487 additions and 20 deletions
@@ -85,6 +85,7 @@ data class TeamDto(
@Json(name = "phone_download_enabled") val phoneDownloadEnabled: Boolean? = false,
@Json(name = "billing_url") val billingUrl: String? = null,
@Json(name = "staff_manage_url") val staffManageUrl: String? = null,
@Json(name = "custom_cover_enabled") val customCoverEnabled: Boolean? = false,
@Json(name = "logo_url") val logoUrl: String? = null,
@Json(name = "primary_color") val primaryColor: String? = null,
@Json(name = "secondary_color") val secondaryColor: String? = null,
@@ -111,6 +112,7 @@ data class TeamDto(
phoneDownloadEnabled = phoneDownloadEnabled ?: false,
billingUrl = billingUrl,
staffManageUrl = staffManageUrl,
customCoverEnabled = customCoverEnabled ?: false,
)
}
@@ -140,6 +142,9 @@ data class MatchDto(
@Json(name = "home_logo_url") val homeLogoUrl: String? = null,
@Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null,
@Json(name = "opponent_logo_url") val opponentLogoUrl: String? = null,
@Json(name = "effective_cover_url") val effectiveCoverUrl: String? = null,
@Json(name = "cover_source") val coverSource: String? = null,
@Json(name = "custom_cover_enabled") val customCoverEnabled: Boolean? = null,
) {
fun toDomain() = Match(
id = id,
@@ -165,6 +170,9 @@ data class MatchDto(
homeLogoUrl = homeLogoUrl,
opponentPrimaryColor = opponentPrimaryColor,
opponentLogoUrl = opponentLogoUrl,
effectiveCoverUrl = effectiveCoverUrl,
coverSource = coverSource ?: "default",
customCoverEnabled = customCoverEnabled ?: false,
)
}
@@ -91,6 +91,8 @@ interface MatchLiveApi {
@Part("match[scoring_rules][points_deciding_set]") pointsDecidingSet: okhttp3.RequestBody?,
@Part("match[scoring_rules][min_point_lead]") minPointLead: okhttp3.RequestBody?,
@Part opponentLogoFile: MultipartBody.Part?,
@Part coverImage: MultipartBody.Part?,
@Part("remove_cover") removeCover: okhttp3.RequestBody?,
): MatchDto
@DELETE("matches/{matchId}")
@@ -122,6 +122,8 @@ class MatchRepository(
sportKey: String? = null,
overlayKind: String? = null,
opponentLogoUri: Uri? = null,
coverImageUri: Uri? = null,
removeCover: Boolean = false,
): Match {
val body = UpdateMatchBodyFull(
opponentName = opponentName,
@@ -134,7 +136,7 @@ class MatchRepository(
opponentPrimaryColor = opponentPrimaryColor,
scoringRules = scoringRules?.toMap().takeIf { !it.isNullOrEmpty() },
)
if (opponentLogoUri != null) {
if (opponentLogoUri != null || coverImageUri != null || removeCover) {
return api.updateMatchMultipart(
matchId = matchId,
opponentName = MultipartBodies.textPart(opponentName),
@@ -146,7 +148,13 @@ class MatchRepository(
pointsPerSet = scoringRules?.pointsPerSet?.let { MultipartBodies.textPart(it.toString()) },
pointsDecidingSet = scoringRules?.pointsDecidingSet?.let { MultipartBodies.textPart(it.toString()) },
minPointLead = scoringRules?.minPointLead?.let { MultipartBodies.textPart(it.toString()) },
opponentLogoFile = MultipartBodies.logoPart(appContext, opponentLogoUri, "opponent_logo_file"),
opponentLogoFile = opponentLogoUri?.let {
MultipartBodies.logoPart(appContext, it, "opponent_logo_file")
},
coverImage = coverImageUri?.let {
MultipartBodies.logoPart(appContext, it, "cover_image")
},
removeCover = if (removeCover) MultipartBodies.textPart("1") else null,
).toDomain()
}
return api.updateMatch(
@@ -43,6 +43,7 @@ data class Team(
val phoneDownloadEnabled: Boolean = false,
val billingUrl: String? = null,
val staffManageUrl: String? = null,
val customCoverEnabled: Boolean = false,
) {
val canUseYoutube: Boolean get() = youtubeEnabled
val isYoutubeReady: Boolean get() = youtubeSelectable
@@ -100,6 +101,9 @@ data class Match(
val homeLogoUrl: String? = null,
val opponentPrimaryColor: String? = null,
val opponentLogoUrl: String? = null,
val effectiveCoverUrl: String? = null,
val coverSource: String = "default",
val customCoverEnabled: Boolean = false,
) {
val hasActiveSession: Boolean get() = activeSessionId != null
@@ -0,0 +1,105 @@
package com.matchlivetv.match_live_tv.ui.wizard
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.core.resolveMediaUrl
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@Composable
fun coverSourceLabel(source: String): String = when (source) {
"match" -> stringResource(R.string.wizard_match_cover_source_match)
"team" -> stringResource(R.string.wizard_match_cover_source_team)
"club" -> stringResource(R.string.wizard_match_cover_source_club)
else -> stringResource(R.string.wizard_match_cover_source_default)
}
@Composable
fun MatchCoverSection(
effectiveCoverUrl: String?,
coverSource: String,
customCoverEnabled: Boolean,
localCoverUri: Uri?,
hasMatchOverride: Boolean,
onPickCover: () -> Unit,
onClearOverride: () -> Unit,
modifier: Modifier = Modifier,
) {
val previewModel = localCoverUri ?: resolveMediaUrl(effectiveCoverUrl)
Column(modifier.fillMaxWidth()) {
Text(
stringResource(R.string.wizard_match_cover_title),
style = MaterialTheme.typography.labelLarge,
color = MatchColors.TextSecondary,
)
Text(
stringResource(R.string.wizard_match_cover_source_label, coverSourceLabel(coverSource)),
style = MaterialTheme.typography.bodyMedium,
color = MatchColors.TextSecondary,
modifier = Modifier.padding(top = 4.dp),
)
Box(
Modifier
.fillMaxWidth()
.padding(top = 12.dp)
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(12.dp))
.background(MatchColors.Surface),
) {
if (previewModel != null) {
AsyncImage(
model = previewModel,
contentDescription = stringResource(R.string.wizard_match_cover_preview_cd),
modifier = Modifier.fillMaxWidth(),
contentScale = ContentScale.Crop,
)
}
}
Text(
stringResource(R.string.wizard_match_cover_hint),
style = MaterialTheme.typography.bodyMedium,
color = MatchColors.TextSecondary,
modifier = Modifier.padding(top = 8.dp),
)
if (customCoverEnabled) {
MatchPrimaryButton(
label = stringResource(R.string.wizard_match_cover_change),
onClick = onPickCover,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
)
if (hasMatchOverride || localCoverUri != null) {
MatchSecondaryButton(
label = stringResource(R.string.wizard_match_cover_reset),
onClick = onClearOverride,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
} else {
Text(
stringResource(R.string.wizard_match_cover_premium_required),
style = MaterialTheme.typography.bodyMedium,
color = MatchColors.TextSecondary,
modifier = Modifier.padding(top = 12.dp),
)
}
}
}
@@ -105,6 +105,11 @@ fun StepMatchScreen(
}
var homeLogoUri by remember { mutableStateOf<Uri?>(null) }
var opponentLogoUri by remember { mutableStateOf<Uri?>(null) }
var coverImageUri by remember { mutableStateOf<Uri?>(null) }
var removeCover by remember { mutableStateOf(false) }
var effectiveCoverUrl by remember { mutableStateOf(match.effectiveCoverUrl) }
var coverSource by remember { mutableStateOf(match.coverSource) }
var customCoverEnabled by remember { mutableStateOf(match.customCoverEnabled) }
LaunchedEffect(match.teamId) {
runCatching { container.matchRepository.fetchTeam(match.teamId) }
@@ -113,10 +118,20 @@ fun StepMatchScreen(
if (team != null) {
homePrimaryColor = normalizeHexColor(team.primaryColor ?: match.homePrimaryColor)
homeLogoUrl = team.logoUrl ?: match.homeLogoUrl
if (!match.customCoverEnabled) {
customCoverEnabled = team.customCoverEnabled
}
}
}
}
val pickCover = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
) { uri ->
coverImageUri = uri
removeCover = false
}
val pickHomeLogo = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
) { uri -> homeLogoUri = uri }
@@ -230,6 +245,24 @@ fun StepMatchScreen(
onClearLogo = { opponentLogoUri = null },
)
Spacer(Modifier.height(16.dp))
MatchCoverSection(
effectiveCoverUrl = effectiveCoverUrl,
coverSource = when {
coverImageUri != null -> "match"
else -> coverSource
},
customCoverEnabled = customCoverEnabled,
localCoverUri = coverImageUri,
hasMatchOverride = match.coverSource == "match",
onPickCover = {
pickCover.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
},
onClearOverride = {
coverImageUri = null
removeCover = true
},
)
Spacer(Modifier.height(16.dp))
MatchOutlinedField(
value = location,
onValueChange = { location = it },
@@ -500,9 +533,13 @@ fun StepMatchScreen(
""
},
opponentLogoUri = opponentLogoUri,
coverImageUri = coverImageUri,
removeCover = removeCover,
)
}.onSuccess { updated ->
container.wizardSession.match = updated
effectiveCoverUrl = updated.effectiveCoverUrl
coverSource = updated.coverSource
onNext()
}.onFailure {
onError(it.message ?: saveGenericError)
@@ -64,6 +64,17 @@
<string name="wizard_match_location_label">Ort</string>
<string name="wizard_match_category_label">Liga (optional)</string>
<string name="wizard_match_category_hint">Z. B. Serie C, Sommerturnier — wird in Beschreibung und Overlay verwendet.</string>
<string name="wizard_match_cover_title">Pausenbild</string>
<string name="wizard_match_cover_source_label">Quelle: %1$s</string>
<string name="wizard_match_cover_source_default">Match Live TV</string>
<string name="wizard_match_cover_source_club">Verein</string>
<string name="wizard_match_cover_source_team">Team</string>
<string name="wizard_match_cover_source_match">Dieses Spiel</string>
<string name="wizard_match_cover_hint">Wird bei Pause oder ohne Signal angezeigt, bis der Stream weitergeht.</string>
<string name="wizard_match_cover_change">Bild für dieses Spiel ändern</string>
<string name="wizard_match_cover_reset">Standardbild verwenden</string>
<string name="wizard_match_cover_premium_required">Individuelles Sponsor-Bild mit Premium Full</string>
<string name="wizard_match_cover_preview_cd">Bildvorschau</string>
<string name="wizard_match_scheduled_label">Geplant für</string>
<string name="wizard_match_custom_overlay_label">Individuelles Video-Overlay</string>
<string name="wizard_match_custom_rules_label">Individuelle Punkteregeln</string>
@@ -64,6 +64,17 @@
<string name="wizard_match_location_label">Location</string>
<string name="wizard_match_category_label">Category (optional)</string>
<string name="wizard_match_category_hint">E.g. Serie C, summer tournament — we\'ll use it in the description and overlay.</string>
<string name="wizard_match_cover_title">Pause cover</string>
<string name="wizard_match_cover_source_label">Source: %1$s</string>
<string name="wizard_match_cover_source_default">Match Live TV</string>
<string name="wizard_match_cover_source_club">Club</string>
<string name="wizard_match_cover_source_team">Team</string>
<string name="wizard_match_cover_source_match">This match</string>
<string name="wizard_match_cover_hint">Shown while paused or offline until the stream resumes.</string>
<string name="wizard_match_cover_change">Change cover for this match</string>
<string name="wizard_match_cover_reset">Use default cover</string>
<string name="wizard_match_cover_premium_required">Custom sponsor cover requires Premium Full</string>
<string name="wizard_match_cover_preview_cd">Cover preview</string>
<string name="wizard_match_scheduled_label">Scheduled for</string>
<string name="wizard_match_custom_overlay_label">Custom video overlay</string>
<string name="wizard_match_custom_rules_label">Custom scoring rules</string>
@@ -64,6 +64,17 @@
<string name="wizard_match_location_label">Lugar</string>
<string name="wizard_match_category_label">Categoría (opcional)</string>
<string name="wizard_match_category_hint">Ej. Serie C, torneo de verano — lo usaremos en la descripción y el overlay.</string>
<string name="wizard_match_cover_title">Imagen de pausa</string>
<string name="wizard_match_cover_source_label">Origen: %1$s</string>
<string name="wizard_match_cover_source_default">Match Live TV</string>
<string name="wizard_match_cover_source_club">Club</string>
<string name="wizard_match_cover_source_team">Equipo</string>
<string name="wizard_match_cover_source_match">Este partido</string>
<string name="wizard_match_cover_hint">Se muestra en pausa o sin señal hasta que retome la transmisión.</string>
<string name="wizard_match_cover_change">Cambiar imagen para este partido</string>
<string name="wizard_match_cover_reset">Usar imagen predeterminada</string>
<string name="wizard_match_cover_premium_required">Imagen de patrocinio personalizada con Premium Full</string>
<string name="wizard_match_cover_preview_cd">Vista previa de la imagen</string>
<string name="wizard_match_scheduled_label">Programado para</string>
<string name="wizard_match_custom_overlay_label">Overlay de vídeo personalizado</string>
<string name="wizard_match_custom_rules_label">Reglas de puntuación personalizadas</string>
@@ -64,6 +64,17 @@
<string name="wizard_match_location_label">Lieu</string>
<string name="wizard_match_category_label">Championnat (facultatif)</string>
<string name="wizard_match_category_hint">Ex. Serie C, tournoi d\'été — utilisé dans la description et l\'overlay.</string>
<string name="wizard_match_cover_title">Image de pause</string>
<string name="wizard_match_cover_source_label">Source : %1$s</string>
<string name="wizard_match_cover_source_default">Match Live TV</string>
<string name="wizard_match_cover_source_club">Club</string>
<string name="wizard_match_cover_source_team">Équipe</string>
<string name="wizard_match_cover_source_match">Ce match</string>
<string name="wizard_match_cover_hint">Affichée en pause ou sans signal jusqu\'à la reprise du direct.</string>
<string name="wizard_match_cover_change">Changer l\'image pour ce match</string>
<string name="wizard_match_cover_reset">Utiliser l\'image par défaut</string>
<string name="wizard_match_cover_premium_required">Image sponsor personnalisée avec Premium Full</string>
<string name="wizard_match_cover_preview_cd">Aperçu de l\'image</string>
<string name="wizard_match_scheduled_label">Programmé pour</string>
<string name="wizard_match_custom_overlay_label">Overlay vidéo personnalisé</string>
<string name="wizard_match_custom_rules_label">Règles de score personnalisées</string>
@@ -65,6 +65,17 @@
<string name="wizard_match_location_label">Luogo</string>
<string name="wizard_match_category_label">Campionato (facoltativo)</string>
<string name="wizard_match_category_hint">Es. Serie C, torneo estivo — lo useremo in descrizione e overlay.</string>
<string name="wizard_match_cover_title">Copertina in pausa</string>
<string name="wizard_match_cover_source_label">Origine: %1$s</string>
<string name="wizard_match_cover_source_default">Match Live TV</string>
<string name="wizard_match_cover_source_club">Società</string>
<string name="wizard_match_cover_source_team">Squadra</string>
<string name="wizard_match_cover_source_match">Questa partita</string>
<string name="wizard_match_cover_hint">Mostrata in pausa o senza segnale finché non riparte la diretta.</string>
<string name="wizard_match_cover_change">Cambia copertina per questa partita</string>
<string name="wizard_match_cover_reset">Usa quella predefinita</string>
<string name="wizard_match_cover_premium_required">Copertina sponsor personalizzata con Premium Full</string>
<string name="wizard_match_cover_preview_cd">Anteprima copertina</string>
<string name="wizard_match_scheduled_label">Programmata per</string>
<string name="wizard_match_custom_overlay_label">Overlay video personalizzato</string>
<string name="wizard_match_custom_rules_label">Regole punteggio personalizzate</string>