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
@@ -57,6 +57,7 @@
713E0129656649FC8630FDC6 /* LivePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46CE696CAB994E2C8E399776 /* LivePreviewView.swift */; };
7575030B9AEB4DEBAD986A23 /* MatchColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2996990D08341ABA52BE287 /* MatchColors.swift */; };
777B73E69F8A4DC6A8E7C1DF /* StepMatchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB037B1369B454D84F6850D /* StepMatchScreen.swift */; };
A1B2C3D4E5F60718293A4B5D /* MatchCoverSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F60718293A4B5D6E /* MatchCoverSection.swift */; };
8723E1F8443A46B3AF49E8A3 /* ScoreState.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA08E23008964D02AB59377E /* ScoreState.swift */; };
8C8FEABCD83C422BA5A5DEA3 /* MatchLiveTv/Resources/de.lproj in Resources */ = {isa = PBXBuildFile; fileRef = EEFE07D4B6FD44199A5B9C25 /* MatchLiveTv/Resources/de.lproj */; };
9391C9EA0B834DC8956483DF /* ActionCableClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98526C26B2584F55AADF0FFF /* ActionCableClient.swift */; };
@@ -194,6 +195,7 @@
E860A0E35362427DA84F8972 /* BroadcastControlsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastControlsOverlay.swift; path = MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift; sourceTree = "<group>"; };
EA08E23008964D02AB59377E /* ScoreState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreState.swift; path = MatchLiveTv/Domain/ScoreState.swift; sourceTree = "<group>"; };
EAB037B1369B454D84F6850D /* StepMatchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepMatchScreen.swift; path = MatchLiveTv/UI/Wizard/StepMatchScreen.swift; sourceTree = "<group>"; };
B2C3D4E5F60718293A4B5D6E /* MatchCoverSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchCoverSection.swift; path = MatchLiveTv/UI/Wizard/MatchCoverSection.swift; sourceTree = "<group>"; };
EE0A49E8D59947448DAA7851 /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AuthRepository.swift; path = MatchLiveTv/Data/Repository/AuthRepository.swift; sourceTree = "<group>"; };
EEFE07D4B6FD44199A5B9C25 /* MatchLiveTv/Resources/de.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/de.lproj; sourceTree = "<group>"; };
F438E7E164F44C7B856C4808 /* LiveBroadcastCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastCoordinator.swift; path = MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift; sourceTree = "<group>"; };
@@ -324,6 +326,7 @@
2B5C00419D09442FA8ED60F3 /* ShareSheet.swift */,
D2996990D08341ABA52BE287 /* MatchColors.swift */,
EAB037B1369B454D84F6850D /* StepMatchScreen.swift */,
B2C3D4E5F60718293A4B5D6E /* MatchCoverSection.swift */,
1079231B9A4E446AA9AD6C0A /* StepNetworkTestScreen.swift */,
7EC0179FE6BC4323B1F9E1D3 /* StepTransmissionScreen.swift */,
6FBBE0F1399840FFA0E7EF2E /* TeamBrandingEditor.swift */,
@@ -502,6 +505,7 @@
1A82C6F343F54284AFA8FECE /* ShareSheet.swift in Sources */,
7575030B9AEB4DEBAD986A23 /* MatchColors.swift in Sources */,
777B73E69F8A4DC6A8E7C1DF /* StepMatchScreen.swift in Sources */,
A1B2C3D4E5F60718293A4B5D /* MatchCoverSection.swift in Sources */,
F67C0C88237F41DE8EDF6CB8 /* StepNetworkTestScreen.swift in Sources */,
2B8E5D9945364094B74165DE /* StepTransmissionScreen.swift in Sources */,
45776A6A76D04EF5A0C0D187 /* TeamBrandingEditor.swift in Sources */,
@@ -323,6 +323,16 @@ enum L10n {
"wizard.error.youtube.unavailable": "YouTube non disponibile per questa squadra",
"wizard.match.away.team.label": "Squadra avversaria",
"wizard.match.category.hint": "Es. Serie C, torneo estivo — lo useremo in descrizione e overlay.",
"wizard.match.cover.title": "Copertina in pausa",
"wizard.match.cover.source.label": "Origine: %1$@",
"wizard.match.cover.source.default": "Match Live TV",
"wizard.match.cover.source.club": "Società",
"wizard.match.cover.source.team": "Squadra",
"wizard.match.cover.source.match": "Questa partita",
"wizard.match.cover.hint": "Mostrata in pausa o senza segnale finché non riparte la diretta.",
"wizard.match.cover.change": "Cambia copertina per questa partita",
"wizard.match.cover.reset": "Usa quella predefinita",
"wizard.match.cover.premium.required": "Copertina sponsor personalizzata con Premium Full",
"wizard.match.category.label": "Campionato (facoltativo)",
"wizard.match.custom.overlay.label": "Overlay video personalizzato",
"wizard.match.custom.rules.label": "Regole punteggio personalizzate",
@@ -628,6 +638,16 @@ enum L10n {
"wizard.error.youtube.unavailable": "YouTube not available for this team",
"wizard.match.away.team.label": "Away team",
"wizard.match.category.hint": "E.g. Serie C, summer tournament — we'll use it in the description and overlay.",
"wizard.match.cover.title": "Pause cover",
"wizard.match.cover.source.label": "Source: %1$@",
"wizard.match.cover.source.default": "Match Live TV",
"wizard.match.cover.source.club": "Club",
"wizard.match.cover.source.team": "Team",
"wizard.match.cover.source.match": "This match",
"wizard.match.cover.hint": "Shown while paused or offline until the stream resumes.",
"wizard.match.cover.change": "Change cover for this match",
"wizard.match.cover.reset": "Use default cover",
"wizard.match.cover.premium.required": "Custom sponsor cover requires Premium Full",
"wizard.match.category.label": "Category (optional)",
"wizard.match.custom.overlay.label": "Custom video overlay",
"wizard.match.custom.rules.label": "Custom scoring rules",
@@ -933,6 +953,16 @@ enum L10n {
"wizard.error.youtube.unavailable": "YouTube non disponible pour cette équipe",
"wizard.match.away.team.label": "Équipe adverse",
"wizard.match.category.hint": "Ex. Serie C, tournoi d'été — utilisé dans la description et l'overlay.",
"wizard.match.cover.title": "Image de pause",
"wizard.match.cover.source.label": "Source : %1$@",
"wizard.match.cover.source.default": "Match Live TV",
"wizard.match.cover.source.club": "Club",
"wizard.match.cover.source.team": "Équipe",
"wizard.match.cover.source.match": "Ce match",
"wizard.match.cover.hint": "Affichée en pause ou sans signal jusqu'à la reprise du direct.",
"wizard.match.cover.change": "Changer l'image pour ce match",
"wizard.match.cover.reset": "Utiliser l'image par défaut",
"wizard.match.cover.premium.required": "Image sponsor personnalisée avec Premium Full",
"wizard.match.category.label": "Championnat (facultatif)",
"wizard.match.custom.overlay.label": "Overlay vidéo personnalisé",
"wizard.match.custom.rules.label": "Règles de score personnalisées",
@@ -1238,6 +1268,16 @@ enum L10n {
"wizard.error.youtube.unavailable": "YouTube für dieses Team nicht verfügbar",
"wizard.match.away.team.label": "Gastteam",
"wizard.match.category.hint": "Z. B. Serie C, Sommerturnier — wird in Beschreibung und Overlay verwendet.",
"wizard.match.cover.title": "Pausenbild",
"wizard.match.cover.source.label": "Quelle: %1$@",
"wizard.match.cover.source.default": "Match Live TV",
"wizard.match.cover.source.club": "Verein",
"wizard.match.cover.source.team": "Team",
"wizard.match.cover.source.match": "Dieses Spiel",
"wizard.match.cover.hint": "Wird bei Pause oder ohne Signal angezeigt, bis der Stream weitergeht.",
"wizard.match.cover.change": "Bild für dieses Spiel ändern",
"wizard.match.cover.reset": "Standardbild verwenden",
"wizard.match.cover.premium.required": "Individuelles Sponsor-Bild mit Premium Full",
"wizard.match.category.label": "Liga (optional)",
"wizard.match.custom.overlay.label": "Individuelles Video-Overlay",
"wizard.match.custom.rules.label": "Individuelle Punkteregeln",
@@ -1543,6 +1583,16 @@ enum L10n {
"wizard.error.youtube.unavailable": "YouTube no disponible para este equipo",
"wizard.match.away.team.label": "Equipo visitante",
"wizard.match.category.hint": "Ej. Serie C, torneo de verano — lo usaremos en la descripción y el overlay.",
"wizard.match.cover.title": "Imagen de pausa",
"wizard.match.cover.source.label": "Origen: %1$@",
"wizard.match.cover.source.default": "Match Live TV",
"wizard.match.cover.source.club": "Club",
"wizard.match.cover.source.team": "Equipo",
"wizard.match.cover.source.match": "Este partido",
"wizard.match.cover.hint": "Se muestra en pausa o sin señal hasta que retome la transmisión.",
"wizard.match.cover.change": "Cambiar imagen para este partido",
"wizard.match.cover.reset": "Usar imagen predeterminada",
"wizard.match.cover.premium.required": "Imagen de patrocinio personalizada con Premium Full",
"wizard.match.category.label": "Categoría (opcional)",
"wizard.match.custom.overlay.label": "Overlay de vídeo personalizado",
"wizard.match.custom.rules.label": "Reglas de puntuación personalizadas",
+10 -2
View File
@@ -101,6 +101,7 @@ struct TeamDto: Decodable {
let phoneDownloadEnabled: Bool?
let billingUrl: String?
let staffManageUrl: String?
let customCoverEnabled: Bool?
let logoUrl: String?
let primaryColor: String?
let secondaryColor: String?
@@ -127,7 +128,8 @@ struct TeamDto: Decodable {
recordingsEnabled: recordingsEnabled ?? false,
phoneDownloadEnabled: phoneDownloadEnabled ?? false,
billingUrl: billingUrl,
staffManageUrl: staffManageUrl
staffManageUrl: staffManageUrl,
customCoverEnabled: customCoverEnabled ?? false
)
}
}
@@ -157,6 +159,9 @@ struct MatchDto: Decodable {
let homeLogoUrl: String?
let opponentPrimaryColor: String?
let opponentLogoUrl: String?
let effectiveCoverUrl: String?
let coverSource: String?
let customCoverEnabled: Bool?
func toDomain() -> Match {
Match(
@@ -182,7 +187,10 @@ struct MatchDto: Decodable {
homeSecondaryColor: homeSecondaryColor,
homeLogoUrl: homeLogoUrl,
opponentPrimaryColor: opponentPrimaryColor,
opponentLogoUrl: opponentLogoUrl
opponentLogoUrl: opponentLogoUrl,
effectiveCoverUrl: effectiveCoverUrl,
coverSource: coverSource ?? "default",
customCoverEnabled: customCoverEnabled ?? false
)
}
}
@@ -101,7 +101,9 @@ final class MatchRepository {
scoringRules: ScoringRules?,
sportKey: String? = nil,
overlayKind: String? = nil,
opponentLogoImage: UIImage? = nil
opponentLogoImage: UIImage? = nil,
coverImage: UIImage? = nil,
removeCover: Bool = false
) async throws -> Match {
let rulesMap = scoringRules?.toMap(setsToWin: setsToWin)
let body = UpdateMatchBodyFull(
@@ -115,7 +117,8 @@ final class MatchRepository {
opponentPrimaryColor: opponentPrimaryColor,
scoringRules: rulesMap
)
if let opponentLogoImage, let data = opponentLogoImage.pngData() {
let needsMultipart = opponentLogoImage != nil || coverImage != nil || removeCover
if needsMultipart {
var fields: [MultipartField] = [
.text("match[opponent_name]", opponentName),
.text("match[sets_to_win]", "\(setsToWin)"),
@@ -129,7 +132,15 @@ final class MatchRepository {
fields.append(.text("match[scoring_rules][points_deciding_set]", "\(rules.pointsDecidingSet)"))
fields.append(.text("match[scoring_rules][min_point_lead]", "\(rules.minPointLead)"))
}
fields.append(.file("opponent_logo_file", data, filename: "opponent.png", mime: "image/png"))
if let opponentLogoImage, let data = opponentLogoImage.pngData() {
fields.append(.file("opponent_logo_file", data, filename: "opponent.png", mime: "image/png"))
}
if let coverImage, let data = coverImage.pngData() {
fields.append(.file("cover_image", data, filename: "cover.png", mime: "image/png"))
}
if removeCover {
fields.append(.text("remove_cover", "1"))
}
return try await api.updateMatchMultipart(id: matchId, fields: fields).toDomain()
}
return try await api.updateMatch(id: matchId, body: UpdateMatchRequestFull(match: body)).toDomain()
@@ -44,6 +44,7 @@ struct Team: Identifiable, Equatable, Sendable {
let phoneDownloadEnabled: Bool
let billingUrl: String?
let staffManageUrl: String?
let customCoverEnabled: Bool
var canUseYoutube: Bool { youtubeEnabled }
var isYoutubeReady: Bool { youtubeSelectable }
@@ -95,6 +96,9 @@ struct Match: Identifiable, Equatable, Sendable {
let homeLogoUrl: String?
let opponentPrimaryColor: String?
let opponentLogoUrl: String?
let effectiveCoverUrl: String?
let coverSource: String
let customCoverEnabled: Bool
var hasActiveSession: Bool { activeSessionId != nil }
@@ -0,0 +1,97 @@
import PhotosUI
import SwiftUI
struct MatchCoverSection: View {
let effectiveCoverUrl: String?
let coverSource: String
let customCoverEnabled: Bool
@Binding var localCoverImage: UIImage?
let hasMatchOverride: Bool
let onClearOverride: () -> Void
@State private var coverPickerItem: PhotosPickerItem?
private var sourceLabel: String {
switch coverSource {
case "match": return L10n.t("wizard.match.cover.source.match")
case "team": return L10n.t("wizard.match.cover.source.team")
case "club": return L10n.t("wizard.match.cover.source.club")
default: return L10n.t("wizard.match.cover.source.default")
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(L10n.t("wizard.match.cover.title"))
.font(MatchTypography.labelLarge)
.foregroundStyle(MatchColors.textSecondary)
Text(L10n.t("wizard.match.cover.source.label", sourceLabel))
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.padding(.top, 4)
Group {
if let localCoverImage {
Image(uiImage: localCoverImage)
.resizable()
.scaledToFill()
} else if let urlString = MediaUrl.resolve(effectiveCoverUrl), let url = URL(string: urlString) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
Color.clear
}
}
} else {
Color.clear
}
}
.frame(maxWidth: .infinity)
.aspectRatio(16 / 9, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 12))
.background(MatchColors.surface)
.padding(.top, 12)
Text(L10n.t("wizard.match.cover.hint"))
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.padding(.top, 8)
if customCoverEnabled {
PhotosPicker(selection: $coverPickerItem, matching: .images) {
Text(L10n.t("wizard.match.cover.change"))
.font(MatchTypography.labelLarge)
.frame(maxWidth: .infinity)
.frame(height: 52)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
}
.buttonStyle(.plain)
.padding(.top, 12)
if hasMatchOverride || localCoverImage != nil {
MatchSecondaryButton(
label: L10n.t("wizard.match.cover.reset"),
action: onClearOverride
)
.padding(.top, 8)
}
} else {
Text(L10n.t("wizard.match.cover.premium.required"))
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.padding(.top, 12)
}
}
.onChange(of: coverPickerItem) { item in
guard let item else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
await MainActor.run { localCoverImage = image }
}
}
}
}
}
@@ -53,6 +53,11 @@ struct StepMatchScreen: View {
@State private var opponentPrimaryColor: String
@State private var homeLogoImage: UIImage?
@State private var opponentLogoImage: UIImage?
@State private var coverImage: UIImage?
@State private var removeCover = false
@State private var effectiveCoverUrl: String?
@State private var coverSource: String
@State private var customCoverEnabled: Bool
@State private var saving = false
@State private var sports: [SportOption] = []
@State private var customRules = false
@@ -84,6 +89,9 @@ struct StepMatchScreen: View {
_opponentLogoUrl = State(initialValue: match.opponentLogoUrl)
_homePrimaryColor = State(initialValue: ColorHex.normalizeHexColor(match.homePrimaryColor))
_opponentPrimaryColor = State(initialValue: ColorHex.normalizeHexColor(match.opponentPrimaryColor, fallback: WizardDefaults.opponentColor))
_effectiveCoverUrl = State(initialValue: match.effectiveCoverUrl)
_coverSource = State(initialValue: match.coverSource)
_customCoverEnabled = State(initialValue: match.customCoverEnabled)
_setsToWin = State(initialValue: min(max(match.setsToWin, 2), 3))
_pointsPerSet = State(initialValue: rules?.pointsPerSet ?? WizardDefaults.pointsPerSet)
_pointsDecidingSet = State(initialValue: rules?.pointsDecidingSet ?? WizardDefaults.pointsDecidingSet)
@@ -150,6 +158,19 @@ struct StepMatchScreen: View {
)
.padding(.bottom, 16)
MatchCoverSection(
effectiveCoverUrl: effectiveCoverUrl,
coverSource: coverImage != nil ? "match" : coverSource,
customCoverEnabled: customCoverEnabled,
localCoverImage: $coverImage,
hasMatchOverride: match.coverSource == "match",
onClearOverride: {
coverImage = nil
removeCover = true
}
)
.padding(.bottom, 16)
WizardOutlinedField(label: L10n.t("wizard.match.location.label"), text: $location)
.padding(.bottom, 12)
@@ -213,9 +234,12 @@ struct StepMatchScreen: View {
.task(id: match.id) {
if let cached = container.wizardSession.team {
applyHomeTeam(cached)
} else if let team = await container.matchRepository.fetchTeam(teamId: match.teamId) {
} else if let team = await container.matchRepository.fetchTeam(teamId: match.teamId) {
container.wizardSession.team = team
applyHomeTeam(team)
if !match.customCoverEnabled {
customCoverEnabled = team.customCoverEnabled
}
}
if let loaded = try? await container.matchRepository.fetchSports() {
sports = loaded
@@ -228,6 +252,9 @@ struct StepMatchScreen: View {
selectedOverlay = defaultOverlay
}
}
.onChange(of: coverImage) { image in
if image != nil { removeCover = false }
}
.onChange(of: customOverlay) { enabled in
if !enabled {
selectedOverlay = defaultOverlay
@@ -414,9 +441,13 @@ struct StepMatchScreen: View {
scoringRules: resolvedRules,
sportKey: effectiveSportKey,
overlayKind: overlayKind,
opponentLogoImage: opponentLogoImage
opponentLogoImage: opponentLogoImage,
coverImage: coverImage,
removeCover: removeCover
)
container.wizardSession.match = updated
effectiveCoverUrl = updated.effectiveCoverUrl
coverSource = updated.coverSource
onNext()
} catch {
if let message = UserFacingError.message(for: error) {
@@ -26,7 +26,10 @@ final class MatchScoringRulesTests: XCTestCase {
homeSecondaryColor: nil,
homeLogoUrl: nil,
opponentPrimaryColor: "#1E3A8A",
opponentLogoUrl: nil
opponentLogoUrl: nil,
effectiveCoverUrl: nil,
coverSource: "default",
customCoverEnabled: false
)
}
@@ -87,7 +90,10 @@ final class MatchScoringRulesTests: XCTestCase {
homeSecondaryColor: nil,
homeLogoUrl: nil,
opponentPrimaryColor: nil,
opponentLogoUrl: nil
opponentLogoUrl: nil,
effectiveCoverUrl: nil,
coverSource: "default",
customCoverEnabled: false
)
XCTAssertFalse(MatchScoringContext(match: basket).usesSetLogic)
}