Allinea iOS a i18n Android 2.0.5 e aggiunge monitoraggio termico nativo.

Completa L10n su login/hub/wizard/broadcast e introduce ThermalStateManager su iOS/Android con degradazione qualità e indicatore in overlay.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Emiliano Frascaro
2026-07-24 11:19:37 +02:00
co-authored by Cursor
parent ff4de33cc5
commit ea5da1eb86
46 changed files with 2675 additions and 1241 deletions
@@ -60,7 +60,7 @@ struct BroadcastControlsOverlay: View {
VStack(alignment: .trailing, spacing: 6) {
SideIconButton(
systemName: controlsVisible ? "eye.slash.fill" : "eye.fill",
accessibilityLabel: controlsVisible ? "Nascondi controlli" : "Mostra controlli",
accessibilityLabel: controlsVisible ? L10n.t("broadcast.hide.controls.cd") : L10n.t("broadcast.show.controls.cd"),
action: onToggleControls
)
BroadcastTelemetryPanel(
@@ -94,11 +94,11 @@ struct BroadcastControlsOverlay: View {
.padding(.bottom, 8)
}
}
.alert("Terminare la diretta?", isPresented: $showTerminateConfirm) {
Button("Annulla", role: .cancel) {}
Button("TERMINA", role: .destructive, action: onTerminate)
.alert(L10n.t("broadcast.terminate.title"), isPresented: $showTerminateConfirm) {
Button(L10n.t("action.cancel"), role: .cancel) {}
Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: onTerminate)
} message: {
Text("Lo streaming verrà chiuso per tutti gli spettatori.")
Text(L10n.t("broadcast.terminate.message"))
}
}
@@ -106,26 +106,26 @@ struct BroadcastControlsOverlay: View {
VStack(spacing: 6) {
SideIconButton(
systemName: "square.and.arrow.up",
accessibilityLabel: "Condividi diretta",
accessibilityLabel: L10n.t("broadcast.share.live.cd"),
action: onShareLive,
enabled: shareLiveEnabled
)
SideIconButton(
systemName: "video.fill",
accessibilityLabel: "Condividi link regia",
accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
action: onShareRegia
)
if let onCloseSet {
SideIconButton(
systemName: "checkmark",
accessibilityLabel: "Chiudi set",
accessibilityLabel: L10n.t("score.action.close.set"),
action: onCloseSet
)
}
if let onAdvancePeriod {
SideIconButton(
systemName: "forward.end.fill",
accessibilityLabel: "Periodo successivo",
accessibilityLabel: L10n.t("broadcast.next.period.cd"),
action: onAdvancePeriod
)
}
@@ -138,13 +138,13 @@ struct BroadcastControlsOverlay: View {
VStack(spacing: 6) {
SideIconButton(
systemName: isPaused ? "play.fill" : "pause.fill",
accessibilityLabel: isPaused ? "Riprendi diretta" : "Pausa diretta",
accessibilityLabel: isPaused ? L10n.t("broadcast.resume.cd") : L10n.t("broadcast.pause.cd"),
action: onPauseOrResume,
highlighted: isPaused
)
SideIconButton(
systemName: "stop.fill",
accessibilityLabel: "Termina diretta",
accessibilityLabel: L10n.t("broadcast.terminate.cd"),
action: { showTerminateConfirm = true },
danger: true
)
@@ -154,7 +154,7 @@ struct BroadcastControlsOverlay: View {
private var scoreControlsRow: some View {
HStack(alignment: .bottom, spacing: 0) {
TeamScoreColumn(
teamLabel: "CASA",
teamLabel: L10n.t("broadcast.team.home.label"),
teamName: homeName,
accentColor: homeAccentColor,
logoUrl: homeLogoUrl,
@@ -171,7 +171,7 @@ struct BroadcastControlsOverlay: View {
ScoreCenterPanel(score: score, boardType: boardType, pointsTarget: pointsTarget)
TeamScoreColumn(
teamLabel: "OSPITE",
teamLabel: L10n.t("broadcast.team.away.label"),
teamName: awayName,
accentColor: awayAccentColor,
logoUrl: awayLogoUrl,
@@ -198,7 +198,7 @@ private struct BroadcastTelemetryPanel: View {
var body: some View {
VStack(alignment: .trailing, spacing: 2) {
Text(cableConnected ? "Tabellone OK" : "Tabellone offline")
Text(cableConnected ? L10n.t("broadcast.scoreboard.connected") : L10n.t("broadcast.scoreboard.offline"))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(cableConnected ? MatchColors.successGreen : MatchColors.textSecondary)
let fpsLabel = fps > 0 ? "\(fps) fps" : "— fps"
@@ -214,11 +214,7 @@ private struct BroadcastTelemetryPanel: View {
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
ThermalIndicator(
tempC: deviceHealth.batteryTempC,
level: deviceHealth.thermalLevel,
label: deviceHealth.thermalLabel
)
ThermalIndicator(state: deviceHealth.thermalState)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
@@ -227,31 +223,27 @@ private struct BroadcastTelemetryPanel: View {
}
private struct ThermalIndicator: View {
let tempC: Double?
let level: Int
let label: String
let state: ThermalState
private var color: Color {
switch level {
case 0: return MatchColors.successGreen
case 1: return MatchColors.accentYellow
case 2: return Color.orange
default: return MatchColors.primaryRed
switch state {
case .nominal: return MatchColors.successGreen
case .fair: return MatchColors.accentYellow
case .serious: return Color.orange
case .critical: return MatchColors.primaryRed
}
}
var body: some View {
let tempText = tempC.map { "\(Int($0))°C" } ?? "—°C"
let warningBg = level >= 1 ? color.opacity(0.18) : Color.clear
let warningBg = state >= .fair ? color.opacity(0.18) : Color.clear
HStack(spacing: 4) {
Text(tempText)
.font(.system(size: 11, weight: level >= 1 ? .bold : .regular))
Text(state.indicatorSymbol)
.font(.system(size: 11))
Text(state.displayLabel)
.font(.system(size: 11, weight: state >= .fair ? .bold : .medium))
.foregroundStyle(color)
if level >= 1 {
Text(label)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(color)
}
.lineLimit(1)
.minimumScaleFactor(0.85)
}
.padding(.horizontal, 4)
.padding(.vertical, 1)
@@ -272,17 +264,17 @@ private struct ScoreCenterPanel: View {
.fontWeight(.bold)
switch boardType {
case "basket", "timed":
Text(score.periodLabel ?? (boardType == "basket" ? "Q\(score.period)" : "\(score.period)° tempo"))
Text(score.periodLabel ?? (boardType == "basket" ? "Q\(score.period)" : L10n.t("broadcast.period.label.timed", score.period)))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
case "generic":
EmptyView()
default:
Text("Set \(score.currentSet) · \(pointsTarget) pt")
Text(L10n.t("broadcast.set.progress", score.currentSet, pointsTarget))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
if score.homeSets > 0 || score.awaySets > 0 {
Text("Set vinti \(score.homeSets)-\(score.awaySets)")
Text(L10n.t("broadcast.sets.won", score.homeSets, score.awaySets))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
}
@@ -322,28 +314,28 @@ private struct TeamScoreColumn: View {
.fontWeight(.bold)
.padding(.vertical, 4)
Spacer().frame(height: 4)
let teamSide = alignEnd ? "ospite" : "casa"
let teamSide = alignEnd ? L10n.t("broadcast.side.away") : L10n.t("broadcast.side.home")
HStack(spacing: 6) {
if alignEnd {
if showBasketButtons {
if let onPlus3 {
ScoreIconButton(label: "+3", tooltip: "+3 \(teamSide)", action: onPlus3, primary: true)
ScoreIconButton(label: "+3", tooltip: L10n.t("broadcast.tooltip.plus.side", 3, teamSide), action: onPlus3, primary: true)
}
if let onPlus2 {
ScoreIconButton(label: "+2", tooltip: "+2 \(teamSide)", action: onPlus2, primary: true)
ScoreIconButton(label: "+2", tooltip: L10n.t("broadcast.tooltip.plus.side", 2, teamSide), action: onPlus2, primary: true)
}
}
ScoreIconButton(label: "+1", tooltip: "Aggiungi punto \(teamSide)", action: onPlus, primary: !showBasketButtons)
ScoreIconButton(label: "", tooltip: "Togli punto \(teamSide)", action: onMinus)
ScoreIconButton(label: "+1", tooltip: L10n.t("broadcast.tooltip.add.point", teamSide), action: onPlus, primary: !showBasketButtons)
ScoreIconButton(label: "", tooltip: L10n.t("broadcast.tooltip.remove.point", teamSide), action: onMinus)
} else {
ScoreIconButton(label: "", tooltip: "Togli punto \(teamSide)", action: onMinus)
ScoreIconButton(label: "+1", tooltip: "Aggiungi punto \(teamSide)", action: onPlus, primary: !showBasketButtons)
ScoreIconButton(label: "", tooltip: L10n.t("broadcast.tooltip.remove.point", teamSide), action: onMinus)
ScoreIconButton(label: "+1", tooltip: L10n.t("broadcast.tooltip.add.point", teamSide), action: onPlus, primary: !showBasketButtons)
if showBasketButtons {
if let onPlus2 {
ScoreIconButton(label: "+2", tooltip: "+2 \(teamSide)", action: onPlus2, primary: true)
ScoreIconButton(label: "+2", tooltip: L10n.t("broadcast.tooltip.plus.side", 2, teamSide), action: onPlus2, primary: true)
}
if let onPlus3 {
ScoreIconButton(label: "+3", tooltip: "+3 \(teamSide)", action: onPlus3, primary: true)
ScoreIconButton(label: "+3", tooltip: L10n.t("broadcast.tooltip.plus.side", 3, teamSide), action: onPlus3, primary: true)
}
}
}
@@ -10,6 +10,7 @@ struct BroadcastScreen: View {
@StateObject private var permissions = BroadcastPermissions()
@StateObject private var scoreDialogHost = LiveScoreDialogHost()
@StateObject private var thermalManager = ThermalStateManager()
init(container: AppContainer, sessionId: String, onFinished: @escaping () -> Void) {
self.container = container
@@ -30,6 +31,7 @@ struct BroadcastScreen: View {
@State private var snackbarMessage: String?
@State private var shareItem: ShareItem?
@State private var bootstrapGeneration = 0
@State private var languageTick = 0
var body: some View {
ZStack {
@@ -44,11 +46,11 @@ struct BroadcastScreen: View {
ProgressView().tint(MatchColors.primaryRed)
} else if !permissions.allGranted {
VStack(spacing: 12) {
Text("Consenti camera e microfono per andare in diretta")
Text(L10n.t("broadcast.permissions.required"))
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.accentYellow)
.multilineTextAlignment(.center)
MatchPrimaryButton(label: "CONCEDI PERMESSI") {
MatchPrimaryButton(label: L10n.t("broadcast.permissions.grant.action")) {
Task { await permissions.requestAll() }
}
.padding(.horizontal, 40)
@@ -56,7 +58,7 @@ struct BroadcastScreen: View {
.padding(24)
} else if let session, let match {
broadcastOverlay(session: session, match: match)
.id(scoreController.score.progressKey())
.id("\(scoreController.score.progressKey())-\(languageTick)")
.zIndex(1)
}
}
@@ -89,7 +91,7 @@ struct BroadcastScreen: View {
.task(id: sessionId) {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 2_000_000_000)
deviceHealth = DeviceTelemetry.snapshot()
deviceHealth = DeviceTelemetry.snapshot(thermalState: thermalManager.state)
}
}
.task(id: sessionId) {
@@ -116,11 +118,35 @@ struct BroadcastScreen: View {
self.error = message
}
}
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("Riprova") {
.onChange(of: thermalManager.state) { _ in
deviceHealth = DeviceTelemetry.snapshot(thermalState: thermalManager.state)
}
.onChange(of: thermalManager.noticeMessage) { message in
if let message { snackbarMessage = message }
}
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
// Aggiorna solo i testi dell'overlay (vedi .id su broadcastOverlay):
// non deve smontare l'engine di broadcast né la sessione in corso.
languageTick += 1
}
.alert(L10n.t("thermal.alert.title"), isPresented: Binding(
get: { thermalManager.showCriticalAlert },
set: { if !$0 { thermalManager.acknowledgeCriticalAlert() } }
)) {
Button(L10n.t("thermal.alert.stop"), role: .destructive) {
Task { await stopStream() }
}
Button(L10n.t("thermal.alert.continue"), role: .cancel) {
thermalManager.acknowledgeCriticalAlert()
}
} message: {
Text(L10n.t("thermal.alert.message"))
}
.alert(L10n.t("common.error.title"), isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button(L10n.t("matches.retry")) {
Task { await retryBroadcast() }
}
Button("Esci", role: .destructive) { onFinished() }
Button(L10n.t("action.exit"), role: .destructive) { onFinished() }
} message: {
Text(error ?? "")
}
@@ -264,7 +290,7 @@ struct BroadcastScreen: View {
targetFps: session.targetFps,
bitrateKbps: metrics.bitrateKbps,
networkType: deviceHealth.networkType,
deviceHealth: deviceHealth
deviceHealth: DeviceTelemetry.snapshot(thermalState: thermalManager.state)
)
}
@@ -279,13 +305,13 @@ struct BroadcastScreen: View {
}
private func broadcastStatusText(isPaused: Bool, metrics: BroadcastMetrics) -> String {
if isPaused { return "PAUSA" }
if isPaused { return L10n.t("broadcast.status.paused") }
switch metrics.phase {
case .live: return "IN DIRETTA"
case .connecting: return "CONNESSIONE…"
case .reconnecting: return "RICONNESSIONE…"
case .error: return metrics.lastError ?? "ERRORE"
default: return "PREVIEW"
case .live: return L10n.t("broadcast.status.live")
case .connecting: return L10n.t("broadcast.status.connecting")
case .reconnecting: return L10n.t("broadcast.status.reconnecting")
case .error: return metrics.lastError ?? L10n.t("broadcast.status.error.fallback")
default: return L10n.t("broadcast.status.preview")
}
}
@@ -302,7 +328,7 @@ struct BroadcastScreen: View {
private func shareLiveLink(session: StreamSession, subject: String) {
let urlString = session.watchShareUrl() ?? "\(AppConfig.apiBaseUrl)/live/\(session.id)"
guard let url = URL(string: urlString) else {
snackbarMessage = "Link diretta non ancora disponibile"
snackbarMessage = L10n.t("broadcast.share.link.unavailable")
return
}
shareItem = ShareItem(items: [url], subject: subject)
@@ -313,12 +339,12 @@ struct BroadcastScreen: View {
do {
let urlString = try await container.sessionRepository.createRegiaLink(sessionId: sessionId)
guard let url = URL(string: urlString) else {
snackbarMessage = "Link regia non valido"
snackbarMessage = L10n.t("broadcast.error.regia.link.invalid")
return
}
shareItem = ShareItem(items: [url], subject: "Link regia — \(subject)")
shareItem = ShareItem(items: [url], subject: L10n.t("broadcast.share.regia.subject", subject))
} catch {
snackbarMessage = UserFacingError.message(for: error) ?? "Errore link regia"
snackbarMessage = UserFacingError.message(for: error) ?? L10n.t("broadcast.error.regia.link")
}
}
}
@@ -345,11 +371,14 @@ struct BroadcastScreen: View {
loading = false
guard generation == bootstrapGeneration else { return }
guard await container.broadcastCoordinator.waitForPreviewSurface() else {
throw APIError.http(500, "Anteprima camera non pronta")
throw APIError.http(500, L10n.t("broadcast.error.camera.preview.not.ready"))
}
guard generation == bootstrapGeneration else { return }
if let url = loaded.rtmpIngestUrl, !url.isEmpty {
let config = broadcastConfig(for: loaded, rtmpUrl: url)
thermalManager.bind(engine: container.broadcastCoordinator.engine)
thermalManager.updateBaseline(config)
thermalManager.start()
try await container.broadcastCoordinator.prepareBroadcast(
sessionId: sessionId,
config: config,
@@ -408,7 +437,7 @@ struct BroadcastScreen: View {
case .none:
state = OverlayState(overlayKind: .none, watermarkVisible: false, broadcastStatus: container.broadcastCoordinator.metrics.phase.toOverlayStatus())
case .basket, .timed:
let period = score.periodLabel ?? (overlayKind == .basket ? "Q\(score.period)" : "\(score.period)° tempo")
let period = score.periodLabel ?? (overlayKind == .basket ? "Q\(score.period)" : L10n.t("broadcast.period.label.timed", score.period))
state = OverlayState(
overlayKind: overlayKind,
compactScoreboard: CompactScoreboardState(
@@ -450,9 +479,9 @@ struct BroadcastScreen: View {
let updated = try await container.sessionRepository.pauseSession(id: sessionId)
session = updated
await container.broadcastCoordinator.pauseBroadcast()
snackbarMessage = "Diretta in pausa"
snackbarMessage = L10n.t("broadcast.snackbar.paused")
} catch {
snackbarMessage = UserFacingError.message(for: error) ?? "Pausa non riuscita"
snackbarMessage = L10n.t("broadcast.snackbar.pause.error", UserFacingError.message(for: error) ?? L10n.t("common.error.generic"))
}
}
}
@@ -469,9 +498,9 @@ struct BroadcastScreen: View {
try await container.broadcastCoordinator.resumeBroadcast(config: config)
updated = try await container.sessionRepository.fetchSession(id: sessionId)
session = updated
snackbarMessage = "Diretta ripresa"
snackbarMessage = L10n.t("broadcast.snackbar.resumed")
} catch {
snackbarMessage = UserFacingError.message(for: error) ?? "Ripresa non riuscita"
snackbarMessage = L10n.t("broadcast.snackbar.resume.error", UserFacingError.message(for: error) ?? L10n.t("common.error.generic"))
}
}
@@ -484,7 +513,7 @@ struct BroadcastScreen: View {
if let fetched = try? await container.sessionRepository.fetchSession(id: sessionId) {
session = fetched
}
snackbarMessage = "Pausa dalla regia"
snackbarMessage = L10n.t("broadcast.snackbar.paused.remote")
}
/// Ripresa dalla regia / echo cable: solo RTMP locale, senza PATCH resume.
@@ -497,10 +526,11 @@ struct BroadcastScreen: View {
session = current
guard let url = current.rtmpIngestUrl, !url.isEmpty else { return }
let config = broadcastConfig(for: current, rtmpUrl: url)
thermalManager.updateBaseline(config)
try await container.broadcastCoordinator.resumeBroadcast(config: config)
snackbarMessage = "Diretta ripresa"
snackbarMessage = L10n.t("broadcast.snackbar.resumed")
} catch {
snackbarMessage = UserFacingError.message(for: error) ?? "Ripresa RTMP non riuscita"
snackbarMessage = L10n.t("broadcast.snackbar.resume.rtmp.error", UserFacingError.message(for: error) ?? L10n.t("common.error.generic"))
}
}
@@ -515,11 +545,12 @@ struct BroadcastScreen: View {
error = nil
guard let session, let url = session.rtmpIngestUrl, !url.isEmpty else { return }
let config = broadcastConfig(for: session, rtmpUrl: url)
thermalManager.updateBaseline(config)
do {
try await container.broadcastCoordinator.resumeBroadcast(config: config)
snackbarMessage = "Riconnessione avviata"
snackbarMessage = L10n.t("broadcast.snackbar.reconnect.started")
} catch {
self.error = UserFacingError.message(for: error) ?? "Riconnessione non riuscita"
self.error = UserFacingError.message(for: error) ?? L10n.t("broadcast.error.reconnect.failed")
}
}
@@ -530,6 +561,7 @@ struct BroadcastScreen: View {
}
private func teardown() async {
thermalManager.stop()
container.scoreController.onScoreDidChange = nil
container.sessionCable.onScoreUpdate = nil
container.sessionCable.onPauseStream = nil
@@ -144,25 +144,25 @@ struct ScoreDialogRouter: View {
case .setWon:
let winner = dialog.winnerSide.map { scoringSideName($0, homeName: homeName, awayName: awayName) } ?? ""
return Alert(
title: Text("Set concluso"),
message: Text("\(winner) vince il set \(dialog.homePoints)-\(dialog.awayPoints).\n\nChiudere il set e passare al successivo?"),
primaryButton: .default(Text("Chiudi set")) { host.resolve(true) },
secondaryButton: .cancel(Text("Continua a segnare")) { host.resolve(false) }
title: Text(L10n.t("score.set.won.title")),
message: Text(L10n.t("score.set.won.message", winner, dialog.homePoints, dialog.awayPoints)),
primaryButton: .default(Text(L10n.t("score.action.close.set"))) { host.resolve(true) },
secondaryButton: .cancel(Text(L10n.t("score.action.continue.scoring"))) { host.resolve(false) }
)
case .closeSetAnyway:
return Alert(
title: Text("Chiudi set"),
message: Text("Il punteggio non soddisfa ancora le regole del torneo. Chiudere il set comunque?"),
primaryButton: .default(Text("Chiudi comunque")) { host.resolve(true) },
secondaryButton: .cancel(Text("Annulla")) { host.resolve(false) }
title: Text(L10n.t("score.action.close.set")),
message: Text(L10n.t("score.close.set.anyway.message")),
primaryButton: .default(Text(L10n.t("score.action.close.anyway"))) { host.resolve(true) },
secondaryButton: .cancel(Text(L10n.t("action.cancel"))) { host.resolve(false) }
)
case .matchWon:
let winner = dialog.winnerSide.map { scoringSideName($0, homeName: homeName, awayName: awayName) } ?? ""
return Alert(
title: Text("Partita terminata"),
message: Text("\(winner) vince la partita (\(dialog.homeSets)-\(dialog.awaySets) set).\n\nChiudere definitivamente la diretta?"),
primaryButton: .default(Text("Chiudi diretta")) { host.resolve(true) },
secondaryButton: .cancel(Text("Continua in onda")) { host.resolve(false) }
title: Text(L10n.t("score.match.won.title")),
message: Text(L10n.t("score.match.won.message", winner, dialog.homeSets, dialog.awaySets)),
primaryButton: .default(Text(L10n.t("score.action.close.live"))) { host.resolve(true) },
secondaryButton: .cancel(Text(L10n.t("score.action.continue.live"))) { host.resolve(false) }
)
}
}