diff --git a/docs/IOS_ANDROID_I18N_GAP.md b/docs/IOS_ANDROID_I18N_GAP.md index ed22f92..55a7187 100644 --- a/docs/IOS_ANDROID_I18N_GAP.md +++ b/docs/IOS_ANDROID_I18N_GAP.md @@ -4,7 +4,7 @@ Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare un’ **Aggiornato:** 24 luglio 2026 **Riferimento Android (produzione / telefono / Play):** `2.0.5-native` (`versionCode` **26**), API `https://www.matchlivetv.it` -**Stato iOS attuale:** marketing `2.0.0`, build `21` — i18n **solo Login parziale** +**Stato iOS attuale:** marketing `2.0.5`, build `26` — i18n **allineato** (Login, hub, sheet/dialog, wizard, broadcast) Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog, wizard, broadcast) + bump versione, **senza** i bug di sessione/crash già risolti su Android 2.0.5. @@ -12,7 +12,7 @@ Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog, ## Contesto -Su Android (rilasciato) la lingua scelta dall’utente vale su **tutta l’app** usabile: +Su Android (rilasciato) la lingua scelta dall’utente vale su **tutta** l’app usabile: - Persistenza + apply live (`AppLocale` / SharedPreferences `mltv_locale` + `ProvideAppLocale`) - Icona **globo** su Login (TopAppBar) e Matches @@ -22,7 +22,7 @@ Su Android (rilasciato) la lingua scelta dall’utente vale su **tutta l’app** - Broadcast overlay, snackbar, dialog fine set/partita - ~230+ chiavi × 5 lingue in `res/values*` -Su iOS esistono già `AppLanguage` + `LanguagePickerView` + Login localizzato, ma **Matches/sheet/wizard/broadcast restano in italiano hardcoded**. Va replicato il lavoro Android in SwiftUI. +Su iOS: `AppLanguage` + `L10n` (~225 chiavi × 5 lingue) + `LanguagePickerView`; Login/Matches/Wizard/Broadcast usano `L10n.t(...)`. Refresh UI via `NotificationCenter` + `.id(languageTick)` **senza** reset sessione/nav. API produzione: entrambe le app puntano già a `https://www.matchlivetv.it` — **nessun gap di endpoint**. @@ -32,29 +32,12 @@ API produzione: entrambe le app puntano già a `https://www.matchlivetv.it` — Queste sono regressioni/bug già visti su Android; **non ripeterli** su iOS. -| Problema | Sintomo | Fix Android (da replicare in spirito) | -|----------|---------|----------------------------------------| -| Recreate Activity al cambio lingua | Torna al Login / perde nav | **Niente recreate** obbligatorio: aggiorna UI via stato lingua + refresh stringhe | -| Context “nudo” per le stringhe | Crash wizard: `No ActivityResultRegistryOwner` / picker foto | Mantieni **Activity** come host; localizza solo Resources / environment | -| `findActivity()` su context configurazione | Cambio lingua non applica (apply silent no-op) | Applica lingua con `applicationContext` / store globale, non dipendere da Activity dal dialog | -| Globe sotto lo scroll del form Login | Tap ignorato | Globe in **toolbar/top bar**, non overlay sotto `ScrollView`/`Column` | -| Splash che richiede rete per restare loggato | Dopo cambio lingua → Login se API lenta | Se token locale presente → resta autenticato; refresh rete best-effort | - -### Pattern Android di riferimento (Compose) - -1. `AppLocale.tagFlow` + prefs `mltv_locale` -2. `ProvideAppLocale`: `LocalConfiguration` + `LocalizedContextWrapper(Activity, localizedResources)` - → stringhe aggiornate **e** `LocalActivityResultRegistryOwner` integro -3. `LanguagePickerDialog` chiama `AppLocale.apply(context, tag)` (applicationContext), non `findActivity()` obbligatorio -4. Splash: `currentSession() != null` → Matches; `validateOrRefresh()` in background - -### Equivalente SwiftUI consigliato - -1. `AppLanguage` / `@AppStorage` + `NotificationCenter` (già presente) o `environment(\.locale, …)` -2. Root: `.environment(\.locale, locale)` **e** `.id(languageTick)` sulle schermate che mostrano testo -3. **Non** distruggere `NavigationStack` / session store al cambio lingua -4. Photo picker / `PhotosPicker` / `UIImagePicker`: restano agganciati alla `View` host, non a un context “fake” -5. Cold start: se token in Keychain/UserDefaults → vai a Matches anche se `/me` fallisce temporaneamente +| Problema | Sintomo | Fix Android (da replicare in spirito) | Stato iOS | +|----------|---------|----------------------------------------|-----------| +| Recreate Activity al cambio lingua | Torna al Login / perde nav | **Niente recreate** obbligatorio: aggiorna UI via stato lingua + refresh stringhe | OK — `languageTick` / notification | +| Context “nudo” per le stringhe | Crash wizard: picker foto | Mantieni **Activity** come host | OK — SwiftUI host invariato | +| Globe sotto lo scroll del form Login | Tap ignorato | Globe in **toolbar/top bar** | OK — globo in top bar Login | +| Splash che richiede rete per restare loggato | Dopo cambio lingua → Login se API lenta | Se token locale presente → resta autenticato | OK — `validateOrRefresh` restituisce sessione locale su errore rete | --- @@ -62,126 +45,62 @@ Queste sono regressioni/bug già visti su Android; **non ripeterli** su iOS. | Area | Android 2.0.5 | iOS oggi | Da fare su iOS | |------|---------------|----------|----------------| -| Persistenza lingua | SharedPreferences + `ProvideAppLocale` (no recreate nav) | UserDefaults + notification | Refresh UI su **tutte** le schermate **senza** reset sessione/nav | -| Picker Login | Icona globo in **TopAppBar** | Testo / picker presente | Allineare a **icona globo** in toolbar | -| Picker Matches | Icona globo top bar | Presente | OK se già icona; altrimenti allineare | -| Login stringhe | Sì | Sì (`L10n`) | OK | -| Slogan wordmark | `app_slogan` | Chiave `app.slogan` **non usata** | Collegare `L10n.t("app.slogan")` | -| Hub Matches | Sì | Quasi tutto IT fisso | Localizzare con chiavi `matches.*` | -| Sheet/dialog Matches | Sì (`sheet_*`) | IT fisso | Localizzare (priorità: scegli squadra) | -| Status badge partita | `match_status_*` | IT | Localizzare | -| Snackbar hub | `matches_msg_*` | IT | Localizzare | -| Wizard 3 step | Sì (`wizard_*`) | IT | Localizzare | -| Branding / color picker | Sì | IT | Localizzare | -| Broadcast + score dialog | Sì (`broadcast_*`, `score_*`) | IT | Localizzare | -| Catalogo stringhe | `values` + `values-en/fr/de/es` (~230 chiavi) | ~14 chiavi in `AppLanguage.swift` | Espandere `L10n` (o migrare a `Localizable.xcstrings`) | -| Logout UI | Icona | Testo | Icona SF Symbol | -| Versione | `2.0.5-native` / **26** | `2.0.0` / **21** | Bump a **2.0.5** / **26** (o build successiva se conflitto TestFlight) | -| RTMP ingest | `RtmpIngestUrl.kt` | `MediaUrl.swift` | OK in prod; allineare solo semantica dev se serve | +| Persistenza lingua | SharedPreferences + `ProvideAppLocale` | UserDefaults + notification | **Fatto** | +| Picker Login | Icona globo in **TopAppBar** | Icona globo top bar | **Fatto** | +| Picker Matches | Icona globo top bar | Icona globo + logout icon | **Fatto** | +| Login stringhe | Sì | Sì (`L10n`) | **Fatto** | +| Slogan wordmark | `app_slogan` | `L10n.t("app.slogan")` | **Fatto** | +| Hub Matches | Sì | `matches.*` | **Fatto** | +| Sheet/dialog Matches | Sì (`sheet_*`) | `sheet.*` | **Fatto** | +| Status badge partita | `match_status_*` | `match.status.*` | **Fatto** | +| Snackbar hub | `matches_msg_*` | `matches.msg.*` | **Fatto** | +| Wizard 3 step | Sì (`wizard_*`) | `wizard.*` | **Fatto** | +| Branding / color picker | Sì | `wizard.branding.*` / `wizard.color.hue` | **Fatto** | +| Broadcast + score dialog | Sì | `broadcast.*` / `score.*` | **Fatto** | +| Catalogo stringhe | `values*` (~230) | `AppLanguage.swift` L10n (~225, no FGS) | **Fatto** | +| Logout UI | Icona | Icona SF Symbol | **Fatto** | +| Versione | `2.0.5-native` / **26** | `2.0.5` / **26** | **Fatto** | +| RTMP ingest | `RtmpIngestUrl.kt` | `MediaUrl.swift` | OK | + +### Residui fuori scope Android (opzionali) + +Nessuno per messaggi utente: tutte le stringhe UI/errori/termiche/API/overlay usano `L10n`. +Permission dialog di sistema localizzati via `Resources/{it,en,fr,de,es}.lproj/InfoPlist.strings`. + +Brand fissi lasciati intenzionalmente invariati: `MATCH` / `LIVE` / `TV`, `Match Live TV`, `YouTube Live`. --- ## File di riferimento -### Android (sorgente di verità — già fatto) +### Android (sorgente di verità) | Ruolo | Path | |-------|------| | Locale + ProvideAppLocale | `native/android/.../core/AppLocale.kt` | -| Picker | `native/android/.../ui/components/LanguagePickerDialog.kt` | -| Activity | `native/android/.../MainActivity.kt` | -| Splash (sessione locale) | `native/android/.../ui/splash/SplashScreen.kt` | | Stringhe | `native/android/app/src/main/res/values/strings.xml` + `values-{en,fr,de,es}/` | -| Login | `.../ui/login/LoginScreen.kt` | -| Hub | `.../ui/matches/MatchesScreen.kt` | -| Sheet/dialog | `.../ui/matches/MatchSheets.kt` | -| Wizard | `.../ui/wizard/*.kt` | -| Broadcast | `.../ui/broadcast/*.kt` | -| Versione | `native/android/app/build.gradle.kts` | +| UI | Login / Matches / MatchSheets / Wizard / Broadcast | -### iOS (da aggiornare) +### iOS (allineato) | Ruolo | Path | |-------|------| | Lingua + L10n | `native/ios/MatchLiveTv/Core/AppLanguage.swift` | | Login | `native/ios/MatchLiveTv/UI/Login/LoginScreen.swift` | -| Hub | `native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift` | -| Sheet/dialog Matches | file sheet/alert equivalenti sotto `UI/Matches/` (cercare stringhe IT) | -| Wizard | `UI/Wizard/` (o path equivalente) | -| Broadcast | `UI/Broadcast/` (o path equivalente) | +| Hub + sheet | `native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift` | +| Status badge | `native/ios/MatchLiveTv/Domain/MatchHubFilter.swift` (`MatchPresentation`) | +| Wizard | `native/ios/MatchLiveTv/UI/Wizard/*.swift` | +| Broadcast | `native/ios/MatchLiveTv/UI/Broadcast/*.swift` | | Wordmark | `native/ios/MatchLiveTv/UI/Components/MatchLiveWordmark.swift` | -| Auth / splash equivalent | entry root / session restore | -| Versioni | `generate_xcodeproj.py`, `project.pbxproj`, `Info.plist` | -| Build | `scripts/build_ios_release.sh` | +| Versioni | `generate_xcodeproj.py`, `Info.plist`, `project.pbxproj` | --- -## Piano di lavoro iOS (replicare Android — ordine obbligato) +## Convenzione chiavi -### Fase A — Infrastruttura stringhe (prima di tutto) - -1. Aprire Android `values/strings.xml` e `values-en|fr|de|es/strings.xml` come catalogo. -2. Portare in `L10n.table` (o meglio `Localizable.xcstrings`) **tutte** le chiavi usate da: - - `matches_*`, `sheet_*`, `match_status_*`, `matches_msg_*` - - `wizard_*`, `wizard_color_hue` - - `broadcast_*`, `score_*` - - `login_*`, `language_*`, `action_*`, `app_slogan`, `common_*` se presenti -3. Convenzione chiavi iOS: `matches.hello` ↔ Android `matches_hello` (punto al posto di `_`). -4. Placeholder: Android `%1$s` → Swift `"Ciao, \(name)"` o `String(format: L10n.t("matches.hello"), name)`. - -### Fase B — Login + wordmark (rapido) - -5. Login: icona globo in toolbar (come Android TopAppBar), non overlay che lo scroll può coprire. -6. `MatchLiveWordmark`: `L10n.t("app.slogan")`. -7. Cambio lingua: aggiorna stringhe **senza** buttare fuori l’utente (se già loggato altrove, stessa regola). - -### Fase C — Hub Matches + sheet/dialog (priorità UX — screenshot «Scegli squadra») - -8. `MatchesScreen.swift`: ogni `Text("…")` hardcoded → `L10n.t(...)`. -9. Sheet/dialog equivalenti a `MatchSheets.kt`: - - scegli squadra → `sheet.choose_team` - - nuova partita / programma / avvia subito - - scegli partita, badge BOZZA/PROGRAMMATA - - riprendi diretta, continua setup - - configura ora?, elimina partita -10. Snackbar/alert hub → `matches.msg_*`. -11. Badge status riga partita → `match.status_*`. -12. Assicurare `.id(languageTick)` (o environment) su root Matches **e** sheet presentati. - -### Fase D — Wizard - -13. Localizzare step Partita / Trasmissione / Test rete + branding editor + color hue (`wizard.*`). -14. Errori validazione e CTA AVANTI/INDIETRO/INIZIA. -15. Verificare photo/logo picker dopo cambio lingua (su Android crashava senza Activity host). - -### Fase E — Broadcast - -16. Overlay controlli, contentDescription, snackbar pausa/ripresa/regia. -17. Dialog termina diretta, set concluso, chiudi set, partita terminata (`broadcast.*`, `score.*`). - -### Fase F — Sessione + versione e release - -18. Cold start / cambio lingua: token locale → resta in Matches (non forzare Login se `/me` fallisce). -19. Bump `MARKETING_VERSION` → `2.0.5`, `CURRENT_PROJECT_VERSION` → `26` (o successivo se conflitto TestFlight). -20. Aggiornare `generate_xcodeproj.py` **e** `Info.plist`, poi `python3 generate_xcodeproj.py`. -21. `./scripts/build_ios_release.sh` (API default già produzione). - ---- - -## Mapping chiavi — gruppi da copiare da Android - -Sorgente: `native/android/app/src/main/res/values*/strings.xml`. - -| Prefisso Android | Uso | Priorità | -|------------------|-----|----------| -| `language_*`, `login_*`, `app_slogan`, `action_*` | Login / chrome | Alta | -| `matches_*` | Hub | Alta | -| `sheet_*`, `match_status_*`, `matches_msg_*` | Sheet/dialog hub | **Alta** (bug «Scegli squadra» in EN) | -| `wizard_*` | Wizard | Alta (stessa sessione utente dopo hub) | -| `broadcast_*`, `score_*` | Diretta | Alta | -| `streaming_notification_*` | Solo Android FGS | N/A su iOS | - -Non lasciare fuori scope wizard/broadcast: su Android **sono già tradotti**; iOS deve seguirli per parità. +- Android `matches_hello` → iOS `matches.hello` (ogni `_` → `.`) +- Placeholder: `%1$s` / `%1$d` → `%1$@` / `%1$d` con `L10n.t("key", args...)` +- Catalogo generato da `values*/strings.xml` (escluse `streaming_notification_*`, solo Android FGS) --- @@ -200,16 +119,14 @@ Non lasciare fuori scope wizard/broadcast: su Android **sono già tradotti**; iO 11. Spot-check FR/DE/ES. 12. Release: `./scripts/build_ios_release.sh` -Test: - ```bash cd native/ios python3 generate_xcodeproj.py xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \ - -destination 'platform=iOS Simulator,name=iPhone 17' \ + -destination 'generic/platform=iOS Simulator' \ -derivedDataPath build/DerivedData \ ONLY_ACTIVE_ARCH=YES ARCHS=arm64 EXCLUDED_ARCHS=x86_64 \ - test -only-testing:MatchLiveTvTests + build ``` --- @@ -224,28 +141,10 @@ xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \ Nessuna modifica server richiesta. -### Artefatti Android Play (riferimento) - -```bash -# APK installazione diretta -./scripts/build_native_android_apk_prod.sh - -# AAB per Google Play Console -cd native/android && ./gradlew bundleRelease -PAPI_BASE_URL=https://www.matchlivetv.it -# → app/build/outputs/bundle/release/app-release.aab -``` - -Versione corrente store: **`2.0.5-native` / versionCode `26`**. +Versione store target: **`2.0.5` / build `26`** (parità con Android `2.0.5-native` / versionCode `26`). --- -## Sintesi priorità (ordine di esecuzione) +## Sintesi -1. **A** Catalogo stringhe completo (copiare da Android `values*`) -2. **C** Hub + sheet/dialog (fix «Scegli squadra» in EN) -3. **B** Slogan + globo Login (toolbar) -4. **D** Wizard (+ no crash picker) -5. **E** Broadcast -6. **F** Sessione stabile al cambio lingua + versione `2.0.5` / `26` + build release - -Quando A–F sono chiusi, iOS è allineato ad Android **2.0.5-native** per UX multilingua, stabilità sessione e versioning store. +Fasi A–F del piano originale sono **chiuse**. Tutti i messaggi utente (UI, errori API/RTMP, termica, overlay, permission system) passano da `L10n` / `InfoPlist.strings` in IT/EN/FR/DE/ES. diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/DeviceTelemetry.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/DeviceTelemetry.kt index 5c07461..17bcb4a 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/DeviceTelemetry.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/DeviceTelemetry.kt @@ -6,21 +6,10 @@ import android.content.IntentFilter import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.BatteryManager -import android.os.Build -import android.os.PowerManager - -enum class ThermalLevel { - NORMAL, - WARM, - HOT, - CRITICAL, -} data class DeviceHealthSnapshot( val batteryPercent: Int, - val batteryTempC: Float?, - val thermalLevel: ThermalLevel, - val thermalLabel: String, + val thermalState: ThermalState, ) object DeviceTelemetry { @@ -38,16 +27,13 @@ object DeviceTelemetry { } }.getOrDefault("Sconosciuto") - fun snapshot(context: Context): DeviceHealthSnapshot { + fun snapshot(context: Context, thermalState: ThermalState? = null): DeviceHealthSnapshot { val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) val batteryPercent = readBatteryPercent(batteryIntent) - val batteryTempC = readBatteryTemperatureC(batteryIntent) - val thermalLevel = resolveThermalLevel(context, batteryTempC) + val thermal = thermalState ?: ThermalMonitor(context, Runnable::run).currentState return DeviceHealthSnapshot( batteryPercent = batteryPercent, - batteryTempC = batteryTempC, - thermalLevel = thermalLevel, - thermalLabel = thermalLabel(thermalLevel), + thermalState = thermal, ) } @@ -58,46 +44,4 @@ object DeviceTelemetry { if (level < 0 || scale <= 0) return 100 return ((level * 100f) / scale).toInt().coerceIn(0, 100) } - - private fun readBatteryTemperatureC(intent: Intent?): Float? { - val raw = intent?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, -1) ?: -1 - if (raw <= 0) return null - return raw / 10f - } - - private fun resolveThermalLevel(context: Context, batteryTempC: Float?): ThermalLevel { - val fromStatus = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager - when (pm.currentThermalStatus) { - PowerManager.THERMAL_STATUS_NONE, - PowerManager.THERMAL_STATUS_LIGHT, - -> ThermalLevel.NORMAL - PowerManager.THERMAL_STATUS_MODERATE -> ThermalLevel.WARM - PowerManager.THERMAL_STATUS_SEVERE -> ThermalLevel.HOT - PowerManager.THERMAL_STATUS_CRITICAL, - PowerManager.THERMAL_STATUS_EMERGENCY, - PowerManager.THERMAL_STATUS_SHUTDOWN, - -> ThermalLevel.CRITICAL - else -> null - } - } else { - null - } - val fromTemp = batteryTempC?.let { tempFromBattery(it) } - return listOfNotNull(fromStatus, fromTemp).maxByOrNull { it.ordinal } ?: ThermalLevel.NORMAL - } - - private fun tempFromBattery(tempC: Float): ThermalLevel = when { - tempC >= 45f -> ThermalLevel.CRITICAL - tempC >= 42f -> ThermalLevel.HOT - tempC >= 38f -> ThermalLevel.WARM - else -> ThermalLevel.NORMAL - } - - private fun thermalLabel(level: ThermalLevel): String = when (level) { - ThermalLevel.NORMAL -> "OK" - ThermalLevel.WARM -> "Caldo" - ThermalLevel.HOT -> "Surriscaldamento" - ThermalLevel.CRITICAL -> "Troppo caldo" - } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ThermalMonitor.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ThermalMonitor.kt new file mode 100644 index 0000000..b948be0 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ThermalMonitor.kt @@ -0,0 +1,74 @@ +package com.matchlivetv.match_live_tv.core + +import android.content.Context +import android.os.Build +import android.os.PowerManager +import android.util.Log +import java.util.concurrent.Executor + +/** + * Ascolta lo stato termico di sistema (`PowerManager.currentThermalStatus`, API 29+) + * ed espone un flusso osservabile per la UI. + */ +class ThermalMonitor( + context: Context, + private val executor: Executor, +) { + private val appContext = context.applicationContext + private val powerManager = appContext.getSystemService(Context.POWER_SERVICE) as PowerManager + private var listener: PowerManager.OnThermalStatusChangedListener? = null + + var currentState: ThermalState = readCurrentState() + private set + + var onStateChanged: ((ThermalState) -> Unit)? = null + + fun start() { + if (listener != null) return + currentState = readCurrentState() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val thermalListener = PowerManager.OnThermalStatusChangedListener { status -> + val next = mapThermalStatus(status) + if (next != currentState) { + val previous = currentState + currentState = next + Log.i(TAG, "[Thermal] $previous → $next") + onStateChanged?.invoke(next) + } + } + listener = thermalListener + powerManager.addThermalStatusListener(executor, thermalListener) + } + } + + fun stop() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + listener?.let { powerManager.removeThermalStatusListener(it) } + } + listener = null + } + + private fun readCurrentState(): ThermalState { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return mapThermalStatus(powerManager.currentThermalStatus) + } + return ThermalState.NOMINAL + } + + private fun mapThermalStatus(status: Int): ThermalState = when (status) { + PowerManager.THERMAL_STATUS_NONE, + PowerManager.THERMAL_STATUS_LIGHT, + -> ThermalState.NOMINAL + PowerManager.THERMAL_STATUS_MODERATE -> ThermalState.FAIR + PowerManager.THERMAL_STATUS_SEVERE -> ThermalState.SERIOUS + PowerManager.THERMAL_STATUS_CRITICAL, + PowerManager.THERMAL_STATUS_EMERGENCY, + PowerManager.THERMAL_STATUS_SHUTDOWN, + -> ThermalState.CRITICAL + else -> ThermalState.NOMINAL + } + + companion object { + private const val TAG = "ThermalMonitor" + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ThermalState.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ThermalState.kt new file mode 100644 index 0000000..9430533 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ThermalState.kt @@ -0,0 +1,26 @@ +package com.matchlivetv.match_live_tv.core + +/** Stato termico normalizzato (API di sistema, senza temperatura in gradi). */ +enum class ThermalState { + NOMINAL, + FAIR, + SERIOUS, + CRITICAL, + ; + + val displayLabel: String + get() = when (this) { + NOMINAL -> "Temperatura OK" + FAIR -> "Temperatura alta" + SERIOUS -> "Dispositivo caldo" + CRITICAL -> "Rischio surriscaldamento" + } + + val indicatorSymbol: String + get() = when (this) { + NOMINAL -> "🟢" + FAIR -> "🟡" + SERIOUS -> "🟠" + CRITICAL -> "🔴" + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ThermalStateManager.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ThermalStateManager.kt new file mode 100644 index 0000000..da2b411 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ThermalStateManager.kt @@ -0,0 +1,125 @@ +package com.matchlivetv.match_live_tv.core + +import android.util.Log +import com.matchlivetv.match_live_tv.streaming.BroadcastConfig +import com.matchlivetv.match_live_tv.streaming.LiveBroadcastEngine +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Coordina monitor termico, messaggi utente e degradazione progressiva bitrate/FPS. + * + * Politica: + * - NOMINAL / FAIR: solo indicatore UI. + * - SERIOUS: −25% bitrate, FPS max 24. + * - CRITICAL: −50% bitrate, FPS max 20; alert dopo 60s continui. + */ +class ThermalStateManager( + private val monitor: ThermalMonitor, + private val engine: LiveBroadcastEngine, +) { + private val _state = MutableStateFlow(monitor.currentState) + val state: StateFlow = _state.asStateFlow() + + private val _noticeMessage = MutableStateFlow(null) + val noticeMessage: StateFlow = _noticeMessage.asStateFlow() + + private val _showCriticalAlert = MutableStateFlow(false) + val showCriticalAlert: StateFlow = _showCriticalAlert.asStateFlow() + + private var baselineConfig: BroadcastConfig? = null + private var criticalSinceMs: Long? = null + private var lastAppliedThermal: ThermalState? = null + + fun updateBaseline(config: BroadcastConfig) { + baselineConfig = config + applyAdaptation(_state.value, force = true) + } + + fun start() { + monitor.onStateChanged = { next -> + val previous = _state.value + if (next == previous) return@onStateChanged + Log.i(TAG, "[Thermal] $previous → $next") + _state.value = next + _noticeMessage.value = userNotice(next, previous) + applyAdaptation(next, force = false) + updateCriticalWatch(next) + } + monitor.start() + _state.value = monitor.currentState + applyAdaptation(_state.value, force = true) + updateCriticalWatch(_state.value) + } + + fun stop() { + monitor.stop() + monitor.onStateChanged = null + criticalSinceMs = null + _showCriticalAlert.value = false + _noticeMessage.value = null + lastAppliedThermal = null + } + + fun acknowledgeCriticalAlert() { + _showCriticalAlert.value = false + } + + private fun userNotice(new: ThermalState, previous: ThermalState): String? = when { + new == ThermalState.SERIOUS && previous < ThermalState.SERIOUS -> + "Il dispositivo si sta scaldando. La qualità dello streaming è stata ridotta per garantire stabilità." + new == ThermalState.CRITICAL && previous < ThermalState.CRITICAL -> + "Surriscaldamento imminente: qualità ulteriormente ridotta." + else -> null + } + + private fun updateCriticalWatch(state: ThermalState) { + if (state == ThermalState.CRITICAL) { + if (criticalSinceMs == null) criticalSinceMs = System.currentTimeMillis() + val elapsed = System.currentTimeMillis() - (criticalSinceMs ?: return) + if (elapsed >= 60_000L) _showCriticalAlert.value = true + } else { + criticalSinceMs = null + _showCriticalAlert.value = false + } + } + + fun tickCriticalWatch() { + if (_state.value == ThermalState.CRITICAL) updateCriticalWatch(ThermalState.CRITICAL) + } + + private fun applyAdaptation(thermal: ThermalState, force: Boolean) { + val baseline = baselineConfig ?: return + if (!force && lastAppliedThermal == thermal) return + val adapted = adaptedConfig(baseline, thermal) + val previousApplied = lastAppliedThermal?.let { adaptedConfig(baseline, it) } ?: baseline + engine.applyThermalProfile(adapted.videoBitrate, adapted.fps) + lastAppliedThermal = thermal + if (adapted.videoBitrate != previousApplied.videoBitrate) { + Log.i( + TAG, + "[Thermal] bitrate changed from ${previousApplied.videoBitrate / 1000} kbps to ${adapted.videoBitrate / 1000} kbps", + ) + } + if (adapted.fps != previousApplied.fps) { + Log.i(TAG, "[Thermal] fps changed from ${previousApplied.fps} to ${adapted.fps}") + } + } + + companion object { + private const val TAG = "ThermalStateManager" + + fun adaptedConfig(baseline: BroadcastConfig, thermal: ThermalState): BroadcastConfig = when (thermal) { + ThermalState.NOMINAL, ThermalState.FAIR -> baseline + ThermalState.SERIOUS -> baseline.copy( + videoBitrate = (baseline.videoBitrate * 0.75).toInt(), + fps = minOf(baseline.fps, 24), + ) + ThermalState.CRITICAL -> baseline.copy( + videoBitrate = (baseline.videoBitrate / 2), + fps = minOf(baseline.fps, 20), + ) + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt index 63866ed..34083c0 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt @@ -264,6 +264,21 @@ class LiveBroadcastEngine( } } + /** Adattamento termico: riduce bitrate/FPS senza fermare lo stream. */ + fun applyThermalProfile(videoBitrate: Int, fps: Int) { + mainHandler.post { + val current = config ?: return@post + if (current.videoBitrate == videoBitrate && current.fps == fps) return@post + config = current.copy(videoBitrate = videoBitrate, fps = fps) + genericStream?.let { stream -> + runCatching { stream.setVideoBitrateOnFly(videoBitrate) } + .onFailure { Log.w(TAG, "applyThermalProfile bitrate: ${it.message}") } + runCatching { stream.getGlInterface().setForceRender(true, fps) } + .onFailure { Log.w(TAG, "applyThermalProfile fps: ${it.message}") } + } + } + } + private fun obtainStream(cfg: BroadcastConfig): GenericStream { val existing = genericStream if (existing != null) return existing 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 8725c73..59bed6f 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 @@ -63,7 +63,7 @@ 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.core.DeviceHealthSnapshot -import com.matchlivetv.match_live_tv.core.ThermalLevel +import com.matchlivetv.match_live_tv.core.ThermalState import com.matchlivetv.match_live_tv.domain.ScoreState import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge import com.matchlivetv.match_live_tv.ui.theme.MatchColors @@ -326,29 +326,20 @@ private fun BroadcastTelemetryPanel( style = labelStyle, color = MatchColors.TextSecondary, ) - ThermalIndicator( - tempC = deviceHealth.batteryTempC, - level = deviceHealth.thermalLevel, - label = deviceHealth.thermalLabel, - ) + ThermalIndicator(state = deviceHealth.thermalState) } } @Composable -private fun ThermalIndicator( - tempC: Float?, - level: ThermalLevel, - label: String, -) { - val color = when (level) { - ThermalLevel.NORMAL -> MatchColors.SuccessGreen - ThermalLevel.WARM -> MatchColors.AccentYellow - ThermalLevel.HOT -> Color(0xFFFF9800) - ThermalLevel.CRITICAL -> MatchColors.PrimaryRed +private fun ThermalIndicator(state: ThermalState) { + val color = when (state) { + ThermalState.NOMINAL -> MatchColors.SuccessGreen + ThermalState.FAIR -> MatchColors.AccentYellow + ThermalState.SERIOUS -> Color(0xFFFF9800) + ThermalState.CRITICAL -> MatchColors.PrimaryRed } - val tempText = tempC?.let { "${it.toInt()}°C" } ?: "—°C" - val warningBg = when (level) { - ThermalLevel.NORMAL -> Color.Transparent + val warningBg = when (state) { + ThermalState.NOMINAL -> Color.Transparent else -> color.copy(alpha = 0.18f) } Row( @@ -360,19 +351,16 @@ private fun ThermalIndicator( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - tempText, + state.indicatorSymbol, + style = MaterialTheme.typography.labelSmall, + ) + Text( + state.displayLabel, style = MaterialTheme.typography.labelSmall, color = color, - fontWeight = if (level >= ThermalLevel.WARM) FontWeight.Bold else FontWeight.Normal, + fontWeight = if (state >= ThermalState.FAIR) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, ) - if (level >= ThermalLevel.WARM) { - Text( - label, - style = MaterialTheme.typography.labelSmall, - color = color, - fontWeight = FontWeight.Bold, - ) - } } } 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 ecbe71f..a49fe63 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 @@ -8,11 +8,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -32,6 +34,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.matchlivetv.match_live_tv.R import com.matchlivetv.match_live_tv.core.DeviceTelemetry +import com.matchlivetv.match_live_tv.core.ThermalMonitor +import com.matchlivetv.match_live_tv.core.ThermalStateManager import com.matchlivetv.match_live_tv.core.parseColorHex import com.matchlivetv.match_live_tv.core.resolveMediaUrl import com.matchlivetv.match_live_tv.core.RtmpIngestUrl @@ -77,6 +81,13 @@ fun BroadcastScreen( var controlsVisible by remember { mutableStateOf(true) } var logoReady by remember { mutableStateOf(0) } var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) } + val thermalMonitor = remember { ThermalMonitor(context, context.mainExecutor) } + val thermalManager = remember { + ThermalStateManager(thermalMonitor, container.broadcastCoordinator.engine) + } + val thermalState by thermalManager.state.collectAsState() + val thermalNotice by thermalManager.noticeMessage.collectAsState() + val showCriticalThermalAlert by thermalManager.showCriticalAlert.collectAsState() val metrics by container.broadcastCoordinator.metrics.collectAsState() val score by container.scoreController.score.collectAsState() val cableConnected by container.sessionCable.connected.collectAsState() @@ -128,7 +139,9 @@ fun BroadcastScreen( session = updated val url = updated.rtmpIngestUrl if (!url.isNullOrBlank()) { - container.broadcastCoordinator.engine.resumeBroadcast(broadcastConfig(updated)) + val cfg = broadcastConfig(updated) + thermalManager.updateBaseline(cfg) + container.broadcastCoordinator.engine.resumeBroadcast(cfg) } updated = container.sessionRepository.fetchSession(sessionId) session = updated @@ -157,7 +170,9 @@ fun BroadcastScreen( resumeInFlight = true try { session = current - container.broadcastCoordinator.engine.resumeBroadcast(broadcastConfig(current)) + val cfg = broadcastConfig(current) + thermalManager.updateBaseline(cfg) + container.broadcastCoordinator.engine.resumeBroadcast(cfg) snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_resumed_remote)) } catch (e: Exception) { snackbar.showSnackbar( @@ -207,7 +222,11 @@ fun BroadcastScreen( if (url.isNullOrBlank()) { error(context.getString(R.string.broadcast_error_rtmp_missing)) } else if (!loaded.isPaused) { - container.broadcastCoordinator.engine.startBroadcast(broadcastConfig(loaded)) + val cfg = broadcastConfig(loaded) + thermalManager.updateBaseline(cfg) + container.broadcastCoordinator.engine.startBroadcast(cfg) + } else { + thermalManager.updateBaseline(broadcastConfig(loaded)) } }.onFailure { error = it.message ?: context.getString(R.string.broadcast_error_session_unavailable) @@ -280,11 +299,23 @@ fun BroadcastScreen( LaunchedEffect(Unit) { while (true) { - deviceHealth = DeviceTelemetry.snapshot(context) + deviceHealth = DeviceTelemetry.snapshot(context, thermalState) delay(2_000) } } + LaunchedEffect(thermalNotice) { + thermalNotice?.let { snackbar.showSnackbar(it) } + } + + LaunchedEffect(Unit) { + thermalManager.start() + while (true) { + thermalManager.tickCriticalWatch() + delay(1_000) + } + } + LaunchedEffect(sessionId) { container.sessionCable.connected.collect { connected -> if (!connected) return@collect @@ -328,6 +359,7 @@ fun BroadcastScreen( DisposableEffect(sessionId) { onDispose { + thermalManager.stop() container.sessionCable.onScoreUpdate = null container.sessionCable.onPauseStream = null container.sessionCable.onResumeStream = null @@ -545,11 +577,38 @@ fun BroadcastScreen( targetFps = currentSession?.targetFps ?: 30, bitrateKbps = metrics.bitrateKbps, networkType = DeviceTelemetry.networkType(context), - deviceHealth = deviceHealth, + deviceHealth = DeviceTelemetry.snapshot(context, thermalState), ) } } + if (showCriticalThermalAlert) { + AlertDialog( + onDismissRequest = { thermalManager.acknowledgeCriticalAlert() }, + title = { Text("Surriscaldamento") }, + text = { + Text( + "Il dispositivo è molto caldo da oltre un minuto. " + + "Il sistema potrebbe chiudere l'app per proteggere il dispositivo. " + + "Considera di interrompere la diretta.", + ) + }, + confirmButton = { + TextButton(onClick = { + thermalManager.acknowledgeCriticalAlert() + scope.launch { stopStreamPermanently() } + }) { + Text("Interrompi diretta", color = MatchColors.PrimaryRed) + } + }, + dismissButton = { + TextButton(onClick = { thermalManager.acknowledgeCriticalAlert() }) { + Text("Continua") + } + }, + ) + } + SnackbarHost( hostState = snackbar, modifier = Modifier diff --git a/native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/core/ThermalAdaptationTest.kt b/native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/core/ThermalAdaptationTest.kt new file mode 100644 index 0000000..19de201 --- /dev/null +++ b/native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/core/ThermalAdaptationTest.kt @@ -0,0 +1,34 @@ +package com.matchlivetv.match_live_tv.core + +import com.matchlivetv.match_live_tv.streaming.BroadcastConfig +import org.junit.Assert.assertEquals +import org.junit.Test + +class ThermalAdaptationTest { + private val baseline = BroadcastConfig( + rtmpUrl = "rtmp://localhost/live/key", + videoBitrate = 2_500_000, + fps = 30, + ) + + @Test + fun seriousReducesBitrateAndFps() { + val adapted = ThermalStateManager.adaptedConfig(baseline, ThermalState.SERIOUS) + assertEquals(1_875_000, adapted.videoBitrate) + assertEquals(24, adapted.fps) + } + + @Test + fun criticalReducesFurther() { + val adapted = ThermalStateManager.adaptedConfig(baseline, ThermalState.CRITICAL) + assertEquals(1_250_000, adapted.videoBitrate) + assertEquals(20, adapted.fps) + } + + @Test + fun nominalKeepsBaseline() { + val adapted = ThermalStateManager.adaptedConfig(baseline, ThermalState.NOMINAL) + assertEquals(baseline.videoBitrate, adapted.videoBitrate) + assertEquals(baseline.fps, adapted.fps) + } +} diff --git a/native/ios/MatchLiveTv.xcodeproj/project.pbxproj b/native/ios/MatchLiveTv.xcodeproj/project.pbxproj index eae410e..f15d022 100644 --- a/native/ios/MatchLiveTv.xcodeproj/project.pbxproj +++ b/native/ios/MatchLiveTv.xcodeproj/project.pbxproj @@ -1,654 +1,272 @@ // !$*UTF8*$! { archiveVersion = 1; - classes = { - }; + classes = {}; objectVersion = 60; objects = { + 4FC590728C2F47DE94AEADA3 /* MatchLiveTvApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveTvApp.swift; path = MatchLiveTv/App/MatchLiveTvApp.swift; sourceTree = ""; }; + 69F95F7CAF894139BA244AFF /* ApiInstant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstant.swift; path = MatchLiveTv/Core/ApiInstant.swift; sourceTree = ""; }; + 5E30D25B02704FAE9C0A4040 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppConfig.swift; path = MatchLiveTv/Core/AppConfig.swift; sourceTree = ""; }; + A59093CB86BB41FE9DF37182 /* AppLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppLanguage.swift; path = MatchLiveTv/Core/AppLanguage.swift; sourceTree = ""; }; + C00F6C82C9A04A50B1D34BA3 /* ColorHex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ColorHex.swift; path = MatchLiveTv/Core/ColorHex.swift; sourceTree = ""; }; + 6F83B04D0BC84879A07C9BFE /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = ""; }; + 33DEE4675BAB413E9F69549B /* MediaUrl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MediaUrl.swift; path = MatchLiveTv/Core/MediaUrl.swift; sourceTree = ""; }; + 14FE2B5F844A482C91245755 /* ThermalState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThermalState.swift; path = MatchLiveTv/Core/Thermal/ThermalState.swift; sourceTree = ""; }; + AA9DF2F1C5F04EA198286266 /* ThermalStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThermalStateManager.swift; path = MatchLiveTv/Core/Thermal/ThermalStateManager.swift; sourceTree = ""; }; + 8EEFEB75DDB04C4986774D4B /* TokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TokenStore.swift; path = MatchLiveTv/Core/TokenStore.swift; sourceTree = ""; }; + C84694361A004982B4007CA5 /* UserFacingError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = UserFacingError.swift; path = MatchLiveTv/Core/UserFacingError.swift; sourceTree = ""; }; + 1AD71EE7F4A54818A6C427C9 /* ApiDtos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiDtos.swift; path = MatchLiveTv/Data/API/ApiDtos.swift; sourceTree = ""; }; + 610902FBCEB7478286C88C08 /* MatchLiveAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveAPI.swift; path = MatchLiveTv/Data/API/MatchLiveAPI.swift; sourceTree = ""; }; + DD9687B43B5840DD96FAD1C9 /* AppContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppContainer.swift; path = MatchLiveTv/Data/AppContainer.swift; sourceTree = ""; }; + 905A6416560A4442AA7D2FCF /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = ""; }; + 9299E98557E04C5F8DC736F8 /* SessionCableService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionCableService.swift; path = MatchLiveTv/Data/Cable/SessionCableService.swift; sourceTree = ""; }; + 49E1D454901048A58DCE7C41 /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AuthRepository.swift; path = MatchLiveTv/Data/Repository/AuthRepository.swift; sourceTree = ""; }; + A90696A40A584C5794938FDA /* MatchRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchRepository.swift; path = MatchLiveTv/Data/Repository/MatchRepository.swift; sourceTree = ""; }; + 2C3F8293D48D44A6A569F452 /* MatchSessionLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSessionLauncher.swift; path = MatchLiveTv/Data/Repository/MatchSessionLauncher.swift; sourceTree = ""; }; + 347DADA50AE44484ABF19903 /* ScoreRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreRepository.swift; path = MatchLiveTv/Data/Repository/ScoreRepository.swift; sourceTree = ""; }; + 69794703A6D74DB9AA3D16A2 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = ""; }; + D274E6DA2B7B47C9822FB517 /* ScoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreController.swift; path = MatchLiveTv/Data/Scoring/ScoreController.swift; sourceTree = ""; }; + E4BAEE4AB7834E19BA57C4A1 /* WizardSessionHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardSessionHolder.swift; path = MatchLiveTv/Data/WizardSessionHolder.swift; sourceTree = ""; }; + 1060DB32269548BE895EFA7F /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = ""; }; + CA265A4F2CAA41DA8F68A35C /* MatchScoringRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRules.swift; path = MatchLiveTv/Domain/MatchScoringRules.swift; sourceTree = ""; }; + 0381775B0B1D4378A8BF289A /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Models.swift; path = MatchLiveTv/Domain/Models.swift; sourceTree = ""; }; + 85D4CBF80A8F447790235E7C /* ScoreState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreState.swift; path = MatchLiveTv/Domain/ScoreState.swift; sourceTree = ""; }; + 2EA97648294C4D98B3D85EFD /* BroadcastModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastModels.swift; path = MatchLiveTv/Streaming/BroadcastModels.swift; sourceTree = ""; }; + FA4A1042B44B41E0AE768E0C /* BroadcastOrientationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastOrientationPolicy.swift; path = MatchLiveTv/Streaming/BroadcastOrientationPolicy.swift; sourceTree = ""; }; + 97DF04B6B21743BC9615E634 /* BroadcastVideoOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastVideoOrientation.swift; path = MatchLiveTv/Streaming/BroadcastVideoOrientation.swift; sourceTree = ""; }; + 1B031A4D51634E96BBD3D8DA /* LiveBroadcastCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastCoordinator.swift; path = MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift; sourceTree = ""; }; + BC442280384D4194A66F25D2 /* LiveBroadcastEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastEngine.swift; path = MatchLiveTv/Streaming/LiveBroadcastEngine.swift; sourceTree = ""; }; + EF6E8429A9DF475E9C964A2A /* CompactScoreboardElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CompactScoreboardElement.swift; path = MatchLiveTv/Streaming/Overlay/CompactScoreboardElement.swift; sourceTree = ""; }; + AA419130C5A248DCA3B4A016 /* OverlayCanvasRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayCanvasRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayCanvasRenderer.swift; sourceTree = ""; }; + 8CF76F8D690C405D9FCC4B8E /* OverlayLogoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayLogoCache.swift; path = MatchLiveTv/Streaming/Overlay/OverlayLogoCache.swift; sourceTree = ""; }; + E1A1C8A803FB4CA1BA741E57 /* OverlayMappings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayMappings.swift; path = MatchLiveTv/Streaming/Overlay/OverlayMappings.swift; sourceTree = ""; }; + 195EFB33D08D484489FD721B /* OverlayRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayRenderer.swift; sourceTree = ""; }; + DB0DF408F60149008C9644C0 /* OverlayState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayState.swift; path = MatchLiveTv/Streaming/Overlay/OverlayState.swift; sourceTree = ""; }; + E9F0583912AA42F787751A7F /* ScoreboardElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreboardElement.swift; path = MatchLiveTv/Streaming/Overlay/ScoreboardElement.swift; sourceTree = ""; }; + FB19CB746D6146D9ABD1A1C4 /* SponsorElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SponsorElement.swift; path = MatchLiveTv/Streaming/Overlay/SponsorElement.swift; sourceTree = ""; }; + CD8C48EA12DB45FF978FA049 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = ""; }; + 838A41F3BF1E42BDAA2CE2E6 /* LivePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LivePreviewView.swift; path = MatchLiveTv/Streaming/Preview/LivePreviewView.swift; sourceTree = ""; }; + 2074992AD4E94430B9524FB3 /* StreamVideoPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamVideoPreset.swift; path = MatchLiveTv/Streaming/StreamVideoPreset.swift; sourceTree = ""; }; + B51507FF41FB4B80831D70E3 /* BroadcastControlsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastControlsOverlay.swift; path = MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift; sourceTree = ""; }; + 8EA7E4972BB74FDD884370D0 /* BroadcastScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastScreen.swift; path = MatchLiveTv/UI/Broadcast/BroadcastScreen.swift; sourceTree = ""; }; + CC2B124ACD024538B20863B8 /* LiveScoreActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveScoreActions.swift; path = MatchLiveTv/UI/Broadcast/LiveScoreActions.swift; sourceTree = ""; }; + 31B0AEBA3A0140D2BC9698DC /* MatchLiveWordmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveWordmark.swift; path = MatchLiveTv/UI/Components/MatchLiveWordmark.swift; sourceTree = ""; }; + 0C84054AB3574CC5B8056694 /* MatchPrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchPrimaryButton.swift; path = MatchLiveTv/UI/Components/MatchPrimaryButton.swift; sourceTree = ""; }; + D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = ""; }; + 3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSecondaryButton.swift; path = MatchLiveTv/UI/Components/MatchSecondaryButton.swift; sourceTree = ""; }; + 1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = ""; }; + DA51BC65490E421AA3D16F02 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoginScreen.swift; path = MatchLiveTv/UI/Login/LoginScreen.swift; sourceTree = ""; }; + 0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchesScreen.swift; path = MatchLiveTv/UI/Matches/MatchesScreen.swift; sourceTree = ""; }; + 5DD00428364442C6B8C55DDF /* AppNavHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppNavHost.swift; path = MatchLiveTv/UI/Navigation/AppNavHost.swift; sourceTree = ""; }; + 9F958AB307244896BD8948D2 /* ModalRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ModalRoutes.swift; path = MatchLiveTv/UI/Navigation/ModalRoutes.swift; sourceTree = ""; }; + 21C2A6AB0E164C8CBDF9CF7F /* Routes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Routes.swift; path = MatchLiveTv/UI/Navigation/Routes.swift; sourceTree = ""; }; + 1702AF57020940DEBF0F9BA0 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = ""; }; + 3004C07E608644AFB636C0C5 /* SplashScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SplashScreen.swift; path = MatchLiveTv/UI/Splash/SplashScreen.swift; sourceTree = ""; }; + DC39718A2EC74B078A0C5DE9 /* KeepScreenOn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KeepScreenOn.swift; path = MatchLiveTv/UI/System/KeepScreenOn.swift; sourceTree = ""; }; + BCEB22897600469E93762209 /* ScreenOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScreenOrientation.swift; path = MatchLiveTv/UI/System/ScreenOrientation.swift; sourceTree = ""; }; + 13BC17CE70034FEE97707B21 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ShareSheet.swift; path = MatchLiveTv/UI/System/ShareSheet.swift; sourceTree = ""; }; + AAAD219E4FA94CF885472711 /* MatchColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchColors.swift; path = MatchLiveTv/UI/Theme/MatchColors.swift; sourceTree = ""; }; + 29F84FF3F1E74E49A3EA5101 /* StepMatchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepMatchScreen.swift; path = MatchLiveTv/UI/Wizard/StepMatchScreen.swift; sourceTree = ""; }; + 1875AE0A77B94DF0921EE457 /* StepNetworkTestScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepNetworkTestScreen.swift; path = MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift; sourceTree = ""; }; + 3168221266004A33BA35042C /* StepTransmissionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepTransmissionScreen.swift; path = MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift; sourceTree = ""; }; + C97603C3A54342799CBD0C03 /* TeamBrandingEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TeamBrandingEditor.swift; path = MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift; sourceTree = ""; }; + 5AFEAFB3E8F047B9A2B0DD04 /* TeamColorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TeamColorPicker.swift; path = MatchLiveTv/UI/Wizard/TeamColorPicker.swift; sourceTree = ""; }; + CE7A5A0E6D9A49EA95918E79 /* WizardComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardComponents.swift; path = MatchLiveTv/UI/Wizard/WizardComponents.swift; sourceTree = ""; }; + 68CB1416AC24491CAA4CE87A /* WizardShellScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardShellScreen.swift; path = MatchLiveTv/UI/Wizard/WizardShellScreen.swift; sourceTree = ""; }; + 73BB032A0DD74458B0E716C6 /* ApiInstantTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstantTests.swift; path = MatchLiveTvTests/ApiInstantTests.swift; sourceTree = ""; }; + C186E88B35A0443494AE6E8E /* BroadcastOrientationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastOrientationPolicyTests.swift; path = MatchLiveTvTests/BroadcastOrientationPolicyTests.swift; sourceTree = ""; }; + 5751DB993C52460D960B8067 /* LiveBroadcastCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastCoordinatorTests.swift; path = MatchLiveTvTests/LiveBroadcastCoordinatorTests.swift; sourceTree = ""; }; + 0E243C00009048F6A778323C /* LiveScoreDialogHostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveScoreDialogHostTests.swift; path = MatchLiveTvTests/LiveScoreDialogHostTests.swift; sourceTree = ""; }; + 634AD5C2E53B4C098582284A /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = ""; }; + B7DE055717014393B3F62D5A /* ScoreActionDecodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreActionDecodeTests.swift; path = MatchLiveTvTests/ScoreActionDecodeTests.swift; sourceTree = ""; }; + C3C1AC6562ED4A908D7C8B0A /* ScoreControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreControllerTests.swift; path = MatchLiveTvTests/ScoreControllerTests.swift; sourceTree = ""; }; + 610A4D03FA384F0287AAF825 /* ThermalAdaptationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThermalAdaptationTests.swift; path = MatchLiveTvTests/ThermalAdaptationTests.swift; sourceTree = ""; }; + F68654552B254000B6F0BE97 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MatchLiveTv/Resources/Assets.xcassets; sourceTree = ""; }; + 1255FF9134D246ECBE1DF72C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = MatchLiveTv/Resources/Info.plist; sourceTree = ""; }; + 6DFD25C0855F482DBEFD8757 /* it.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/it.lproj; sourceTree = ""; }; + 12C3FA28B28146B9A8E3A70A /* en.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/en.lproj; sourceTree = ""; }; + 5D760AAFB9C34DACBFC2387A /* fr.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/fr.lproj; sourceTree = ""; }; + 85B0F928887C44128434974E /* de.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/de.lproj; sourceTree = ""; }; + E907C350C3E5410FB4A2038D /* es.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/es.lproj; sourceTree = ""; }; + 6FF24BB96E9C40F6A9F6E25A /* MatchLiveTv.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MatchLiveTv.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 8F679C9C03AE47D6A028DBAC /* MatchLiveTvTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MatchLiveTvTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 31418599149E4F7684EABE04 /* HaishinKit */ = {isa = XCSwiftPackageProductDependency; package = 4901BAD7A32F4CFD805083BE; productName = HaishinKit; }; + 1712041BB26748D78A1D8F49 /* RTMPHaishinKit */ = {isa = XCSwiftPackageProductDependency; package = 4901BAD7A32F4CFD805083BE; productName = RTMPHaishinKit; }; + F7624153A1C84C218B602FFB /* MatchLiveTvApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC590728C2F47DE94AEADA3 /* MatchLiveTvApp.swift */; }; + D973F2B503854C6AA61A1559 /* ApiInstant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F95F7CAF894139BA244AFF /* ApiInstant.swift */; }; + FE2D8C8359CC4DA189DF5DC6 /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E30D25B02704FAE9C0A4040 /* AppConfig.swift */; }; + B607A146AE754F6A819FB186 /* AppLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59093CB86BB41FE9DF37182 /* AppLanguage.swift */; }; + B0D93B493FD0427CA72F5862 /* ColorHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = C00F6C82C9A04A50B1D34BA3 /* ColorHex.swift */; }; + 3C223B19BA2149C5B7E5CC5A /* DeviceTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F83B04D0BC84879A07C9BFE /* DeviceTelemetry.swift */; }; + 3D119DE97DE5452D903F364C /* MediaUrl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEE4675BAB413E9F69549B /* MediaUrl.swift */; }; + 8ABF00BDC15C400A9089FC28 /* ThermalState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14FE2B5F844A482C91245755 /* ThermalState.swift */; }; + 7D2649B9131F4DDA83F68B72 /* ThermalStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9DF2F1C5F04EA198286266 /* ThermalStateManager.swift */; }; + AB71730B7CA24D3E8667BCFF /* TokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EEFEB75DDB04C4986774D4B /* TokenStore.swift */; }; + ADA58448C2344DADAA90F212 /* UserFacingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = C84694361A004982B4007CA5 /* UserFacingError.swift */; }; + 95F7871858C44379A62012BA /* ApiDtos.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AD71EE7F4A54818A6C427C9 /* ApiDtos.swift */; }; + 3FBBEF8DFF5E4F5D9E34C860 /* MatchLiveAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 610902FBCEB7478286C88C08 /* MatchLiveAPI.swift */; }; + 91694276C76B438F8B52448F /* AppContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9687B43B5840DD96FAD1C9 /* AppContainer.swift */; }; + 51D849AFAC8D4F57BEF05CAB /* ActionCableClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 905A6416560A4442AA7D2FCF /* ActionCableClient.swift */; }; + D771BABE58A3439CA1768F7F /* SessionCableService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9299E98557E04C5F8DC736F8 /* SessionCableService.swift */; }; + 23C149E317BD416D9CF0B171 /* AuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E1D454901048A58DCE7C41 /* AuthRepository.swift */; }; + 43C57FF23CB347AC99284468 /* MatchRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90696A40A584C5794938FDA /* MatchRepository.swift */; }; + 8661E674C82545C4AEC0A7CE /* MatchSessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C3F8293D48D44A6A569F452 /* MatchSessionLauncher.swift */; }; + E133D004CF0A4FF7A51A47B1 /* ScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347DADA50AE44484ABF19903 /* ScoreRepository.swift */; }; + 72090ACA64CB4885BE7AB333 /* SessionRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69794703A6D74DB9AA3D16A2 /* SessionRepository.swift */; }; + D80261301CA740568D8807D2 /* ScoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D274E6DA2B7B47C9822FB517 /* ScoreController.swift */; }; + 9ECD8B486DF84890BD39B108 /* WizardSessionHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4BAEE4AB7834E19BA57C4A1 /* WizardSessionHolder.swift */; }; + C217B8AD49D04976A6D7FC7E /* MatchHubFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1060DB32269548BE895EFA7F /* MatchHubFilter.swift */; }; + 978AAF829F654537AC473F52 /* MatchScoringRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA265A4F2CAA41DA8F68A35C /* MatchScoringRules.swift */; }; + 032C790773A848B7A0434C67 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0381775B0B1D4378A8BF289A /* Models.swift */; }; + A37BC192D6C9433F83FABAA8 /* ScoreState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85D4CBF80A8F447790235E7C /* ScoreState.swift */; }; + 99F1D8A337EE46C8BDD34F60 /* BroadcastModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA97648294C4D98B3D85EFD /* BroadcastModels.swift */; }; + F024262D0D0F4B759E1A4F14 /* BroadcastOrientationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4A1042B44B41E0AE768E0C /* BroadcastOrientationPolicy.swift */; }; + FE9E0B76650246E490722317 /* BroadcastVideoOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97DF04B6B21743BC9615E634 /* BroadcastVideoOrientation.swift */; }; + BD642A0BD19D424985FBF1A3 /* LiveBroadcastCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B031A4D51634E96BBD3D8DA /* LiveBroadcastCoordinator.swift */; }; + B48421E7BD5D48E499948E2E /* LiveBroadcastEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC442280384D4194A66F25D2 /* LiveBroadcastEngine.swift */; }; + C6207D98BB484493BEDD4BBB /* CompactScoreboardElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF6E8429A9DF475E9C964A2A /* CompactScoreboardElement.swift */; }; + E715CA8C60E6439683E1DA0E /* OverlayCanvasRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA419130C5A248DCA3B4A016 /* OverlayCanvasRenderer.swift */; }; + 35F5E2E5FB0D4B9FA475ADA4 /* OverlayLogoCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CF76F8D690C405D9FCC4B8E /* OverlayLogoCache.swift */; }; + 6D8B0F1792E440C19F49AD03 /* OverlayMappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1C8A803FB4CA1BA741E57 /* OverlayMappings.swift */; }; + CC563D93928C438AB0B78E99 /* OverlayRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195EFB33D08D484489FD721B /* OverlayRenderer.swift */; }; + 9343740F4EB94F609A4D1A51 /* OverlayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0DF408F60149008C9644C0 /* OverlayState.swift */; }; + D71C1DFF234742DA81D23AF7 /* ScoreboardElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F0583912AA42F787751A7F /* ScoreboardElement.swift */; }; + DADD5004759C409F82D4FEE2 /* SponsorElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB19CB746D6146D9ABD1A1C4 /* SponsorElement.swift */; }; + 957381C0C3A447E7A3240ACD /* WatermarkElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD8C48EA12DB45FF978FA049 /* WatermarkElement.swift */; }; + 326AEF0FBFE74201BE70D2DD /* LivePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 838A41F3BF1E42BDAA2CE2E6 /* LivePreviewView.swift */; }; + DCC6F79099C641DF9EA6F36F /* StreamVideoPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2074992AD4E94430B9524FB3 /* StreamVideoPreset.swift */; }; + CA82A2382C4A45BE88276507 /* BroadcastControlsOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51507FF41FB4B80831D70E3 /* BroadcastControlsOverlay.swift */; }; + 37E8D25C18014E0C8139D84D /* BroadcastScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EA7E4972BB74FDD884370D0 /* BroadcastScreen.swift */; }; + 67A37D0BB28F4BD1872BACA6 /* LiveScoreActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2B124ACD024538B20863B8 /* LiveScoreActions.swift */; }; + 7AA96446B33C4ECCB90DDFD0 /* MatchLiveWordmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31B0AEBA3A0140D2BC9698DC /* MatchLiveWordmark.swift */; }; + 599290557AE4430EA30582D7 /* MatchPrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84054AB3574CC5B8056694 /* MatchPrimaryButton.swift */; }; + D71CB837FE844932AA590D30 /* MatchScreenScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */; }; + 52806453AD4C4E59BA2F05DE /* MatchSecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */; }; + FA601701F8684EE2BFDBBBFD /* MatchStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.swift */; }; + 8C0225C7F87A4F9A8A0C2919 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA51BC65490E421AA3D16F02 /* LoginScreen.swift */; }; + 7293CD3C1B634333A59FA782 /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */; }; + 465980490F2D417E87241EBA /* AppNavHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD00428364442C6B8C55DDF /* AppNavHost.swift */; }; + 044E04EEE71D472EA524C555 /* ModalRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F958AB307244896BD8948D2 /* ModalRoutes.swift */; }; + 52CEBF8B18834CB8B650FE16 /* Routes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21C2A6AB0E164C8CBDF9CF7F /* Routes.swift */; }; + 94ADCCD464DD43149D0C0E60 /* BroadcastPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1702AF57020940DEBF0F9BA0 /* BroadcastPermissions.swift */; }; + F23C2967ED7140EB8DCB2D2A /* SplashScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3004C07E608644AFB636C0C5 /* SplashScreen.swift */; }; + 47AD4ADC4ACE4F6DA7AD708C /* KeepScreenOn.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC39718A2EC74B078A0C5DE9 /* KeepScreenOn.swift */; }; + 8B0A9A9DBAF346199EBA21B3 /* ScreenOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCEB22897600469E93762209 /* ScreenOrientation.swift */; }; + D826E98233304F5FB27B27C3 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13BC17CE70034FEE97707B21 /* ShareSheet.swift */; }; + 579A3A8D49DF43E7B11942C6 /* MatchColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAD219E4FA94CF885472711 /* MatchColors.swift */; }; + D0BEA6E726C04A95A8CC0018 /* StepMatchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29F84FF3F1E74E49A3EA5101 /* StepMatchScreen.swift */; }; + 15DA64BB40B14C9B899A3729 /* StepNetworkTestScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1875AE0A77B94DF0921EE457 /* StepNetworkTestScreen.swift */; }; + 4DF6B21EFDD54DF999385511 /* StepTransmissionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3168221266004A33BA35042C /* StepTransmissionScreen.swift */; }; + AEF63662B6B5454F92986710 /* TeamBrandingEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97603C3A54342799CBD0C03 /* TeamBrandingEditor.swift */; }; + 78179BA533074F1ABBB7391E /* TeamColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFEAFB3E8F047B9A2B0DD04 /* TeamColorPicker.swift */; }; + 45E14846E79845CE9C20C5EC /* WizardComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7A5A0E6D9A49EA95918E79 /* WizardComponents.swift */; }; + 2049733B63794135A4BFBB53 /* WizardShellScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68CB1416AC24491CAA4CE87A /* WizardShellScreen.swift */; }; + 6B75AFC14BBF4E2B97762ECB /* ApiInstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73BB032A0DD74458B0E716C6 /* ApiInstantTests.swift */; }; + 9BA38C8D635F4770A232AE85 /* BroadcastOrientationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C186E88B35A0443494AE6E8E /* BroadcastOrientationPolicyTests.swift */; }; + 1889CDB168794D81A5B24699 /* LiveBroadcastCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5751DB993C52460D960B8067 /* LiveBroadcastCoordinatorTests.swift */; }; + E25CB4FB944141D0AAB57EDF /* LiveScoreDialogHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E243C00009048F6A778323C /* LiveScoreDialogHostTests.swift */; }; + 6BE3B2533E6A4E3DA32EA972 /* MatchScoringRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 634AD5C2E53B4C098582284A /* MatchScoringRulesTests.swift */; }; + B88859D9B34E4F289913DDFC /* ScoreActionDecodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7DE055717014393B3F62D5A /* ScoreActionDecodeTests.swift */; }; + B8CC446DB3AE4BABB4D739D1 /* ScoreControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C1AC6562ED4A908D7C8B0A /* ScoreControllerTests.swift */; }; + 6CCB67439127403180D9C697 /* ThermalAdaptationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 610A4D03FA384F0287AAF825 /* ThermalAdaptationTests.swift */; }; + C44DD19CDB6545A381B61D20 /* Assets in Resources */ = {isa = PBXBuildFile; fileRef = F68654552B254000B6F0BE97; }; + 7C8A2DFFAB6B4CB8AA3E4FE8 /* it.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 6DFD25C0855F482DBEFD8757; }; + 8D479F8873F94073938D737E /* en.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 12C3FA28B28146B9A8E3A70A; }; + 9A894DBC632C4904BC5D9A78 /* fr.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 5D760AAFB9C34DACBFC2387A; }; + 9246A8532B66443A83535192 /* de.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 85B0F928887C44128434974E; }; + 5DA941A5928A4F638223030C /* es.lproj in Resources */ = {isa = PBXBuildFile; fileRef = E907C350C3E5410FB4A2038D; }; + B6A69164398F4F9FB84EC475 = {isa = PBXGroup; children = (86BA4622CE11450BAD7902B9, E068E1D4709041789B1E8F5A, 2165E0C34A8B43F28AC2B21C); sourceTree = ""; }; + 86BA4622CE11450BAD7902B9 = {isa = PBXGroup; children = (4FC590728C2F47DE94AEADA3, 69F95F7CAF894139BA244AFF, 5E30D25B02704FAE9C0A4040, A59093CB86BB41FE9DF37182, C00F6C82C9A04A50B1D34BA3, 6F83B04D0BC84879A07C9BFE, 33DEE4675BAB413E9F69549B, 14FE2B5F844A482C91245755, AA9DF2F1C5F04EA198286266, 8EEFEB75DDB04C4986774D4B, C84694361A004982B4007CA5, 1AD71EE7F4A54818A6C427C9, 610902FBCEB7478286C88C08, DD9687B43B5840DD96FAD1C9, 905A6416560A4442AA7D2FCF, 9299E98557E04C5F8DC736F8, 49E1D454901048A58DCE7C41, A90696A40A584C5794938FDA, 2C3F8293D48D44A6A569F452, 347DADA50AE44484ABF19903, 69794703A6D74DB9AA3D16A2, D274E6DA2B7B47C9822FB517, E4BAEE4AB7834E19BA57C4A1, 1060DB32269548BE895EFA7F, CA265A4F2CAA41DA8F68A35C, 0381775B0B1D4378A8BF289A, 85D4CBF80A8F447790235E7C, 2EA97648294C4D98B3D85EFD, FA4A1042B44B41E0AE768E0C, 97DF04B6B21743BC9615E634, 1B031A4D51634E96BBD3D8DA, BC442280384D4194A66F25D2, EF6E8429A9DF475E9C964A2A, AA419130C5A248DCA3B4A016, 8CF76F8D690C405D9FCC4B8E, E1A1C8A803FB4CA1BA741E57, 195EFB33D08D484489FD721B, DB0DF408F60149008C9644C0, E9F0583912AA42F787751A7F, FB19CB746D6146D9ABD1A1C4, CD8C48EA12DB45FF978FA049, 838A41F3BF1E42BDAA2CE2E6, 2074992AD4E94430B9524FB3, B51507FF41FB4B80831D70E3, 8EA7E4972BB74FDD884370D0, CC2B124ACD024538B20863B8, 31B0AEBA3A0140D2BC9698DC, 0C84054AB3574CC5B8056694, D7434175C1E849D7A8308896, 3DE0F41386A741B2A6FC538B, 1F6A499FE7D94EF498DA9A84, DA51BC65490E421AA3D16F02, 0A439BB96CCB4C038EEF44DD, 5DD00428364442C6B8C55DDF, 9F958AB307244896BD8948D2, 21C2A6AB0E164C8CBDF9CF7F, 1702AF57020940DEBF0F9BA0, 3004C07E608644AFB636C0C5, DC39718A2EC74B078A0C5DE9, BCEB22897600469E93762209, 13BC17CE70034FEE97707B21, AAAD219E4FA94CF885472711, 29F84FF3F1E74E49A3EA5101, 1875AE0A77B94DF0921EE457, 3168221266004A33BA35042C, C97603C3A54342799CBD0C03, 5AFEAFB3E8F047B9A2B0DD04, CE7A5A0E6D9A49EA95918E79, 68CB1416AC24491CAA4CE87A, F68654552B254000B6F0BE97, 1255FF9134D246ECBE1DF72C, 6DFD25C0855F482DBEFD8757, 12C3FA28B28146B9A8E3A70A, 5D760AAFB9C34DACBFC2387A, 85B0F928887C44128434974E, E907C350C3E5410FB4A2038D); name = MatchLiveTv; sourceTree = ""; }; + E068E1D4709041789B1E8F5A = {isa = PBXGroup; children = (73BB032A0DD74458B0E716C6, C186E88B35A0443494AE6E8E, 5751DB993C52460D960B8067, 0E243C00009048F6A778323C, 634AD5C2E53B4C098582284A, B7DE055717014393B3F62D5A, C3C1AC6562ED4A908D7C8B0A, 610A4D03FA384F0287AAF825); name = MatchLiveTvTests; sourceTree = ""; }; + 2165E0C34A8B43F28AC2B21C = {isa = PBXGroup; children = (6FF24BB96E9C40F6A9F6E25A, 8F679C9C03AE47D6A028DBAC); name = Products; sourceTree = ""; }; + 3B35A518C56047A48825F0F8 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (F7624153A1C84C218B602FFB, D973F2B503854C6AA61A1559, FE2D8C8359CC4DA189DF5DC6, B607A146AE754F6A819FB186, B0D93B493FD0427CA72F5862, 3C223B19BA2149C5B7E5CC5A, 3D119DE97DE5452D903F364C, 8ABF00BDC15C400A9089FC28, 7D2649B9131F4DDA83F68B72, AB71730B7CA24D3E8667BCFF, ADA58448C2344DADAA90F212, 95F7871858C44379A62012BA, 3FBBEF8DFF5E4F5D9E34C860, 91694276C76B438F8B52448F, 51D849AFAC8D4F57BEF05CAB, D771BABE58A3439CA1768F7F, 23C149E317BD416D9CF0B171, 43C57FF23CB347AC99284468, 8661E674C82545C4AEC0A7CE, E133D004CF0A4FF7A51A47B1, 72090ACA64CB4885BE7AB333, D80261301CA740568D8807D2, 9ECD8B486DF84890BD39B108, C217B8AD49D04976A6D7FC7E, 978AAF829F654537AC473F52, 032C790773A848B7A0434C67, A37BC192D6C9433F83FABAA8, 99F1D8A337EE46C8BDD34F60, F024262D0D0F4B759E1A4F14, FE9E0B76650246E490722317, BD642A0BD19D424985FBF1A3, B48421E7BD5D48E499948E2E, C6207D98BB484493BEDD4BBB, E715CA8C60E6439683E1DA0E, 35F5E2E5FB0D4B9FA475ADA4, 6D8B0F1792E440C19F49AD03, CC563D93928C438AB0B78E99, 9343740F4EB94F609A4D1A51, D71C1DFF234742DA81D23AF7, DADD5004759C409F82D4FEE2, 957381C0C3A447E7A3240ACD, 326AEF0FBFE74201BE70D2DD, DCC6F79099C641DF9EA6F36F, CA82A2382C4A45BE88276507, 37E8D25C18014E0C8139D84D, 67A37D0BB28F4BD1872BACA6, 7AA96446B33C4ECCB90DDFD0, 599290557AE4430EA30582D7, D71CB837FE844932AA590D30, 52806453AD4C4E59BA2F05DE, FA601701F8684EE2BFDBBBFD, 8C0225C7F87A4F9A8A0C2919, 7293CD3C1B634333A59FA782, 465980490F2D417E87241EBA, 044E04EEE71D472EA524C555, 52CEBF8B18834CB8B650FE16, 94ADCCD464DD43149D0C0E60, F23C2967ED7140EB8DCB2D2A, 47AD4ADC4ACE4F6DA7AD708C, 8B0A9A9DBAF346199EBA21B3, D826E98233304F5FB27B27C3, 579A3A8D49DF43E7B11942C6, D0BEA6E726C04A95A8CC0018, 15DA64BB40B14C9B899A3729, 4DF6B21EFDD54DF999385511, AEF63662B6B5454F92986710, 78179BA533074F1ABBB7391E, 45E14846E79845CE9C20C5EC, 2049733B63794135A4BFBB53); runOnlyForDeploymentPostprocessing = 0; }; + 729C230D5EB64A76BCD517B4 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (6B75AFC14BBF4E2B97762ECB, 9BA38C8D635F4770A232AE85, 1889CDB168794D81A5B24699, E25CB4FB944141D0AAB57EDF, 6BE3B2533E6A4E3DA32EA972, B88859D9B34E4F289913DDFC, B8CC446DB3AE4BABB4D739D1, 6CCB67439127403180D9C697); runOnlyForDeploymentPostprocessing = 0; }; + 6B4D5383389142E7AE9C4F41 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (C44DD19CDB6545A381B61D20, 7C8A2DFFAB6B4CB8AA3E4FE8, 8D479F8873F94073938D737E, 9A894DBC632C4904BC5D9A78, 9246A8532B66443A83535192, 5DA941A5928A4F638223030C); runOnlyForDeploymentPostprocessing = 0; }; + AA7CFC56BCC040A4AB487E2C = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; + 6E9DDC30CE734AC1AB90CC24 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; + D3DDF0C54D1744C09B415888 = {isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 26; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_ENABLE_EXPLICIT_MODULES = NO; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 2.0.5; + PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv; + PRODUCT_NAME = "$(TARGET_NAME)"; + API_BASE_URL = "https://www.matchlivetv.it"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; -/* Begin PBXBuildFile section */ - 01EF2265D1624199B171D57E /* LivePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3F630293570453BB85BE288 /* LivePreviewView.swift */; }; - 035B95F7CE8643C48038A114 /* ApiDtos.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F08BB3F014B44F18A1203FE /* ApiDtos.swift */; }; - 0469F385C3BD41489812C2EC /* TeamBrandingEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A71899E82D448797DCE7E8 /* TeamBrandingEditor.swift */; }; - 066B7E46EB6245BA94C12BA6 /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 395345091E364AFF9FFB34B9 /* AppConfig.swift */; }; - 0D3A76C230854286AA528524 /* AppContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 590CA24C9659490E9A8EC555 /* AppContainer.swift */; }; - 0D666A37E49749C68C0DB06B /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A23B62DE25C94F47B560D742 /* ShareSheet.swift */; }; - 0D81FEE2FA88468C98652291 /* WizardShellScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29358C1BDC864C408AB4FAD0 /* WizardShellScreen.swift */; }; - 0F6F3A1F9EA74107BDFAF930 /* SessionCableService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3D8CA843FF54424B181675B /* SessionCableService.swift */; }; - 100AF197AD334A1AB38D271B /* ModalRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44E182032F64EBBA55ADCEB /* ModalRoutes.swift */; }; - 226769D5E66848FB90B5BFEE /* MatchStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5964C8AF73D4617BB437E4A /* MatchStatusBadge.swift */; }; - 2B400EFC857D4EDF9CCF73B9 /* BroadcastModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 820BDE9FBCCB4BABACD2AC89 /* BroadcastModels.swift */; }; - 35B3BCE4D68F47C4ABD1E955 /* MatchPrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E92E0A7DB54267AC53F60A /* MatchPrimaryButton.swift */; }; - 3E25B3A6F51242B9847DBA6F /* ScoreState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A6EEEAD38F4F509310B82E /* ScoreState.swift */; }; - 3EF78D9394DF41D89E0A43F8 /* SponsorElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC3D4B19E974898AA9CAC76 /* SponsorElement.swift */; }; - 3F5B3BD00C6F48379469C171 /* TokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC6373E955749DF9AB5DB5B /* TokenStore.swift */; }; - 42EF33716F6843B69945E6AB /* MatchLiveTvApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EB02413D134D6289DB90DA /* MatchLiveTvApp.swift */; }; - 46F6F77D670A49BFB4A9B757 /* UserFacingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109F8ABCCCF649EFB3E6647C /* UserFacingError.swift */; }; - 4C1FDA2D80034A2FB6550DAB /* MatchRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AF23C6CDE24E1AB227F019 /* MatchRepository.swift */; }; - 4C2639482FE6DA960015AAF3 /* HaishinKit in Frameworks */ = {isa = PBXBuildFile; productRef = BC6C276807664B6CB76A8A9D /* HaishinKit */; }; - 4C2639492FE6DA960015AAF3 /* RTMPHaishinKit in Frameworks */ = {isa = PBXBuildFile; productRef = 8F64C5AA49464C419712EAC5 /* RTMPHaishinKit */; }; - 4D74385A3406406ABFA45B44 /* OverlayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA288DB764E24438AE8826DC /* OverlayState.swift */; }; - 4FFA52FD7FF8410AA8FA673E /* MatchLiveAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9889D83B0787422397EF3181 /* MatchLiveAPI.swift */; }; - 52EB3701353841BDB837C75E /* WatermarkElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D95106A21804A2EAEDCECB7 /* WatermarkElement.swift */; }; - 539D6877DF3C478E86200649 /* LiveBroadcastCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F2049B408743AA82AF43F3 /* LiveBroadcastCoordinator.swift */; }; - 557CA481D63840D3AA6EFD5B /* ColorHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68D5ED55DB884EA2AC986F61 /* ColorHex.swift */; }; - 5AE324386FA64E1C8BA5F21F /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F961133AE74D4ECE9FDC3EBF /* LoginScreen.swift */; }; - 5BEB4A09B526452D8CCBBE1B /* SessionRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3691F2136AAD4545B655F544 /* SessionRepository.swift */; }; - 5CA7D12629854A4A9B9DF084 /* TeamColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68C47301A29345819A2CDEC8 /* TeamColorPicker.swift */; }; - 5DB4B7C040F6449198E5D07D /* ActionCableClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E91F93272E41A78E4CBECC /* ActionCableClient.swift */; }; - 60D2848965684DB5BFEBD5E3 /* MatchLiveTv/Resources/Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1B4E0A284897418CB3B60E19 /* MatchLiveTv/Resources/Assets.xcassets */; }; - 63CB7A84409D49CEBD74DE4D /* AuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1B3BC7CA8B4B25BCC77F6E /* AuthRepository.swift */; }; - 67818D53F7EA46C2A4850214 /* ScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE709C8660614B9DA5A2D657 /* ScoreRepository.swift */; }; - 6B4C7990A5D94A2C8AC69FC1 /* WizardComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7250114A6FB34430BB20B414 /* WizardComponents.swift */; }; - 6DFF8FAA4ECF46F1928851A4 /* ScoreboardElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B9108C739D484BAD4FF40F /* ScoreboardElement.swift */; }; - 727B74004C3741D9BA510F3B /* LiveScoreDialogHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FB35A567D04317B0421156 /* LiveScoreDialogHostTests.swift */; }; - 735CF034B3BA49BE9BBDD261 /* AppNavHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1265527D9925494BAE66B5A0 /* AppNavHost.swift */; }; - 75D6916EAD4E46CA9E36582C /* MatchScoringRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87A9A8AEACEA48FABD06BD52 /* MatchScoringRulesTests.swift */; }; - 7636A7A0E3CC477799338D29 /* LiveBroadcastCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B55F65BAA6146AF84B010CC /* LiveBroadcastCoordinatorTests.swift */; }; - 794CC6694C74486BAB61CA3B /* ApiInstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0707542005B4DCC90E3A072 /* ApiInstantTests.swift */; }; - 7D0DF967FC03423394839915 /* OverlayMappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611ECD9494B94B75B9866791 /* OverlayMappings.swift */; }; - 8179BD051391404B9E2235C2 /* KeepScreenOn.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D75DB685D64B93A65E9FC8 /* KeepScreenOn.swift */; }; - 81DB6B2543BC407EBB056C4B /* BroadcastScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985215153D0A44DC8B429731 /* BroadcastScreen.swift */; }; - 833558A9A0A043ECB8E146F6 /* ScoreActionDecodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3A1338FBA0411587BB5F4B /* ScoreActionDecodeTests.swift */; }; - 87DB3FBE122F444B87B9D73B /* LiveBroadcastEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8B7397C0BD4EC59851185D /* LiveBroadcastEngine.swift */; }; - F1A2B3C4D5E6478990ABCDEF /* StreamVideoPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6478990ABCDEF /* StreamVideoPreset.swift */; }; - 8841513B4E1743E983E7D2C8 /* BroadcastControlsOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF2832197EE409AA0B0334C /* BroadcastControlsOverlay.swift */; }; - 8C32CAC69405444DA4BB1E36 /* ScoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41CF197E5D543CE9A949497 /* ScoreController.swift */; }; - 8D7FCD0052674C0EB4F5C811 /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63D6F174CE084074972CABF6 /* MatchesScreen.swift */; }; - 8F6D3E3D61584BBA865AD2D7 /* OverlayLogoCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10BEC6633F9F446587F1818C /* OverlayLogoCache.swift */; }; - 919B83984A5347989781366F /* MatchScoringRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48D1A4C43A24A1E815A646D /* MatchScoringRules.swift */; }; - 92E4CAC8F45141A8899749C5 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DF32351AC9D47F39C23B29A /* Models.swift */; }; - AA9BCB0DA5164D43BD7A43FA /* CompactScoreboardElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC36378EDDDF44F29ACAD3C6 /* CompactScoreboardElement.swift */; }; - AEA8CCA49E514EA3984F297E /* BroadcastOrientationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE4871BCA51457CB720047B /* BroadcastOrientationPolicy.swift */; }; - AFA2540B2280448A81DFF5CF /* Routes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586F014DA6724EEB810107AD /* Routes.swift */; }; - B6F8B4BA2E49480DBD2CA846 /* MatchScreenScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7E9EFA6947F4A799570A271 /* MatchScreenScaffold.swift */; }; - C1D2EA29B25C4B06B03B6FFE /* BroadcastVideoOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA50054BF694B9D82EA97ED /* BroadcastVideoOrientation.swift */; }; - C3B1B44C77084C05B325F935 /* StepNetworkTestScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C26A0114F73483A85C70772 /* StepNetworkTestScreen.swift */; }; - C9B90AAE70C142DEB8996100 /* ApiInstant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 354D09291641417F99E27D5A /* ApiInstant.swift */; }; - A1B2C3D4E5F64789A0B1C2D3 /* AppLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F6478901A2B3C4D5 /* AppLanguage.swift */; }; - CF8E45A2BFE34E4BBE259654 /* DeviceTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 258DECC661314224B6A98F47 /* DeviceTelemetry.swift */; }; - D08665EFFF27465294B620B5 /* ScoreControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F7A645B14A44C69304E136 /* ScoreControllerTests.swift */; }; - DBBB006ED6C946A2950D805C /* StepMatchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8992D152CF7F478BA6469E36 /* StepMatchScreen.swift */; }; - DC848628856A4FCB86EE9DA7 /* MatchSecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8736A8A330D4815A3D56104 /* MatchSecondaryButton.swift */; }; - DF09C441105448CDBAFBD9AF /* OverlayRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49FD73A6D4254EAD9484E159 /* OverlayRenderer.swift */; }; - DFD70B8BFDAD427DA63CD6B1 /* MatchSessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7060C0C306D44E9AA8AAF5D4 /* MatchSessionLauncher.swift */; }; - E01D498C8EF64F85A41EAC54 /* StepTransmissionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94EDDD3FE78C4DC288424A6E /* StepTransmissionScreen.swift */; }; - E15E57A4C69D49F7AB0C916A /* BroadcastPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC868CFCFB6429F84E481B4 /* BroadcastPermissions.swift */; }; - E4C3F469616A42818F63CE89 /* WizardSessionHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 973C4EEE1DCD40448D7F2FAD /* WizardSessionHolder.swift */; }; - E5ECF142981E4D58B3040FB5 /* MatchColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E15E2F8F9114F299D8EB017 /* MatchColors.swift */; }; - E75B706F137249FF807FE0DD /* BroadcastOrientationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49FDEC2FCAD14E7C9D671A27 /* BroadcastOrientationPolicyTests.swift */; }; - EB3031C7972041F2BE6C3933 /* MediaUrl.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0BD001D8384E1CAD5A327B /* MediaUrl.swift */; }; - ECA9E663AF8B4F19BA4AD6D2 /* ScreenOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A279824047D4B7EB08A3F30 /* ScreenOrientation.swift */; }; - ED730C9B9068449E8D8AF6C8 /* OverlayCanvasRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B5BF42CCF934AC7A9516374 /* OverlayCanvasRenderer.swift */; }; - F0497D8B51DE4173AF395AF8 /* MatchHubFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F71C110A07B4F71AB71E6ED /* MatchHubFilter.swift */; }; - F065E5BE5EC34955B133C189 /* MatchLiveWordmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C6CE9645ACD4713AFC66C58 /* MatchLiveWordmark.swift */; }; - F8324F96CA074A0B8A71BB7B /* LiveScoreActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A224E4FD5069421AAB0C4CF6 /* LiveScoreActions.swift */; }; - FCDD0B3AA1F046B4B912A2F7 /* SplashScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB3CE35029F144899C81993B /* SplashScreen.swift */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - B2EE838DC30044B18C9AEE34 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 456AEB32689E48DEA025761E /* Project object */; - proxyType = 1; - remoteGlobalIDString = C1A6F02007F74525A4124BD0; - remoteInfo = MatchLiveTv; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXFileReference section */ - 0E15E2F8F9114F299D8EB017 /* MatchColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchColors.swift; path = MatchLiveTv/UI/Theme/MatchColors.swift; sourceTree = ""; }; - 109F8ABCCCF649EFB3E6647C /* UserFacingError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = UserFacingError.swift; path = MatchLiveTv/Core/UserFacingError.swift; sourceTree = ""; }; - 10BEC6633F9F446587F1818C /* OverlayLogoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayLogoCache.swift; path = MatchLiveTv/Streaming/Overlay/OverlayLogoCache.swift; sourceTree = ""; }; - 11E92E0A7DB54267AC53F60A /* MatchPrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchPrimaryButton.swift; path = MatchLiveTv/UI/Components/MatchPrimaryButton.swift; sourceTree = ""; }; - 1265527D9925494BAE66B5A0 /* AppNavHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppNavHost.swift; path = MatchLiveTv/UI/Navigation/AppNavHost.swift; sourceTree = ""; }; - 13EB02413D134D6289DB90DA /* MatchLiveTvApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveTvApp.swift; path = MatchLiveTv/App/MatchLiveTvApp.swift; sourceTree = ""; }; - 1B4E0A284897418CB3B60E19 /* MatchLiveTv/Resources/Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MatchLiveTv/Resources/Assets.xcassets; sourceTree = ""; }; - 1B5BF42CCF934AC7A9516374 /* OverlayCanvasRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayCanvasRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayCanvasRenderer.swift; sourceTree = ""; }; - 1CC868CFCFB6429F84E481B4 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = ""; }; - 1D3A1338FBA0411587BB5F4B /* ScoreActionDecodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreActionDecodeTests.swift; path = MatchLiveTvTests/ScoreActionDecodeTests.swift; sourceTree = ""; }; - 1EF154DE4E184011B1E29261 /* MatchLiveTvTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MatchLiveTvTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 258DECC661314224B6A98F47 /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = ""; }; - 27FB35A567D04317B0421156 /* LiveScoreDialogHostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveScoreDialogHostTests.swift; path = MatchLiveTvTests/LiveScoreDialogHostTests.swift; sourceTree = ""; }; - 29358C1BDC864C408AB4FAD0 /* WizardShellScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardShellScreen.swift; path = MatchLiveTv/UI/Wizard/WizardShellScreen.swift; sourceTree = ""; }; - 2DF2832197EE409AA0B0334C /* BroadcastControlsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastControlsOverlay.swift; path = MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift; sourceTree = ""; }; - 354D09291641417F99E27D5A /* ApiInstant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstant.swift; path = MatchLiveTv/Core/ApiInstant.swift; sourceTree = ""; }; - B2C3D4E5F6478901A2B3C4D5 /* AppLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppLanguage.swift; path = MatchLiveTv/Core/AppLanguage.swift; sourceTree = ""; }; - 3691F2136AAD4545B655F544 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = ""; }; - 395345091E364AFF9FFB34B9 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppConfig.swift; path = MatchLiveTv/Core/AppConfig.swift; sourceTree = ""; }; - 49FD73A6D4254EAD9484E159 /* OverlayRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayRenderer.swift; sourceTree = ""; }; - 49FDEC2FCAD14E7C9D671A27 /* BroadcastOrientationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastOrientationPolicyTests.swift; path = MatchLiveTvTests/BroadcastOrientationPolicyTests.swift; sourceTree = ""; }; - 51A6EEEAD38F4F509310B82E /* ScoreState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreState.swift; path = MatchLiveTv/Domain/ScoreState.swift; sourceTree = ""; }; - 586F014DA6724EEB810107AD /* Routes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Routes.swift; path = MatchLiveTv/UI/Navigation/Routes.swift; sourceTree = ""; }; - 590CA24C9659490E9A8EC555 /* AppContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppContainer.swift; path = MatchLiveTv/Data/AppContainer.swift; sourceTree = ""; }; - 5C26A0114F73483A85C70772 /* StepNetworkTestScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepNetworkTestScreen.swift; path = MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift; sourceTree = ""; }; - 5F8323C8BC81496A8A1DE9B5 /* MatchLiveTv/Resources/Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = MatchLiveTv/Resources/Info.plist; sourceTree = ""; }; - 611ECD9494B94B75B9866791 /* OverlayMappings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayMappings.swift; path = MatchLiveTv/Streaming/Overlay/OverlayMappings.swift; sourceTree = ""; }; - 63D6F174CE084074972CABF6 /* MatchesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchesScreen.swift; path = MatchLiveTv/UI/Matches/MatchesScreen.swift; sourceTree = ""; }; - 68C47301A29345819A2CDEC8 /* TeamColorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TeamColorPicker.swift; path = MatchLiveTv/UI/Wizard/TeamColorPicker.swift; sourceTree = ""; }; - 68D5ED55DB884EA2AC986F61 /* ColorHex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ColorHex.swift; path = MatchLiveTv/Core/ColorHex.swift; sourceTree = ""; }; - 6C6CE9645ACD4713AFC66C58 /* MatchLiveWordmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveWordmark.swift; path = MatchLiveTv/UI/Components/MatchLiveWordmark.swift; sourceTree = ""; }; - 6DF32351AC9D47F39C23B29A /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Models.swift; path = MatchLiveTv/Domain/Models.swift; sourceTree = ""; }; - 7060C0C306D44E9AA8AAF5D4 /* MatchSessionLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSessionLauncher.swift; path = MatchLiveTv/Data/Repository/MatchSessionLauncher.swift; sourceTree = ""; }; - 7250114A6FB34430BB20B414 /* WizardComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardComponents.swift; path = MatchLiveTv/UI/Wizard/WizardComponents.swift; sourceTree = ""; }; - 77A71899E82D448797DCE7E8 /* TeamBrandingEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TeamBrandingEditor.swift; path = MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift; sourceTree = ""; }; - 7B55F65BAA6146AF84B010CC /* LiveBroadcastCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastCoordinatorTests.swift; path = MatchLiveTvTests/LiveBroadcastCoordinatorTests.swift; sourceTree = ""; }; - 7D95106A21804A2EAEDCECB7 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = ""; }; - 7F08BB3F014B44F18A1203FE /* ApiDtos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiDtos.swift; path = MatchLiveTv/Data/API/ApiDtos.swift; sourceTree = ""; }; - 820BDE9FBCCB4BABACD2AC89 /* BroadcastModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastModels.swift; path = MatchLiveTv/Streaming/BroadcastModels.swift; sourceTree = ""; }; - 87A9A8AEACEA48FABD06BD52 /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = ""; }; - 8992D152CF7F478BA6469E36 /* StepMatchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepMatchScreen.swift; path = MatchLiveTv/UI/Wizard/StepMatchScreen.swift; sourceTree = ""; }; - 8A279824047D4B7EB08A3F30 /* ScreenOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScreenOrientation.swift; path = MatchLiveTv/UI/System/ScreenOrientation.swift; sourceTree = ""; }; - 8F71C110A07B4F71AB71E6ED /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = ""; }; - 94EDDD3FE78C4DC288424A6E /* StepTransmissionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepTransmissionScreen.swift; path = MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift; sourceTree = ""; }; - 973C4EEE1DCD40448D7F2FAD /* WizardSessionHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardSessionHolder.swift; path = MatchLiveTv/Data/WizardSessionHolder.swift; sourceTree = ""; }; - 985215153D0A44DC8B429731 /* BroadcastScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastScreen.swift; path = MatchLiveTv/UI/Broadcast/BroadcastScreen.swift; sourceTree = ""; }; - 9889D83B0787422397EF3181 /* MatchLiveAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveAPI.swift; path = MatchLiveTv/Data/API/MatchLiveAPI.swift; sourceTree = ""; }; - A1D75DB685D64B93A65E9FC8 /* KeepScreenOn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KeepScreenOn.swift; path = MatchLiveTv/UI/System/KeepScreenOn.swift; sourceTree = ""; }; - A224E4FD5069421AAB0C4CF6 /* LiveScoreActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveScoreActions.swift; path = MatchLiveTv/UI/Broadcast/LiveScoreActions.swift; sourceTree = ""; }; - A23B62DE25C94F47B560D742 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ShareSheet.swift; path = MatchLiveTv/UI/System/ShareSheet.swift; sourceTree = ""; }; - A3D8CA843FF54424B181675B /* SessionCableService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionCableService.swift; path = MatchLiveTv/Data/Cable/SessionCableService.swift; sourceTree = ""; }; - A3F2049B408743AA82AF43F3 /* LiveBroadcastCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastCoordinator.swift; path = MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift; sourceTree = ""; }; - A5964C8AF73D4617BB437E4A /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = ""; }; - AB1B3BC7CA8B4B25BCC77F6E /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AuthRepository.swift; path = MatchLiveTv/Data/Repository/AuthRepository.swift; sourceTree = ""; }; - ABC6373E955749DF9AB5DB5B /* TokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TokenStore.swift; path = MatchLiveTv/Core/TokenStore.swift; sourceTree = ""; }; - AC36378EDDDF44F29ACAD3C6 /* CompactScoreboardElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CompactScoreboardElement.swift; path = MatchLiveTv/Streaming/Overlay/CompactScoreboardElement.swift; sourceTree = ""; }; - B2B9108C739D484BAD4FF40F /* ScoreboardElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreboardElement.swift; path = MatchLiveTv/Streaming/Overlay/ScoreboardElement.swift; sourceTree = ""; }; - B8736A8A330D4815A3D56104 /* MatchSecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSecondaryButton.swift; path = MatchLiveTv/UI/Components/MatchSecondaryButton.swift; sourceTree = ""; }; - BA288DB764E24438AE8826DC /* OverlayState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayState.swift; path = MatchLiveTv/Streaming/Overlay/OverlayState.swift; sourceTree = ""; }; - BC8B7397C0BD4EC59851185D /* LiveBroadcastEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastEngine.swift; path = MatchLiveTv/Streaming/LiveBroadcastEngine.swift; sourceTree = ""; }; - A1B2C3D4E5F6478990ABCDEF /* StreamVideoPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamVideoPreset.swift; path = MatchLiveTv/Streaming/StreamVideoPreset.swift; sourceTree = ""; }; - BCA50054BF694B9D82EA97ED /* BroadcastVideoOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastVideoOrientation.swift; path = MatchLiveTv/Streaming/BroadcastVideoOrientation.swift; sourceTree = ""; }; - BDC3D4B19E974898AA9CAC76 /* SponsorElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SponsorElement.swift; path = MatchLiveTv/Streaming/Overlay/SponsorElement.swift; sourceTree = ""; }; - BFE4871BCA51457CB720047B /* BroadcastOrientationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastOrientationPolicy.swift; path = MatchLiveTv/Streaming/BroadcastOrientationPolicy.swift; sourceTree = ""; }; - C2AF23C6CDE24E1AB227F019 /* MatchRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchRepository.swift; path = MatchLiveTv/Data/Repository/MatchRepository.swift; sourceTree = ""; }; - C44E182032F64EBBA55ADCEB /* ModalRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ModalRoutes.swift; path = MatchLiveTv/UI/Navigation/ModalRoutes.swift; sourceTree = ""; }; - C7E9EFA6947F4A799570A271 /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = ""; }; - C8F7A645B14A44C69304E136 /* ScoreControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreControllerTests.swift; path = MatchLiveTvTests/ScoreControllerTests.swift; sourceTree = ""; }; - CB3CE35029F144899C81993B /* SplashScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SplashScreen.swift; path = MatchLiveTv/UI/Splash/SplashScreen.swift; sourceTree = ""; }; - CE709C8660614B9DA5A2D657 /* ScoreRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreRepository.swift; path = MatchLiveTv/Data/Repository/ScoreRepository.swift; sourceTree = ""; }; - D48D1A4C43A24A1E815A646D /* MatchScoringRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRules.swift; path = MatchLiveTv/Domain/MatchScoringRules.swift; sourceTree = ""; }; - DE0BD001D8384E1CAD5A327B /* MediaUrl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MediaUrl.swift; path = MatchLiveTv/Core/MediaUrl.swift; sourceTree = ""; }; - E3F630293570453BB85BE288 /* LivePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LivePreviewView.swift; path = MatchLiveTv/Streaming/Preview/LivePreviewView.swift; sourceTree = ""; }; - E8E91F93272E41A78E4CBECC /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = ""; }; - F0707542005B4DCC90E3A072 /* ApiInstantTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstantTests.swift; path = MatchLiveTvTests/ApiInstantTests.swift; sourceTree = ""; }; - F41CF197E5D543CE9A949497 /* ScoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreController.swift; path = MatchLiveTv/Data/Scoring/ScoreController.swift; sourceTree = ""; }; - F7FCFC922786459EAF4D1813 /* MatchLiveTv.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MatchLiveTv.app; sourceTree = BUILT_PRODUCTS_DIR; }; - F961133AE74D4ECE9FDC3EBF /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoginScreen.swift; path = MatchLiveTv/UI/Login/LoginScreen.swift; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 3793049D651342FCB0DCB8E7 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 4C2639492FE6DA960015AAF3 /* RTMPHaishinKit in Frameworks */, - 4C2639482FE6DA960015AAF3 /* HaishinKit in Frameworks */, + ENABLE_TESTABILITY = YES; + }; name = Debug; }; + 974D195BCA9F41ECA825E63D = {isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 26; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_ENABLE_EXPLICIT_MODULES = NO; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", ); - runOnlyForDeploymentPostprocessing = 0; - }; - CBDEBD8700894FF8ABB072C4 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 043A9CA61B924E8AA7DD8BAC /* Products */ = { - isa = PBXGroup; - children = ( - F7FCFC922786459EAF4D1813 /* MatchLiveTv.app */, - 1EF154DE4E184011B1E29261 /* MatchLiveTvTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 3295305662B540D3B837C583 = { - isa = PBXGroup; - children = ( - 957E89149C624576B613E912 /* MatchLiveTv */, - F3A8835D1D314FBC9DE71AF9 /* MatchLiveTvTests */, - 043A9CA61B924E8AA7DD8BAC /* Products */, - ); - sourceTree = ""; - }; - 957E89149C624576B613E912 /* MatchLiveTv */ = { - isa = PBXGroup; - children = ( - 13EB02413D134D6289DB90DA /* MatchLiveTvApp.swift */, - 354D09291641417F99E27D5A /* ApiInstant.swift */, - B2C3D4E5F6478901A2B3C4D5 /* AppLanguage.swift */, - 395345091E364AFF9FFB34B9 /* AppConfig.swift */, - 68D5ED55DB884EA2AC986F61 /* ColorHex.swift */, - 258DECC661314224B6A98F47 /* DeviceTelemetry.swift */, - DE0BD001D8384E1CAD5A327B /* MediaUrl.swift */, - ABC6373E955749DF9AB5DB5B /* TokenStore.swift */, - 109F8ABCCCF649EFB3E6647C /* UserFacingError.swift */, - 7F08BB3F014B44F18A1203FE /* ApiDtos.swift */, - 9889D83B0787422397EF3181 /* MatchLiveAPI.swift */, - 590CA24C9659490E9A8EC555 /* AppContainer.swift */, - E8E91F93272E41A78E4CBECC /* ActionCableClient.swift */, - A3D8CA843FF54424B181675B /* SessionCableService.swift */, - AB1B3BC7CA8B4B25BCC77F6E /* AuthRepository.swift */, - C2AF23C6CDE24E1AB227F019 /* MatchRepository.swift */, - 7060C0C306D44E9AA8AAF5D4 /* MatchSessionLauncher.swift */, - CE709C8660614B9DA5A2D657 /* ScoreRepository.swift */, - 3691F2136AAD4545B655F544 /* SessionRepository.swift */, - F41CF197E5D543CE9A949497 /* ScoreController.swift */, - 973C4EEE1DCD40448D7F2FAD /* WizardSessionHolder.swift */, - 8F71C110A07B4F71AB71E6ED /* MatchHubFilter.swift */, - D48D1A4C43A24A1E815A646D /* MatchScoringRules.swift */, - 6DF32351AC9D47F39C23B29A /* Models.swift */, - 51A6EEEAD38F4F509310B82E /* ScoreState.swift */, - 820BDE9FBCCB4BABACD2AC89 /* BroadcastModels.swift */, - BFE4871BCA51457CB720047B /* BroadcastOrientationPolicy.swift */, - BCA50054BF694B9D82EA97ED /* BroadcastVideoOrientation.swift */, - A3F2049B408743AA82AF43F3 /* LiveBroadcastCoordinator.swift */, - BC8B7397C0BD4EC59851185D /* LiveBroadcastEngine.swift */, - A1B2C3D4E5F6478990ABCDEF /* StreamVideoPreset.swift */, - AC36378EDDDF44F29ACAD3C6 /* CompactScoreboardElement.swift */, - 1B5BF42CCF934AC7A9516374 /* OverlayCanvasRenderer.swift */, - 10BEC6633F9F446587F1818C /* OverlayLogoCache.swift */, - 611ECD9494B94B75B9866791 /* OverlayMappings.swift */, - 49FD73A6D4254EAD9484E159 /* OverlayRenderer.swift */, - BA288DB764E24438AE8826DC /* OverlayState.swift */, - B2B9108C739D484BAD4FF40F /* ScoreboardElement.swift */, - BDC3D4B19E974898AA9CAC76 /* SponsorElement.swift */, - 7D95106A21804A2EAEDCECB7 /* WatermarkElement.swift */, - E3F630293570453BB85BE288 /* LivePreviewView.swift */, - 2DF2832197EE409AA0B0334C /* BroadcastControlsOverlay.swift */, - 985215153D0A44DC8B429731 /* BroadcastScreen.swift */, - A224E4FD5069421AAB0C4CF6 /* LiveScoreActions.swift */, - 6C6CE9645ACD4713AFC66C58 /* MatchLiveWordmark.swift */, - 11E92E0A7DB54267AC53F60A /* MatchPrimaryButton.swift */, - C7E9EFA6947F4A799570A271 /* MatchScreenScaffold.swift */, - B8736A8A330D4815A3D56104 /* MatchSecondaryButton.swift */, - A5964C8AF73D4617BB437E4A /* MatchStatusBadge.swift */, - F961133AE74D4ECE9FDC3EBF /* LoginScreen.swift */, - 63D6F174CE084074972CABF6 /* MatchesScreen.swift */, - 1265527D9925494BAE66B5A0 /* AppNavHost.swift */, - C44E182032F64EBBA55ADCEB /* ModalRoutes.swift */, - 586F014DA6724EEB810107AD /* Routes.swift */, - 1CC868CFCFB6429F84E481B4 /* BroadcastPermissions.swift */, - CB3CE35029F144899C81993B /* SplashScreen.swift */, - A1D75DB685D64B93A65E9FC8 /* KeepScreenOn.swift */, - 8A279824047D4B7EB08A3F30 /* ScreenOrientation.swift */, - A23B62DE25C94F47B560D742 /* ShareSheet.swift */, - 0E15E2F8F9114F299D8EB017 /* MatchColors.swift */, - 8992D152CF7F478BA6469E36 /* StepMatchScreen.swift */, - 5C26A0114F73483A85C70772 /* StepNetworkTestScreen.swift */, - 94EDDD3FE78C4DC288424A6E /* StepTransmissionScreen.swift */, - 77A71899E82D448797DCE7E8 /* TeamBrandingEditor.swift */, - 68C47301A29345819A2CDEC8 /* TeamColorPicker.swift */, - 7250114A6FB34430BB20B414 /* WizardComponents.swift */, - 29358C1BDC864C408AB4FAD0 /* WizardShellScreen.swift */, - 1B4E0A284897418CB3B60E19 /* MatchLiveTv/Resources/Assets.xcassets */, - 5F8323C8BC81496A8A1DE9B5 /* MatchLiveTv/Resources/Info.plist */, - ); - name = MatchLiveTv; - sourceTree = ""; - }; - F3A8835D1D314FBC9DE71AF9 /* MatchLiveTvTests */ = { - isa = PBXGroup; - children = ( - F0707542005B4DCC90E3A072 /* ApiInstantTests.swift */, - 49FDEC2FCAD14E7C9D671A27 /* BroadcastOrientationPolicyTests.swift */, - 7B55F65BAA6146AF84B010CC /* LiveBroadcastCoordinatorTests.swift */, - 27FB35A567D04317B0421156 /* LiveScoreDialogHostTests.swift */, - 87A9A8AEACEA48FABD06BD52 /* MatchScoringRulesTests.swift */, - 1D3A1338FBA0411587BB5F4B /* ScoreActionDecodeTests.swift */, - C8F7A645B14A44C69304E136 /* ScoreControllerTests.swift */, - ); - name = MatchLiveTvTests; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - C1A6F02007F74525A4124BD0 /* MatchLiveTv */ = { - isa = PBXNativeTarget; - buildConfigurationList = B85A15C67B064173ACD38B52 /* Build configuration list for PBXNativeTarget "MatchLiveTv" */; - buildPhases = ( - CECE99C00DA949F1A6F9E016 /* Sources */, - 3793049D651342FCB0DCB8E7 /* Frameworks */, - 702AFFCE779D46568D8EFD3B /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = MatchLiveTv; - packageProductDependencies = ( - BC6C276807664B6CB76A8A9D /* HaishinKit */, - 8F64C5AA49464C419712EAC5 /* RTMPHaishinKit */, - ); - productName = MatchLiveTv; - productReference = F7FCFC922786459EAF4D1813 /* MatchLiveTv.app */; - productType = "com.apple.product-type.application"; - }; - D2DB6900C94F48F0A49F19BB /* MatchLiveTvTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 557C4FE091C3492A98F420D2 /* Build configuration list for PBXNativeTarget "MatchLiveTvTests" */; - buildPhases = ( - E214E6AEA37A48A8A6B5FCF4 /* Sources */, - CBDEBD8700894FF8ABB072C4 /* Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - 7AF81B34F40A4032BF55ECCC /* PBXTargetDependency */, - ); - name = MatchLiveTvTests; - productName = MatchLiveTvTests; - productReference = 1EF154DE4E184011B1E29261 /* MatchLiveTvTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 456AEB32689E48DEA025761E /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 0; - LastSwiftUpdateCheck = 1600; - }; - buildConfigurationList = D9A53CE9A9354D1598FD2265 /* Build configuration list for PBXProject "MatchLiveTv" */; - compatibilityVersion = "Xcode 15.0"; - developmentRegion = it; - hasScannedForEncodings = 0; - knownRegions = ( - it, - en, - Base, - ); - mainGroup = 3295305662B540D3B837C583; - packageReferences = ( - 6645D37DD2684C94860AEF1B /* XCRemoteSwiftPackageReference "HaishinKit" */, - ); - productRefGroup = 043A9CA61B924E8AA7DD8BAC /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - C1A6F02007F74525A4124BD0 /* MatchLiveTv */, - D2DB6900C94F48F0A49F19BB /* MatchLiveTvTests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 702AFFCE779D46568D8EFD3B /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 60D2848965684DB5BFEBD5E3 /* MatchLiveTv/Resources/Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - CECE99C00DA949F1A6F9E016 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 42EF33716F6843B69945E6AB /* MatchLiveTvApp.swift in Sources */, - C9B90AAE70C142DEB8996100 /* ApiInstant.swift in Sources */, - A1B2C3D4E5F64789A0B1C2D3 /* AppLanguage.swift in Sources */, - 066B7E46EB6245BA94C12BA6 /* AppConfig.swift in Sources */, - 557CA481D63840D3AA6EFD5B /* ColorHex.swift in Sources */, - CF8E45A2BFE34E4BBE259654 /* DeviceTelemetry.swift in Sources */, - EB3031C7972041F2BE6C3933 /* MediaUrl.swift in Sources */, - 3F5B3BD00C6F48379469C171 /* TokenStore.swift in Sources */, - 46F6F77D670A49BFB4A9B757 /* UserFacingError.swift in Sources */, - 035B95F7CE8643C48038A114 /* ApiDtos.swift in Sources */, - 4FFA52FD7FF8410AA8FA673E /* MatchLiveAPI.swift in Sources */, - 0D3A76C230854286AA528524 /* AppContainer.swift in Sources */, - 5DB4B7C040F6449198E5D07D /* ActionCableClient.swift in Sources */, - 0F6F3A1F9EA74107BDFAF930 /* SessionCableService.swift in Sources */, - 63CB7A84409D49CEBD74DE4D /* AuthRepository.swift in Sources */, - 4C1FDA2D80034A2FB6550DAB /* MatchRepository.swift in Sources */, - DFD70B8BFDAD427DA63CD6B1 /* MatchSessionLauncher.swift in Sources */, - 67818D53F7EA46C2A4850214 /* ScoreRepository.swift in Sources */, - 5BEB4A09B526452D8CCBBE1B /* SessionRepository.swift in Sources */, - 8C32CAC69405444DA4BB1E36 /* ScoreController.swift in Sources */, - E4C3F469616A42818F63CE89 /* WizardSessionHolder.swift in Sources */, - F0497D8B51DE4173AF395AF8 /* MatchHubFilter.swift in Sources */, - 919B83984A5347989781366F /* MatchScoringRules.swift in Sources */, - 92E4CAC8F45141A8899749C5 /* Models.swift in Sources */, - 3E25B3A6F51242B9847DBA6F /* ScoreState.swift in Sources */, - 2B400EFC857D4EDF9CCF73B9 /* BroadcastModels.swift in Sources */, - AEA8CCA49E514EA3984F297E /* BroadcastOrientationPolicy.swift in Sources */, - C1D2EA29B25C4B06B03B6FFE /* BroadcastVideoOrientation.swift in Sources */, - 539D6877DF3C478E86200649 /* LiveBroadcastCoordinator.swift in Sources */, - 87DB3FBE122F444B87B9D73B /* LiveBroadcastEngine.swift in Sources */, - F1A2B3C4D5E6478990ABCDEF /* StreamVideoPreset.swift in Sources */, - AA9BCB0DA5164D43BD7A43FA /* CompactScoreboardElement.swift in Sources */, - ED730C9B9068449E8D8AF6C8 /* OverlayCanvasRenderer.swift in Sources */, - 8F6D3E3D61584BBA865AD2D7 /* OverlayLogoCache.swift in Sources */, - 7D0DF967FC03423394839915 /* OverlayMappings.swift in Sources */, - DF09C441105448CDBAFBD9AF /* OverlayRenderer.swift in Sources */, - 4D74385A3406406ABFA45B44 /* OverlayState.swift in Sources */, - 6DFF8FAA4ECF46F1928851A4 /* ScoreboardElement.swift in Sources */, - 3EF78D9394DF41D89E0A43F8 /* SponsorElement.swift in Sources */, - 52EB3701353841BDB837C75E /* WatermarkElement.swift in Sources */, - 01EF2265D1624199B171D57E /* LivePreviewView.swift in Sources */, - 8841513B4E1743E983E7D2C8 /* BroadcastControlsOverlay.swift in Sources */, - 81DB6B2543BC407EBB056C4B /* BroadcastScreen.swift in Sources */, - F8324F96CA074A0B8A71BB7B /* LiveScoreActions.swift in Sources */, - F065E5BE5EC34955B133C189 /* MatchLiveWordmark.swift in Sources */, - 35B3BCE4D68F47C4ABD1E955 /* MatchPrimaryButton.swift in Sources */, - B6F8B4BA2E49480DBD2CA846 /* MatchScreenScaffold.swift in Sources */, - DC848628856A4FCB86EE9DA7 /* MatchSecondaryButton.swift in Sources */, - 226769D5E66848FB90B5BFEE /* MatchStatusBadge.swift in Sources */, - 5AE324386FA64E1C8BA5F21F /* LoginScreen.swift in Sources */, - 8D7FCD0052674C0EB4F5C811 /* MatchesScreen.swift in Sources */, - 735CF034B3BA49BE9BBDD261 /* AppNavHost.swift in Sources */, - 100AF197AD334A1AB38D271B /* ModalRoutes.swift in Sources */, - AFA2540B2280448A81DFF5CF /* Routes.swift in Sources */, - E15E57A4C69D49F7AB0C916A /* BroadcastPermissions.swift in Sources */, - FCDD0B3AA1F046B4B912A2F7 /* SplashScreen.swift in Sources */, - 8179BD051391404B9E2235C2 /* KeepScreenOn.swift in Sources */, - ECA9E663AF8B4F19BA4AD6D2 /* ScreenOrientation.swift in Sources */, - 0D666A37E49749C68C0DB06B /* ShareSheet.swift in Sources */, - E5ECF142981E4D58B3040FB5 /* MatchColors.swift in Sources */, - DBBB006ED6C946A2950D805C /* StepMatchScreen.swift in Sources */, - C3B1B44C77084C05B325F935 /* StepNetworkTestScreen.swift in Sources */, - E01D498C8EF64F85A41EAC54 /* StepTransmissionScreen.swift in Sources */, - 0469F385C3BD41489812C2EC /* TeamBrandingEditor.swift in Sources */, - 5CA7D12629854A4A9B9DF084 /* TeamColorPicker.swift in Sources */, - 6B4C7990A5D94A2C8AC69FC1 /* WizardComponents.swift in Sources */, - 0D81FEE2FA88468C98652291 /* WizardShellScreen.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - E214E6AEA37A48A8A6B5FCF4 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 794CC6694C74486BAB61CA3B /* ApiInstantTests.swift in Sources */, - E75B706F137249FF807FE0DD /* BroadcastOrientationPolicyTests.swift in Sources */, - 7636A7A0E3CC477799338D29 /* LiveBroadcastCoordinatorTests.swift in Sources */, - 727B74004C3741D9BA510F3B /* LiveScoreDialogHostTests.swift in Sources */, - 75D6916EAD4E46CA9E36582C /* MatchScoringRulesTests.swift in Sources */, - 833558A9A0A043ECB8E146F6 /* ScoreActionDecodeTests.swift in Sources */, - D08665EFFF27465294B620B5 /* ScoreControllerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 7AF81B34F40A4032BF55ECCC /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = C1A6F02007F74525A4124BD0 /* MatchLiveTv */; - targetProxy = B2EE838DC30044B18C9AEE34 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin XCBuildConfiguration section */ - 225662CBBF56481BAEE29648 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - MARKETING_VERSION = 2.0.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.tests"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MatchLiveTv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MatchLiveTv"; - }; - name = Debug; - }; - 25DF4C4290CD4B85A1FB5B00 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - API_BASE_URL = "https://www.matchlivetv.it"; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; - DEVELOPMENT_TEAM = S8Q9TWBRG5; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 2.0.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SWIFT_ENABLE_EXPLICIT_MODULES = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; - 2619E7D33A5048E098621AAC /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - MARKETING_VERSION = 2.0.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.tests"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MatchLiveTv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MatchLiveTv"; - }; - name = Release; - }; - 908522446C5E40DBBCEC19A6 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 9D109C6FCBD247FEBF780C10 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - F65D58B3D4D6450FA8626BAA /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - API_BASE_URL = "https://www.matchlivetv.it"; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; - DEVELOPMENT_TEAM = S8Q9TWBRG5; - ENABLE_TESTABILITY = YES; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 2.0.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SWIFT_ENABLE_EXPLICIT_MODULES = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 557C4FE091C3492A98F420D2 /* Build configuration list for PBXNativeTarget "MatchLiveTvTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 225662CBBF56481BAEE29648 /* Debug */, - 2619E7D33A5048E098621AAC /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - B85A15C67B064173ACD38B52 /* Build configuration list for PBXNativeTarget "MatchLiveTv" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - F65D58B3D4D6450FA8626BAA /* Debug */, - 25DF4C4290CD4B85A1FB5B00 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - D9A53CE9A9354D1598FD2265 /* Build configuration list for PBXProject "MatchLiveTv" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 908522446C5E40DBBCEC19A6 /* Debug */, - 9D109C6FCBD247FEBF780C10 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCRemoteSwiftPackageReference section */ - 6645D37DD2684C94860AEF1B /* XCRemoteSwiftPackageReference "HaishinKit" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/shogo4405/HaishinKit.swift"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.0.0; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 8F64C5AA49464C419712EAC5 /* RTMPHaishinKit */ = { - isa = XCSwiftPackageProductDependency; - package = 6645D37DD2684C94860AEF1B /* XCRemoteSwiftPackageReference "HaishinKit" */; - productName = RTMPHaishinKit; - }; - BC6C276807664B6CB76A8A9D /* HaishinKit */ = { - isa = XCSwiftPackageProductDependency; - package = 6645D37DD2684C94860AEF1B /* XCRemoteSwiftPackageReference "HaishinKit" */; - productName = HaishinKit; - }; -/* End XCSwiftPackageProductDependency section */ + MARKETING_VERSION = 2.0.5; + PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv; + PRODUCT_NAME = "$(TARGET_NAME)"; + API_BASE_URL = "https://www.matchlivetv.it"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; name = Release; }; + BD9014D2E1B34341B2930049 = {isa = XCBuildConfiguration; buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 26; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MARKETING_VERSION = 2.0.5; + PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MatchLiveTv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MatchLiveTv"; + }; name = Debug; }; + F613BB5D3D6744F3BD58BCBB = {isa = XCBuildConfiguration; buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 26; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MARKETING_VERSION = 2.0.5; + PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MatchLiveTv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MatchLiveTv"; + }; name = Release; }; + E2D337A0090F45C3BD12CEA4 = {isa = XCBuildConfiguration; buildSettings = {IPHONEOS_DEPLOYMENT_TARGET = 16.0; SWIFT_VERSION = 5.0; }; name = Debug; }; + DF036DDA33454934B5DBA96F = {isa = XCBuildConfiguration; buildSettings = {IPHONEOS_DEPLOYMENT_TARGET = 16.0; SWIFT_VERSION = 5.0; }; name = Release; }; + 51779CE259A14053B83F1D94 = {isa = XCConfigurationList; buildConfigurations = (D3DDF0C54D1744C09B415888, 974D195BCA9F41ECA825E63D); defaultConfigurationName = Release; }; + 70CF9007D5E9469CB989FABF = {isa = XCConfigurationList; buildConfigurations = (BD9014D2E1B34341B2930049, F613BB5D3D6744F3BD58BCBB); defaultConfigurationName = Release; }; + 86E5AF075085439BA443DBA7 = {isa = XCConfigurationList; buildConfigurations = (E2D337A0090F45C3BD12CEA4, DF036DDA33454934B5DBA96F); defaultConfigurationName = Release; }; + 768EC47B451F4731A05BE1F9 = {isa = PBXNativeTarget; buildConfigurationList = 51779CE259A14053B83F1D94; buildPhases = (3B35A518C56047A48825F0F8, AA7CFC56BCC040A4AB487E2C, 6B4D5383389142E7AE9C4F41); buildRules = (); dependencies = (); name = MatchLiveTv; packageProductDependencies = (31418599149E4F7684EABE04, 1712041BB26748D78A1D8F49); productName = MatchLiveTv; productReference = 6FF24BB96E9C40F6A9F6E25A; productType = "com.apple.product-type.application"; }; + 057F8600A1DB4A8DB4A46300 = {isa = PBXContainerItemProxy; containerPortal = 1906C5F192A44C6284A9FC4C /* Project object */; proxyType = 1; remoteGlobalIDString = 768EC47B451F4731A05BE1F9; remoteInfo = MatchLiveTv; }; + 56E58DFB903041BB95600D33 = {isa = PBXTargetDependency; target = 768EC47B451F4731A05BE1F9 /* MatchLiveTv */; targetProxy = 057F8600A1DB4A8DB4A46300 /* PBXContainerItemProxy */; }; + F6079500E62048DB89AE3866 = {isa = PBXNativeTarget; buildConfigurationList = 70CF9007D5E9469CB989FABF; buildPhases = (729C230D5EB64A76BCD517B4, 6E9DDC30CE734AC1AB90CC24); buildRules = (); dependencies = (56E58DFB903041BB95600D33); name = MatchLiveTvTests; productName = MatchLiveTvTests; productReference = 8F679C9C03AE47D6A028DBAC; productType = "com.apple.product-type.bundle.unit-test"; }; + 4901BAD7A32F4CFD805083BE = {isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/shogo4405/HaishinKit.swift"; requirement = {kind = upToNextMajorVersion; minimumVersion = 2.0.0;}; }; + 1906C5F192A44C6284A9FC4C = {isa = PBXProject; attributes = {BuildIndependentTargetsInParallel = 0; LastSwiftUpdateCheck = 1600;}; buildConfigurationList = 86E5AF075085439BA443DBA7; compatibilityVersion = "Xcode 15.0"; developmentRegion = it; mainGroup = B6A69164398F4F9FB84EC475; packageReferences = (4901BAD7A32F4CFD805083BE); productRefGroup = 2165E0C34A8B43F28AC2B21C; targets = (768EC47B451F4731A05BE1F9, F6079500E62048DB89AE3866); }; }; - rootObject = 456AEB32689E48DEA025761E /* Project object */; + rootObject = 1906C5F192A44C6284A9FC4C /* Project object */; } diff --git a/native/ios/MatchLiveTv.xcodeproj/xcshareddata/xcschemes/MatchLiveTv.xcscheme b/native/ios/MatchLiveTv.xcodeproj/xcshareddata/xcschemes/MatchLiveTv.xcscheme index 21d9698..3a0a6db 100644 --- a/native/ios/MatchLiveTv.xcodeproj/xcshareddata/xcschemes/MatchLiveTv.xcscheme +++ b/native/ios/MatchLiveTv.xcodeproj/xcshareddata/xcschemes/MatchLiveTv.xcscheme @@ -3,28 +3,28 @@ - + - + - + - + - + diff --git a/native/ios/MatchLiveTv/Core/AppLanguage.swift b/native/ios/MatchLiveTv/Core/AppLanguage.swift index 064d2ba..100b0dc 100644 --- a/native/ios/MatchLiveTv/Core/AppLanguage.swift +++ b/native/ios/MatchLiveTv/Core/AppLanguage.swift @@ -15,11 +15,11 @@ enum AppLanguage: String, CaseIterable, Identifiable { var nativeLabel: String { switch self { case .system: return L10n.t("language.system") - case .it: return "Italiano" - case .en: return "English" - case .fr: return "Français" - case .de: return "Deutsch" - case .es: return "Español" + case .it: return L10n.t("language.italian") + case .en: return L10n.t("language.english") + case .fr: return L10n.t("language.french") + case .de: return L10n.t("language.german") + case .es: return L10n.t("language.spanish") } } @@ -34,7 +34,6 @@ enum AppLanguage: String, CaseIterable, Identifiable { } else { UserDefaults.standard.set(newValue.rawValue, forKey: storageKey) } - // Forza refresh UI tramite notification NotificationCenter.default.post(name: .appLanguageDidChange, object: nil) } } @@ -51,6 +50,8 @@ enum AppLanguage: String, CaseIterable, Identifiable { } } + static var locale: Locale { Locale(identifier: resolvedCode) } + private static let storageKey = "mltv.appLanguage" } @@ -66,82 +67,1381 @@ enum L10n { return key } + static func t(_ key: String, _ args: CVarArg...) -> String { + String(format: t(key), locale: AppLanguage.locale, arguments: args) + } + private static let table: [String: [String: String]] = [ "it": [ - "language.label": "Lingua", - "language.system": "Sistema", - "action.logout": "Esci", - "action.login": "Accedi", "action.cancel": "Annulla", + "action.delete": "Elimina", + "action.exit": "Esci", + "action.login": "Accedi", + "action.logout": "Esci", + "action.ok": "OK", + "action.save": "Salva", + "api.error.decoding": "Risposta del server non valida", + "api.error.forbidden": "Operazione non consentita", + "api.error.http": "Errore HTTP %1$d", + "api.error.invalid.url": "URL non valido", + "api.error.not.found": "Risorsa non trovata", + "api.error.score.invalid": "Risposta punteggio non valida", + "api.error.server": "Errore del server. Riprova tra qualche istante.", + "api.error.unauthorized": "Sessione scaduta. Accedi di nuovo.", + "app.name": "Match Live TV", + "app.slogan": "Ogni partita, ogni evento, per i tuoi tifosi.", + "broadcast.close.set.button": "CHIUDI SET", + "broadcast.error.camera.preview.not.ready": "Anteprima camera non pronta", + "broadcast.error.permissions.camera.mic": "Permessi camera e microfono richiesti", + "broadcast.error.reconnect.failed": "Riconnessione non riuscita", + "broadcast.error.regia.link": "Errore link regia", + "broadcast.error.regia.link.invalid": "Link regia non valido", + "broadcast.error.rtmp.connect.failed": "Connessione RTMP fallita", + "broadcast.error.rtmp.missing": "URL RTMP mancante", + "broadcast.error.rtmp.session.create": "Impossibile creare sessione RTMP", + "broadcast.error.rtmp.url.invalid": "URL RTMP non valido", + "broadcast.error.session.unavailable": "Sessione non disponibile", + "broadcast.hide.controls.cd": "Nascondi controlli", + "broadcast.next.period.cd": "Periodo successivo", + "broadcast.pause.cd": "Pausa diretta", + "broadcast.period.label.timed": "%1$d° tempo", + "broadcast.permissions.grant.action": "CONCEDI PERMESSI", + "broadcast.permissions.required": "Consenti camera e microfono per andare in diretta", + "broadcast.points.target": "%1$d pt", + "broadcast.resume.cd": "Riprendi diretta", + "broadcast.scoreboard.connected": "Tabellone OK", + "broadcast.scoreboard.offline": "Tabellone offline", + "broadcast.set.progress": "Set %1$d · %2$d pt", + "broadcast.sets.won": "Set vinti %1$d-%2$d", + "broadcast.share.chooser.title": "Condividi", + "broadcast.share.link.unavailable": "Link diretta non ancora disponibile", + "broadcast.share.live.cd": "Condividi diretta", + "broadcast.share.regia.cd": "Condividi link regia", + "broadcast.share.regia.subject": "Link regia — %1$@", + "broadcast.share.subject.fallback": "Diretta Match Live TV", + "broadcast.show.controls.cd": "Mostra controlli", + "broadcast.side.away": "ospite", + "broadcast.side.home": "casa", + "broadcast.snackbar.closed.remote": "Diretta chiusa dalla regia", + "broadcast.snackbar.pause.error": "Pausa: %1$@", + "broadcast.snackbar.paused": "Diretta in pausa", + "broadcast.snackbar.paused.remote": "Pausa dalla regia", + "broadcast.snackbar.reconnect.started": "Riconnessione avviata", + "broadcast.snackbar.resume.error": "Ripresa: %1$@", + "broadcast.snackbar.resume.rtmp.error": "Ripresa RTMP: %1$@", + "broadcast.snackbar.resumed": "Diretta ripresa", + "broadcast.snackbar.resumed.remote": "Ripresa dalla regia", + "broadcast.status.connecting": "CONNESSIONE…", + "broadcast.status.error.fallback": "ERRORE", + "broadcast.status.live": "IN DIRETTA", + "broadcast.status.paused": "PAUSA", + "broadcast.status.preview": "PREVIEW", + "broadcast.status.reconnecting": "RICONNESSIONE…", + "broadcast.team.away.label": "OSPITE", + "broadcast.team.home.label": "CASA", + "broadcast.team.sets.count": "%1$d set", + "broadcast.terminate.cd": "Termina diretta", + "broadcast.terminate.confirm": "TERMINA", + "broadcast.terminate.message": "Lo streaming verrà chiuso per tutti gli spettatori.", + "broadcast.terminate.title": "Terminare la diretta?", + "broadcast.tooltip.add.point": "Aggiungi punto %1$@", + "broadcast.tooltip.plus.side": "+%1$d %2$@", + "broadcast.tooltip.remove.point": "Togli punto %1$@", + "common.close": "Chiudi", + "common.error.generic": "errore", + "common.error.title": "Errore", + "error.operation.failed": "Operazione non riuscita", + "error.rtmp.failed": "Connessione RTMP fallita. Verifica rete e server streaming.", + "error.rtmp.failed.dev": "Connessione RTMP fallita. Verifica che MediaMTX sia raggiungibile (in dev: porta 1935 su localhost).", + "error.rtmp.interrupted": "Connessione RTMP interrotta", + "error.rtmp.invalid.url": "URL RTMP non valido. Controlla l'indirizzo di ingest dal server.", + "error.rtmp.timeout": "Timeout connessione RTMP. Verifica rete e server MediaMTX (porta 1935).", + "error.rtmp.unreachable": "Server RTMP non raggiungibile. In dev locale usa localhost:1935 (non l'host Docker «mediamtx»).", + "language.english": "English", + "language.french": "Français", + "language.german": "Deutsch", + "language.italian": "Italiano", + "language.label": "Lingua", + "language.spanish": "Español", + "language.system": "Sistema", "login.email": "Email", + "login.email.placeholder": "coach@team.com", + "login.error.credentials": "Email o password non corretti", + "login.error.generic": "Login fallito", + "login.error.unreachable": "Server non raggiungibile. Verifica la connessione.", "login.password": "Password", "login.submit": "Accedi", - "login.error.credentials": "Email o password non corretti", - "login.error.unreachable": "Server non raggiungibile. Verifica la connessione.", - "login.error.generic": "Login fallito", + "match.status.live": "IN CORSO", + "match.status.resume": "RIPRENDI", + "match.status.scheduled": "PROGRAMMATA", + "match.status.start": "AVVIA", + "matches.active.team": "Squadra attiva: %1$@.", + "matches.change": "Cambia", + "matches.empty.hint": "Programma una partita o avviane una nuova con «Nuova partita».", + "matches.empty.title": "Nessuna partita in calendario", + "matches.hello": "Ciao, %1$@", + "matches.load.error": "Errore caricamento", + "matches.msg.create.failed": "Impossibile creare la partita", + "matches.msg.delete.failed": "Impossibile eliminare", + "matches.msg.deleted": "Partita eliminata", + "matches.msg.load.error": "Errore caricamento", + "matches.msg.resume.failed": "Impossibile riprendere la diretta", + "matches.msg.schedule.error": "Errore creazione partita", + "matches.msg.scheduled.ok": "Partita programmata — visibile sul sito", + "matches.multi.team.hint": "Hai più squadre: verifica quella selezionata sopra.", + "matches.new": "Nuova partita", + "matches.no.other": "Nessuna altra partita in calendario.", + "matches.no.team.body": "Per trasmettere devi essere responsabile della trasmissione di almeno una squadra. Chiedi al tuo club di aggiungerti come staff trasmissione.", + "matches.no.team.title": "Nessuna squadra assegnata", + "matches.ready.title": "Pronte da avviare", + "matches.retry": "Riprova", + "matches.schedule": "Partita programmata", + "matches.scheduled.title": "Partite programmate", + "matches.subtitle": "Riprendi una diretta in corso o avvia una partita programmata.", + "matches.tap.change.team": "Tocca per cambiare squadra", + "matches.team.for.live": "Squadra per la diretta", "matches.title": "Partite", - "app.slogan": "Ogni partita, ogni evento, per i tuoi tifosi.", - ], + "network.type.cellular": "4G", + "network.type.ethernet": "Ethernet", + "network.type.unknown": "Sconosciuto", + "network.type.wifi": "WiFi", + "overlay.set.label": "Set %1$d", + "score.action.close.anyway": "Chiudi comunque", + "score.action.close.live": "Chiudi diretta", + "score.action.close.set": "Chiudi set", + "score.action.continue.live": "Continua in onda", + "score.action.continue.scoring": "Continua a segnare", + "score.close.set.anyway.message": "Il punteggio non soddisfa ancora le regole del torneo. Chiudere il set comunque?", + "score.match.won.message": "%1$@ vince la partita (%2$d-%3$d set).\\n\\nChiudere definitivamente la diretta?", + "score.match.won.title": "Partita terminata", + "score.set.won.message": "%1$@ vince il set %2$d-%3$d.\\n\\nChiudere il set e passare al successivo?", + "score.set.won.title": "Set concluso", + "session.error.no.active": "Nessuna sessione attiva", + "session.error.not.connected": "Sessione non collegata", + "sheet.badge.calendar": "IN CALENDARIO", + "sheet.badge.draft": "BOZZA", + "sheet.badge.scheduled": "PROGRAMMATA", + "sheet.choose.match": "Scegli partita", + "sheet.choose.match.lead": "Partite già programmate sul sito o in app.", + "sheet.choose.team": "Scegli squadra", + "sheet.configure": "Configura", + "sheet.configure.now.body": "Puoi preparare la trasmissione subito, oppure tornare quando sei in palestra.", + "sheet.configure.now.title": "Configurare ora?", + "sheet.continue.setup": "CONTINUA CONFIGURAZIONE", + "sheet.date.time": "Data e ora", + "sheet.delete": "Elimina", + "sheet.delete.match.body": "Eliminare «%1$@ vs %2$@»?\n\nL'operazione non si può annullare.", + "sheet.delete.match.title": "Elimina partita", + "sheet.later": "Più tardi", + "sheet.live.in.progress": "Diretta in corso", + "sheet.location.optional": "Luogo (opzionale)", + "sheet.new.match.lead": "Programma in anticipo o avvia la configurazione diretta subito.", + "sheet.new.match.title": "Nuova partita", + "sheet.opponent": "Avversario", + "sheet.quick.option": "Avvia subito", + "sheet.quick.option.sub": "Crea la partita e passa al wizard senza orario", + "sheet.resume.banner": "Riprendi diretta in corso", + "sheet.resume.camera": "RIPRENDI CAMERA E TRASMETTI", + "sheet.save.schedule": "SALVA IN PROGRAMMA", + "sheet.schedule.lead": "La diretta non parte ora: comparirà sul sito con data e ora. Avvierai lo streaming dall'app quando sei in palestra.", + "sheet.schedule.option": "Programma partita", + "sheet.schedule.option.sub": "Data, ora e avversario — visibile anche sul sito", + "sheet.schedule.title": "Programma partita", + "sheet.section.scheduled": "Programmate", + "sheet.section.unscheduled": "Senza orario", + "sheet.status": "Stato: %1$@", + "thermal.alert.continue": "Continua", + "thermal.alert.message": "Il dispositivo è molto caldo da oltre un minuto. iOS potrebbe chiudere l'app per proteggere il dispositivo. Considera di interrompere la diretta.", + "thermal.alert.stop": "Interrompi diretta", + "thermal.alert.title": "Surriscaldamento", + "thermal.notice.critical": "Surriscaldamento imminente: qualità ulteriormente ridotta.", + "thermal.notice.serious": "Il dispositivo si sta scaldando. La qualità dello streaming è stata ridotta per garantire stabilità.", + "thermal.state.critical": "Rischio surriscaldamento", + "thermal.state.fair": "Temperatura alta", + "thermal.state.nominal": "Temperatura OK", + "thermal.state.serious": "Dispositivo caldo", + "wizard.action.back": "Indietro", + "wizard.action.copy": "COPIA", + "wizard.action.next": "AVANTI >", + "wizard.action.share": "CONDIVIDI", + "wizard.action.share.regia.link": "CONDIVIDI LINK REGIA", + "wizard.action.start": "INIZIA >", + "wizard.branding.cancel": "ANNULLA", + "wizard.branding.color.label": "Colore squadra", + "wizard.branding.customize.title": "Personalizza", + "wizard.branding.edit.team.cd": "Modifica squadra", + "wizard.branding.logo.label": "Logo", + "wizard.branding.no.logo": "Nessun logo", + "wizard.branding.opponent.name.placeholder": "Nome avversario", + "wizard.branding.remove.logo": "RIMUOVI", + "wizard.branding.save": "SALVA", + "wizard.branding.team.fallback.name": "Squadra", + "wizard.branding.team.name.label": "Nome squadra", + "wizard.branding.upload.logo": "CARICA LOGO", + "wizard.color.hue": "Tonalità", + "wizard.complete.transmission.step": "Completa lo step Trasmissione", + "wizard.error.opponent.name.required": "Inserisci il nome avversario", + "wizard.error.overtime.duration.required": "Inserisci la durata dei supplementari in minuti", + "wizard.error.period.duration.required": "Inserisci la durata del periodo in minuti", + "wizard.error.points.per.set.required": "Inserisci i punti per vincere un set", + "wizard.error.regia.link": "Errore link regia", + "wizard.error.save.generic": "Errore nel salvataggio", + "wizard.error.session.create": "Errore creazione sessione", + "wizard.error.start.live": "Errore avvio diretta", + "wizard.error.tiebreak.points.required": "Inserisci i punti del tie-break", + "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.category.label": "Campionato (facoltativo)", + "wizard.match.custom.overlay.label": "Overlay video personalizzato", + "wizard.match.custom.rules.label": "Regole punteggio personalizzate", + "wizard.match.details.title": "Dettagli partita", + "wizard.match.home.team.label": "Squadra di casa", + "wizard.match.location.label": "Luogo", + "wizard.match.minutes.per.period.basket": "Minuti per quarto", + "wizard.match.minutes.per.period.timed": "Minuti per tempo", + "wizard.match.not.found": "Partita non trovata", + "wizard.match.overtime.minutes.label": "Minuti supplementari", + "wizard.match.periods.basket.label": "Quarti", + "wizard.match.periods.timed.label": "Tempi", + "wizard.match.points.per.set.label": "Punti per vincere un set", + "wizard.match.rules.custom.sets": "Torneo non standard: imposta set e punteggi.", + "wizard.match.rules.custom.timed": "Torneo non standard: tempi e periodi personalizzati.", + "wizard.match.rules.standard.sets": "Standard FIPAV: 3 set per vincere, set a 25, tie-break a 15.", + "wizard.match.rules.standard.sport": "Regole standard dello sport selezionato.", + "wizard.match.scheduled.label": "Programmata per", + "wizard.match.sets.to.win.label": "Set da vincere la partita", + "wizard.match.tiebreak.points.label": "Punti tie-break (ultimo set)", + "wizard.network.download.label": "Download", + "wizard.network.latency.label": "Latenza", + "wizard.network.link.live.label": "Link diretta", + "wizard.network.link.youtube.label": "Link YouTube", + "wizard.network.quality.label": "Qualità streaming (automatica)", + "wizard.network.ready.label": "PRONTO PER ANDARE IN DIRETTA", + "wizard.network.share.subject": "Diretta — %1$@ vs %2$@", + "wizard.network.test.running.label": "TEST IN CORSO...", + "wizard.network.test.start.label": "AVVIA TEST RETE", + "wizard.network.test.subtitle": "Verifica che la connessione regga l'upload della diretta.", + "wizard.network.test.title": "Test rete", + "wizard.network.type.label": "Tipo rete", + "wizard.network.upload.label": "Upload", + "wizard.share.chooser.live": "Condividi diretta", + "wizard.share.chooser.regia": "Condividi regia", + "wizard.step.title.match": "01 · Partita", + "wizard.step.title.network": "03 · Test rete", + "wizard.step.title.transmission": "02 · Trasmissione", + "wizard.transmission.backend.note": "La partita resta sempre visibile nel backend della squadra per tutta la durata dell'abbonamento.", + "wizard.transmission.plan.label": "Piano %1$@", + "wizard.transmission.platform.site.subtitle": "Diretta sul nostro sito (incluso)", + "wizard.transmission.platform.title": "Piattaforma", + "wizard.transmission.public.desc": "Compare nell'elenco dirette su MatchLiveTV.it, nelle ricerche e sul canale YouTube (se selezionato).", + "wizard.transmission.public.label": "PUBBLICO", + "wizard.transmission.unlisted.desc": "Non compare negli elenchi pubblici né nelle ricerche. Solo chi ha il link può guardare.", + "wizard.transmission.unlisted.label": "NON IN ELENCO", + "wizard.transmission.visibility.title": "Visibilità", + "wizard.transmission.youtube.activating": "Canale in attivazione", + "wizard.transmission.youtube.badge": "Premium", + "wizard.transmission.youtube.premium.required": "Premium Light o Full", + "youtube.channel.club": "Canale società · %1$@", + "youtube.channel.club.fallback": "società", + "youtube.channel.platform": "Canale Match Live TV" + ], "en": [ - "language.label": "Language", - "language.system": "System", - "action.logout": "Log out", - "action.login": "Log in", "action.cancel": "Cancel", + "action.delete": "Delete", + "action.exit": "Exit", + "action.login": "Log in", + "action.logout": "Log out", + "action.ok": "OK", + "action.save": "Save", + "api.error.decoding": "Invalid server response", + "api.error.forbidden": "Operation not allowed", + "api.error.http": "HTTP error %1$d", + "api.error.invalid.url": "Invalid URL", + "api.error.not.found": "Resource not found", + "api.error.score.invalid": "Invalid score response", + "api.error.server": "Server error. Please try again in a moment.", + "api.error.unauthorized": "Session expired. Please sign in again.", + "app.name": "Match Live TV", + "app.slogan": "Every match, every event, for your fans.", + "broadcast.close.set.button": "CLOSE SET", + "broadcast.error.camera.preview.not.ready": "Camera preview not ready", + "broadcast.error.permissions.camera.mic": "Camera and microphone permission required", + "broadcast.error.reconnect.failed": "Reconnection failed", + "broadcast.error.regia.link": "Control room link error", + "broadcast.error.regia.link.invalid": "Invalid control-room link", + "broadcast.error.rtmp.connect.failed": "RTMP connection failed", + "broadcast.error.rtmp.missing": "Missing RTMP URL", + "broadcast.error.rtmp.session.create": "Unable to create RTMP session", + "broadcast.error.rtmp.url.invalid": "Invalid RTMP URL", + "broadcast.error.session.unavailable": "Session not available", + "broadcast.hide.controls.cd": "Hide controls", + "broadcast.next.period.cd": "Next period", + "broadcast.pause.cd": "Pause live", + "broadcast.period.label.timed": "Period %1$d", + "broadcast.permissions.grant.action": "GRANT PERMISSIONS", + "broadcast.permissions.required": "Allow camera and microphone to go live", + "broadcast.points.target": "%1$d pts", + "broadcast.resume.cd": "Resume live", + "broadcast.scoreboard.connected": "Scoreboard OK", + "broadcast.scoreboard.offline": "Scoreboard offline", + "broadcast.set.progress": "Set %1$d · %2$d pts", + "broadcast.sets.won": "Sets won %1$d-%2$d", + "broadcast.share.chooser.title": "Share", + "broadcast.share.link.unavailable": "Live link not available yet", + "broadcast.share.live.cd": "Share live stream", + "broadcast.share.regia.cd": "Share control room link", + "broadcast.share.regia.subject": "Control room link — %1$@", + "broadcast.share.subject.fallback": "Match Live TV stream", + "broadcast.show.controls.cd": "Show controls", + "broadcast.side.away": "away", + "broadcast.side.home": "home", + "broadcast.snackbar.closed.remote": "Live closed from control room", + "broadcast.snackbar.pause.error": "Pause: %1$@", + "broadcast.snackbar.paused": "Live paused", + "broadcast.snackbar.paused.remote": "Paused from control room", + "broadcast.snackbar.reconnect.started": "Reconnecting…", + "broadcast.snackbar.resume.error": "Resume: %1$@", + "broadcast.snackbar.resume.rtmp.error": "RTMP resume: %1$@", + "broadcast.snackbar.resumed": "Live resumed", + "broadcast.snackbar.resumed.remote": "Resumed from control room", + "broadcast.status.connecting": "CONNECTING…", + "broadcast.status.error.fallback": "ERROR", + "broadcast.status.live": "LIVE", + "broadcast.status.paused": "PAUSED", + "broadcast.status.preview": "PREVIEW", + "broadcast.status.reconnecting": "RECONNECTING…", + "broadcast.team.away.label": "AWAY", + "broadcast.team.home.label": "HOME", + "broadcast.team.sets.count": "%1$d sets", + "broadcast.terminate.cd": "End live", + "broadcast.terminate.confirm": "END", + "broadcast.terminate.message": "The stream will be closed for all viewers.", + "broadcast.terminate.title": "End the live stream?", + "broadcast.tooltip.add.point": "Add point %1$@", + "broadcast.tooltip.plus.side": "+%1$d %2$@", + "broadcast.tooltip.remove.point": "Remove point %1$@", + "common.close": "Close", + "common.error.generic": "error", + "common.error.title": "Error", + "error.operation.failed": "Operation failed", + "error.rtmp.failed": "RTMP connection failed. Check network and streaming server.", + "error.rtmp.failed.dev": "RTMP connection failed. Check that MediaMTX is reachable (in dev: port 1935 on localhost).", + "error.rtmp.interrupted": "RTMP connection interrupted", + "error.rtmp.invalid.url": "Invalid RTMP URL. Check the ingest address from the server.", + "error.rtmp.timeout": "RTMP connection timeout. Check network and MediaMTX server (port 1935).", + "error.rtmp.unreachable": "RTMP server unreachable. In local dev use localhost:1935 (not the Docker host «mediamtx»).", + "language.english": "English", + "language.french": "Français", + "language.german": "Deutsch", + "language.italian": "Italiano", + "language.label": "Language", + "language.spanish": "Español", + "language.system": "System", "login.email": "Email", + "login.email.placeholder": "coach@team.com", + "login.error.credentials": "Incorrect email or password", + "login.error.generic": "Login failed", + "login.error.unreachable": "Server unreachable. Check your connection.", "login.password": "Password", "login.submit": "Log in", - "login.error.credentials": "Incorrect email or password", - "login.error.unreachable": "Server unreachable. Check your connection.", - "login.error.generic": "Login failed", + "match.status.live": "LIVE", + "match.status.resume": "RESUME", + "match.status.scheduled": "SCHEDULED", + "match.status.start": "START", + "matches.active.team": "Active team: %1$@.", + "matches.change": "Change", + "matches.empty.hint": "Schedule a match or start a new one with «New match».", + "matches.empty.title": "No matches on the calendar", + "matches.hello": "Hi, %1$@", + "matches.load.error": "Loading error", + "matches.msg.create.failed": "Could not create the match", + "matches.msg.delete.failed": "Could not delete", + "matches.msg.deleted": "Match deleted", + "matches.msg.load.error": "Loading error", + "matches.msg.resume.failed": "Could not resume the live stream", + "matches.msg.schedule.error": "Error creating match", + "matches.msg.scheduled.ok": "Match scheduled — visible on the site", + "matches.multi.team.hint": "You have multiple teams: check the one selected above.", + "matches.new": "New match", + "matches.no.other": "No other matches on the calendar.", + "matches.no.team.body": "To stream you must be transmission staff for at least one team. Ask your club to add you as streaming staff.", + "matches.no.team.title": "No team assigned", + "matches.ready.title": "Ready to start", + "matches.retry": "Retry", + "matches.schedule": "Scheduled match", + "matches.scheduled.title": "Scheduled matches", + "matches.subtitle": "Resume a live stream or start a scheduled match.", + "matches.tap.change.team": "Tap to change team", + "matches.team.for.live": "Team for the live stream", "matches.title": "Matches", - "app.slogan": "Every match, every event, for your fans.", - ], + "network.type.cellular": "4G", + "network.type.ethernet": "Ethernet", + "network.type.unknown": "Unknown", + "network.type.wifi": "WiFi", + "overlay.set.label": "Set %1$d", + "score.action.close.anyway": "Close anyway", + "score.action.close.live": "End live", + "score.action.close.set": "Close set", + "score.action.continue.live": "Keep streaming", + "score.action.continue.scoring": "Keep scoring", + "score.close.set.anyway.message": "The score doesn't meet the tournament rules yet. Close the set anyway?", + "score.match.won.message": "%1$@ wins the match (%2$d-%3$d sets).\\n\\nEnd the live stream for good?", + "score.match.won.title": "Match finished", + "score.set.won.message": "%1$@ wins the set %2$d-%3$d.\\n\\nClose the set and move to the next one?", + "score.set.won.title": "Set finished", + "session.error.no.active": "No active session", + "session.error.not.connected": "Session not connected", + "sheet.badge.calendar": "ON CALENDAR", + "sheet.badge.draft": "DRAFT", + "sheet.badge.scheduled": "SCHEDULED", + "sheet.choose.match": "Choose match", + "sheet.choose.match.lead": "Matches already scheduled on the site or in the app.", + "sheet.choose.team": "Choose team", + "sheet.configure": "Configure", + "sheet.configure.now.body": "You can prepare the stream now, or come back when you are at the gym.", + "sheet.configure.now.title": "Configure now?", + "sheet.continue.setup": "CONTINUE SETUP", + "sheet.date.time": "Date and time", + "sheet.delete": "Delete", + "sheet.delete.match.body": "Delete «%1$@ vs %2$@»?\n\nThis cannot be undone.", + "sheet.delete.match.title": "Delete match", + "sheet.later": "Later", + "sheet.live.in.progress": "Live in progress", + "sheet.location.optional": "Location (optional)", + "sheet.new.match.lead": "Schedule ahead or start live setup right away.", + "sheet.new.match.title": "New match", + "sheet.opponent": "Opponent", + "sheet.quick.option": "Start now", + "sheet.quick.option.sub": "Create the match and open the wizard without a time", + "sheet.resume.banner": "Resume live stream", + "sheet.resume.camera": "RESUME CAMERA AND STREAM", + "sheet.save.schedule": "SAVE TO SCHEDULE", + "sheet.schedule.lead": "Streaming does not start now: it will appear on the site with date and time. You will start streaming from the app when you are at the gym.", + "sheet.schedule.option": "Schedule match", + "sheet.schedule.option.sub": "Date, time and opponent — also visible on the site", + "sheet.schedule.title": "Schedule match", + "sheet.section.scheduled": "Scheduled", + "sheet.section.unscheduled": "No time set", + "sheet.status": "Status: %1$@", + "thermal.alert.continue": "Continue", + "thermal.alert.message": "The device has been very hot for over a minute. iOS may quit the app to protect the device. Consider stopping the live stream.", + "thermal.alert.stop": "Stop live", + "thermal.alert.title": "Overheating", + "thermal.notice.critical": "Overheating imminent: quality reduced further.", + "thermal.notice.serious": "The device is getting warm. Streaming quality was reduced to keep things stable.", + "thermal.state.critical": "Overheating risk", + "thermal.state.fair": "High temperature", + "thermal.state.nominal": "Temperature OK", + "thermal.state.serious": "Device warm", + "wizard.action.back": "Back", + "wizard.action.copy": "COPY", + "wizard.action.next": "NEXT >", + "wizard.action.share": "SHARE", + "wizard.action.share.regia.link": "SHARE CONTROL ROOM LINK", + "wizard.action.start": "START >", + "wizard.branding.cancel": "CANCEL", + "wizard.branding.color.label": "Team color", + "wizard.branding.customize.title": "Customize", + "wizard.branding.edit.team.cd": "Edit team", + "wizard.branding.logo.label": "Logo", + "wizard.branding.no.logo": "No logo", + "wizard.branding.opponent.name.placeholder": "Opponent name", + "wizard.branding.remove.logo": "REMOVE", + "wizard.branding.save": "SAVE", + "wizard.branding.team.fallback.name": "Team", + "wizard.branding.team.name.label": "Team name", + "wizard.branding.upload.logo": "UPLOAD LOGO", + "wizard.color.hue": "Hue", + "wizard.complete.transmission.step": "Complete the Broadcast step", + "wizard.error.opponent.name.required": "Enter the opponent's name", + "wizard.error.overtime.duration.required": "Enter the overtime duration in minutes", + "wizard.error.period.duration.required": "Enter the period duration in minutes", + "wizard.error.points.per.set.required": "Enter the points to win a set", + "wizard.error.regia.link": "Control room link error", + "wizard.error.save.generic": "Error while saving", + "wizard.error.session.create": "Error creating session", + "wizard.error.start.live": "Error starting the live stream", + "wizard.error.tiebreak.points.required": "Enter the tie-break points", + "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.category.label": "Category (optional)", + "wizard.match.custom.overlay.label": "Custom video overlay", + "wizard.match.custom.rules.label": "Custom scoring rules", + "wizard.match.details.title": "Match details", + "wizard.match.home.team.label": "Home team", + "wizard.match.location.label": "Location", + "wizard.match.minutes.per.period.basket": "Minutes per quarter", + "wizard.match.minutes.per.period.timed": "Minutes per half", + "wizard.match.not.found": "Match not found", + "wizard.match.overtime.minutes.label": "Overtime minutes", + "wizard.match.periods.basket.label": "Quarters", + "wizard.match.periods.timed.label": "Halves", + "wizard.match.points.per.set.label": "Points to win a set", + "wizard.match.rules.custom.sets": "Non-standard tournament: set your sets and points.", + "wizard.match.rules.custom.timed": "Non-standard tournament: custom times and periods.", + "wizard.match.rules.standard.sets": "FIVB standard: 3 sets to win, sets to 25, tie-break to 15.", + "wizard.match.rules.standard.sport": "Standard rules for the selected sport.", + "wizard.match.scheduled.label": "Scheduled for", + "wizard.match.sets.to.win.label": "Sets to win the match", + "wizard.match.tiebreak.points.label": "Tie-break points (last set)", + "wizard.network.download.label": "Download", + "wizard.network.latency.label": "Latency", + "wizard.network.link.live.label": "Live link", + "wizard.network.link.youtube.label": "YouTube link", + "wizard.network.quality.label": "Streaming quality (automatic)", + "wizard.network.ready.label": "READY TO GO LIVE", + "wizard.network.share.subject": "Live — %1$@ vs %2$@", + "wizard.network.test.running.label": "TEST IN PROGRESS...", + "wizard.network.test.start.label": "START NETWORK TEST", + "wizard.network.test.subtitle": "Check that your connection can handle the live upload.", + "wizard.network.test.title": "Network test", + "wizard.network.type.label": "Network type", + "wizard.network.upload.label": "Upload", + "wizard.share.chooser.live": "Share live stream", + "wizard.share.chooser.regia": "Share control room", + "wizard.step.title.match": "01 · Match", + "wizard.step.title.network": "03 · Network test", + "wizard.step.title.transmission": "02 · Broadcast", + "wizard.transmission.backend.note": "The match always stays visible in the team backend for the whole subscription period.", + "wizard.transmission.plan.label": "Plan %1$@", + "wizard.transmission.platform.site.subtitle": "Live on our site (included)", + "wizard.transmission.platform.title": "Platform", + "wizard.transmission.public.desc": "Shown in the live list on MatchLiveTV.it, in search and on the YouTube channel (if selected).", + "wizard.transmission.public.label": "PUBLIC", + "wizard.transmission.unlisted.desc": "Not shown in public lists or search. Only those with the link can watch.", + "wizard.transmission.unlisted.label": "UNLISTED", + "wizard.transmission.visibility.title": "Visibility", + "wizard.transmission.youtube.activating": "Channel being activated", + "wizard.transmission.youtube.badge": "Premium", + "wizard.transmission.youtube.premium.required": "Premium Light or Full", + "youtube.channel.club": "Club channel · %1$@", + "youtube.channel.club.fallback": "club", + "youtube.channel.platform": "Match Live TV channel" + ], "fr": [ - "language.label": "Langue", - "language.system": "Système", - "action.logout": "Déconnexion", - "action.login": "Connexion", "action.cancel": "Annuler", + "action.delete": "Supprimer", + "action.exit": "Quitter", + "action.login": "Connexion", + "action.logout": "Déconnexion", + "action.ok": "OK", + "action.save": "Enregistrer", + "api.error.decoding": "Réponse du serveur non valide", + "api.error.forbidden": "Opération non autorisée", + "api.error.http": "Erreur HTTP %1$d", + "api.error.invalid.url": "URL non valide", + "api.error.not.found": "Ressource introuvable", + "api.error.score.invalid": "Réponse de score non valide", + "api.error.server": "Erreur serveur. Réessayez dans un instant.", + "api.error.unauthorized": "Session expirée. Connectez-vous à nouveau.", + "app.name": "Match Live TV", + "app.slogan": "Chaque match, chaque événement, pour vos fans.", + "broadcast.close.set.button": "FERMER LE SET", + "broadcast.error.camera.preview.not.ready": "Aperçu caméra non prêt", + "broadcast.error.permissions.camera.mic": "Autorisations caméra et micro requises", + "broadcast.error.reconnect.failed": "Échec de la reconnexion", + "broadcast.error.regia.link": "Erreur du lien régie", + "broadcast.error.regia.link.invalid": "Lien régie non valide", + "broadcast.error.rtmp.connect.failed": "Échec de la connexion RTMP", + "broadcast.error.rtmp.missing": "URL RTMP manquante", + "broadcast.error.rtmp.session.create": "Impossible de créer la session RTMP", + "broadcast.error.rtmp.url.invalid": "URL RTMP non valide", + "broadcast.error.session.unavailable": "Session non disponible", + "broadcast.hide.controls.cd": "Masquer les commandes", + "broadcast.next.period.cd": "Période suivante", + "broadcast.pause.cd": "Mettre le direct en pause", + "broadcast.period.label.timed": "Période %1$d", + "broadcast.permissions.grant.action": "ACCORDER LES AUTORISATIONS", + "broadcast.permissions.required": "Autorisez la caméra et le micro pour passer en direct", + "broadcast.points.target": "%1$d pts", + "broadcast.resume.cd": "Reprendre le direct", + "broadcast.scoreboard.connected": "Tableau OK", + "broadcast.scoreboard.offline": "Tableau hors ligne", + "broadcast.set.progress": "Set %1$d · %2$d pts", + "broadcast.sets.won": "Sets gagnés %1$d-%2$d", + "broadcast.share.chooser.title": "Partager", + "broadcast.share.link.unavailable": "Lien du direct pas encore disponible", + "broadcast.share.live.cd": "Partager le direct", + "broadcast.share.regia.cd": "Partager le lien régie", + "broadcast.share.regia.subject": "Lien régie — %1$@", + "broadcast.share.subject.fallback": "Direct Match Live TV", + "broadcast.show.controls.cd": "Afficher les commandes", + "broadcast.side.away": "extérieur", + "broadcast.side.home": "domicile", + "broadcast.snackbar.closed.remote": "Direct fermé depuis la régie", + "broadcast.snackbar.pause.error": "Pause : %1$@", + "broadcast.snackbar.paused": "Direct en pause", + "broadcast.snackbar.paused.remote": "Mis en pause depuis la régie", + "broadcast.snackbar.reconnect.started": "Reconnexion en cours…", + "broadcast.snackbar.resume.error": "Reprise : %1$@", + "broadcast.snackbar.resume.rtmp.error": "Reprise RTMP : %1$@", + "broadcast.snackbar.resumed": "Direct repris", + "broadcast.snackbar.resumed.remote": "Repris depuis la régie", + "broadcast.status.connecting": "CONNEXION…", + "broadcast.status.error.fallback": "ERREUR", + "broadcast.status.live": "EN DIRECT", + "broadcast.status.paused": "PAUSE", + "broadcast.status.preview": "APERÇU", + "broadcast.status.reconnecting": "RECONNEXION…", + "broadcast.team.away.label": "EXTÉRIEUR", + "broadcast.team.home.label": "DOMICILE", + "broadcast.team.sets.count": "%1$d sets", + "broadcast.terminate.cd": "Terminer le direct", + "broadcast.terminate.confirm": "TERMINER", + "broadcast.terminate.message": "Le streaming sera fermé pour tous les spectateurs.", + "broadcast.terminate.title": "Terminer le direct ?", + "broadcast.tooltip.add.point": "Ajouter un point %1$@", + "broadcast.tooltip.plus.side": "+%1$d %2$@", + "broadcast.tooltip.remove.point": "Retirer un point %1$@", + "common.close": "Fermer", + "common.error.generic": "erreur", + "common.error.title": "Erreur", + "error.operation.failed": "Opération échouée", + "error.rtmp.failed": "Échec de la connexion RTMP. Vérifiez le réseau et le serveur de streaming.", + "error.rtmp.failed.dev": "Échec de la connexion RTMP. Vérifiez que MediaMTX est accessible (en dev : port 1935 sur localhost).", + "error.rtmp.interrupted": "Connexion RTMP interrompue", + "error.rtmp.invalid.url": "URL RTMP non valide. Vérifiez l'adresse d'ingest du serveur.", + "error.rtmp.timeout": "Délai de connexion RTMP dépassé. Vérifiez le réseau et MediaMTX (port 1935).", + "error.rtmp.unreachable": "Serveur RTMP inaccessible. En local, utilisez localhost:1935 (pas l'hôte Docker «mediamtx»).", + "language.english": "English", + "language.french": "Français", + "language.german": "Deutsch", + "language.italian": "Italiano", + "language.label": "Langue", + "language.spanish": "Español", + "language.system": "Système", "login.email": "E-mail", + "login.email.placeholder": "coach@equipe.com", + "login.error.credentials": "E-mail ou mot de passe incorrect", + "login.error.generic": "Échec de la connexion", + "login.error.unreachable": "Serveur inaccessible. Vérifiez la connexion.", "login.password": "Mot de passe", "login.submit": "Connexion", - "login.error.credentials": "E-mail ou mot de passe incorrect", - "login.error.unreachable": "Serveur inaccessible. Vérifiez la connexion.", - "login.error.generic": "Échec de la connexion", + "match.status.live": "EN DIRECT", + "match.status.resume": "REPRENDRE", + "match.status.scheduled": "PLANIFIÉ", + "match.status.start": "DÉMARRER", + "matches.active.team": "Équipe active : %1$@.", + "matches.change": "Changer", + "matches.empty.hint": "Programmez un match ou démarrez-en un avec « Nouveau match ».", + "matches.empty.title": "Aucun match au calendrier", + "matches.hello": "Bonjour, %1$@", + "matches.load.error": "Erreur de chargement", + "matches.msg.create.failed": "Impossible de créer le match", + "matches.msg.delete.failed": "Impossible de supprimer", + "matches.msg.deleted": "Match supprimé", + "matches.msg.load.error": "Erreur de chargement", + "matches.msg.resume.failed": "Impossible de reprendre le direct", + "matches.msg.schedule.error": "Erreur lors de la création du match", + "matches.msg.scheduled.ok": "Match planifié — visible sur le site", + "matches.multi.team.hint": "Vous avez plusieurs équipes : vérifiez celle sélectionnée ci-dessus.", + "matches.new": "Nouveau match", + "matches.no.other": "Aucun autre match au calendrier.", + "matches.no.team.body": "Pour diffuser, vous devez être responsable de la transmission d'au moins une équipe. Demandez à votre club de vous ajouter comme staff diffusion.", + "matches.no.team.title": "Aucune équipe assignée", + "matches.ready.title": "Prêts à démarrer", + "matches.retry": "Réessayer", + "matches.schedule": "Match programmé", + "matches.scheduled.title": "Matchs programmés", + "matches.subtitle": "Reprenez un direct en cours ou démarrez un match programmé.", + "matches.tap.change.team": "Appuyez pour changer d'équipe", + "matches.team.for.live": "Équipe pour le direct", "matches.title": "Matchs", - "app.slogan": "Chaque match, chaque événement, pour vos fans.", - ], + "network.type.cellular": "4G", + "network.type.ethernet": "Ethernet", + "network.type.unknown": "Inconnu", + "network.type.wifi": "WiFi", + "overlay.set.label": "Set %1$d", + "score.action.close.anyway": "Fermer quand même", + "score.action.close.live": "Fermer le direct", + "score.action.close.set": "Fermer le set", + "score.action.continue.live": "Continuer le direct", + "score.action.continue.scoring": "Continuer à marquer", + "score.close.set.anyway.message": "Le score ne respecte pas encore les règles du tournoi. Fermer le set quand même ?", + "score.match.won.message": "%1$@ remporte le match (%2$d-%3$d sets).\\n\\nFermer définitivement le direct ?", + "score.match.won.title": "Match terminé", + "score.set.won.message": "%1$@ remporte le set %2$d-%3$d.\\n\\nFermer le set et passer au suivant ?", + "score.set.won.title": "Set terminé", + "session.error.no.active": "Aucune session active", + "session.error.not.connected": "Session non connectée", + "sheet.badge.calendar": "AU CALENDRIER", + "sheet.badge.draft": "BROUILLON", + "sheet.badge.scheduled": "PLANIFIÉ", + "sheet.choose.match": "Choisir le match", + "sheet.choose.match.lead": "Matchs déjà planifiés sur le site ou dans l'app.", + "sheet.choose.team": "Choisir l'équipe", + "sheet.configure": "Configurer", + "sheet.configure.now.body": "Vous pouvez préparer la diffusion maintenant, ou revenir une fois en salle.", + "sheet.configure.now.title": "Configurer maintenant ?", + "sheet.continue.setup": "CONTINUER LA CONFIGURATION", + "sheet.date.time": "Date et heure", + "sheet.delete": "Supprimer", + "sheet.delete.match.body": "Supprimer « %1$@ vs %2$@ » ?\n\nCette action est irréversible.", + "sheet.delete.match.title": "Supprimer le match", + "sheet.later": "Plus tard", + "sheet.live.in.progress": "Direct en cours", + "sheet.location.optional": "Lieu (facultatif)", + "sheet.new.match.lead": "Planifiez à l'avance ou lancez la configuration du direct tout de suite.", + "sheet.new.match.title": "Nouveau match", + "sheet.opponent": "Adversaire", + "sheet.quick.option": "Démarrer maintenant", + "sheet.quick.option.sub": "Créez le match et ouvrez l'assistant sans horaire", + "sheet.resume.banner": "Reprendre le direct en cours", + "sheet.resume.camera": "REPRENDRE CAMÉRA ET DIFFUSER", + "sheet.save.schedule": "ENREGISTRER AU CALENDRIER", + "sheet.schedule.lead": "Le direct ne démarre pas maintenant : il apparaîtra sur le site avec date et heure. Vous lancerez le streaming depuis l'app une fois en salle.", + "sheet.schedule.option": "Planifier le match", + "sheet.schedule.option.sub": "Date, heure et adversaire — aussi visibles sur le site", + "sheet.schedule.title": "Planifier le match", + "sheet.section.scheduled": "Planifiés", + "sheet.section.unscheduled": "Sans horaire", + "sheet.status": "État : %1$@", + "thermal.alert.continue": "Continuer", + "thermal.alert.message": "L'appareil est très chaud depuis plus d'une minute. iOS peut fermer l'app pour protéger l'appareil. Envisagez d'interrompre le direct.", + "thermal.alert.stop": "Arrêter le direct", + "thermal.alert.title": "Surchauffe", + "thermal.notice.critical": "Surchauffe imminente : qualité encore réduite.", + "thermal.notice.serious": "L'appareil chauffe. La qualité du streaming a été réduite pour assurer la stabilité.", + "thermal.state.critical": "Risque de surchauffe", + "thermal.state.fair": "Température élevée", + "thermal.state.nominal": "Température OK", + "thermal.state.serious": "Appareil chaud", + "wizard.action.back": "Retour", + "wizard.action.copy": "COPIER", + "wizard.action.next": "SUIVANT >", + "wizard.action.share": "PARTAGER", + "wizard.action.share.regia.link": "PARTAGER LE LIEN RÉGIE", + "wizard.action.start": "DÉMARRER >", + "wizard.branding.cancel": "ANNULER", + "wizard.branding.color.label": "Couleur de l'équipe", + "wizard.branding.customize.title": "Personnaliser", + "wizard.branding.edit.team.cd": "Modifier l'équipe", + "wizard.branding.logo.label": "Logo", + "wizard.branding.no.logo": "Aucun logo", + "wizard.branding.opponent.name.placeholder": "Nom de l'adversaire", + "wizard.branding.remove.logo": "SUPPRIMER", + "wizard.branding.save": "ENREGISTRER", + "wizard.branding.team.fallback.name": "Équipe", + "wizard.branding.team.name.label": "Nom de l'équipe", + "wizard.branding.upload.logo": "CHARGER LE LOGO", + "wizard.color.hue": "Teinte", + "wizard.complete.transmission.step": "Complétez l'étape Diffusion", + "wizard.error.opponent.name.required": "Saisissez le nom de l'adversaire", + "wizard.error.overtime.duration.required": "Saisissez la durée de la prolongation en minutes", + "wizard.error.period.duration.required": "Saisissez la durée de la période en minutes", + "wizard.error.points.per.set.required": "Saisissez les points pour gagner un set", + "wizard.error.regia.link": "Erreur du lien régie", + "wizard.error.save.generic": "Erreur lors de l'enregistrement", + "wizard.error.session.create": "Erreur lors de la création de la session", + "wizard.error.start.live": "Erreur au démarrage du direct", + "wizard.error.tiebreak.points.required": "Saisissez les points du tie-break", + "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.category.label": "Championnat (facultatif)", + "wizard.match.custom.overlay.label": "Overlay vidéo personnalisé", + "wizard.match.custom.rules.label": "Règles de score personnalisées", + "wizard.match.details.title": "Détails du match", + "wizard.match.home.team.label": "Équipe à domicile", + "wizard.match.location.label": "Lieu", + "wizard.match.minutes.per.period.basket": "Minutes par quart-temps", + "wizard.match.minutes.per.period.timed": "Minutes par mi-temps", + "wizard.match.not.found": "Match introuvable", + "wizard.match.overtime.minutes.label": "Minutes de prolongation", + "wizard.match.periods.basket.label": "Quart-temps", + "wizard.match.periods.timed.label": "Mi-temps", + "wizard.match.points.per.set.label": "Points pour gagner un set", + "wizard.match.rules.custom.sets": "Tournoi non standard : définissez sets et points.", + "wizard.match.rules.custom.timed": "Tournoi non standard : temps et périodes personnalisés.", + "wizard.match.rules.standard.sets": "Standard FIVB : 3 sets gagnants, sets à 25, tie-break à 15.", + "wizard.match.rules.standard.sport": "Règles standard du sport sélectionné.", + "wizard.match.scheduled.label": "Programmé pour", + "wizard.match.sets.to.win.label": "Sets pour gagner le match", + "wizard.match.tiebreak.points.label": "Points du tie-break (dernier set)", + "wizard.network.download.label": "Téléchargement", + "wizard.network.latency.label": "Latence", + "wizard.network.link.live.label": "Lien du direct", + "wizard.network.link.youtube.label": "Lien YouTube", + "wizard.network.quality.label": "Qualité de streaming (automatique)", + "wizard.network.ready.label": "PRÊT À PASSER EN DIRECT", + "wizard.network.share.subject": "Direct — %1$@ vs %2$@", + "wizard.network.test.running.label": "TEST EN COURS...", + "wizard.network.test.start.label": "LANCER LE TEST RÉSEAU", + "wizard.network.test.subtitle": "Vérifiez que votre connexion supporte l'envoi du direct.", + "wizard.network.test.title": "Test réseau", + "wizard.network.type.label": "Type de réseau", + "wizard.network.upload.label": "Envoi", + "wizard.share.chooser.live": "Partager le direct", + "wizard.share.chooser.regia": "Partager la régie", + "wizard.step.title.match": "01 · Match", + "wizard.step.title.network": "03 · Test réseau", + "wizard.step.title.transmission": "02 · Diffusion", + "wizard.transmission.backend.note": "Le match reste toujours visible dans le backend de l'équipe pendant toute la durée de l'abonnement.", + "wizard.transmission.plan.label": "Forfait %1$@", + "wizard.transmission.platform.site.subtitle": "Direct sur notre site (inclus)", + "wizard.transmission.platform.title": "Plateforme", + "wizard.transmission.public.desc": "Apparaît dans la liste des directs sur MatchLiveTV.it, dans les recherches et sur la chaîne YouTube (si sélectionnée).", + "wizard.transmission.public.label": "PUBLIC", + "wizard.transmission.unlisted.desc": "N'apparaît pas dans les listes publiques ni dans les recherches. Seules les personnes ayant le lien peuvent regarder.", + "wizard.transmission.unlisted.label": "NON RÉPERTORIÉ", + "wizard.transmission.visibility.title": "Visibilité", + "wizard.transmission.youtube.activating": "Chaîne en cours d'activation", + "wizard.transmission.youtube.badge": "Premium", + "wizard.transmission.youtube.premium.required": "Premium Light ou Full", + "youtube.channel.club": "Chaîne club · %1$@", + "youtube.channel.club.fallback": "club", + "youtube.channel.platform": "Chaîne Match Live TV" + ], "de": [ - "language.label": "Sprache", - "language.system": "System", - "action.logout": "Abmelden", - "action.login": "Anmelden", "action.cancel": "Abbrechen", + "action.delete": "Löschen", + "action.exit": "Beenden", + "action.login": "Anmelden", + "action.logout": "Abmelden", + "action.ok": "OK", + "action.save": "Speichern", + "api.error.decoding": "Ungültige Serverantwort", + "api.error.forbidden": "Vorgang nicht erlaubt", + "api.error.http": "HTTP-Fehler %1$d", + "api.error.invalid.url": "Ungültige URL", + "api.error.not.found": "Ressource nicht gefunden", + "api.error.score.invalid": "Ungültige Punktestand-Antwort", + "api.error.server": "Serverfehler. Bitte gleich noch einmal versuchen.", + "api.error.unauthorized": "Sitzung abgelaufen. Bitte erneut anmelden.", + "app.name": "Match Live TV", + "app.slogan": "Jedes Spiel, jedes Event, für eure Fans.", + "broadcast.close.set.button": "SATZ BEENDEN", + "broadcast.error.camera.preview.not.ready": "Kameravorschau nicht bereit", + "broadcast.error.permissions.camera.mic": "Kamera- und Mikrofonberechtigung erforderlich", + "broadcast.error.reconnect.failed": "Wiederverbindung fehlgeschlagen", + "broadcast.error.regia.link": "Fehler beim Regie-Link", + "broadcast.error.regia.link.invalid": "Ungültiger Regie-Link", + "broadcast.error.rtmp.connect.failed": "RTMP-Verbindung fehlgeschlagen", + "broadcast.error.rtmp.missing": "RTMP-URL fehlt", + "broadcast.error.rtmp.session.create": "RTMP-Sitzung konnte nicht erstellt werden", + "broadcast.error.rtmp.url.invalid": "Ungültige RTMP-URL", + "broadcast.error.session.unavailable": "Sitzung nicht verfügbar", + "broadcast.hide.controls.cd": "Steuerung ausblenden", + "broadcast.next.period.cd": "Nächste Periode", + "broadcast.pause.cd": "Livestream pausieren", + "broadcast.period.label.timed": "Halbzeit %1$d", + "broadcast.permissions.grant.action": "BERECHTIGUNGEN ERTEILEN", + "broadcast.permissions.required": "Erlaube Kamera und Mikrofon, um live zu gehen", + "broadcast.points.target": "%1$d Pkt.", + "broadcast.resume.cd": "Livestream fortsetzen", + "broadcast.scoreboard.connected": "Anzeigetafel OK", + "broadcast.scoreboard.offline": "Anzeigetafel offline", + "broadcast.set.progress": "Satz %1$d · %2$d Pkt.", + "broadcast.sets.won": "Sätze gewonnen %1$d-%2$d", + "broadcast.share.chooser.title": "Teilen", + "broadcast.share.link.unavailable": "Live-Link noch nicht verfügbar", + "broadcast.share.live.cd": "Livestream teilen", + "broadcast.share.regia.cd": "Regie-Link teilen", + "broadcast.share.regia.subject": "Regie-Link — %1$@", + "broadcast.share.subject.fallback": "Match Live TV Livestream", + "broadcast.show.controls.cd": "Steuerung einblenden", + "broadcast.side.away": "Gast", + "broadcast.side.home": "Heim", + "broadcast.snackbar.closed.remote": "Livestream von der Regie beendet", + "broadcast.snackbar.pause.error": "Pause: %1$@", + "broadcast.snackbar.paused": "Livestream pausiert", + "broadcast.snackbar.paused.remote": "Von der Regie pausiert", + "broadcast.snackbar.reconnect.started": "Wiederverbindung gestartet", + "broadcast.snackbar.resume.error": "Fortsetzen: %1$@", + "broadcast.snackbar.resume.rtmp.error": "RTMP-Fortsetzung: %1$@", + "broadcast.snackbar.resumed": "Livestream fortgesetzt", + "broadcast.snackbar.resumed.remote": "Von der Regie fortgesetzt", + "broadcast.status.connecting": "VERBINDUNG…", + "broadcast.status.error.fallback": "FEHLER", + "broadcast.status.live": "LIVE", + "broadcast.status.paused": "PAUSE", + "broadcast.status.preview": "VORSCHAU", + "broadcast.status.reconnecting": "NEUVERBINDUNG…", + "broadcast.team.away.label": "GAST", + "broadcast.team.home.label": "HEIM", + "broadcast.team.sets.count": "%1$d Sätze", + "broadcast.terminate.cd": "Livestream beenden", + "broadcast.terminate.confirm": "BEENDEN", + "broadcast.terminate.message": "Der Stream wird für alle Zuschauer beendet.", + "broadcast.terminate.title": "Livestream beenden?", + "broadcast.tooltip.add.point": "Punkt hinzufügen %1$@", + "broadcast.tooltip.plus.side": "+%1$d %2$@", + "broadcast.tooltip.remove.point": "Punkt entfernen %1$@", + "common.close": "Schließen", + "common.error.generic": "Fehler", + "common.error.title": "Fehler", + "error.operation.failed": "Vorgang fehlgeschlagen", + "error.rtmp.failed": "RTMP-Verbindung fehlgeschlagen. Netzwerk und Streaming-Server prüfen.", + "error.rtmp.failed.dev": "RTMP-Verbindung fehlgeschlagen. Prüfen Sie, ob MediaMTX erreichbar ist (Dev: Port 1935 auf localhost).", + "error.rtmp.interrupted": "RTMP-Verbindung unterbrochen", + "error.rtmp.invalid.url": "Ungültige RTMP-URL. Prüfen Sie die Ingest-Adresse vom Server.", + "error.rtmp.timeout": "RTMP-Timeout. Netzwerk und MediaMTX (Port 1935) prüfen.", + "error.rtmp.unreachable": "RTMP-Server nicht erreichbar. Lokal localhost:1935 verwenden (nicht Docker-Host «mediamtx»).", + "language.english": "English", + "language.french": "Français", + "language.german": "Deutsch", + "language.italian": "Italiano", + "language.label": "Sprache", + "language.spanish": "Español", + "language.system": "System", "login.email": "E-Mail", + "login.email.placeholder": "coach@team.com", + "login.error.credentials": "Falsche E-Mail oder Passwort", + "login.error.generic": "Anmeldung fehlgeschlagen", + "login.error.unreachable": "Server nicht erreichbar. Verbindung prüfen.", "login.password": "Passwort", "login.submit": "Anmelden", - "login.error.credentials": "Falsche E-Mail oder Passwort", - "login.error.unreachable": "Server nicht erreichbar. Verbindung prüfen.", - "login.error.generic": "Anmeldung fehlgeschlagen", + "match.status.live": "LIVE", + "match.status.resume": "FORTSETZEN", + "match.status.scheduled": "GEPLANT", + "match.status.start": "STARTEN", + "matches.active.team": "Aktives Team: %1$@.", + "matches.change": "Wechseln", + "matches.empty.hint": "Plane ein Spiel oder starte ein neues mit «Neues Spiel».", + "matches.empty.title": "Keine Spiele im Kalender", + "matches.hello": "Hallo, %1$@", + "matches.load.error": "Ladefehler", + "matches.msg.create.failed": "Spiel konnte nicht erstellt werden", + "matches.msg.delete.failed": "Löschen fehlgeschlagen", + "matches.msg.deleted": "Spiel gelöscht", + "matches.msg.load.error": "Ladefehler", + "matches.msg.resume.failed": "Livestream konnte nicht fortgesetzt werden", + "matches.msg.schedule.error": "Fehler beim Erstellen des Spiels", + "matches.msg.scheduled.ok": "Spiel geplant — auf der Website sichtbar", + "matches.multi.team.hint": "Du hast mehrere Teams: prüfe die oben ausgewählte.", + "matches.new": "Neues Spiel", + "matches.no.other": "Keine weiteren Spiele im Kalender.", + "matches.no.team.body": "Zum Streamen musst du Übertragungs-Staff für mindestens ein Team sein. Bitte deinen Verein, dich als Streaming-Staff hinzuzufügen.", + "matches.no.team.title": "Kein Team zugewiesen", + "matches.ready.title": "Bereit zum Start", + "matches.retry": "Erneut versuchen", + "matches.schedule": "Geplantes Spiel", + "matches.scheduled.title": "Geplante Spiele", + "matches.subtitle": "Nimm einen laufenden Livestream wieder auf oder starte ein geplantes Spiel.", + "matches.tap.change.team": "Tippen zum Teamwechsel", + "matches.team.for.live": "Team für den Livestream", "matches.title": "Spiele", - "app.slogan": "Jedes Spiel, jedes Event, für eure Fans.", - ], + "network.type.cellular": "4G", + "network.type.ethernet": "Ethernet", + "network.type.unknown": "Unbekannt", + "network.type.wifi": "WiFi", + "overlay.set.label": "Satz %1$d", + "score.action.close.anyway": "Trotzdem beenden", + "score.action.close.live": "Livestream beenden", + "score.action.close.set": "Satz beenden", + "score.action.continue.live": "Weiter senden", + "score.action.continue.scoring": "Weiter punkten", + "score.close.set.anyway.message": "Der Punktestand erfüllt die Turnierregeln noch nicht. Satz trotzdem beenden?", + "score.match.won.message": "%1$@ gewinnt das Spiel (%2$d:%3$d Sätze).\\n\\nLivestream endgültig beenden?", + "score.match.won.title": "Spiel beendet", + "score.set.won.message": "%1$@ gewinnt den Satz %2$d:%3$d.\\n\\nSatz beenden und zum nächsten wechseln?", + "score.set.won.title": "Satz beendet", + "session.error.no.active": "Keine aktive Sitzung", + "session.error.not.connected": "Sitzung nicht verbunden", + "sheet.badge.calendar": "IM KALENDER", + "sheet.badge.draft": "ENTWURF", + "sheet.badge.scheduled": "GEPLANT", + "sheet.choose.match": "Spiel wählen", + "sheet.choose.match.lead": "Bereits auf der Website oder in der App geplante Spiele.", + "sheet.choose.team": "Team wählen", + "sheet.configure": "Einrichten", + "sheet.configure.now.body": "Du kannst die Übertragung jetzt vorbereiten oder zurückkommen, wenn du in der Halle bist.", + "sheet.configure.now.title": "Jetzt einrichten?", + "sheet.continue.setup": "EINRICHTUNG FORTSETZEN", + "sheet.date.time": "Datum und Uhrzeit", + "sheet.delete": "Löschen", + "sheet.delete.match.body": "«%1$@ vs %2$@» löschen?\n\nDies kann nicht rückgängig gemacht werden.", + "sheet.delete.match.title": "Spiel löschen", + "sheet.later": "Später", + "sheet.live.in.progress": "Live läuft", + "sheet.location.optional": "Ort (optional)", + "sheet.new.match.lead": "Im Voraus planen oder die Live-Einrichtung sofort starten.", + "sheet.new.match.title": "Neues Spiel", + "sheet.opponent": "Gegner", + "sheet.quick.option": "Jetzt starten", + "sheet.quick.option.sub": "Spiel erstellen und Wizard ohne Uhrzeit öffnen", + "sheet.resume.banner": "Laufenden Livestream fortsetzen", + "sheet.resume.camera": "KAMERA FORTSETZEN UND STREAMEN", + "sheet.save.schedule": "IM KALENDER SPEICHERN", + "sheet.schedule.lead": "Der Livestream startet jetzt nicht: Er erscheint auf der Website mit Datum und Uhrzeit. Du startest das Streaming in der App, wenn du in der Halle bist.", + "sheet.schedule.option": "Spiel planen", + "sheet.schedule.option.sub": "Datum, Uhrzeit und Gegner — auch auf der Website sichtbar", + "sheet.schedule.title": "Spiel planen", + "sheet.section.scheduled": "Geplant", + "sheet.section.unscheduled": "Ohne Uhrzeit", + "sheet.status": "Status: %1$@", + "thermal.alert.continue": "Weiter", + "thermal.alert.message": "Das Gerät ist seit über einer Minute sehr heiß. iOS kann die App zum Schutz beenden. Erwägen Sie, den Livestream zu stoppen.", + "thermal.alert.stop": "Live beenden", + "thermal.alert.title": "Überhitzung", + "thermal.notice.critical": "Überhitzung droht: Qualität weiter reduziert.", + "thermal.notice.serious": "Das Gerät wird warm. Die Streaming-Qualität wurde für mehr Stabilität reduziert.", + "thermal.state.critical": "Überhitzungsrisiko", + "thermal.state.fair": "Hohe Temperatur", + "thermal.state.nominal": "Temperatur OK", + "thermal.state.serious": "Gerät warm", + "wizard.action.back": "Zurück", + "wizard.action.copy": "KOPIEREN", + "wizard.action.next": "WEITER >", + "wizard.action.share": "TEILEN", + "wizard.action.share.regia.link": "REGIE-LINK TEILEN", + "wizard.action.start": "STARTEN >", + "wizard.branding.cancel": "ABBRECHEN", + "wizard.branding.color.label": "Teamfarbe", + "wizard.branding.customize.title": "Anpassen", + "wizard.branding.edit.team.cd": "Team bearbeiten", + "wizard.branding.logo.label": "Logo", + "wizard.branding.no.logo": "Kein Logo", + "wizard.branding.opponent.name.placeholder": "Name des Gegners", + "wizard.branding.remove.logo": "ENTFERNEN", + "wizard.branding.save": "SPEICHERN", + "wizard.branding.team.fallback.name": "Team", + "wizard.branding.team.name.label": "Teamname", + "wizard.branding.upload.logo": "LOGO HOCHLADEN", + "wizard.color.hue": "Farbton", + "wizard.complete.transmission.step": "Schließe den Schritt Übertragung ab", + "wizard.error.opponent.name.required": "Gib den Namen des Gegners ein", + "wizard.error.overtime.duration.required": "Gib die Dauer der Verlängerung in Minuten ein", + "wizard.error.period.duration.required": "Gib die Dauer der Periode in Minuten ein", + "wizard.error.points.per.set.required": "Gib die Punkte zum Satzgewinn ein", + "wizard.error.regia.link": "Fehler beim Regie-Link", + "wizard.error.save.generic": "Fehler beim Speichern", + "wizard.error.session.create": "Fehler beim Erstellen der Sitzung", + "wizard.error.start.live": "Fehler beim Starten des Livestreams", + "wizard.error.tiebreak.points.required": "Gib die Tie-Break-Punkte ein", + "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.category.label": "Liga (optional)", + "wizard.match.custom.overlay.label": "Individuelles Video-Overlay", + "wizard.match.custom.rules.label": "Individuelle Punkteregeln", + "wizard.match.details.title": "Spieldetails", + "wizard.match.home.team.label": "Heimteam", + "wizard.match.location.label": "Ort", + "wizard.match.minutes.per.period.basket": "Minuten pro Viertel", + "wizard.match.minutes.per.period.timed": "Minuten pro Halbzeit", + "wizard.match.not.found": "Spiel nicht gefunden", + "wizard.match.overtime.minutes.label": "Verlängerungsminuten", + "wizard.match.periods.basket.label": "Viertel", + "wizard.match.periods.timed.label": "Halbzeiten", + "wizard.match.points.per.set.label": "Punkte zum Satzgewinn", + "wizard.match.rules.custom.sets": "Nicht standardmäßiges Turnier: Sätze und Punkte festlegen.", + "wizard.match.rules.custom.timed": "Nicht standardmäßiges Turnier: individuelle Zeiten und Perioden.", + "wizard.match.rules.standard.sets": "FIVB-Standard: 3 Gewinnsätze, Sätze bis 25, Tie-Break bis 15.", + "wizard.match.rules.standard.sport": "Standardregeln der gewählten Sportart.", + "wizard.match.scheduled.label": "Geplant für", + "wizard.match.sets.to.win.label": "Sätze zum Spielgewinn", + "wizard.match.tiebreak.points.label": "Tie-Break-Punkte (letzter Satz)", + "wizard.network.download.label": "Download", + "wizard.network.latency.label": "Latenz", + "wizard.network.link.live.label": "Live-Link", + "wizard.network.link.youtube.label": "YouTube-Link", + "wizard.network.quality.label": "Streaming-Qualität (automatisch)", + "wizard.network.ready.label": "BEREIT FÜR DEN LIVE-START", + "wizard.network.share.subject": "Live — %1$@ gegen %2$@", + "wizard.network.test.running.label": "TEST LÄUFT...", + "wizard.network.test.start.label": "NETZWERKTEST STARTEN", + "wizard.network.test.subtitle": "Prüfe, ob deine Verbindung den Live-Upload verkraftet.", + "wizard.network.test.title": "Netzwerktest", + "wizard.network.type.label": "Netzwerktyp", + "wizard.network.upload.label": "Upload", + "wizard.share.chooser.live": "Livestream teilen", + "wizard.share.chooser.regia": "Regie teilen", + "wizard.step.title.match": "01 · Spiel", + "wizard.step.title.network": "03 · Netzwerktest", + "wizard.step.title.transmission": "02 · Übertragung", + "wizard.transmission.backend.note": "Das Spiel bleibt für die gesamte Abo-Dauer im Team-Backend sichtbar.", + "wizard.transmission.plan.label": "Tarif %1$@", + "wizard.transmission.platform.site.subtitle": "Live auf unserer Website (inklusive)", + "wizard.transmission.platform.title": "Plattform", + "wizard.transmission.public.desc": "Erscheint in der Live-Liste auf MatchLiveTV.it, in der Suche und im YouTube-Kanal (falls ausgewählt).", + "wizard.transmission.public.label": "ÖFFENTLICH", + "wizard.transmission.unlisted.desc": "Erscheint nicht in öffentlichen Listen oder in der Suche. Nur mit dem Link kann zugesehen werden.", + "wizard.transmission.unlisted.label": "NICHT GELISTET", + "wizard.transmission.visibility.title": "Sichtbarkeit", + "wizard.transmission.youtube.activating": "Kanal wird aktiviert", + "wizard.transmission.youtube.badge": "Premium", + "wizard.transmission.youtube.premium.required": "Premium Light oder Full", + "youtube.channel.club": "Vereinskanal · %1$@", + "youtube.channel.club.fallback": "Verein", + "youtube.channel.platform": "Match Live TV-Kanal" + ], "es": [ - "language.label": "Idioma", - "language.system": "Sistema", - "action.logout": "Salir", - "action.login": "Acceder", "action.cancel": "Cancelar", + "action.delete": "Eliminar", + "action.exit": "Salir", + "action.login": "Acceder", + "action.logout": "Salir", + "action.ok": "OK", + "action.save": "Guardar", + "api.error.decoding": "Respuesta del servidor no válida", + "api.error.forbidden": "Operación no permitida", + "api.error.http": "Error HTTP %1$d", + "api.error.invalid.url": "URL no válida", + "api.error.not.found": "Recurso no encontrado", + "api.error.score.invalid": "Respuesta de marcador no válida", + "api.error.server": "Error del servidor. Inténtalo de nuevo en un momento.", + "api.error.unauthorized": "Sesión caducada. Vuelve a iniciar sesión.", + "app.name": "Match Live TV", + "app.slogan": "Cada partido, cada evento, para tus aficionados.", + "broadcast.close.set.button": "CERRAR SET", + "broadcast.error.camera.preview.not.ready": "Vista previa de cámara no lista", + "broadcast.error.permissions.camera.mic": "Se requieren permisos de cámara y micrófono", + "broadcast.error.reconnect.failed": "Reconexión fallida", + "broadcast.error.regia.link": "Error en el enlace de regie", + "broadcast.error.regia.link.invalid": "Enlace de regia no válido", + "broadcast.error.rtmp.connect.failed": "Conexión RTMP fallida", + "broadcast.error.rtmp.missing": "Falta la URL RTMP", + "broadcast.error.rtmp.session.create": "No se pudo crear la sesión RTMP", + "broadcast.error.rtmp.url.invalid": "URL RTMP no válida", + "broadcast.error.session.unavailable": "Sesión no disponible", + "broadcast.hide.controls.cd": "Ocultar controles", + "broadcast.next.period.cd": "Periodo siguiente", + "broadcast.pause.cd": "Pausar directo", + "broadcast.period.label.timed": "Tiempo %1$d", + "broadcast.permissions.grant.action": "CONCEDER PERMISOS", + "broadcast.permissions.required": "Permite la cámara y el micrófono para salir en directo", + "broadcast.points.target": "%1$d pts", + "broadcast.resume.cd": "Reanudar directo", + "broadcast.scoreboard.connected": "Marcador OK", + "broadcast.scoreboard.offline": "Marcador sin conexión", + "broadcast.set.progress": "Set %1$d · %2$d pts", + "broadcast.sets.won": "Sets ganados %1$d-%2$d", + "broadcast.share.chooser.title": "Compartir", + "broadcast.share.link.unavailable": "Enlace del directo aún no disponible", + "broadcast.share.live.cd": "Compartir directo", + "broadcast.share.regia.cd": "Compartir enlace de regie", + "broadcast.share.regia.subject": "Enlace de regie — %1$@", + "broadcast.share.subject.fallback": "Directo de Match Live TV", + "broadcast.show.controls.cd": "Mostrar controles", + "broadcast.side.away": "visitante", + "broadcast.side.home": "local", + "broadcast.snackbar.closed.remote": "Directo cerrado desde regie", + "broadcast.snackbar.pause.error": "Pausa: %1$@", + "broadcast.snackbar.paused": "Directo en pausa", + "broadcast.snackbar.paused.remote": "Pausado desde regie", + "broadcast.snackbar.reconnect.started": "Reconexión iniciada", + "broadcast.snackbar.resume.error": "Reanudación: %1$@", + "broadcast.snackbar.resume.rtmp.error": "Reanudación RTMP: %1$@", + "broadcast.snackbar.resumed": "Directo reanudado", + "broadcast.snackbar.resumed.remote": "Reanudado desde regie", + "broadcast.status.connecting": "CONECTANDO…", + "broadcast.status.error.fallback": "ERROR", + "broadcast.status.live": "EN DIRECTO", + "broadcast.status.paused": "PAUSA", + "broadcast.status.preview": "VISTA PREVIA", + "broadcast.status.reconnecting": "RECONECTANDO…", + "broadcast.team.away.label": "VISITANTE", + "broadcast.team.home.label": "LOCAL", + "broadcast.team.sets.count": "%1$d sets", + "broadcast.terminate.cd": "Terminar directo", + "broadcast.terminate.confirm": "TERMINAR", + "broadcast.terminate.message": "El streaming se cerrará para todos los espectadores.", + "broadcast.terminate.title": "¿Terminar el directo?", + "broadcast.tooltip.add.point": "Añadir punto %1$@", + "broadcast.tooltip.plus.side": "+%1$d %2$@", + "broadcast.tooltip.remove.point": "Quitar punto %1$@", + "common.close": "Cerrar", + "common.error.generic": "error", + "common.error.title": "Error", + "error.operation.failed": "Operación fallida", + "error.rtmp.failed": "Conexión RTMP fallida. Comprueba la red y el servidor de streaming.", + "error.rtmp.failed.dev": "Conexión RTMP fallida. Comprueba que MediaMTX sea alcanzable (en dev: puerto 1935 en localhost).", + "error.rtmp.interrupted": "Conexión RTMP interrumpida", + "error.rtmp.invalid.url": "URL RTMP no válida. Comprueba la dirección de ingest del servidor.", + "error.rtmp.timeout": "Tiempo de espera RTMP. Comprueba la red y MediaMTX (puerto 1935).", + "error.rtmp.unreachable": "Servidor RTMP no alcanzable. En local usa localhost:1935 (no el host Docker «mediamtx»).", + "language.english": "English", + "language.french": "Français", + "language.german": "Deutsch", + "language.italian": "Italiano", + "language.label": "Idioma", + "language.spanish": "Español", + "language.system": "Sistema", "login.email": "Email", + "login.email.placeholder": "coach@equipo.com", + "login.error.credentials": "Email o contraseña incorrectos", + "login.error.generic": "Error de acceso", + "login.error.unreachable": "Servidor no disponible. Comprueba la conexión.", "login.password": "Contraseña", "login.submit": "Acceder", - "login.error.credentials": "Email o contraseña incorrectos", - "login.error.unreachable": "Servidor no disponible. Comprueba la conexión.", - "login.error.generic": "Error de acceso", + "match.status.live": "EN DIRECTO", + "match.status.resume": "REANUDAR", + "match.status.scheduled": "PROGRAMADO", + "match.status.start": "INICIAR", + "matches.active.team": "Equipo activo: %1$@.", + "matches.change": "Cambiar", + "matches.empty.hint": "Programa un partido o inicia uno nuevo con «Nuevo partido».", + "matches.empty.title": "Ningún partido en el calendario", + "matches.hello": "Hola, %1$@", + "matches.load.error": "Error de carga", + "matches.msg.create.failed": "No se pudo crear el partido", + "matches.msg.delete.failed": "No se pudo eliminar", + "matches.msg.deleted": "Partido eliminado", + "matches.msg.load.error": "Error de carga", + "matches.msg.resume.failed": "No se pudo reanudar el directo", + "matches.msg.schedule.error": "Error al crear el partido", + "matches.msg.scheduled.ok": "Partido programado — visible en el sitio", + "matches.multi.team.hint": "Tienes varios equipos: comprueba el seleccionado arriba.", + "matches.new": "Nuevo partido", + "matches.no.other": "Ningún otro partido en el calendario.", + "matches.no.team.body": "Para emitir debes ser responsable de transmisión de al menos un equipo. Pide a tu club que te añada como staff de streaming.", + "matches.no.team.title": "Ningún equipo asignado", + "matches.ready.title": "Listos para empezar", + "matches.retry": "Reintentar", + "matches.schedule": "Partido programado", + "matches.scheduled.title": "Partidos programados", + "matches.subtitle": "Reanuda un directo en curso o inicia un partido programado.", + "matches.tap.change.team": "Toca para cambiar de equipo", + "matches.team.for.live": "Equipo para el directo", "matches.title": "Partidos", - "app.slogan": "Cada partido, cada evento, para tus aficionados.", - ], + "network.type.cellular": "4G", + "network.type.ethernet": "Ethernet", + "network.type.unknown": "Desconocido", + "network.type.wifi": "WiFi", + "overlay.set.label": "Set %1$d", + "score.action.close.anyway": "Cerrar de todas formas", + "score.action.close.live": "Cerrar directo", + "score.action.close.set": "Cerrar set", + "score.action.continue.live": "Seguir en directo", + "score.action.continue.scoring": "Seguir puntuando", + "score.close.set.anyway.message": "El marcador aún no cumple las reglas del torneo. ¿Cerrar el set de todas formas?", + "score.match.won.message": "%1$@ gana el partido (%2$d-%3$d sets).\\n\\n¿Cerrar definitivamente el directo?", + "score.match.won.title": "Partido finalizado", + "score.set.won.message": "%1$@ gana el set %2$d-%3$d.\\n\\n¿Cerrar el set y pasar al siguiente?", + "score.set.won.title": "Set finalizado", + "session.error.no.active": "Ninguna sesión activa", + "session.error.not.connected": "Sesión no conectada", + "sheet.badge.calendar": "EN CALENDARIO", + "sheet.badge.draft": "BORRADOR", + "sheet.badge.scheduled": "PROGRAMADO", + "sheet.choose.match": "Elegir partido", + "sheet.choose.match.lead": "Partidos ya programados en el sitio o en la app.", + "sheet.choose.team": "Elegir equipo", + "sheet.configure": "Configurar", + "sheet.configure.now.body": "Puedes preparar la emisión ahora o volver cuando estés en el pabellón.", + "sheet.configure.now.title": "¿Configurar ahora?", + "sheet.continue.setup": "CONTINUAR CONFIGURACIÓN", + "sheet.date.time": "Fecha y hora", + "sheet.delete": "Eliminar", + "sheet.delete.match.body": "¿Eliminar «%1$@ vs %2$@»?\n\nEsta acción no se puede deshacer.", + "sheet.delete.match.title": "Eliminar partido", + "sheet.later": "Más tarde", + "sheet.live.in.progress": "Directo en curso", + "sheet.location.optional": "Lugar (opcional)", + "sheet.new.match.lead": "Programa con antelación o inicia ya la configuración del directo.", + "sheet.new.match.title": "Nuevo partido", + "sheet.opponent": "Rival", + "sheet.quick.option": "Empezar ahora", + "sheet.quick.option.sub": "Crea el partido y abre el asistente sin horario", + "sheet.resume.banner": "Reanudar directo en curso", + "sheet.resume.camera": "REANUDAR CÁMARA Y EMITIR", + "sheet.save.schedule": "GUARDAR EN EL CALENDARIO", + "sheet.schedule.lead": "El directo no empieza ahora: aparecerá en el sitio con fecha y hora. Iniciarás el streaming desde la app cuando estés en el pabellón.", + "sheet.schedule.option": "Programar partido", + "sheet.schedule.option.sub": "Fecha, hora y rival — también visible en el sitio", + "sheet.schedule.title": "Programar partido", + "sheet.section.scheduled": "Programados", + "sheet.section.unscheduled": "Sin horario", + "sheet.status": "Estado: %1$@", + "thermal.alert.continue": "Continuar", + "thermal.alert.message": "El dispositivo lleva más de un minuto muy caliente. iOS podría cerrar la app para protegerlo. Considera interrumpir la directa.", + "thermal.alert.stop": "Interrumpir directa", + "thermal.alert.title": "Sobrecalentamiento", + "thermal.notice.critical": "Sobrecalentamiento inminente: calidad reducida aún más.", + "thermal.notice.serious": "El dispositivo se está calentando. Se redujo la calidad del streaming para mantener la estabilidad.", + "thermal.state.critical": "Riesgo de sobrecalentamiento", + "thermal.state.fair": "Temperatura alta", + "thermal.state.nominal": "Temperatura OK", + "thermal.state.serious": "Dispositivo caliente", + "wizard.action.back": "Atrás", + "wizard.action.copy": "COPIAR", + "wizard.action.next": "SIGUIENTE >", + "wizard.action.share": "COMPARTIR", + "wizard.action.share.regia.link": "COMPARTIR ENLACE DE REGIE", + "wizard.action.start": "EMPEZAR >", + "wizard.branding.cancel": "CANCELAR", + "wizard.branding.color.label": "Color del equipo", + "wizard.branding.customize.title": "Personalizar", + "wizard.branding.edit.team.cd": "Editar equipo", + "wizard.branding.logo.label": "Logo", + "wizard.branding.no.logo": "Sin logo", + "wizard.branding.opponent.name.placeholder": "Nombre del rival", + "wizard.branding.remove.logo": "QUITAR", + "wizard.branding.save": "GUARDAR", + "wizard.branding.team.fallback.name": "Equipo", + "wizard.branding.team.name.label": "Nombre del equipo", + "wizard.branding.upload.logo": "SUBIR LOGO", + "wizard.color.hue": "Tono", + "wizard.complete.transmission.step": "Completa el paso Transmisión", + "wizard.error.opponent.name.required": "Introduce el nombre del rival", + "wizard.error.overtime.duration.required": "Introduce la duración de la prórroga en minutos", + "wizard.error.period.duration.required": "Introduce la duración del periodo en minutos", + "wizard.error.points.per.set.required": "Introduce los puntos para ganar un set", + "wizard.error.regia.link": "Error en el enlace de regie", + "wizard.error.save.generic": "Error al guardar", + "wizard.error.session.create": "Error al crear la sesión", + "wizard.error.start.live": "Error al iniciar el directo", + "wizard.error.tiebreak.points.required": "Introduce los puntos del tie-break", + "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.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", + "wizard.match.details.title": "Detalles del partido", + "wizard.match.home.team.label": "Equipo local", + "wizard.match.location.label": "Lugar", + "wizard.match.minutes.per.period.basket": "Minutos por cuarto", + "wizard.match.minutes.per.period.timed": "Minutos por tiempo", + "wizard.match.not.found": "Partido no encontrado", + "wizard.match.overtime.minutes.label": "Minutos de prórroga", + "wizard.match.periods.basket.label": "Cuartos", + "wizard.match.periods.timed.label": "Tiempos", + "wizard.match.points.per.set.label": "Puntos para ganar un set", + "wizard.match.rules.custom.sets": "Torneo no estándar: define sets y puntos.", + "wizard.match.rules.custom.timed": "Torneo no estándar: tiempos y periodos personalizados.", + "wizard.match.rules.standard.sets": "Estándar FIVB: 3 sets para ganar, sets a 25, tie-break a 15.", + "wizard.match.rules.standard.sport": "Reglas estándar del deporte seleccionado.", + "wizard.match.scheduled.label": "Programado para", + "wizard.match.sets.to.win.label": "Sets para ganar el partido", + "wizard.match.tiebreak.points.label": "Puntos del tie-break (último set)", + "wizard.network.download.label": "Descarga", + "wizard.network.latency.label": "Latencia", + "wizard.network.link.live.label": "Enlace del directo", + "wizard.network.link.youtube.label": "Enlace de YouTube", + "wizard.network.quality.label": "Calidad de streaming (automática)", + "wizard.network.ready.label": "LISTO PARA EMITIR EN DIRECTO", + "wizard.network.share.subject": "Directo — %1$@ vs %2$@", + "wizard.network.test.running.label": "TEST EN CURSO...", + "wizard.network.test.start.label": "INICIAR TEST DE RED", + "wizard.network.test.subtitle": "Comprueba que tu conexión soporta la subida del directo.", + "wizard.network.test.title": "Test de red", + "wizard.network.type.label": "Tipo de red", + "wizard.network.upload.label": "Subida", + "wizard.share.chooser.live": "Compartir directo", + "wizard.share.chooser.regia": "Compartir regie", + "wizard.step.title.match": "01 · Partido", + "wizard.step.title.network": "03 · Test de red", + "wizard.step.title.transmission": "02 · Transmisión", + "wizard.transmission.backend.note": "El partido permanece siempre visible en el backend del equipo durante toda la suscripción.", + "wizard.transmission.plan.label": "Plan %1$@", + "wizard.transmission.platform.site.subtitle": "En directo en nuestro sitio (incluido)", + "wizard.transmission.platform.title": "Plataforma", + "wizard.transmission.public.desc": "Aparece en la lista de directos de MatchLiveTV.it, en las búsquedas y en el canal de YouTube (si se selecciona).", + "wizard.transmission.public.label": "PÚBLICO", + "wizard.transmission.unlisted.desc": "No aparece en listas públicas ni en búsquedas. Solo puede verlo quien tenga el enlace.", + "wizard.transmission.unlisted.label": "NO LISTADO", + "wizard.transmission.visibility.title": "Visibilidad", + "wizard.transmission.youtube.activating": "Canal en activación", + "wizard.transmission.youtube.badge": "Premium", + "wizard.transmission.youtube.premium.required": "Premium Light o Full", + "youtube.channel.club": "Canal del club · %1$@", + "youtube.channel.club.fallback": "club", + "youtube.channel.platform": "Canal Match Live TV" + ] ] } @@ -179,3 +1479,4 @@ struct LanguagePickerView: View { } } } + diff --git a/native/ios/MatchLiveTv/Core/DeviceTelemetry.swift b/native/ios/MatchLiveTv/Core/DeviceTelemetry.swift index 7ee7529..eaf1e0e 100644 --- a/native/ios/MatchLiveTv/Core/DeviceTelemetry.swift +++ b/native/ios/MatchLiveTv/Core/DeviceTelemetry.swift @@ -4,48 +4,33 @@ import Network struct DeviceHealth: Sendable { let batteryPercent: Int - let batteryTempC: Double? - let thermalLevel: Int - let thermalLabel: String + let thermalState: ThermalState let networkType: String } enum DeviceTelemetry { - static func snapshot() -> DeviceHealth { + static func snapshot(thermalState: ThermalState? = nil) -> DeviceHealth { UIDevice.current.isBatteryMonitoringEnabled = true let level = Int((UIDevice.current.batteryLevel >= 0 ? UIDevice.current.batteryLevel : 1) * 100) - let thermal = ProcessInfo.processInfo.thermalState - let (thermalLevel, label) = thermalInfo(thermal) + let thermal = thermalState ?? ThermalState.from(ProcessInfo.processInfo.thermalState) return DeviceHealth( batteryPercent: level, - batteryTempC: nil, - thermalLevel: thermalLevel, - thermalLabel: label, + thermalState: thermal, networkType: currentNetworkType() ) } - private static func thermalInfo(_ state: ProcessInfo.ThermalState) -> (Int, String) { - switch state { - case .nominal: return (0, "OK") - case .fair: return (1, "Caldo") - case .serious: return (2, "Surriscaldamento") - case .critical: return (3, "Troppo caldo") - @unknown default: return (0, "OK") - } - } - private static func currentNetworkType() -> String { let monitor = NWPathMonitor() let semaphore = DispatchSemaphore(value: 0) - var result = "Sconosciuto" + var result = L10n.t("network.type.unknown") monitor.pathUpdateHandler = { path in if path.usesInterfaceType(.wifi) { - result = "WiFi" + result = L10n.t("network.type.wifi") } else if path.usesInterfaceType(.cellular) { - result = "4G" + result = L10n.t("network.type.cellular") } else if path.usesInterfaceType(.wiredEthernet) { - result = "Ethernet" + result = L10n.t("network.type.ethernet") } semaphore.signal() } diff --git a/native/ios/MatchLiveTv/Core/Thermal/ThermalState.swift b/native/ios/MatchLiveTv/Core/Thermal/ThermalState.swift new file mode 100644 index 0000000..5b9d2b8 --- /dev/null +++ b/native/ios/MatchLiveTv/Core/Thermal/ThermalState.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Stato termico iOS (`ProcessInfo.ThermalState`). Nessuna temperatura in gradi — solo livelli di sistema. +enum ThermalState: Int, Sendable, Equatable, Comparable { + case nominal = 0 + case fair = 1 + case serious = 2 + case critical = 3 + + static func from(_ state: ProcessInfo.ThermalState) -> ThermalState { + switch state { + case .nominal: return .nominal + case .fair: return .fair + case .serious: return .serious + case .critical: return .critical + @unknown default: return .nominal + } + } + + static func < (lhs: ThermalState, rhs: ThermalState) -> Bool { + lhs.rawValue < rhs.rawValue + } + + /// Etichetta compatta per l'overlay telemetry (angolo alto destro). + var displayLabel: String { + switch self { + case .nominal: return L10n.t("thermal.state.nominal") + case .fair: return L10n.t("thermal.state.fair") + case .serious: return L10n.t("thermal.state.serious") + case .critical: return L10n.t("thermal.state.critical") + } + } + + var indicatorSymbol: String { + switch self { + case .nominal: return "🟢" + case .fair: return "🟡" + case .serious: return "🟠" + case .critical: return "🔴" + } + } +} diff --git a/native/ios/MatchLiveTv/Core/Thermal/ThermalStateManager.swift b/native/ios/MatchLiveTv/Core/Thermal/ThermalStateManager.swift new file mode 100644 index 0000000..a408589 --- /dev/null +++ b/native/ios/MatchLiveTv/Core/Thermal/ThermalStateManager.swift @@ -0,0 +1,161 @@ +import Foundation +import os.log + +/// Monitor termico riutilizzabile: osserva `ProcessInfo.thermalState`, espone stato alla UI +/// e degrada progressivamente bitrate/FPS dello stream quando necessario. +/// +/// Politica adottata: +/// - `.nominal` / `.fair`: nessuna modifica automatica al profilo video (solo indicatore UI). +/// - `.serious`: −25% bitrate, FPS max 24. +/// - `.critical`: −50% bitrate, FPS max 20; alert esplicito se persiste > 60s. +@MainActor +final class ThermalStateManager: ObservableObject { + @Published private(set) var state: ThermalState = .from(ProcessInfo.processInfo.thermalState) + @Published var noticeMessage: String? + @Published var showCriticalAlert = false + + private let log = Logger(subsystem: "com.matchlivetv.match-live-tv", category: "Thermal") + private weak var engine: LiveBroadcastEngine? + private var observer: (any NSObjectProtocol)? + private var previousState: ThermalState? + private var baselineConfig: BroadcastConfig? + private var criticalSince: Date? + private var criticalWatchTask: Task? + private var lastAppliedThermal: ThermalState? + + func bind(engine: LiveBroadcastEngine) { + self.engine = engine + } + + func updateBaseline(_ config: BroadcastConfig) { + baselineConfig = config + Task { await applyAdaptationIfNeeded(for: state, force: true) } + } + + func start() { + guard observer == nil else { return } + let current = ThermalState.from(ProcessInfo.processInfo.thermalState) + previousState = current + state = current + observer = NotificationCenter.default.addObserver( + forName: ProcessInfo.thermalStateDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.handleThermalChange() + } + } + logTransition(from: nil, to: current) + Task { await applyAdaptationIfNeeded(for: current, force: true) } + updateCriticalWatch() + } + + func stop() { + if let observer { + NotificationCenter.default.removeObserver(observer) + self.observer = nil + } + criticalWatchTask?.cancel() + criticalWatchTask = nil + criticalSince = nil + showCriticalAlert = false + noticeMessage = nil + previousState = nil + lastAppliedThermal = nil + } + + func acknowledgeCriticalAlert() { + showCriticalAlert = false + } + + private func handleThermalChange() { + let next = ThermalState.from(ProcessInfo.processInfo.thermalState) + guard next != state else { return } + let previous = state + state = next + logTransition(from: previous, to: next) + previousState = previous + noticeMessage = userNotice(for: next, previous: previous) + Task { await applyAdaptationIfNeeded(for: next, force: false) } + updateCriticalWatch() + } + + private func logTransition(from previous: ThermalState?, to new: ThermalState) { + let fromLabel = previous.map { String(describing: $0) } ?? "—" + log.info("[Thermal] \(fromLabel, privacy: .public) → \(String(describing: new), privacy: .public)") + print("[Thermal] \(fromLabel) → \(new)") + } + + private func userNotice(for new: ThermalState, previous: ThermalState?) -> String? { + switch new { + case .nominal, .fair: + return nil + case .serious where previous ?? .nominal < .serious: + return L10n.t("thermal.notice.serious") + case .critical where previous ?? .nominal < .critical: + return L10n.t("thermal.notice.critical") + default: + return nil + } + } + + private func updateCriticalWatch() { + criticalWatchTask?.cancel() + if state == .critical { + if criticalSince == nil { criticalSince = Date() } + criticalWatchTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 1_000_000_000) + guard state == .critical, let since = criticalSince else { return } + if Date().timeIntervalSince(since) >= 60 { + showCriticalAlert = true + return + } + } + } + } else { + criticalSince = nil + showCriticalAlert = false + } + } + + private func applyAdaptationIfNeeded(for thermal: ThermalState, force: Bool) async { + guard let baseline = baselineConfig, let engine else { return } + if !force, lastAppliedThermal == thermal { return } + let adapted = Self.adaptedConfig(from: baseline, for: thermal) + let previousApplied = lastAppliedThermal.map { Self.adaptedConfig(from: baseline, for: $0) } ?? baseline + await engine.applyThermalProfile( + videoBitrate: adapted.videoBitrate, + fps: adapted.fps + ) + lastAppliedThermal = thermal + if adapted.videoBitrate != previousApplied.videoBitrate { + let fromKbps = previousApplied.videoBitrate / 1000 + let toKbps = adapted.videoBitrate / 1000 + log.info("[Thermal] bitrate changed from \(fromKbps) kbps to \(toKbps) kbps") + print("[Thermal] bitrate changed from \(fromKbps) kbps to \(toKbps) kbps") + } + if adapted.fps != previousApplied.fps { + log.info("[Thermal] fps changed from \(previousApplied.fps) to \(adapted.fps)") + print("[Thermal] fps changed from \(previousApplied.fps) to \(adapted.fps)") + } + } + + static func adaptedConfig(from baseline: BroadcastConfig, for thermal: ThermalState) -> BroadcastConfig { + switch thermal { + case .nominal, .fair: + return baseline + case .serious: + var cfg = baseline + cfg.videoBitrate = Int(Double(baseline.videoBitrate) * 0.75) + cfg.fps = min(baseline.fps, 24) + return cfg + case .critical: + var cfg = baseline + cfg.videoBitrate = Int(Double(baseline.videoBitrate) * 0.50) + cfg.fps = min(baseline.fps, 20) + return cfg + } + } +} diff --git a/native/ios/MatchLiveTv/Core/UserFacingError.swift b/native/ios/MatchLiveTv/Core/UserFacingError.swift index 39bd98d..7026401 100644 --- a/native/ios/MatchLiveTv/Core/UserFacingError.swift +++ b/native/ios/MatchLiveTv/Core/UserFacingError.swift @@ -8,7 +8,8 @@ enum UserFacingError { return ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled } - static func message(for error: Error, fallback: String = "Operazione non riuscita") -> String? { + static func message(for error: Error, fallback: String? = nil) -> String? { + let resolvedFallback = fallback ?? L10n.t("error.operation.failed") if isCancellation(error) { return nil } if let rtmp = rtmpMessage(for: error) { return rtmp } if let api = error as? LocalizedError, let description = api.errorDescription, !description.isEmpty { @@ -16,9 +17,9 @@ enum UserFacingError { } let text = error.localizedDescription if text.contains("RTMPConnection.Error") || text.contains("RTMPHaishinKit") { - return "Connessione RTMP fallita. Verifica che MediaMTX sia raggiungibile (in dev: porta 1935 su localhost)." + return L10n.t("error.rtmp.failed.dev") } - return text.isEmpty ? fallback : text + return text.isEmpty ? resolvedFallback : text } private static func rtmpMessage(for error: Error) -> String? { @@ -26,14 +27,14 @@ enum UserFacingError { let localized = error.localizedDescription guard text.contains("RTMPConnection") || localized.contains("RTMPConnection") else { return nil } if localized.contains("unsupportedCommand") || text.contains("unsupportedCommand") { - return "URL RTMP non valido. Controlla l'indirizzo di ingest dal server." + return L10n.t("error.rtmp.invalid.url") } if localized.contains("connectionTimedOut") || text.contains("connectionTimedOut") { - return "Timeout connessione RTMP. Verifica rete e server MediaMTX (porta 1935)." + return L10n.t("error.rtmp.timeout") } if localized.contains("socketError") || text.contains("socketError") { - return "Server RTMP non raggiungibile. In dev locale usa localhost:1935 (non l'host Docker «mediamtx»)." + return L10n.t("error.rtmp.unreachable") } - return "Connessione RTMP fallita. Verifica rete e server streaming." + return L10n.t("error.rtmp.failed") } } diff --git a/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift b/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift index 443f196..56052ea 100644 --- a/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift +++ b/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift @@ -8,10 +8,10 @@ enum APIError: LocalizedError { var errorDescription: String? { switch self { - case .invalidURL: return "URL non valido" + case .invalidURL: return L10n.t("api.error.invalid.url") case .http(let code, let body): return Self.friendlyHttpMessage(code: code, body: body) - case .decoding: return "Risposta del server non valida" - case .unauthorized: return "Sessione scaduta. Accedi di nuovo." + case .decoding: return L10n.t("api.error.decoding") + case .unauthorized: return L10n.t("api.error.unauthorized") } } @@ -23,7 +23,7 @@ enum APIError: LocalizedError { return message } if let error = json["error"] as? String, !error.isEmpty, !error.hasPrefix("{") { - if code >= 500 { return "Errore del server. Riprova tra qualche istante." } + if code >= 500 { return L10n.t("api.error.server") } return error } } @@ -31,10 +31,10 @@ enum APIError: LocalizedError { return body } switch code { - case 500, 502, 503: return "Errore del server. Riprova tra qualche istante." - case 404: return "Risorsa non trovata" - case 403: return "Operazione non consentita" - default: return "Errore HTTP \(code)" + case 500, 502, 503: return L10n.t("api.error.server") + case 404: return L10n.t("api.error.not.found") + case 403: return L10n.t("api.error.forbidden") + default: return L10n.t("api.error.http", code) } } } diff --git a/native/ios/MatchLiveTv/Data/Repository/MatchRepository.swift b/native/ios/MatchLiveTv/Data/Repository/MatchRepository.swift index 7c2373f..c8c77be 100644 --- a/native/ios/MatchLiveTv/Data/Repository/MatchRepository.swift +++ b/native/ios/MatchLiveTv/Data/Repository/MatchRepository.swift @@ -54,7 +54,7 @@ final class MatchRepository { func createQuickMatch(teamId: String) async throws -> Match { try await api.createMatch( teamId: teamId, - body: CreateMatchRequest(match: CreateMatchBody(opponentName: "Avversario", sport: "volleyball", setsToWin: 3, scheduledAt: nil, location: nil)) + body: CreateMatchRequest(match: CreateMatchBody(opponentName: L10n.t("sheet.opponent"), sport: "volleyball", setsToWin: 3, scheduledAt: nil, location: nil)) ).toDomain() } diff --git a/native/ios/MatchLiveTv/Data/Repository/MatchSessionLauncher.swift b/native/ios/MatchLiveTv/Data/Repository/MatchSessionLauncher.swift index 5e2e493..7cdcf98 100644 --- a/native/ios/MatchLiveTv/Data/Repository/MatchSessionLauncher.swift +++ b/native/ios/MatchLiveTv/Data/Repository/MatchSessionLauncher.swift @@ -7,7 +7,7 @@ enum MatchSessionLauncher { sessionRepository: SessionRepository ) async throws -> String { guard let sessionId = match.activeSessionId else { - throw APIError.http(400, "Nessuna sessione attiva") + throw APIError.http(400, L10n.t("session.error.no.active")) } _ = try await sessionRepository.fetchSession(id: sessionId) return sessionId diff --git a/native/ios/MatchLiveTv/Data/Scoring/ScoreController.swift b/native/ios/MatchLiveTv/Data/Scoring/ScoreController.swift index efdccdb..1425cfa 100644 --- a/native/ios/MatchLiveTv/Data/Scoring/ScoreController.swift +++ b/native/ios/MatchLiveTv/Data/Scoring/ScoreController.swift @@ -92,7 +92,7 @@ final class ScoreController: ObservableObject { syncGeneration += 1 guard let sessionId else { score = previous - lastActionError = "Sessione non collegata" + lastActionError = L10n.t("session.error.not.connected") return false } do { @@ -103,7 +103,7 @@ final class ScoreController: ObservableObject { return true } score = previous - lastActionError = "Risposta punteggio non valida" + lastActionError = L10n.t("api.error.score.invalid") return false } catch { score = previous @@ -223,7 +223,7 @@ final class ScoreController: ObservableObject { sessionCable.sendScoreUpdate(score) } else { guard generation == syncGeneration else { return } - lastActionError = "Risposta punteggio non valida" + lastActionError = L10n.t("api.error.score.invalid") } } catch { guard generation == syncGeneration else { return } diff --git a/native/ios/MatchLiveTv/Domain/MatchHubFilter.swift b/native/ios/MatchLiveTv/Domain/MatchHubFilter.swift index 0a5851b..ce9d2ca 100644 --- a/native/ios/MatchLiveTv/Domain/MatchHubFilter.swift +++ b/native/ios/MatchLiveTv/Domain/MatchHubFilter.swift @@ -35,9 +35,9 @@ enum MatchHubFilter { enum MatchPresentation { static func statusLabel(for match: Match) -> String { - if match.canResumeCamera { return "RIPRENDI" } - if match.hasActiveSession { return "IN CORSO" } - if ApiInstant.isScheduledFuture(match.scheduledAt) { return "PROGRAMMATA" } - return "AVVIA" + if match.canResumeCamera { return L10n.t("match.status.resume") } + if match.hasActiveSession { return L10n.t("match.status.live") } + if ApiInstant.isScheduledFuture(match.scheduledAt) { return L10n.t("match.status.scheduled") } + return L10n.t("match.status.start") } } diff --git a/native/ios/MatchLiveTv/Domain/Models.swift b/native/ios/MatchLiveTv/Domain/Models.swift index 0096469..1997a6b 100644 --- a/native/ios/MatchLiveTv/Domain/Models.swift +++ b/native/ios/MatchLiveTv/Domain/Models.swift @@ -54,11 +54,11 @@ struct Team: Identifiable, Equatable, Sendable { } var youtubeDestinationLabel: String { - if youtubeUsesPlatformChannel { return "Canale Match Live TV" } + if youtubeUsesPlatformChannel { return L10n.t("youtube.channel.platform") } let name = youtubeTeamChannelTitle?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? clubName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - ?? "società" - return "Canale società · \(name)" + ?? L10n.t("youtube.channel.club.fallback") + return L10n.t("youtube.channel.club", name) } } diff --git a/native/ios/MatchLiveTv/Resources/Info.plist b/native/ios/MatchLiveTv/Resources/Info.plist index 9c89af2..1bcc2d0 100644 --- a/native/ios/MatchLiveTv/Resources/Info.plist +++ b/native/ios/MatchLiveTv/Resources/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.0.0 + 2.0.5 CFBundleURLTypes @@ -32,7 +32,7 @@ CFBundleVersion - 21 + 26 LSRequiresIPhoneOS NSCameraUsageDescription diff --git a/native/ios/MatchLiveTv/Resources/de.lproj/InfoPlist.strings b/native/ios/MatchLiveTv/Resources/de.lproj/InfoPlist.strings new file mode 100644 index 0000000..9523b12 --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/de.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +"NSCameraUsageDescription" = "Match Live TV nutzt die Kamera für Live-Übertragungen von Spielen."; +"NSMicrophoneUsageDescription" = "Match Live TV nutzt das Mikrofon für den Live-Ton."; +"NSPhotoLibraryUsageDescription" = "Match Live TV nutzt Fotos für Team-Logos."; diff --git a/native/ios/MatchLiveTv/Resources/en.lproj/InfoPlist.strings b/native/ios/MatchLiveTv/Resources/en.lproj/InfoPlist.strings new file mode 100644 index 0000000..395782f --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/en.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +"NSCameraUsageDescription" = "Match Live TV uses the camera to broadcast live matches."; +"NSMicrophoneUsageDescription" = "Match Live TV uses the microphone for live audio."; +"NSPhotoLibraryUsageDescription" = "Match Live TV uses photos for team logos."; diff --git a/native/ios/MatchLiveTv/Resources/es.lproj/InfoPlist.strings b/native/ios/MatchLiveTv/Resources/es.lproj/InfoPlist.strings new file mode 100644 index 0000000..27b5bdc --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/es.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +"NSCameraUsageDescription" = "Match Live TV usa la cámara para transmitir partidos en directo."; +"NSMicrophoneUsageDescription" = "Match Live TV usa el micrófono para el audio de la directa."; +"NSPhotoLibraryUsageDescription" = "Match Live TV usa fotos para los logos de los equipos."; diff --git a/native/ios/MatchLiveTv/Resources/fr.lproj/InfoPlist.strings b/native/ios/MatchLiveTv/Resources/fr.lproj/InfoPlist.strings new file mode 100644 index 0000000..b334bb2 --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/fr.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +"NSCameraUsageDescription" = "Match Live TV utilise la caméra pour diffuser les matchs en direct."; +"NSMicrophoneUsageDescription" = "Match Live TV utilise le micro pour l'audio du direct."; +"NSPhotoLibraryUsageDescription" = "Match Live TV utilise les photos pour les logos d'équipe."; diff --git a/native/ios/MatchLiveTv/Resources/it.lproj/InfoPlist.strings b/native/ios/MatchLiveTv/Resources/it.lproj/InfoPlist.strings new file mode 100644 index 0000000..3b8ab41 --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/it.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +"NSCameraUsageDescription" = "Match Live TV usa la fotocamera per trasmettere le partite in diretta."; +"NSMicrophoneUsageDescription" = "Match Live TV usa il microfono per l'audio della diretta."; +"NSPhotoLibraryUsageDescription" = "Match Live TV usa le foto per i loghi delle squadre."; diff --git a/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift b/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift index 3db8384..0887508 100644 --- a/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift +++ b/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift @@ -179,10 +179,22 @@ final class LiveBroadcastEngine: ObservableObject { await stopBroadcast() } + /// Adattamento termico: aggiorna profilo video senza interrompere la sessione RTMP attiva. + func applyThermalProfile(videoBitrate: Int, fps: Int) async { + guard var current = config else { return } + guard current.videoBitrate != videoBitrate || current.fps != fps else { return } + current.videoBitrate = videoBitrate + current.fps = fps + config = current + if pipelineConfigured, let stream = await rtmpSession?.stream { + try? await applyCodecSettings(to: stream, config: current) + } + } + private func configurePipeline(_ config: BroadcastConfig) async throws { guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized, AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { - throw APIError.http(403, "Permessi camera e microfono richiesti") + throw APIError.http(403, L10n.t("broadcast.error.permissions.camera.mic")) } if pipelineConfigured { await attachPreviewIfNeeded() @@ -252,7 +264,7 @@ final class LiveBroadcastEngine: ObservableObject { guard previewAttached, generation == broadcastGeneration else { if generation == broadcastGeneration { publishPending = false - setPhase(.error, error: "Anteprima camera non pronta") + setPhase(.error, error: L10n.t("broadcast.error.camera.preview.not.ready")) } return } @@ -274,14 +286,14 @@ final class LiveBroadcastEngine: ObservableObject { scheduleReconnect() return } - let message = UserFacingError.message(for: error) ?? "Connessione RTMP fallita" + let message = UserFacingError.message(for: error) ?? L10n.t("broadcast.error.rtmp.connect.failed") setPhase(.error, error: message) } } private func publish(config: BroadcastConfig, generation: Int) async throws { guard let url = URL(string: config.rtmpUrl) else { - throw APIError.http(400, "URL RTMP non valido") + throw APIError.http(400, L10n.t("broadcast.error.rtmp.url.invalid")) } await Self.ensureRTMPFactoryRegistered() await applyVideoOrientationIfNeeded(force: true) @@ -291,7 +303,7 @@ final class LiveBroadcastEngine: ObservableObject { .setMode(.publish) .build() guard let session else { - throw APIError.http(500, "Impossibile creare sessione RTMP") + throw APIError.http(500, L10n.t("broadcast.error.rtmp.session.create")) } let stream = await session.stream @@ -395,7 +407,7 @@ final class LiveBroadcastEngine: ObservableObject { reconnectAttempts += 1 if reconnectAttempts > config.maxReconnectAttempts { reconnectTask = nil - setPhase(.error, error: "Connessione RTMP interrotta") + setPhase(.error, error: L10n.t("error.rtmp.interrupted")) return } diff --git a/native/ios/MatchLiveTv/Streaming/Overlay/ScoreboardElement.swift b/native/ios/MatchLiveTv/Streaming/Overlay/ScoreboardElement.swift index 32036f8..77bb596 100644 --- a/native/ios/MatchLiveTv/Streaming/Overlay/ScoreboardElement.swift +++ b/native/ios/MatchLiveTv/Streaming/Overlay/ScoreboardElement.swift @@ -173,13 +173,6 @@ struct ScoreboardElement: OverlayElement { } private func ordinalSetLabel(_ setNumber: Int) -> String { - switch setNumber { - case 1: return "1° set" - case 2: return "2° set" - case 3: return "3° set" - case 4: return "4° set" - case 5: return "5° set" - default: return "Set \(setNumber)" - } + L10n.t("overlay.set.label", setNumber) } } diff --git a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift index c2d0810..084ac75 100644 --- a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift +++ b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift @@ -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) } } } diff --git a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift index b23e2e7..f15ad7a 100644 --- a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift +++ b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift @@ -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 diff --git a/native/ios/MatchLiveTv/UI/Broadcast/LiveScoreActions.swift b/native/ios/MatchLiveTv/UI/Broadcast/LiveScoreActions.swift index 7c29ecc..f5b1923 100644 --- a/native/ios/MatchLiveTv/UI/Broadcast/LiveScoreActions.swift +++ b/native/ios/MatchLiveTv/UI/Broadcast/LiveScoreActions.swift @@ -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) } ) } } diff --git a/native/ios/MatchLiveTv/UI/Components/MatchLiveWordmark.swift b/native/ios/MatchLiveTv/UI/Components/MatchLiveWordmark.swift index c4383a3..9551570 100644 --- a/native/ios/MatchLiveTv/UI/Components/MatchLiveWordmark.swift +++ b/native/ios/MatchLiveTv/UI/Components/MatchLiveWordmark.swift @@ -29,7 +29,7 @@ struct MatchLiveWordmark: View { .foregroundStyle(MatchColors.textSecondary) } if showSlogan { - Text("OGNI PARTITA, OGNI EVENTO, PER I TUOI TIFOSI.") + Text(L10n.t("app.slogan").uppercased()) .font(.system(size: 12, weight: .medium)) .foregroundStyle(MatchColors.textSecondary) .multilineTextAlignment(.center) diff --git a/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift b/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift index d47cb15..369c223 100644 --- a/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift +++ b/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift @@ -16,18 +16,24 @@ struct LoginScreen: View { MatchScreenScaffold { ScrollView { VStack(spacing: 0) { - MatchLiveWordmark(showSlogan: true) - .padding(.top, 32) - Button(L10n.t("language.label")) { - showLanguagePicker = true + HStack { + Spacer() + Button { + showLanguagePicker = true + } label: { + Image(systemName: "globe") + .foregroundStyle(MatchColors.textSecondary) + } + .accessibilityLabel(L10n.t("language.label")) } - .foregroundStyle(MatchColors.textSecondary) - .padding(.top, 16) + .padding(.top, 8) + MatchLiveWordmark(showSlogan: true) + .padding(.top, 16) Text(L10n.t("login.submit").uppercased()) .font(MatchTypography.headlineMedium) .padding(.top, 32) VStack(spacing: 16) { - MatchTextField(title: L10n.t("login.email"), text: $email, placeholder: "coach@squadra.it", keyboard: .emailAddress) + MatchTextField(title: L10n.t("login.email"), text: $email, placeholder: L10n.t("login.email.placeholder"), keyboard: .emailAddress) MatchSecureField(title: L10n.t("login.password"), text: $password, visible: $passwordVisible) } .padding(.top, 32) diff --git a/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift b/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift index 99c1d11..35214c2 100644 --- a/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift +++ b/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift @@ -36,13 +36,16 @@ struct MatchesScreen: View { .foregroundStyle(MatchColors.textSecondary) } .accessibilityLabel(L10n.t("language.label")) - Button(L10n.t("action.logout")) { + Button { Task { await container.authRepository.logout() onLogout() } + } label: { + Image(systemName: "rectangle.portrait.and.arrow.right") + .foregroundStyle(MatchColors.textSecondary) } - .foregroundStyle(MatchColors.textSecondary) + .accessibilityLabel(L10n.t("action.logout")) } .padding(.horizontal, 16) .padding(.vertical, 8) @@ -53,29 +56,33 @@ struct MatchesScreen: View { ProgressView().tint(MatchColors.primaryRed) } else if teams.isEmpty { VStack(spacing: 16) { - Text("Nessuna squadra disponibile") + Text(L10n.t("matches.no.team.title")) .foregroundStyle(MatchColors.textSecondary) - MatchPrimaryButton(label: "RIPROVA", action: { reload(showSpinner: false) }) + Text(L10n.t("matches.no.team.body")) + .font(MatchTypography.bodyMedium) + .foregroundStyle(MatchColors.textSecondary) + .multilineTextAlignment(.center) + MatchPrimaryButton(label: L10n.t("matches.retry").uppercased(), action: { reload(showSpinner: false) }) } .padding(24) } else if let error { VStack(spacing: 16) { Text(error).foregroundStyle(MatchColors.primaryRed).multilineTextAlignment(.center) - MatchPrimaryButton(label: "RIPROVA", action: { reload(showSpinner: false) }) + MatchPrimaryButton(label: L10n.t("matches.retry").uppercased(), action: { reload(showSpinner: false) }) } .padding(24) } else { ScrollView { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 16) { - Text("Ciao, \(container.tokenStore.session?.user.name ?? "")") + Text(L10n.t("matches.hello", container.tokenStore.session?.user.name ?? "")) .font(MatchTypography.headlineMedium) - Text("Riprendi una diretta in corso o avvia una partita programmata.") + Text(L10n.t("matches.subtitle")) .font(MatchTypography.bodyMedium) .foregroundStyle(MatchColors.textSecondary) HStack(spacing: 12) { - MatchSecondaryButton(label: "PARTITA PROGRAMMATA", action: { showSchedule = true }) - MatchPrimaryButton(label: "NUOVA PARTITA", action: { showNewMatch = true }) + MatchSecondaryButton(label: L10n.t("matches.schedule").uppercased(), action: { showSchedule = true }) + MatchPrimaryButton(label: L10n.t("matches.new").uppercased(), action: { showNewMatch = true }) } if let activeTeam { TeamPickerBar( @@ -108,7 +115,7 @@ struct MatchesScreen: View { .padding(.horizontal, 24) .padding(.vertical, 12) } else if calendarMatches.isEmpty { - Text("Nessuna altra partita in calendario.") + Text(L10n.t("matches.no.other")) .font(MatchTypography.bodyMedium) .foregroundStyle(MatchColors.textSecondary) .multilineTextAlignment(.center) @@ -136,27 +143,41 @@ struct MatchesScreen: View { } ) .task(id: refreshToken) { reload(showSpinner: refreshToken == 0) } - .alert("Riprendi diretta?", isPresented: Binding(get: { resumeMatch != nil }, set: { if !$0 { resumeMatch = nil } })) { - Button("Riprendi") { + .alert(L10n.t("sheet.configure.now.title"), isPresented: Binding(get: { resumeMatch != nil }, set: { if !$0 { resumeMatch = nil } })) { + Button(L10n.t("sheet.resume.camera")) { if let match = resumeMatch { resumeBroadcast(match) } resumeMatch = nil } - Button("Configura", role: .cancel) { + Button(L10n.t("sheet.configure"), role: .cancel) { if let match = resumeMatch { onOpenSetup(match.id) } resumeMatch = nil } + } message: { + Text(L10n.t("sheet.configure.now.body")) } - .alert("Elimina partita?", isPresented: Binding(get: { deleteMatch != nil }, set: { if !$0 { deleteMatch = nil } })) { - Button("Elimina", role: .destructive) { + .alert( + L10n.t("sheet.delete.match.title"), + isPresented: Binding(get: { deleteMatch != nil }, set: { if !$0 { deleteMatch = nil } }) + ) { + Button(L10n.t("sheet.delete"), role: .destructive) { if let match = deleteMatch { Task { - try? await container.matchRepository.deleteMatch(matchId: match.id) - reload(showSpinner: false) + do { + try await container.matchRepository.deleteMatch(matchId: match.id) + snackbar = L10n.t("matches.msg.deleted") + reload(showSpinner: false) + } catch { + snackbar = L10n.t("matches.msg.delete.failed") + } } } deleteMatch = nil } - Button("Annulla", role: .cancel) { deleteMatch = nil } + Button(L10n.t("action.cancel"), role: .cancel) { deleteMatch = nil } + } message: { + if let match = deleteMatch { + Text(L10n.t("sheet.delete.match.body", match.teamName, match.opponentName)) + } } .sheet(isPresented: $showNewMatch) { NewMatchSheet( @@ -229,21 +250,21 @@ struct MatchesScreen: View { private var calendarSectionTitle: String { if calendarMatches.isEmpty && activeMatch == nil { - return "Nessuna partita in calendario" + return L10n.t("matches.empty.title") } if !scheduledMatches.isEmpty { - return "Partite programmate" + return L10n.t("matches.scheduled.title") } - return "Pronte da avviare" + return L10n.t("matches.ready.title") } private var emptyCalendarMessage: String { - var message = "Programma una partita o avviane una nuova con «Nuova partita»." + var message = L10n.t("matches.empty.hint") if let teamName = activeTeam?.name { - message += "\n\nSquadra attiva: \(teamName)." + message += "\n\n" + L10n.t("matches.active.team", teamName) } if teams.count > 1 { - message += "\nHai più squadre: verifica quella selezionata sopra." + message += "\n" + L10n.t("matches.multi.team.hint") } return message } @@ -260,7 +281,7 @@ struct MatchesScreen: View { matches = try await container.matchRepository.fetchMatchesForTeam(teamId: team.id) } } catch { - self.error = UserFacingError.message(for: error) + self.error = UserFacingError.message(for: error) ?? L10n.t("matches.msg.load.error") } loading = false refreshing = false @@ -279,7 +300,7 @@ struct MatchesScreen: View { let sessionId = try await MatchSessionLauncher.resumeBroadcastSession(match: match, sessionRepository: container.sessionRepository) onOpenBroadcast(sessionId) } catch { - snackbar = UserFacingError.message(for: error) + snackbar = UserFacingError.message(for: error) ?? L10n.t("matches.msg.resume.failed") } actionLoading = false } @@ -294,7 +315,7 @@ struct MatchesScreen: View { reload(showSpinner: false) onOpenSetup(match.id) } catch { - snackbar = UserFacingError.message(for: error) + snackbar = UserFacingError.message(for: error) ?? L10n.t("matches.msg.create.failed") } actionLoading = false } @@ -335,7 +356,7 @@ private struct ActiveSessionBanner: View { Image(systemName: "video.fill") .foregroundStyle(MatchColors.primaryRed) VStack(alignment: .leading, spacing: 4) { - Text("Riprendi diretta in corso") + Text(L10n.t("sheet.resume.banner")) .font(MatchTypography.titleMedium) .foregroundStyle(MatchColors.primaryRed) Text("\(match.teamName) vs \(match.opponentName)") @@ -409,23 +430,23 @@ private struct NewMatchSheet: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - Text("Nuova partita") + Text(L10n.t("sheet.new.match.title")) .font(MatchTypography.headlineMedium) - Text("Programma in anticipo o avvia la configurazione diretta subito.") + Text(L10n.t("sheet.new.match.lead")) .font(MatchTypography.bodyMedium) .foregroundStyle(MatchColors.textSecondary) .padding(.top, 6) VStack(spacing: 10) { SheetOptionTile( systemImage: "calendar.badge.clock", - title: "Programma partita", - subtitle: "Data, ora e avversario — visibile anche sul sito", + title: L10n.t("sheet.schedule.option"), + subtitle: L10n.t("sheet.schedule.option.sub"), action: onSchedule ) SheetOptionTile( systemImage: "play.circle", - title: "Avvia subito", - subtitle: "Crea la partita e passa al wizard senza orario", + title: L10n.t("sheet.quick.option"), + subtitle: L10n.t("sheet.quick.option.sub"), action: onQuickStart ) } @@ -481,15 +502,18 @@ private struct ScheduleMatchSheet: View { var body: some View { NavigationStack { Form { - TextField("Avversario", text: $opponent) - TextField("Luogo", text: $location) - DatePicker("Data", selection: $date) + TextField(L10n.t("sheet.opponent"), text: $opponent) + TextField(L10n.t("sheet.location.optional"), text: $location) + DatePicker(L10n.t("sheet.date.time"), selection: $date) } - .navigationTitle("Partita programmata") + .navigationTitle(L10n.t("sheet.schedule.title")) .toolbar { - ToolbarItem(placement: .cancellationAction) { Button("Chiudi") { dismiss() } } + ToolbarItem(placement: .cancellationAction) { + Button(L10n.t("common.close")) { dismiss() } + } ToolbarItem(placement: .confirmationAction) { - Button("Crea") { create() }.disabled(opponent.isEmpty || teamId == nil) + Button(L10n.t("sheet.save.schedule")) { create() } + .disabled(opponent.isEmpty || teamId == nil) } } } @@ -498,13 +522,16 @@ private struct ScheduleMatchSheet: View { private func create() { guard let teamId else { return } Task { - if let match = try? await container.matchRepository.createScheduledMatch( - teamId: teamId, - opponentName: opponent, - scheduledAt: date, - location: location.nilIfEmpty - ) { + do { + let match = try await container.matchRepository.createScheduledMatch( + teamId: teamId, + opponentName: opponent, + scheduledAt: date, + location: location.nilIfEmpty + ) onCreated(match) + } catch { + // Sheet chiude solo su successo; errori gestiti dal chiamante via snackbar se necessario. } } } @@ -532,7 +559,7 @@ private struct TeamPickerSheet: View { } } } - .navigationTitle("Squadra") + .navigationTitle(L10n.t("sheet.choose.team")) } } } diff --git a/native/ios/MatchLiveTv/UI/Navigation/AppNavHost.swift b/native/ios/MatchLiveTv/UI/Navigation/AppNavHost.swift index d943d55..15316eb 100644 --- a/native/ios/MatchLiveTv/UI/Navigation/AppNavHost.swift +++ b/native/ios/MatchLiveTv/UI/Navigation/AppNavHost.swift @@ -7,6 +7,13 @@ struct AppNavHost: View { @State private var broadcastRoute: BroadcastRoute? /// Incrementato al ritorno da wizard/broadcast per ricaricare l'hub partite. @State private var matchesRefreshToken = 0 + @State private var languageTick = 0 + + /// Locale SwiftUI (DatePicker, ecc.) aggiornata al cambio lingua senza resettare la NavigationStack. + private var appLocale: Locale { + _ = languageTick + return AppLanguage.locale + } var body: some View { NavigationStack(path: $path) { @@ -39,6 +46,10 @@ struct AppNavHost: View { } } } + .environment(\.locale, appLocale) + .onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in + languageTick += 1 + } .lockPortraitOrientation() .fullScreenCover(item: $wizardRoute) { route in WizardShellScreen( diff --git a/native/ios/MatchLiveTv/UI/Wizard/StepMatchScreen.swift b/native/ios/MatchLiveTv/UI/Wizard/StepMatchScreen.swift index 128435d..4c95812 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/StepMatchScreen.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/StepMatchScreen.swift @@ -124,12 +124,12 @@ struct StepMatchScreen: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { - Text("Dettagli partita") + Text(L10n.t("wizard.match.details.title")) .font(MatchTypography.headlineMedium) .padding(.bottom, 20) TeamBrandingRow( - sectionLabel: "Squadra di casa", + sectionLabel: L10n.t("wizard.match.home.team.label"), teamName: .constant(match.teamName), nameEditable: false, remoteLogoUrl: homeLogoUrl, @@ -140,7 +140,7 @@ struct StepMatchScreen: View { .padding(.bottom, 16) TeamBrandingRow( - sectionLabel: "Squadra avversaria", + sectionLabel: L10n.t("wizard.match.away.team.label"), teamName: $opponent, nameEditable: true, remoteLogoUrl: opponentLogoUrl, @@ -150,25 +150,25 @@ struct StepMatchScreen: View { ) .padding(.bottom, 16) - WizardOutlinedField(label: "Luogo", text: $location) + WizardOutlinedField(label: L10n.t("wizard.match.location.label"), text: $location) .padding(.bottom, 12) - WizardOutlinedField(label: "Campionato (facoltativo)", text: $campionato) - Text("Es. Serie C, torneo estivo — lo useremo in descrizione e overlay.") + WizardOutlinedField(label: L10n.t("wizard.match.category.label"), text: $campionato) + Text(L10n.t("wizard.match.category.hint")) .font(MatchTypography.bodyMedium) .foregroundStyle(MatchColors.textSecondary) .padding(.top, 4) if isScheduledMatch { WizardReadOnlyField( - label: "Programmata per", + label: L10n.t("wizard.match.scheduled.label"), value: ApiInstant.formatMatchDate(match.scheduledAt) ?? "—" ) .padding(.top, 16) } if !allowedOverlays.isEmpty { - Toggle("Overlay video personalizzato", isOn: $customOverlay) + Toggle(L10n.t("wizard.match.custom.overlay.label"), isOn: $customOverlay) .padding(.top, 20) if customOverlay { VStack(spacing: 6) { @@ -186,7 +186,7 @@ struct StepMatchScreen: View { Toggle(isOn: $customRules) { VStack(alignment: .leading, spacing: 4) { - Text("Regole punteggio personalizzate") + Text(L10n.t("wizard.match.custom.rules.label")) Text(customRulesDescription) .font(MatchTypography.bodyMedium) .foregroundStyle(MatchColors.textSecondary) @@ -202,7 +202,7 @@ struct StepMatchScreen: View { volleyRulesSection } - MatchPrimaryButton(label: "AVANTI >", action: saveAndContinue, enabled: !saving, loading: saving) + MatchPrimaryButton(label: L10n.t("wizard.action.next"), action: saveAndContinue, enabled: !saving, loading: saving) .padding(.top, 32) } .padding(.horizontal, 20) @@ -244,20 +244,20 @@ struct StepMatchScreen: View { private var customRulesDescription: String { if !customRules && ["basket", "timed"].contains(boardType) { - return "Regole standard dello sport selezionato." + return L10n.t("wizard.match.rules.standard.sport") } if customRules && ["basket", "timed"].contains(boardType) { - return "Torneo non standard: tempi e periodi personalizzati." + return L10n.t("wizard.match.rules.custom.timed") } if customRules { - return "Torneo non standard: imposta set e punteggi." + return L10n.t("wizard.match.rules.custom.sets") } - return "Standard FIPAV: 3 set per vincere, set a 25, tie-break a 15." + return L10n.t("wizard.match.rules.standard.sets") } private var periodRulesSection: some View { VStack(alignment: .leading, spacing: 12) { - Text(boardType == "basket" ? "Quarti" : "Tempi") + Text(boardType == "basket" ? L10n.t("wizard.match.periods.basket.label") : L10n.t("wizard.match.periods.timed.label")) .font(MatchTypography.bodyMedium) .padding(.top, 16) HStack(spacing: 8) { @@ -271,7 +271,7 @@ struct StepMatchScreen: View { } } WizardOutlinedField( - label: boardType == "basket" ? "Minuti per quarto" : "Minuti per tempo", + label: boardType == "basket" ? L10n.t("wizard.match.minutes.per.period.basket") : L10n.t("wizard.match.minutes.per.period.timed"), text: $periodDurationText ) .onChange(of: periodDurationText) { text in @@ -279,7 +279,7 @@ struct StepMatchScreen: View { if filtered != text { periodDurationText = filtered } if let value = Int(filtered) { periodDurationMins = min(max(value, 1), 120) } } - WizardOutlinedField(label: "Minuti supplementari", text: $overtimeDurationText) + WizardOutlinedField(label: L10n.t("wizard.match.overtime.minutes.label"), text: $overtimeDurationText) .onChange(of: overtimeDurationText) { text in let filtered = String(text.filter(\.isNumber).prefix(3)) if filtered != text { overtimeDurationText = filtered } @@ -290,7 +290,7 @@ struct StepMatchScreen: View { private var volleyRulesSection: some View { VStack(alignment: .leading, spacing: 12) { - Text("Set da vincere la partita") + Text(L10n.t("wizard.match.sets.to.win.label")) .font(MatchTypography.bodyMedium) .padding(.top, 16) HStack(spacing: 8) { @@ -303,13 +303,13 @@ struct StepMatchScreen: View { .frame(maxWidth: .infinity) } } - WizardOutlinedField(label: "Punti per vincere un set", text: $pointsPerSetText) + WizardOutlinedField(label: L10n.t("wizard.match.points.per.set.label"), text: $pointsPerSetText) .onChange(of: pointsPerSetText) { text in let filtered = String(text.filter(\.isNumber).prefix(2)) if filtered != text { pointsPerSetText = filtered } if let value = Int(filtered) { pointsPerSet = min(max(value, 1), 99) } } - WizardOutlinedField(label: "Punti tie-break (ultimo set)", text: $pointsDecidingSetText) + WizardOutlinedField(label: L10n.t("wizard.match.tiebreak.points.label"), text: $pointsDecidingSetText) .onChange(of: pointsDecidingSetText) { text in let filtered = String(text.filter(\.isNumber).prefix(2)) if filtered != text { pointsDecidingSetText = filtered } @@ -321,17 +321,17 @@ struct StepMatchScreen: View { private func saveAndContinue() { let trimmedOpponent = opponent.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedOpponent.isEmpty else { - onError("Inserisci il nome avversario") + onError(L10n.t("wizard.error.opponent.name.required")) return } if customRules && ["volley", "racket"].contains(boardType) { guard let perSet = Int(pointsPerSetText), perSet >= 1 else { - onError("Inserisci i punti per vincere un set") + onError(L10n.t("wizard.error.points.per.set.required")) return } guard let deciding = Int(pointsDecidingSetText), deciding >= 1 else { - onError("Inserisci i punti del tie-break") + onError(L10n.t("wizard.error.tiebreak.points.required")) return } pointsPerSet = perSet @@ -340,11 +340,11 @@ struct StepMatchScreen: View { if customRules && ["basket", "timed"].contains(boardType) { guard let periodMins = Int(periodDurationText), periodMins >= 1 else { - onError("Inserisci la durata del periodo in minuti") + onError(L10n.t("wizard.error.period.duration.required")) return } guard let overtimeMins = Int(overtimeDurationText), overtimeMins >= 1 else { - onError("Inserisci la durata dei supplementari in minuti") + onError(L10n.t("wizard.error.overtime.duration.required")) return } periodDurationMins = periodMins diff --git a/native/ios/MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift b/native/ios/MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift index f32501a..ff6126a 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift @@ -45,35 +45,35 @@ struct StepNetworkTestScreen: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { - Text("Test rete") + Text(L10n.t("wizard.network.test.title")) .font(MatchTypography.headlineMedium) - Text("Verifica che la connessione regga l'upload della diretta.") + Text(L10n.t("wizard.network.test.subtitle")) .font(MatchTypography.bodyMedium) .foregroundStyle(MatchColors.textSecondary) .padding(.top, 8) HStack(spacing: 8) { MetricCard( - label: "Download", + label: L10n.t("wizard.network.download.label"), value: testing ? "..." : String(format: "%.1f Mbps", downloadMbps) ) MetricCard( - label: "Upload", + label: L10n.t("wizard.network.upload.label"), value: testing ? "..." : String(format: "%.1f Mbps", uploadMbps), highlight: ready ) MetricCard( - label: "Latenza", + label: L10n.t("wizard.network.latency.label"), value: testing ? "..." : "\(latencyMs) ms" ) } .padding(.top, 24) - MetricCard(label: "Tipo rete", value: networkType) + MetricCard(label: L10n.t("wizard.network.type.label"), value: networkType) .padding(.top, 12) if testCompleted && ready { - Text("PRONTO PER ANDARE IN DIRETTA") + Text(L10n.t("wizard.network.ready.label")) .font(MatchTypography.titleMedium) .foregroundStyle(MatchColors.successGreen) .frame(maxWidth: .infinity) @@ -82,7 +82,7 @@ struct StepNetworkTestScreen: View { if let quality = selectedQualityLabel { WizardReadOnlyField( - label: "Qualità streaming (automatica)", + label: L10n.t("wizard.network.quality.label"), value: quality ) .padding(.top, 12) @@ -91,20 +91,20 @@ struct StepNetworkTestScreen: View { if testCompleted, let shareUrl { WizardReadOnlyField( - label: currentSession.platform == "youtube" ? "Link YouTube" : "Link diretta", + label: currentSession.platform == "youtube" ? L10n.t("wizard.network.link.youtube.label") : L10n.t("wizard.network.link.live.label"), value: shareUrl ) .padding(.top, 16) HStack(spacing: 8) { - MatchSecondaryButton(label: "COPIA", action: { copyToClipboard(shareUrl) }) + MatchSecondaryButton(label: L10n.t("wizard.action.copy"), action: { copyToClipboard(shareUrl) }) if let url = URL(string: shareUrl) { ShareLink( item: url, - subject: Text("Diretta — \(match.teamName) vs \(match.opponentName)"), + subject: Text(L10n.t("wizard.network.share.subject", match.teamName, match.opponentName)), message: Text(shareUrl) ) { - Text("CONDIVIDI") + Text(L10n.t("wizard.action.share")) .font(MatchTypography.labelLarge) .frame(maxWidth: .infinity) .frame(height: 52) @@ -115,13 +115,13 @@ struct StepNetworkTestScreen: View { } .padding(.top, 8) - MatchSecondaryButton(label: "CONDIVIDI LINK REGIA", action: shareRegiaLink) + MatchSecondaryButton(label: L10n.t("wizard.action.share.regia.link"), action: shareRegiaLink) .padding(.top, 8) } if !testCompleted { MatchSecondaryButton( - label: testing ? "TEST IN CORSO..." : "AVVIA TEST RETE", + label: testing ? L10n.t("wizard.network.test.running.label") : L10n.t("wizard.network.test.start.label"), action: runTest, enabled: !testing ) @@ -133,10 +133,10 @@ struct StepNetworkTestScreen: View { let backWidth = (proxy.size.width - spacing) / 3 let forwardWidth = (proxy.size.width - spacing) * 2 / 3 HStack(spacing: spacing) { - MatchSecondaryButton(label: "Indietro", action: onBack, enabled: !starting) + MatchSecondaryButton(label: L10n.t("wizard.action.back"), action: onBack, enabled: !starting) .frame(width: backWidth) MatchPrimaryButton( - label: "INIZIA >", + label: L10n.t("wizard.action.start"), action: startLive, enabled: ready, loading: starting @@ -230,12 +230,12 @@ struct StepNetworkTestScreen: View { do { let url = try await container.sessionRepository.createRegiaLink(sessionId: currentSession.id) guard let link = URL(string: url) else { - onError("Link regia non valido") + onError(L10n.t("wizard.error.regia.link")) return } shareItem = ShareItem( items: [link], - subject: "Link regia — \(match.teamName) vs \(match.opponentName)" + subject: L10n.t("broadcast.share.regia.subject", "\(match.teamName) vs \(match.opponentName)") ) } catch { if let message = UserFacingError.message(for: error) { diff --git a/native/ios/MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift b/native/ios/MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift index 5b20777..2727545 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift @@ -17,9 +17,9 @@ struct StepTransmissionScreen: View { } private var youtubeSubtitle: String { - guard let team else { return "Canale in attivazione" } - if !team.canUseYoutube { return "Premium Light o Full" } - if !team.isYoutubeReady { return "Canale in attivazione" } + guard let team else { return L10n.t("wizard.transmission.youtube.activating") } + if !team.canUseYoutube { return L10n.t("wizard.transmission.youtube.premium.required") } + if !team.isYoutubeReady { return L10n.t("wizard.transmission.youtube.activating") } return team.youtubeDestinationLabel } @@ -27,18 +27,18 @@ struct StepTransmissionScreen: View { ScrollView { VStack(alignment: .leading, spacing: 0) { if let plan = team?.planName { - Text("Piano \(plan)") + Text(L10n.t("wizard.transmission.plan.label", plan)) .font(MatchTypography.bodyMedium) .foregroundStyle(MatchColors.textSecondary) .padding(.bottom, 12) } - Text("Piattaforma") + Text(L10n.t("wizard.transmission.platform.title")) .font(MatchTypography.headlineMedium) WizardPlatformCard( title: "Match Live TV", - subtitle: "Diretta sul nostro sito (incluso)", + subtitle: L10n.t("wizard.transmission.platform.site.subtitle"), selected: platform == "matchlivetv", onClick: { platform = "matchlivetv" } ) @@ -49,24 +49,24 @@ struct StepTransmissionScreen: View { subtitle: youtubeSubtitle, selected: platform == "youtube", enabled: youtubeReady, - badge: team?.canUseYoutube == false ? "Premium" : nil, + badge: team?.canUseYoutube == false ? L10n.t("wizard.transmission.youtube.badge") : nil, onClick: selectYoutube ) .padding(.top, 8) - Text("Visibilità") + Text(L10n.t("wizard.transmission.visibility.title")) .font(MatchTypography.headlineMedium) .padding(.top, 24) HStack(spacing: 8) { WizardChoiceButton( - label: "PUBBLICO", + label: L10n.t("wizard.transmission.public.label"), selected: privacy == "public", action: { privacy = "public" } ) .frame(maxWidth: .infinity) WizardChoiceButton( - label: "NON IN ELENCO", + label: L10n.t("wizard.transmission.unlisted.label"), selected: privacy == "unlisted", action: { privacy = "unlisted" } ) @@ -80,7 +80,7 @@ struct StepTransmissionScreen: View { .fixedSize(horizontal: false, vertical: true) .padding(.top, 8) - Text("La partita resta sempre visibile nel backend della squadra per tutta la durata dell'abbonamento.") + Text(L10n.t("wizard.transmission.backend.note")) .font(MatchTypography.bodyMedium) .foregroundStyle(MatchColors.textSecondary) .fixedSize(horizontal: false, vertical: true) @@ -88,7 +88,7 @@ struct StepTransmissionScreen: View { WizardFooterButtons( onBack: onBack, - forwardLabel: "AVANTI >", + forwardLabel: L10n.t("wizard.action.next"), forwardLoading: creating, onForward: createSessionAndContinue ) @@ -111,16 +111,16 @@ struct StepTransmissionScreen: View { private var visibilityDescription: String { if privacy == "public" { - return "Compare nell'elenco dirette su MatchLiveTV.it, nelle ricerche e sul canale YouTube (se selezionato)." + return L10n.t("wizard.transmission.public.desc") } - return "Non compare negli elenchi pubblici né nelle ricerche. Solo chi ha il link può guardare." + return L10n.t("wizard.transmission.unlisted.desc") } private func selectYoutube() { if youtubeReady { platform = "youtube" } else { - onError("YouTube non disponibile per questa squadra") + onError(L10n.t("wizard.error.youtube.unavailable")) } } diff --git a/native/ios/MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift b/native/ios/MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift index 9a0abff..897dfc1 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift @@ -34,18 +34,18 @@ struct TeamBrandingRow: View { if isConfigured { TeamLogoImage(remoteLogoUrl: remoteLogoUrl, localLogoImage: localLogoImage, size: 44) ColorAccentBar(color: ColorHex.swiftUIColor(displayColorHex), height: 36) - Text(teamName.isEmpty ? "Squadra" : teamName) + Text(teamName.isEmpty ? L10n.t("wizard.branding.team.fallback.name") : teamName) .font(MatchTypography.titleMedium) .lineLimit(1) } else { ColorAccentBar(color: ColorHex.swiftUIColor(displayColorHex), height: 32) if nameEditable { - TextField("Nome avversario", text: $teamName) + TextField(L10n.t("wizard.branding.opponent.name.placeholder"), text: $teamName) .font(MatchTypography.titleMedium) .padding(8) .overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1)) } else { - Text(teamName.isEmpty ? "Squadra" : teamName) + Text(teamName.isEmpty ? L10n.t("wizard.branding.team.fallback.name") : teamName) .font(MatchTypography.titleMedium) .lineLimit(1) } @@ -57,6 +57,7 @@ struct TeamBrandingRow: View { .frame(width: 44, height: 44) } .buttonStyle(.plain) + .accessibilityLabel(L10n.t("wizard.branding.edit.team.cd")) } .padding(.horizontal, 12) .padding(.vertical, 10) @@ -100,29 +101,29 @@ private struct TeamBrandingCustomizeSheet: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { - Text("Personalizza").font(MatchTypography.headlineMedium) + Text(L10n.t("wizard.branding.customize.title")).font(MatchTypography.headlineMedium) Text(title) .font(MatchTypography.bodyMedium) .foregroundStyle(MatchColors.textSecondary) .padding(.top, 4) if nameEditable { - Text("Nome squadra").font(MatchTypography.labelLarge).padding(.top, 20) - TextField("Nome avversario", text: $draftName) + Text(L10n.t("wizard.branding.team.name.label")).font(MatchTypography.labelLarge).padding(.top, 20) + TextField(L10n.t("wizard.branding.opponent.name.placeholder"), text: $draftName) .padding(12) .overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1)) .padding(.top, 8) } else { - Text("Nome squadra").font(MatchTypography.labelLarge).padding(.top, 20) + Text(L10n.t("wizard.branding.team.name.label")).font(MatchTypography.labelLarge).padding(.top, 20) Text(teamName).font(MatchTypography.titleMedium).padding(.top, 4) } - Text("Logo").font(MatchTypography.labelLarge).padding(.top, 20) + Text(L10n.t("wizard.branding.logo.label")).font(MatchTypography.labelLarge).padding(.top, 20) HStack(spacing: 14) { TeamLogoImage(remoteLogoUrl: remoteLogoUrl, localLogoImage: localLogoImage, size: 72) VStack(alignment: .leading, spacing: 8) { PhotosPicker(selection: $logoPickerItem, matching: .images) { - Text("CARICA LOGO") + Text(L10n.t("wizard.branding.upload.logo")) .font(MatchTypography.labelLarge) .frame(maxWidth: .infinity) .frame(height: 52) @@ -130,7 +131,7 @@ private struct TeamBrandingCustomizeSheet: View { } .buttonStyle(.plain) if hasLogo { - MatchSecondaryButton(label: "RIMUOVI", action: { + MatchSecondaryButton(label: L10n.t("wizard.branding.remove.logo"), action: { localLogoImage = nil logoPickerItem = nil }) @@ -139,13 +140,13 @@ private struct TeamBrandingCustomizeSheet: View { } .padding(.top, 10) - Text("Colore squadra").font(MatchTypography.labelLarge).padding(.top, 20) + Text(L10n.t("wizard.branding.color.label")).font(MatchTypography.labelLarge).padding(.top, 20) TeamColorPickerPanel(initialColorHex: draftColor.isEmpty ? fallbackColorHex : draftColor) { draftColor = $0 } .padding(.top, 12) - MatchPrimaryButton(label: "SALVA", action: save) + MatchPrimaryButton(label: L10n.t("wizard.branding.save"), action: save) .padding(.top, 24) - MatchSecondaryButton(label: "ANNULLA", action: onDismiss) + MatchSecondaryButton(label: L10n.t("wizard.branding.cancel"), action: onDismiss) .padding(.top, 8) } .padding(.horizontal, 20) @@ -205,7 +206,7 @@ private struct TeamLogoImage: View { private var placeholder: some View { ZStack { MatchColors.surface - Text("Nessun logo") + Text(L10n.t("wizard.branding.no.logo")) .font(.caption) .foregroundStyle(MatchColors.textSecondary) } diff --git a/native/ios/MatchLiveTv/UI/Wizard/TeamColorPicker.swift b/native/ios/MatchLiveTv/UI/Wizard/TeamColorPicker.swift index 6ead8a0..87e103b 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/TeamColorPicker.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/TeamColorPicker.swift @@ -19,7 +19,7 @@ struct TeamColorPickerPanel: View { .overlay(Circle().stroke(MatchColors.outline, lineWidth: 2)) saturationBrightnessPicker VStack(alignment: .leading, spacing: 4) { - Text("Tonalità") + Text(L10n.t("wizard.color.hue")) .font(MatchTypography.bodyMedium) .foregroundStyle(MatchColors.textSecondary) hueGradient diff --git a/native/ios/MatchLiveTv/UI/Wizard/WizardComponents.swift b/native/ios/MatchLiveTv/UI/Wizard/WizardComponents.swift index 2f020e8..4d1aa4e 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/WizardComponents.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/WizardComponents.swift @@ -1,9 +1,12 @@ import SwiftUI -private let wizardStepTitles = ["01 · Partita", "02 · Trasmissione", "03 · Test rete"] - func wizardStepTitle(_ step: Int) -> String { - wizardStepTitles[min(max(step, 1), 3) - 1] + let titles = [ + L10n.t("wizard.step.title.match"), + L10n.t("wizard.step.title.transmission"), + L10n.t("wizard.step.title.network") + ] + return titles[min(max(step, 1), 3) - 1] } struct WizardStepIndicator: View { @@ -32,7 +35,7 @@ struct WizardFooterButtons: View { var body: some View { HStack(spacing: 12) { if showBack { - MatchSecondaryButton(label: "Indietro", action: onBack) + MatchSecondaryButton(label: L10n.t("wizard.action.back"), action: onBack) .frame(maxWidth: .infinity) } MatchPrimaryButton(label: forwardLabel, action: onForward, loading: forwardLoading) diff --git a/native/ios/MatchLiveTv/UI/Wizard/WizardShellScreen.swift b/native/ios/MatchLiveTv/UI/Wizard/WizardShellScreen.swift index 999985f..6cd43dc 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/WizardShellScreen.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/WizardShellScreen.swift @@ -11,6 +11,7 @@ struct WizardShellScreen: View { @State private var team: Team? @State private var currentStep: Int @State private var error: String? + @State private var languageTick = 0 init(container: AppContainer, matchId: String, step: Int, onClose: @escaping () -> Void, onStartLive: @escaping (String) -> Void) { self.container = container @@ -28,6 +29,7 @@ struct WizardShellScreen: View { Button(action: onClose) { Image(systemName: "xmark").foregroundStyle(.white) } + .accessibilityLabel(L10n.t("common.close")) Text(wizardStepTitle(currentStep)) .font(MatchTypography.titleMedium) Spacer(minLength: 0) @@ -70,7 +72,7 @@ struct WizardShellScreen: View { onError: { presentError($0) } ) } else { - Text("Completa lo step Trasmissione") + Text(L10n.t("wizard.complete.transmission.step")) .font(MatchTypography.bodyMedium) .foregroundStyle(MatchColors.textSecondary) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -99,11 +101,15 @@ struct WizardShellScreen: View { } } } - .alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { - Button("OK", role: .cancel) {} + .alert(L10n.t("common.error.generic").capitalized, isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { + Button(L10n.t("action.ok"), role: .cancel) {} } message: { Text(error ?? "") } + .onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in + languageTick += 1 + } + .id(languageTick) } private func presentError(_ message: String) { diff --git a/native/ios/MatchLiveTvTests/ThermalAdaptationTests.swift b/native/ios/MatchLiveTvTests/ThermalAdaptationTests.swift new file mode 100644 index 0000000..ad2d0a9 --- /dev/null +++ b/native/ios/MatchLiveTvTests/ThermalAdaptationTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import MatchLiveTv + +@MainActor +final class ThermalAdaptationTests: XCTestCase { + func testSeriousReducesBitrateAndFps() { + let baseline = BroadcastConfig( + rtmpUrl: "rtmp://localhost/live/key", + videoBitrate: 2_500_000, + fps: 30 + ) + let adapted = ThermalStateManager.adaptedConfig(from: baseline, for: .serious) + XCTAssertEqual(adapted.videoBitrate, 1_875_000) + XCTAssertEqual(adapted.fps, 24) + } + + func testCriticalReducesFurther() { + let baseline = BroadcastConfig( + rtmpUrl: "rtmp://localhost/live/key", + videoBitrate: 2_500_000, + fps: 30 + ) + let adapted = ThermalStateManager.adaptedConfig(from: baseline, for: .critical) + XCTAssertEqual(adapted.videoBitrate, 1_250_000) + XCTAssertEqual(adapted.fps, 20) + } + + func testNominalKeepsBaseline() { + let baseline = BroadcastConfig( + rtmpUrl: "rtmp://localhost/live/key", + videoBitrate: 2_500_000, + fps: 30 + ) + let adapted = ThermalStateManager.adaptedConfig(from: baseline, for: .nominal) + XCTAssertEqual(adapted.videoBitrate, baseline.videoBitrate) + XCTAssertEqual(adapted.fps, baseline.fps) + } + + func testThermalStateLabels() { + AppLanguage.current = .it + XCTAssertEqual(ThermalState.nominal.displayLabel, L10n.t("thermal.state.nominal")) + XCTAssertEqual(ThermalState.fair.displayLabel, L10n.t("thermal.state.fair")) + XCTAssertEqual(ThermalState.serious.displayLabel, L10n.t("thermal.state.serious")) + XCTAssertEqual(ThermalState.critical.displayLabel, L10n.t("thermal.state.critical")) + AppLanguage.current = .en + XCTAssertEqual(ThermalState.nominal.displayLabel, "Temperature OK") + AppLanguage.current = .system + } +} diff --git a/native/ios/generate_xcodeproj.py b/native/ios/generate_xcodeproj.py index 9605bd1..72e5735 100755 --- a/native/ios/generate_xcodeproj.py +++ b/native/ios/generate_xcodeproj.py @@ -17,6 +17,9 @@ ids = {f: uid() for f in app_files + test_files} bf = {f: uid() for f in app_files + test_files} assets_ref, assets_bf = uid(), uid() info_ref = uid() +lproj_langs = ["it", "en", "fr", "de", "es"] +lproj_refs = {lang: uid() for lang in lproj_langs} +lproj_bfs = {lang: uid() for lang in lproj_langs} app_product, test_product = uid(), uid() app_target, test_target = uid(), uid() app_sources, app_frameworks, app_resources = uid(), uid(), uid() @@ -51,6 +54,12 @@ for f, fid in ids.items(): lines += [ f'\t\t{assets_ref} /* Assets.xcassets */ = {{isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MatchLiveTv/Resources/Assets.xcassets; sourceTree = ""; }};', f'\t\t{info_ref} /* Info.plist */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = MatchLiveTv/Resources/Info.plist; sourceTree = ""; }};', +] +for lang in lproj_langs: + lines.append( + f'\t\t{lproj_refs[lang]} /* {lang}.lproj */ = {{isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/{lang}.lproj; sourceTree = ""; }};' + ) +lines += [ f'\t\t{app_product} /* MatchLiveTv.app */ = {{isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MatchLiveTv.app; sourceTree = BUILT_PRODUCTS_DIR; }};', f'\t\t{test_product} /* MatchLiveTvTests.xctest */ = {{isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MatchLiveTvTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }};', f'\t\t{hk} /* HaishinKit */ = {{isa = XCSwiftPackageProductDependency; package = {pkg}; productName = HaishinKit; }};', @@ -62,9 +71,15 @@ for f, bid in bf.items(): lines += [ f'\t\t{assets_bf} /* Assets in Resources */ = {{isa = PBXBuildFile; fileRef = {assets_ref}; }};', ] +for lang in lproj_langs: + lines.append( + f'\t\t{lproj_bfs[lang]} /* {lang}.lproj in Resources */ = {{isa = PBXBuildFile; fileRef = {lproj_refs[lang]}; }};' + ) -app_children = ", ".join(ids[f] for f in app_files) + f", {assets_ref}, {info_ref}" +lproj_children = ", ".join(lproj_refs[lang] for lang in lproj_langs) +app_children = ", ".join(ids[f] for f in app_files) + f", {assets_ref}, {info_ref}, {lproj_children}" test_children = ", ".join(ids[f] for f in test_files) +resource_bfs = ", ".join([assets_bf] + [lproj_bfs[lang] for lang in lproj_langs]) lines += [ f'\t\t{main_group} = {{isa = PBXGroup; children = ({app_group}, {test_group}, {products_group}); sourceTree = ""; }};', @@ -73,7 +88,7 @@ lines += [ f'\t\t{products_group} = {{isa = PBXGroup; children = ({app_product}, {test_product}); name = Products; sourceTree = ""; }};', f'\t\t{app_sources} = {{isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ({", ".join(bf[f] for f in app_files)}); runOnlyForDeploymentPostprocessing = 0; }};', f'\t\t{test_sources} = {{isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ({", ".join(bf[f] for f in test_files) if test_files else ""}); runOnlyForDeploymentPostprocessing = 0; }};', - f'\t\t{app_resources} = {{isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ({assets_bf}); runOnlyForDeploymentPostprocessing = 0; }};', + f'\t\t{app_resources} = {{isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ({resource_bfs}); runOnlyForDeploymentPostprocessing = 0; }};', f'\t\t{app_frameworks} = {{isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }};', f'\t\t{test_frameworks} = {{isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }};', ] @@ -81,7 +96,7 @@ lines += [ app_settings = """ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 26; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -92,7 +107,7 @@ app_settings = """ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.0.5; PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv; PRODUCT_NAME = "$(TARGET_NAME)"; API_BASE_URL = "https://www.matchlivetv.it"; @@ -107,10 +122,10 @@ app_debug_settings = app_settings + """ test_settings = """ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 26; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.0.5; PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos;