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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Emiliano Frascaro
2026-07-24 11:19:37 +02:00
co-authored by Cursor
parent ff4de33cc5
commit ea5da1eb86
46 changed files with 2675 additions and 1241 deletions
+49 -150
View File
@@ -4,7 +4,7 @@ Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare un
**Aggiornato:** 24 luglio 2026 **Aggiornato:** 24 luglio 2026
**Riferimento Android (produzione / telefono / Play):** `2.0.5-native` (`versionCode` **26**), API `https://www.matchlivetv.it` **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. 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 ## Contesto
Su Android (rilasciato) la lingua scelta dallutente vale su **tutta lapp** usabile: Su Android (rilasciato) la lingua scelta dallutente vale su **tutta** lapp usabile:
- Persistenza + apply live (`AppLocale` / SharedPreferences `mltv_locale` + `ProvideAppLocale`) - Persistenza + apply live (`AppLocale` / SharedPreferences `mltv_locale` + `ProvideAppLocale`)
- Icona **globo** su Login (TopAppBar) e Matches - Icona **globo** su Login (TopAppBar) e Matches
@@ -22,7 +22,7 @@ Su Android (rilasciato) la lingua scelta dallutente vale su **tutta lapp**
- Broadcast overlay, snackbar, dialog fine set/partita - Broadcast overlay, snackbar, dialog fine set/partita
- ~230+ chiavi × 5 lingue in `res/values*` - ~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**. 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. Queste sono regressioni/bug già visti su Android; **non ripeterli** su iOS.
| Problema | Sintomo | Fix Android (da replicare in spirito) | | 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 | | 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: `No ActivityResultRegistryOwner` / picker foto | Mantieni **Activity** come host; localizza solo Resources / environment | | Context “nudo” per le stringhe | Crash wizard: picker foto | Mantieni **Activity** come host | OK — SwiftUI host invariato |
| `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** | OK — globo in top bar Login |
| 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 | OK — `validateOrRefresh` restituisce sessione locale su errore rete |
| 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
--- ---
@@ -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 | | 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 | | Persistenza lingua | SharedPreferences + `ProvideAppLocale` | UserDefaults + notification | **Fatto** |
| Picker Login | Icona globo in **TopAppBar** | Testo / picker presente | Allineare a **icona globo** in toolbar | | Picker Login | Icona globo in **TopAppBar** | Icona globo top bar | **Fatto** |
| Picker Matches | Icona globo top bar | Presente | OK se già icona; altrimenti allineare | | Picker Matches | Icona globo top bar | Icona globo + logout icon | **Fatto** |
| Login stringhe | Sì | Sì (`L10n`) | OK | | Login stringhe | Sì | Sì (`L10n`) | **Fatto** |
| Slogan wordmark | `app_slogan` | Chiave `app.slogan` **non usata** | Collegare `L10n.t("app.slogan")` | | Slogan wordmark | `app_slogan` | `L10n.t("app.slogan")` | **Fatto** |
| Hub Matches | Sì | Quasi tutto IT fisso | Localizzare con chiavi `matches.*` | | Hub Matches | Sì | `matches.*` | **Fatto** |
| Sheet/dialog Matches | Sì (`sheet_*`) | IT fisso | Localizzare (priorità: scegli squadra) | | Sheet/dialog Matches | Sì (`sheet_*`) | `sheet.*` | **Fatto** |
| Status badge partita | `match_status_*` | IT | Localizzare | | Status badge partita | `match_status_*` | `match.status.*` | **Fatto** |
| Snackbar hub | `matches_msg_*` | IT | Localizzare | | Snackbar hub | `matches_msg_*` | `matches.msg.*` | **Fatto** |
| Wizard 3 step | Sì (`wizard_*`) | IT | Localizzare | | Wizard 3 step | Sì (`wizard_*`) | `wizard.*` | **Fatto** |
| Branding / color picker | Sì | IT | Localizzare | | Branding / color picker | Sì | `wizard.branding.*` / `wizard.color.hue` | **Fatto** |
| Broadcast + score dialog | Sì (`broadcast_*`, `score_*`) | IT | Localizzare | | Broadcast + score dialog | Sì | `broadcast.*` / `score.*` | **Fatto** |
| Catalogo stringhe | `values` + `values-en/fr/de/es` (~230 chiavi) | ~14 chiavi in `AppLanguage.swift` | Espandere `L10n` (o migrare a `Localizable.xcstrings`) | | Catalogo stringhe | `values*` (~230) | `AppLanguage.swift` L10n (~225, no FGS) | **Fatto** |
| Logout UI | Icona | Testo | Icona SF Symbol | | Logout UI | Icona | Icona SF Symbol | **Fatto** |
| Versione | `2.0.5-native` / **26** | `2.0.0` / **21** | Bump a **2.0.5** / **26** (o build successiva se conflitto TestFlight) | | Versione | `2.0.5-native` / **26** | `2.0.5` / **26** | **Fatto** |
| RTMP ingest | `RtmpIngestUrl.kt` | `MediaUrl.swift` | OK in prod; allineare solo semantica dev se serve | | 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 ## File di riferimento
### Android (sorgente di verità — già fatto) ### Android (sorgente di verità)
| Ruolo | Path | | Ruolo | Path |
|-------|------| |-------|------|
| Locale + ProvideAppLocale | `native/android/.../core/AppLocale.kt` | | 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}/` | | Stringhe | `native/android/app/src/main/res/values/strings.xml` + `values-{en,fr,de,es}/` |
| Login | `.../ui/login/LoginScreen.kt` | | UI | Login / Matches / MatchSheets / Wizard / Broadcast |
| 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` |
### iOS (da aggiornare) ### iOS (allineato)
| Ruolo | Path | | Ruolo | Path |
|-------|------| |-------|------|
| Lingua + L10n | `native/ios/MatchLiveTv/Core/AppLanguage.swift` | | Lingua + L10n | `native/ios/MatchLiveTv/Core/AppLanguage.swift` |
| Login | `native/ios/MatchLiveTv/UI/Login/LoginScreen.swift` | | Login | `native/ios/MatchLiveTv/UI/Login/LoginScreen.swift` |
| Hub | `native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift` | | Hub + sheet | `native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift` |
| Sheet/dialog Matches | file sheet/alert equivalenti sotto `UI/Matches/` (cercare stringhe IT) | | Status badge | `native/ios/MatchLiveTv/Domain/MatchHubFilter.swift` (`MatchPresentation`) |
| Wizard | `UI/Wizard/` (o path equivalente) | | Wizard | `native/ios/MatchLiveTv/UI/Wizard/*.swift` |
| Broadcast | `UI/Broadcast/` (o path equivalente) | | Broadcast | `native/ios/MatchLiveTv/UI/Broadcast/*.swift` |
| Wordmark | `native/ios/MatchLiveTv/UI/Components/MatchLiveWordmark.swift` | | Wordmark | `native/ios/MatchLiveTv/UI/Components/MatchLiveWordmark.swift` |
| Auth / splash equivalent | entry root / session restore | | Versioni | `generate_xcodeproj.py`, `Info.plist`, `project.pbxproj` |
| Versioni | `generate_xcodeproj.py`, `project.pbxproj`, `Info.plist` |
| Build | `scripts/build_ios_release.sh` |
--- ---
## Piano di lavoro iOS (replicare Android — ordine obbligato) ## Convenzione chiavi
### Fase A — Infrastruttura stringhe (prima di tutto) - Android `matches_hello` → iOS `matches.hello` (ogni `_``.`)
- Placeholder: `%1$s` / `%1$d``%1$@` / `%1$d` con `L10n.t("key", args...)`
1. Aprire Android `values/strings.xml` e `values-en|fr|de|es/strings.xml` come catalogo. - Catalogo generato da `values*/strings.xml` (escluse `streaming_notification_*`, solo Android FGS)
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 lutente (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à.
--- ---
@@ -200,16 +119,14 @@ Non lasciare fuori scope wizard/broadcast: su Android **sono già tradotti**; iO
11. Spot-check FR/DE/ES. 11. Spot-check FR/DE/ES.
12. Release: `./scripts/build_ios_release.sh` 12. Release: `./scripts/build_ios_release.sh`
Test:
```bash ```bash
cd native/ios cd native/ios
python3 generate_xcodeproj.py python3 generate_xcodeproj.py
xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \ xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \
-destination 'platform=iOS Simulator,name=iPhone 17' \ -destination 'generic/platform=iOS Simulator' \
-derivedDataPath build/DerivedData \ -derivedDataPath build/DerivedData \
ONLY_ACTIVE_ARCH=YES ARCHS=arm64 EXCLUDED_ARCHS=x86_64 \ 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. Nessuna modifica server richiesta.
### Artefatti Android Play (riferimento) Versione store target: **`2.0.5` / build `26`** (parità con Android `2.0.5-native` / versionCode `26`).
```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`**.
--- ---
## Sintesi priorità (ordine di esecuzione) ## Sintesi
1. **A** Catalogo stringhe completo (copiare da Android `values*`) Fasi AF 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.
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 AF sono chiusi, iOS è allineato ad Android **2.0.5-native** per UX multilingua, stabilità sessione e versioning store.
@@ -6,21 +6,10 @@ import android.content.IntentFilter
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.os.BatteryManager import android.os.BatteryManager
import android.os.Build
import android.os.PowerManager
enum class ThermalLevel {
NORMAL,
WARM,
HOT,
CRITICAL,
}
data class DeviceHealthSnapshot( data class DeviceHealthSnapshot(
val batteryPercent: Int, val batteryPercent: Int,
val batteryTempC: Float?, val thermalState: ThermalState,
val thermalLevel: ThermalLevel,
val thermalLabel: String,
) )
object DeviceTelemetry { object DeviceTelemetry {
@@ -38,16 +27,13 @@ object DeviceTelemetry {
} }
}.getOrDefault("Sconosciuto") }.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 batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val batteryPercent = readBatteryPercent(batteryIntent) val batteryPercent = readBatteryPercent(batteryIntent)
val batteryTempC = readBatteryTemperatureC(batteryIntent) val thermal = thermalState ?: ThermalMonitor(context, Runnable::run).currentState
val thermalLevel = resolveThermalLevel(context, batteryTempC)
return DeviceHealthSnapshot( return DeviceHealthSnapshot(
batteryPercent = batteryPercent, batteryPercent = batteryPercent,
batteryTempC = batteryTempC, thermalState = thermal,
thermalLevel = thermalLevel,
thermalLabel = thermalLabel(thermalLevel),
) )
} }
@@ -58,46 +44,4 @@ object DeviceTelemetry {
if (level < 0 || scale <= 0) return 100 if (level < 0 || scale <= 0) return 100
return ((level * 100f) / scale).toInt().coerceIn(0, 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"
}
} }
@@ -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"
}
}
@@ -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 -> "🔴"
}
}
@@ -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<ThermalState> = _state.asStateFlow()
private val _noticeMessage = MutableStateFlow<String?>(null)
val noticeMessage: StateFlow<String?> = _noticeMessage.asStateFlow()
private val _showCriticalAlert = MutableStateFlow(false)
val showCriticalAlert: StateFlow<Boolean> = _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),
)
}
}
}
@@ -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 { private fun obtainStream(cfg: BroadcastConfig): GenericStream {
val existing = genericStream val existing = genericStream
if (existing != null) return existing if (existing != null) return existing
@@ -63,7 +63,7 @@ import coil.compose.AsyncImage
import com.matchlivetv.match_live_tv.R import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.core.resolveMediaUrl import com.matchlivetv.match_live_tv.core.resolveMediaUrl
import com.matchlivetv.match_live_tv.core.DeviceHealthSnapshot 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.domain.ScoreState
import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge
import com.matchlivetv.match_live_tv.ui.theme.MatchColors import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@@ -326,29 +326,20 @@ private fun BroadcastTelemetryPanel(
style = labelStyle, style = labelStyle,
color = MatchColors.TextSecondary, color = MatchColors.TextSecondary,
) )
ThermalIndicator( ThermalIndicator(state = deviceHealth.thermalState)
tempC = deviceHealth.batteryTempC,
level = deviceHealth.thermalLevel,
label = deviceHealth.thermalLabel,
)
} }
} }
@Composable @Composable
private fun ThermalIndicator( private fun ThermalIndicator(state: ThermalState) {
tempC: Float?, val color = when (state) {
level: ThermalLevel, ThermalState.NOMINAL -> MatchColors.SuccessGreen
label: String, ThermalState.FAIR -> MatchColors.AccentYellow
) { ThermalState.SERIOUS -> Color(0xFFFF9800)
val color = when (level) { ThermalState.CRITICAL -> MatchColors.PrimaryRed
ThermalLevel.NORMAL -> MatchColors.SuccessGreen
ThermalLevel.WARM -> MatchColors.AccentYellow
ThermalLevel.HOT -> Color(0xFFFF9800)
ThermalLevel.CRITICAL -> MatchColors.PrimaryRed
} }
val tempText = tempC?.let { "${it.toInt()}°C" } ?: "—°C" val warningBg = when (state) {
val warningBg = when (level) { ThermalState.NOMINAL -> Color.Transparent
ThermalLevel.NORMAL -> Color.Transparent
else -> color.copy(alpha = 0.18f) else -> color.copy(alpha = 0.18f)
} }
Row( Row(
@@ -360,21 +351,18 @@ private fun ThermalIndicator(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( Text(
tempText, state.indicatorSymbol,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = color,
fontWeight = if (level >= ThermalLevel.WARM) FontWeight.Bold else FontWeight.Normal,
) )
if (level >= ThermalLevel.WARM) {
Text( Text(
label, state.displayLabel,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = color, color = color,
fontWeight = FontWeight.Bold, fontWeight = if (state >= ThermalState.FAIR) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
) )
} }
} }
}
private fun formatBitrateKbps(kbps: Long): String { private fun formatBitrateKbps(kbps: Long): String {
if (kbps >= 1000) return "${"%.1f".format(kbps / 1000.0)} Mbps" if (kbps >= 1000) return "${"%.1f".format(kbps / 1000.0)} Mbps"
@@ -8,11 +8,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -32,6 +34,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import com.matchlivetv.match_live_tv.R import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.core.DeviceTelemetry 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.parseColorHex
import com.matchlivetv.match_live_tv.core.resolveMediaUrl import com.matchlivetv.match_live_tv.core.resolveMediaUrl
import com.matchlivetv.match_live_tv.core.RtmpIngestUrl import com.matchlivetv.match_live_tv.core.RtmpIngestUrl
@@ -77,6 +81,13 @@ fun BroadcastScreen(
var controlsVisible by remember { mutableStateOf(true) } var controlsVisible by remember { mutableStateOf(true) }
var logoReady by remember { mutableStateOf(0) } var logoReady by remember { mutableStateOf(0) }
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) } 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 metrics by container.broadcastCoordinator.metrics.collectAsState()
val score by container.scoreController.score.collectAsState() val score by container.scoreController.score.collectAsState()
val cableConnected by container.sessionCable.connected.collectAsState() val cableConnected by container.sessionCable.connected.collectAsState()
@@ -128,7 +139,9 @@ fun BroadcastScreen(
session = updated session = updated
val url = updated.rtmpIngestUrl val url = updated.rtmpIngestUrl
if (!url.isNullOrBlank()) { 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) updated = container.sessionRepository.fetchSession(sessionId)
session = updated session = updated
@@ -157,7 +170,9 @@ fun BroadcastScreen(
resumeInFlight = true resumeInFlight = true
try { try {
session = current 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)) snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_resumed_remote))
} catch (e: Exception) { } catch (e: Exception) {
snackbar.showSnackbar( snackbar.showSnackbar(
@@ -207,7 +222,11 @@ fun BroadcastScreen(
if (url.isNullOrBlank()) { if (url.isNullOrBlank()) {
error(context.getString(R.string.broadcast_error_rtmp_missing)) error(context.getString(R.string.broadcast_error_rtmp_missing))
} else if (!loaded.isPaused) { } 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 { }.onFailure {
error = it.message ?: context.getString(R.string.broadcast_error_session_unavailable) error = it.message ?: context.getString(R.string.broadcast_error_session_unavailable)
@@ -280,11 +299,23 @@ fun BroadcastScreen(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (true) { while (true) {
deviceHealth = DeviceTelemetry.snapshot(context) deviceHealth = DeviceTelemetry.snapshot(context, thermalState)
delay(2_000) delay(2_000)
} }
} }
LaunchedEffect(thermalNotice) {
thermalNotice?.let { snackbar.showSnackbar(it) }
}
LaunchedEffect(Unit) {
thermalManager.start()
while (true) {
thermalManager.tickCriticalWatch()
delay(1_000)
}
}
LaunchedEffect(sessionId) { LaunchedEffect(sessionId) {
container.sessionCable.connected.collect { connected -> container.sessionCable.connected.collect { connected ->
if (!connected) return@collect if (!connected) return@collect
@@ -328,6 +359,7 @@ fun BroadcastScreen(
DisposableEffect(sessionId) { DisposableEffect(sessionId) {
onDispose { onDispose {
thermalManager.stop()
container.sessionCable.onScoreUpdate = null container.sessionCable.onScoreUpdate = null
container.sessionCable.onPauseStream = null container.sessionCable.onPauseStream = null
container.sessionCable.onResumeStream = null container.sessionCable.onResumeStream = null
@@ -545,11 +577,38 @@ fun BroadcastScreen(
targetFps = currentSession?.targetFps ?: 30, targetFps = currentSession?.targetFps ?: 30,
bitrateKbps = metrics.bitrateKbps, bitrateKbps = metrics.bitrateKbps,
networkType = DeviceTelemetry.networkType(context), 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( SnackbarHost(
hostState = snackbar, hostState = snackbar,
modifier = Modifier modifier = Modifier
@@ -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)
}
}
+241 -623
View File
@@ -1,654 +1,272 @@
// !$*UTF8*$! // !$*UTF8*$!
{ {
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {};
};
objectVersion = 60; objectVersion = 60;
objects = { objects = {
4FC590728C2F47DE94AEADA3 /* MatchLiveTvApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveTvApp.swift; path = MatchLiveTv/App/MatchLiveTvApp.swift; sourceTree = "<group>"; };
/* Begin PBXBuildFile section */ 69F95F7CAF894139BA244AFF /* ApiInstant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstant.swift; path = MatchLiveTv/Core/ApiInstant.swift; sourceTree = "<group>"; };
01EF2265D1624199B171D57E /* LivePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3F630293570453BB85BE288 /* LivePreviewView.swift */; }; 5E30D25B02704FAE9C0A4040 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppConfig.swift; path = MatchLiveTv/Core/AppConfig.swift; sourceTree = "<group>"; };
035B95F7CE8643C48038A114 /* ApiDtos.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F08BB3F014B44F18A1203FE /* ApiDtos.swift */; }; A59093CB86BB41FE9DF37182 /* AppLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppLanguage.swift; path = MatchLiveTv/Core/AppLanguage.swift; sourceTree = "<group>"; };
0469F385C3BD41489812C2EC /* TeamBrandingEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A71899E82D448797DCE7E8 /* TeamBrandingEditor.swift */; }; C00F6C82C9A04A50B1D34BA3 /* ColorHex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ColorHex.swift; path = MatchLiveTv/Core/ColorHex.swift; sourceTree = "<group>"; };
066B7E46EB6245BA94C12BA6 /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 395345091E364AFF9FFB34B9 /* AppConfig.swift */; }; 6F83B04D0BC84879A07C9BFE /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = "<group>"; };
0D3A76C230854286AA528524 /* AppContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 590CA24C9659490E9A8EC555 /* AppContainer.swift */; }; 33DEE4675BAB413E9F69549B /* MediaUrl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MediaUrl.swift; path = MatchLiveTv/Core/MediaUrl.swift; sourceTree = "<group>"; };
0D666A37E49749C68C0DB06B /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A23B62DE25C94F47B560D742 /* ShareSheet.swift */; }; 14FE2B5F844A482C91245755 /* ThermalState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThermalState.swift; path = MatchLiveTv/Core/Thermal/ThermalState.swift; sourceTree = "<group>"; };
0D81FEE2FA88468C98652291 /* WizardShellScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29358C1BDC864C408AB4FAD0 /* WizardShellScreen.swift */; }; AA9DF2F1C5F04EA198286266 /* ThermalStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThermalStateManager.swift; path = MatchLiveTv/Core/Thermal/ThermalStateManager.swift; sourceTree = "<group>"; };
0F6F3A1F9EA74107BDFAF930 /* SessionCableService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3D8CA843FF54424B181675B /* SessionCableService.swift */; }; 8EEFEB75DDB04C4986774D4B /* TokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TokenStore.swift; path = MatchLiveTv/Core/TokenStore.swift; sourceTree = "<group>"; };
100AF197AD334A1AB38D271B /* ModalRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44E182032F64EBBA55ADCEB /* ModalRoutes.swift */; }; C84694361A004982B4007CA5 /* UserFacingError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = UserFacingError.swift; path = MatchLiveTv/Core/UserFacingError.swift; sourceTree = "<group>"; };
226769D5E66848FB90B5BFEE /* MatchStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5964C8AF73D4617BB437E4A /* MatchStatusBadge.swift */; }; 1AD71EE7F4A54818A6C427C9 /* ApiDtos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiDtos.swift; path = MatchLiveTv/Data/API/ApiDtos.swift; sourceTree = "<group>"; };
2B400EFC857D4EDF9CCF73B9 /* BroadcastModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 820BDE9FBCCB4BABACD2AC89 /* BroadcastModels.swift */; }; 610902FBCEB7478286C88C08 /* MatchLiveAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveAPI.swift; path = MatchLiveTv/Data/API/MatchLiveAPI.swift; sourceTree = "<group>"; };
35B3BCE4D68F47C4ABD1E955 /* MatchPrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E92E0A7DB54267AC53F60A /* MatchPrimaryButton.swift */; }; DD9687B43B5840DD96FAD1C9 /* AppContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppContainer.swift; path = MatchLiveTv/Data/AppContainer.swift; sourceTree = "<group>"; };
3E25B3A6F51242B9847DBA6F /* ScoreState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A6EEEAD38F4F509310B82E /* ScoreState.swift */; }; 905A6416560A4442AA7D2FCF /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = "<group>"; };
3EF78D9394DF41D89E0A43F8 /* SponsorElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC3D4B19E974898AA9CAC76 /* SponsorElement.swift */; }; 9299E98557E04C5F8DC736F8 /* SessionCableService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionCableService.swift; path = MatchLiveTv/Data/Cable/SessionCableService.swift; sourceTree = "<group>"; };
3F5B3BD00C6F48379469C171 /* TokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC6373E955749DF9AB5DB5B /* TokenStore.swift */; }; 49E1D454901048A58DCE7C41 /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AuthRepository.swift; path = MatchLiveTv/Data/Repository/AuthRepository.swift; sourceTree = "<group>"; };
42EF33716F6843B69945E6AB /* MatchLiveTvApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EB02413D134D6289DB90DA /* MatchLiveTvApp.swift */; }; A90696A40A584C5794938FDA /* MatchRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchRepository.swift; path = MatchLiveTv/Data/Repository/MatchRepository.swift; sourceTree = "<group>"; };
46F6F77D670A49BFB4A9B757 /* UserFacingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109F8ABCCCF649EFB3E6647C /* UserFacingError.swift */; }; 2C3F8293D48D44A6A569F452 /* MatchSessionLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSessionLauncher.swift; path = MatchLiveTv/Data/Repository/MatchSessionLauncher.swift; sourceTree = "<group>"; };
4C1FDA2D80034A2FB6550DAB /* MatchRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AF23C6CDE24E1AB227F019 /* MatchRepository.swift */; }; 347DADA50AE44484ABF19903 /* ScoreRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreRepository.swift; path = MatchLiveTv/Data/Repository/ScoreRepository.swift; sourceTree = "<group>"; };
4C2639482FE6DA960015AAF3 /* HaishinKit in Frameworks */ = {isa = PBXBuildFile; productRef = BC6C276807664B6CB76A8A9D /* HaishinKit */; }; 69794703A6D74DB9AA3D16A2 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = "<group>"; };
4C2639492FE6DA960015AAF3 /* RTMPHaishinKit in Frameworks */ = {isa = PBXBuildFile; productRef = 8F64C5AA49464C419712EAC5 /* RTMPHaishinKit */; }; D274E6DA2B7B47C9822FB517 /* ScoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreController.swift; path = MatchLiveTv/Data/Scoring/ScoreController.swift; sourceTree = "<group>"; };
4D74385A3406406ABFA45B44 /* OverlayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA288DB764E24438AE8826DC /* OverlayState.swift */; }; E4BAEE4AB7834E19BA57C4A1 /* WizardSessionHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardSessionHolder.swift; path = MatchLiveTv/Data/WizardSessionHolder.swift; sourceTree = "<group>"; };
4FFA52FD7FF8410AA8FA673E /* MatchLiveAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9889D83B0787422397EF3181 /* MatchLiveAPI.swift */; }; 1060DB32269548BE895EFA7F /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = "<group>"; };
52EB3701353841BDB837C75E /* WatermarkElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D95106A21804A2EAEDCECB7 /* WatermarkElement.swift */; }; CA265A4F2CAA41DA8F68A35C /* MatchScoringRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRules.swift; path = MatchLiveTv/Domain/MatchScoringRules.swift; sourceTree = "<group>"; };
539D6877DF3C478E86200649 /* LiveBroadcastCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F2049B408743AA82AF43F3 /* LiveBroadcastCoordinator.swift */; }; 0381775B0B1D4378A8BF289A /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Models.swift; path = MatchLiveTv/Domain/Models.swift; sourceTree = "<group>"; };
557CA481D63840D3AA6EFD5B /* ColorHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68D5ED55DB884EA2AC986F61 /* ColorHex.swift */; }; 85D4CBF80A8F447790235E7C /* ScoreState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreState.swift; path = MatchLiveTv/Domain/ScoreState.swift; sourceTree = "<group>"; };
5AE324386FA64E1C8BA5F21F /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F961133AE74D4ECE9FDC3EBF /* LoginScreen.swift */; }; 2EA97648294C4D98B3D85EFD /* BroadcastModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastModels.swift; path = MatchLiveTv/Streaming/BroadcastModels.swift; sourceTree = "<group>"; };
5BEB4A09B526452D8CCBBE1B /* SessionRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3691F2136AAD4545B655F544 /* SessionRepository.swift */; }; FA4A1042B44B41E0AE768E0C /* BroadcastOrientationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastOrientationPolicy.swift; path = MatchLiveTv/Streaming/BroadcastOrientationPolicy.swift; sourceTree = "<group>"; };
5CA7D12629854A4A9B9DF084 /* TeamColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68C47301A29345819A2CDEC8 /* TeamColorPicker.swift */; }; 97DF04B6B21743BC9615E634 /* BroadcastVideoOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastVideoOrientation.swift; path = MatchLiveTv/Streaming/BroadcastVideoOrientation.swift; sourceTree = "<group>"; };
5DB4B7C040F6449198E5D07D /* ActionCableClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E91F93272E41A78E4CBECC /* ActionCableClient.swift */; }; 1B031A4D51634E96BBD3D8DA /* LiveBroadcastCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastCoordinator.swift; path = MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift; sourceTree = "<group>"; };
60D2848965684DB5BFEBD5E3 /* MatchLiveTv/Resources/Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1B4E0A284897418CB3B60E19 /* MatchLiveTv/Resources/Assets.xcassets */; }; BC442280384D4194A66F25D2 /* LiveBroadcastEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastEngine.swift; path = MatchLiveTv/Streaming/LiveBroadcastEngine.swift; sourceTree = "<group>"; };
63CB7A84409D49CEBD74DE4D /* AuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1B3BC7CA8B4B25BCC77F6E /* AuthRepository.swift */; }; EF6E8429A9DF475E9C964A2A /* CompactScoreboardElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CompactScoreboardElement.swift; path = MatchLiveTv/Streaming/Overlay/CompactScoreboardElement.swift; sourceTree = "<group>"; };
67818D53F7EA46C2A4850214 /* ScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE709C8660614B9DA5A2D657 /* ScoreRepository.swift */; }; AA419130C5A248DCA3B4A016 /* OverlayCanvasRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayCanvasRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayCanvasRenderer.swift; sourceTree = "<group>"; };
6B4C7990A5D94A2C8AC69FC1 /* WizardComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7250114A6FB34430BB20B414 /* WizardComponents.swift */; }; 8CF76F8D690C405D9FCC4B8E /* OverlayLogoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayLogoCache.swift; path = MatchLiveTv/Streaming/Overlay/OverlayLogoCache.swift; sourceTree = "<group>"; };
6DFF8FAA4ECF46F1928851A4 /* ScoreboardElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B9108C739D484BAD4FF40F /* ScoreboardElement.swift */; }; E1A1C8A803FB4CA1BA741E57 /* OverlayMappings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayMappings.swift; path = MatchLiveTv/Streaming/Overlay/OverlayMappings.swift; sourceTree = "<group>"; };
727B74004C3741D9BA510F3B /* LiveScoreDialogHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FB35A567D04317B0421156 /* LiveScoreDialogHostTests.swift */; }; 195EFB33D08D484489FD721B /* OverlayRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayRenderer.swift; sourceTree = "<group>"; };
735CF034B3BA49BE9BBDD261 /* AppNavHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1265527D9925494BAE66B5A0 /* AppNavHost.swift */; }; DB0DF408F60149008C9644C0 /* OverlayState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayState.swift; path = MatchLiveTv/Streaming/Overlay/OverlayState.swift; sourceTree = "<group>"; };
75D6916EAD4E46CA9E36582C /* MatchScoringRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87A9A8AEACEA48FABD06BD52 /* MatchScoringRulesTests.swift */; }; E9F0583912AA42F787751A7F /* ScoreboardElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreboardElement.swift; path = MatchLiveTv/Streaming/Overlay/ScoreboardElement.swift; sourceTree = "<group>"; };
7636A7A0E3CC477799338D29 /* LiveBroadcastCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B55F65BAA6146AF84B010CC /* LiveBroadcastCoordinatorTests.swift */; }; FB19CB746D6146D9ABD1A1C4 /* SponsorElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SponsorElement.swift; path = MatchLiveTv/Streaming/Overlay/SponsorElement.swift; sourceTree = "<group>"; };
794CC6694C74486BAB61CA3B /* ApiInstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0707542005B4DCC90E3A072 /* ApiInstantTests.swift */; }; CD8C48EA12DB45FF978FA049 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = "<group>"; };
7D0DF967FC03423394839915 /* OverlayMappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611ECD9494B94B75B9866791 /* OverlayMappings.swift */; }; 838A41F3BF1E42BDAA2CE2E6 /* LivePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LivePreviewView.swift; path = MatchLiveTv/Streaming/Preview/LivePreviewView.swift; sourceTree = "<group>"; };
8179BD051391404B9E2235C2 /* KeepScreenOn.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D75DB685D64B93A65E9FC8 /* KeepScreenOn.swift */; }; 2074992AD4E94430B9524FB3 /* StreamVideoPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamVideoPreset.swift; path = MatchLiveTv/Streaming/StreamVideoPreset.swift; sourceTree = "<group>"; };
81DB6B2543BC407EBB056C4B /* BroadcastScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985215153D0A44DC8B429731 /* BroadcastScreen.swift */; }; B51507FF41FB4B80831D70E3 /* BroadcastControlsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastControlsOverlay.swift; path = MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift; sourceTree = "<group>"; };
833558A9A0A043ECB8E146F6 /* ScoreActionDecodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3A1338FBA0411587BB5F4B /* ScoreActionDecodeTests.swift */; }; 8EA7E4972BB74FDD884370D0 /* BroadcastScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastScreen.swift; path = MatchLiveTv/UI/Broadcast/BroadcastScreen.swift; sourceTree = "<group>"; };
87DB3FBE122F444B87B9D73B /* LiveBroadcastEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8B7397C0BD4EC59851185D /* LiveBroadcastEngine.swift */; }; CC2B124ACD024538B20863B8 /* LiveScoreActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveScoreActions.swift; path = MatchLiveTv/UI/Broadcast/LiveScoreActions.swift; sourceTree = "<group>"; };
F1A2B3C4D5E6478990ABCDEF /* StreamVideoPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6478990ABCDEF /* StreamVideoPreset.swift */; }; 31B0AEBA3A0140D2BC9698DC /* MatchLiveWordmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveWordmark.swift; path = MatchLiveTv/UI/Components/MatchLiveWordmark.swift; sourceTree = "<group>"; };
8841513B4E1743E983E7D2C8 /* BroadcastControlsOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF2832197EE409AA0B0334C /* BroadcastControlsOverlay.swift */; }; 0C84054AB3574CC5B8056694 /* MatchPrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchPrimaryButton.swift; path = MatchLiveTv/UI/Components/MatchPrimaryButton.swift; sourceTree = "<group>"; };
8C32CAC69405444DA4BB1E36 /* ScoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41CF197E5D543CE9A949497 /* ScoreController.swift */; }; D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; };
8D7FCD0052674C0EB4F5C811 /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63D6F174CE084074972CABF6 /* MatchesScreen.swift */; }; 3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSecondaryButton.swift; path = MatchLiveTv/UI/Components/MatchSecondaryButton.swift; sourceTree = "<group>"; };
8F6D3E3D61584BBA865AD2D7 /* OverlayLogoCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10BEC6633F9F446587F1818C /* OverlayLogoCache.swift */; }; 1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; };
919B83984A5347989781366F /* MatchScoringRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48D1A4C43A24A1E815A646D /* MatchScoringRules.swift */; }; DA51BC65490E421AA3D16F02 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoginScreen.swift; path = MatchLiveTv/UI/Login/LoginScreen.swift; sourceTree = "<group>"; };
92E4CAC8F45141A8899749C5 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DF32351AC9D47F39C23B29A /* Models.swift */; }; 0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchesScreen.swift; path = MatchLiveTv/UI/Matches/MatchesScreen.swift; sourceTree = "<group>"; };
AA9BCB0DA5164D43BD7A43FA /* CompactScoreboardElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC36378EDDDF44F29ACAD3C6 /* CompactScoreboardElement.swift */; }; 5DD00428364442C6B8C55DDF /* AppNavHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppNavHost.swift; path = MatchLiveTv/UI/Navigation/AppNavHost.swift; sourceTree = "<group>"; };
AEA8CCA49E514EA3984F297E /* BroadcastOrientationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE4871BCA51457CB720047B /* BroadcastOrientationPolicy.swift */; }; 9F958AB307244896BD8948D2 /* ModalRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ModalRoutes.swift; path = MatchLiveTv/UI/Navigation/ModalRoutes.swift; sourceTree = "<group>"; };
AFA2540B2280448A81DFF5CF /* Routes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586F014DA6724EEB810107AD /* Routes.swift */; }; 21C2A6AB0E164C8CBDF9CF7F /* Routes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Routes.swift; path = MatchLiveTv/UI/Navigation/Routes.swift; sourceTree = "<group>"; };
B6F8B4BA2E49480DBD2CA846 /* MatchScreenScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7E9EFA6947F4A799570A271 /* MatchScreenScaffold.swift */; }; 1702AF57020940DEBF0F9BA0 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = "<group>"; };
C1D2EA29B25C4B06B03B6FFE /* BroadcastVideoOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA50054BF694B9D82EA97ED /* BroadcastVideoOrientation.swift */; }; 3004C07E608644AFB636C0C5 /* SplashScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SplashScreen.swift; path = MatchLiveTv/UI/Splash/SplashScreen.swift; sourceTree = "<group>"; };
C3B1B44C77084C05B325F935 /* StepNetworkTestScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C26A0114F73483A85C70772 /* StepNetworkTestScreen.swift */; }; DC39718A2EC74B078A0C5DE9 /* KeepScreenOn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KeepScreenOn.swift; path = MatchLiveTv/UI/System/KeepScreenOn.swift; sourceTree = "<group>"; };
C9B90AAE70C142DEB8996100 /* ApiInstant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 354D09291641417F99E27D5A /* ApiInstant.swift */; }; BCEB22897600469E93762209 /* ScreenOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScreenOrientation.swift; path = MatchLiveTv/UI/System/ScreenOrientation.swift; sourceTree = "<group>"; };
A1B2C3D4E5F64789A0B1C2D3 /* AppLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F6478901A2B3C4D5 /* AppLanguage.swift */; }; 13BC17CE70034FEE97707B21 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ShareSheet.swift; path = MatchLiveTv/UI/System/ShareSheet.swift; sourceTree = "<group>"; };
CF8E45A2BFE34E4BBE259654 /* DeviceTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 258DECC661314224B6A98F47 /* DeviceTelemetry.swift */; }; AAAD219E4FA94CF885472711 /* MatchColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchColors.swift; path = MatchLiveTv/UI/Theme/MatchColors.swift; sourceTree = "<group>"; };
D08665EFFF27465294B620B5 /* ScoreControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F7A645B14A44C69304E136 /* ScoreControllerTests.swift */; }; 29F84FF3F1E74E49A3EA5101 /* StepMatchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepMatchScreen.swift; path = MatchLiveTv/UI/Wizard/StepMatchScreen.swift; sourceTree = "<group>"; };
DBBB006ED6C946A2950D805C /* StepMatchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8992D152CF7F478BA6469E36 /* StepMatchScreen.swift */; }; 1875AE0A77B94DF0921EE457 /* StepNetworkTestScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepNetworkTestScreen.swift; path = MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift; sourceTree = "<group>"; };
DC848628856A4FCB86EE9DA7 /* MatchSecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8736A8A330D4815A3D56104 /* MatchSecondaryButton.swift */; }; 3168221266004A33BA35042C /* StepTransmissionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepTransmissionScreen.swift; path = MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift; sourceTree = "<group>"; };
DF09C441105448CDBAFBD9AF /* OverlayRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49FD73A6D4254EAD9484E159 /* OverlayRenderer.swift */; }; C97603C3A54342799CBD0C03 /* TeamBrandingEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TeamBrandingEditor.swift; path = MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift; sourceTree = "<group>"; };
DFD70B8BFDAD427DA63CD6B1 /* MatchSessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7060C0C306D44E9AA8AAF5D4 /* MatchSessionLauncher.swift */; }; 5AFEAFB3E8F047B9A2B0DD04 /* TeamColorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TeamColorPicker.swift; path = MatchLiveTv/UI/Wizard/TeamColorPicker.swift; sourceTree = "<group>"; };
E01D498C8EF64F85A41EAC54 /* StepTransmissionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94EDDD3FE78C4DC288424A6E /* StepTransmissionScreen.swift */; }; CE7A5A0E6D9A49EA95918E79 /* WizardComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardComponents.swift; path = MatchLiveTv/UI/Wizard/WizardComponents.swift; sourceTree = "<group>"; };
E15E57A4C69D49F7AB0C916A /* BroadcastPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC868CFCFB6429F84E481B4 /* BroadcastPermissions.swift */; }; 68CB1416AC24491CAA4CE87A /* WizardShellScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardShellScreen.swift; path = MatchLiveTv/UI/Wizard/WizardShellScreen.swift; sourceTree = "<group>"; };
E4C3F469616A42818F63CE89 /* WizardSessionHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 973C4EEE1DCD40448D7F2FAD /* WizardSessionHolder.swift */; }; 73BB032A0DD74458B0E716C6 /* ApiInstantTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstantTests.swift; path = MatchLiveTvTests/ApiInstantTests.swift; sourceTree = "<group>"; };
E5ECF142981E4D58B3040FB5 /* MatchColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E15E2F8F9114F299D8EB017 /* MatchColors.swift */; }; C186E88B35A0443494AE6E8E /* BroadcastOrientationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastOrientationPolicyTests.swift; path = MatchLiveTvTests/BroadcastOrientationPolicyTests.swift; sourceTree = "<group>"; };
E75B706F137249FF807FE0DD /* BroadcastOrientationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49FDEC2FCAD14E7C9D671A27 /* BroadcastOrientationPolicyTests.swift */; }; 5751DB993C52460D960B8067 /* LiveBroadcastCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastCoordinatorTests.swift; path = MatchLiveTvTests/LiveBroadcastCoordinatorTests.swift; sourceTree = "<group>"; };
EB3031C7972041F2BE6C3933 /* MediaUrl.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0BD001D8384E1CAD5A327B /* MediaUrl.swift */; }; 0E243C00009048F6A778323C /* LiveScoreDialogHostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveScoreDialogHostTests.swift; path = MatchLiveTvTests/LiveScoreDialogHostTests.swift; sourceTree = "<group>"; };
ECA9E663AF8B4F19BA4AD6D2 /* ScreenOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A279824047D4B7EB08A3F30 /* ScreenOrientation.swift */; }; 634AD5C2E53B4C098582284A /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = "<group>"; };
ED730C9B9068449E8D8AF6C8 /* OverlayCanvasRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B5BF42CCF934AC7A9516374 /* OverlayCanvasRenderer.swift */; }; B7DE055717014393B3F62D5A /* ScoreActionDecodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreActionDecodeTests.swift; path = MatchLiveTvTests/ScoreActionDecodeTests.swift; sourceTree = "<group>"; };
F0497D8B51DE4173AF395AF8 /* MatchHubFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F71C110A07B4F71AB71E6ED /* MatchHubFilter.swift */; }; C3C1AC6562ED4A908D7C8B0A /* ScoreControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreControllerTests.swift; path = MatchLiveTvTests/ScoreControllerTests.swift; sourceTree = "<group>"; };
F065E5BE5EC34955B133C189 /* MatchLiveWordmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C6CE9645ACD4713AFC66C58 /* MatchLiveWordmark.swift */; }; 610A4D03FA384F0287AAF825 /* ThermalAdaptationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThermalAdaptationTests.swift; path = MatchLiveTvTests/ThermalAdaptationTests.swift; sourceTree = "<group>"; };
F8324F96CA074A0B8A71BB7B /* LiveScoreActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A224E4FD5069421AAB0C4CF6 /* LiveScoreActions.swift */; }; F68654552B254000B6F0BE97 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MatchLiveTv/Resources/Assets.xcassets; sourceTree = "<group>"; };
FCDD0B3AA1F046B4B912A2F7 /* SplashScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB3CE35029F144899C81993B /* SplashScreen.swift */; }; 1255FF9134D246ECBE1DF72C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = MatchLiveTv/Resources/Info.plist; sourceTree = "<group>"; };
/* End PBXBuildFile section */ 6DFD25C0855F482DBEFD8757 /* it.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/it.lproj; sourceTree = "<group>"; };
12C3FA28B28146B9A8E3A70A /* en.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/en.lproj; sourceTree = "<group>"; };
/* Begin PBXContainerItemProxy section */ 5D760AAFB9C34DACBFC2387A /* fr.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/fr.lproj; sourceTree = "<group>"; };
B2EE838DC30044B18C9AEE34 /* PBXContainerItemProxy */ = { 85B0F928887C44128434974E /* de.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/de.lproj; sourceTree = "<group>"; };
isa = PBXContainerItemProxy; E907C350C3E5410FB4A2038D /* es.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/es.lproj; sourceTree = "<group>"; };
containerPortal = 456AEB32689E48DEA025761E /* Project object */; 6FF24BB96E9C40F6A9F6E25A /* MatchLiveTv.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MatchLiveTv.app; sourceTree = BUILT_PRODUCTS_DIR; };
proxyType = 1; 8F679C9C03AE47D6A028DBAC /* MatchLiveTvTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MatchLiveTvTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
remoteGlobalIDString = C1A6F02007F74525A4124BD0; 31418599149E4F7684EABE04 /* HaishinKit */ = {isa = XCSwiftPackageProductDependency; package = 4901BAD7A32F4CFD805083BE; productName = HaishinKit; };
remoteInfo = MatchLiveTv; 1712041BB26748D78A1D8F49 /* RTMPHaishinKit */ = {isa = XCSwiftPackageProductDependency; package = 4901BAD7A32F4CFD805083BE; productName = RTMPHaishinKit; };
}; F7624153A1C84C218B602FFB /* MatchLiveTvApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC590728C2F47DE94AEADA3 /* MatchLiveTvApp.swift */; };
/* End PBXContainerItemProxy section */ D973F2B503854C6AA61A1559 /* ApiInstant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F95F7CAF894139BA244AFF /* ApiInstant.swift */; };
FE2D8C8359CC4DA189DF5DC6 /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E30D25B02704FAE9C0A4040 /* AppConfig.swift */; };
/* Begin PBXFileReference section */ B607A146AE754F6A819FB186 /* AppLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59093CB86BB41FE9DF37182 /* AppLanguage.swift */; };
0E15E2F8F9114F299D8EB017 /* MatchColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchColors.swift; path = MatchLiveTv/UI/Theme/MatchColors.swift; sourceTree = "<group>"; }; B0D93B493FD0427CA72F5862 /* ColorHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = C00F6C82C9A04A50B1D34BA3 /* ColorHex.swift */; };
109F8ABCCCF649EFB3E6647C /* UserFacingError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = UserFacingError.swift; path = MatchLiveTv/Core/UserFacingError.swift; sourceTree = "<group>"; }; 3C223B19BA2149C5B7E5CC5A /* DeviceTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F83B04D0BC84879A07C9BFE /* DeviceTelemetry.swift */; };
10BEC6633F9F446587F1818C /* OverlayLogoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayLogoCache.swift; path = MatchLiveTv/Streaming/Overlay/OverlayLogoCache.swift; sourceTree = "<group>"; }; 3D119DE97DE5452D903F364C /* MediaUrl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEE4675BAB413E9F69549B /* MediaUrl.swift */; };
11E92E0A7DB54267AC53F60A /* MatchPrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchPrimaryButton.swift; path = MatchLiveTv/UI/Components/MatchPrimaryButton.swift; sourceTree = "<group>"; }; 8ABF00BDC15C400A9089FC28 /* ThermalState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14FE2B5F844A482C91245755 /* ThermalState.swift */; };
1265527D9925494BAE66B5A0 /* AppNavHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppNavHost.swift; path = MatchLiveTv/UI/Navigation/AppNavHost.swift; sourceTree = "<group>"; }; 7D2649B9131F4DDA83F68B72 /* ThermalStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9DF2F1C5F04EA198286266 /* ThermalStateManager.swift */; };
13EB02413D134D6289DB90DA /* MatchLiveTvApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveTvApp.swift; path = MatchLiveTv/App/MatchLiveTvApp.swift; sourceTree = "<group>"; }; AB71730B7CA24D3E8667BCFF /* TokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EEFEB75DDB04C4986774D4B /* TokenStore.swift */; };
1B4E0A284897418CB3B60E19 /* MatchLiveTv/Resources/Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MatchLiveTv/Resources/Assets.xcassets; sourceTree = "<group>"; }; ADA58448C2344DADAA90F212 /* UserFacingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = C84694361A004982B4007CA5 /* UserFacingError.swift */; };
1B5BF42CCF934AC7A9516374 /* OverlayCanvasRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayCanvasRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayCanvasRenderer.swift; sourceTree = "<group>"; }; 95F7871858C44379A62012BA /* ApiDtos.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AD71EE7F4A54818A6C427C9 /* ApiDtos.swift */; };
1CC868CFCFB6429F84E481B4 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = "<group>"; }; 3FBBEF8DFF5E4F5D9E34C860 /* MatchLiveAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 610902FBCEB7478286C88C08 /* MatchLiveAPI.swift */; };
1D3A1338FBA0411587BB5F4B /* ScoreActionDecodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreActionDecodeTests.swift; path = MatchLiveTvTests/ScoreActionDecodeTests.swift; sourceTree = "<group>"; }; 91694276C76B438F8B52448F /* AppContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9687B43B5840DD96FAD1C9 /* AppContainer.swift */; };
1EF154DE4E184011B1E29261 /* MatchLiveTvTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MatchLiveTvTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 51D849AFAC8D4F57BEF05CAB /* ActionCableClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 905A6416560A4442AA7D2FCF /* ActionCableClient.swift */; };
258DECC661314224B6A98F47 /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = "<group>"; }; D771BABE58A3439CA1768F7F /* SessionCableService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9299E98557E04C5F8DC736F8 /* SessionCableService.swift */; };
27FB35A567D04317B0421156 /* LiveScoreDialogHostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveScoreDialogHostTests.swift; path = MatchLiveTvTests/LiveScoreDialogHostTests.swift; sourceTree = "<group>"; }; 23C149E317BD416D9CF0B171 /* AuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E1D454901048A58DCE7C41 /* AuthRepository.swift */; };
29358C1BDC864C408AB4FAD0 /* WizardShellScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardShellScreen.swift; path = MatchLiveTv/UI/Wizard/WizardShellScreen.swift; sourceTree = "<group>"; }; 43C57FF23CB347AC99284468 /* MatchRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90696A40A584C5794938FDA /* MatchRepository.swift */; };
2DF2832197EE409AA0B0334C /* BroadcastControlsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastControlsOverlay.swift; path = MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift; sourceTree = "<group>"; }; 8661E674C82545C4AEC0A7CE /* MatchSessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C3F8293D48D44A6A569F452 /* MatchSessionLauncher.swift */; };
354D09291641417F99E27D5A /* ApiInstant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstant.swift; path = MatchLiveTv/Core/ApiInstant.swift; sourceTree = "<group>"; }; E133D004CF0A4FF7A51A47B1 /* ScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347DADA50AE44484ABF19903 /* ScoreRepository.swift */; };
B2C3D4E5F6478901A2B3C4D5 /* AppLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppLanguage.swift; path = MatchLiveTv/Core/AppLanguage.swift; sourceTree = "<group>"; }; 72090ACA64CB4885BE7AB333 /* SessionRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69794703A6D74DB9AA3D16A2 /* SessionRepository.swift */; };
3691F2136AAD4545B655F544 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = "<group>"; }; D80261301CA740568D8807D2 /* ScoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D274E6DA2B7B47C9822FB517 /* ScoreController.swift */; };
395345091E364AFF9FFB34B9 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppConfig.swift; path = MatchLiveTv/Core/AppConfig.swift; sourceTree = "<group>"; }; 9ECD8B486DF84890BD39B108 /* WizardSessionHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4BAEE4AB7834E19BA57C4A1 /* WizardSessionHolder.swift */; };
49FD73A6D4254EAD9484E159 /* OverlayRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayRenderer.swift; sourceTree = "<group>"; }; C217B8AD49D04976A6D7FC7E /* MatchHubFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1060DB32269548BE895EFA7F /* MatchHubFilter.swift */; };
49FDEC2FCAD14E7C9D671A27 /* BroadcastOrientationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastOrientationPolicyTests.swift; path = MatchLiveTvTests/BroadcastOrientationPolicyTests.swift; sourceTree = "<group>"; }; 978AAF829F654537AC473F52 /* MatchScoringRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA265A4F2CAA41DA8F68A35C /* MatchScoringRules.swift */; };
51A6EEEAD38F4F509310B82E /* ScoreState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreState.swift; path = MatchLiveTv/Domain/ScoreState.swift; sourceTree = "<group>"; }; 032C790773A848B7A0434C67 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0381775B0B1D4378A8BF289A /* Models.swift */; };
586F014DA6724EEB810107AD /* Routes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Routes.swift; path = MatchLiveTv/UI/Navigation/Routes.swift; sourceTree = "<group>"; }; A37BC192D6C9433F83FABAA8 /* ScoreState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85D4CBF80A8F447790235E7C /* ScoreState.swift */; };
590CA24C9659490E9A8EC555 /* AppContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppContainer.swift; path = MatchLiveTv/Data/AppContainer.swift; sourceTree = "<group>"; }; 99F1D8A337EE46C8BDD34F60 /* BroadcastModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA97648294C4D98B3D85EFD /* BroadcastModels.swift */; };
5C26A0114F73483A85C70772 /* StepNetworkTestScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepNetworkTestScreen.swift; path = MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift; sourceTree = "<group>"; }; F024262D0D0F4B759E1A4F14 /* BroadcastOrientationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4A1042B44B41E0AE768E0C /* BroadcastOrientationPolicy.swift */; };
5F8323C8BC81496A8A1DE9B5 /* MatchLiveTv/Resources/Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = MatchLiveTv/Resources/Info.plist; sourceTree = "<group>"; }; FE9E0B76650246E490722317 /* BroadcastVideoOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97DF04B6B21743BC9615E634 /* BroadcastVideoOrientation.swift */; };
611ECD9494B94B75B9866791 /* OverlayMappings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayMappings.swift; path = MatchLiveTv/Streaming/Overlay/OverlayMappings.swift; sourceTree = "<group>"; }; BD642A0BD19D424985FBF1A3 /* LiveBroadcastCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B031A4D51634E96BBD3D8DA /* LiveBroadcastCoordinator.swift */; };
63D6F174CE084074972CABF6 /* MatchesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchesScreen.swift; path = MatchLiveTv/UI/Matches/MatchesScreen.swift; sourceTree = "<group>"; }; B48421E7BD5D48E499948E2E /* LiveBroadcastEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC442280384D4194A66F25D2 /* LiveBroadcastEngine.swift */; };
68C47301A29345819A2CDEC8 /* TeamColorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TeamColorPicker.swift; path = MatchLiveTv/UI/Wizard/TeamColorPicker.swift; sourceTree = "<group>"; }; C6207D98BB484493BEDD4BBB /* CompactScoreboardElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF6E8429A9DF475E9C964A2A /* CompactScoreboardElement.swift */; };
68D5ED55DB884EA2AC986F61 /* ColorHex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ColorHex.swift; path = MatchLiveTv/Core/ColorHex.swift; sourceTree = "<group>"; }; E715CA8C60E6439683E1DA0E /* OverlayCanvasRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA419130C5A248DCA3B4A016 /* OverlayCanvasRenderer.swift */; };
6C6CE9645ACD4713AFC66C58 /* MatchLiveWordmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveWordmark.swift; path = MatchLiveTv/UI/Components/MatchLiveWordmark.swift; sourceTree = "<group>"; }; 35F5E2E5FB0D4B9FA475ADA4 /* OverlayLogoCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CF76F8D690C405D9FCC4B8E /* OverlayLogoCache.swift */; };
6DF32351AC9D47F39C23B29A /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Models.swift; path = MatchLiveTv/Domain/Models.swift; sourceTree = "<group>"; }; 6D8B0F1792E440C19F49AD03 /* OverlayMappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1C8A803FB4CA1BA741E57 /* OverlayMappings.swift */; };
7060C0C306D44E9AA8AAF5D4 /* MatchSessionLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSessionLauncher.swift; path = MatchLiveTv/Data/Repository/MatchSessionLauncher.swift; sourceTree = "<group>"; }; CC563D93928C438AB0B78E99 /* OverlayRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195EFB33D08D484489FD721B /* OverlayRenderer.swift */; };
7250114A6FB34430BB20B414 /* WizardComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardComponents.swift; path = MatchLiveTv/UI/Wizard/WizardComponents.swift; sourceTree = "<group>"; }; 9343740F4EB94F609A4D1A51 /* OverlayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0DF408F60149008C9644C0 /* OverlayState.swift */; };
77A71899E82D448797DCE7E8 /* TeamBrandingEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TeamBrandingEditor.swift; path = MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift; sourceTree = "<group>"; }; D71C1DFF234742DA81D23AF7 /* ScoreboardElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F0583912AA42F787751A7F /* ScoreboardElement.swift */; };
7B55F65BAA6146AF84B010CC /* LiveBroadcastCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastCoordinatorTests.swift; path = MatchLiveTvTests/LiveBroadcastCoordinatorTests.swift; sourceTree = "<group>"; }; DADD5004759C409F82D4FEE2 /* SponsorElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB19CB746D6146D9ABD1A1C4 /* SponsorElement.swift */; };
7D95106A21804A2EAEDCECB7 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = "<group>"; }; 957381C0C3A447E7A3240ACD /* WatermarkElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD8C48EA12DB45FF978FA049 /* WatermarkElement.swift */; };
7F08BB3F014B44F18A1203FE /* ApiDtos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiDtos.swift; path = MatchLiveTv/Data/API/ApiDtos.swift; sourceTree = "<group>"; }; 326AEF0FBFE74201BE70D2DD /* LivePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 838A41F3BF1E42BDAA2CE2E6 /* LivePreviewView.swift */; };
820BDE9FBCCB4BABACD2AC89 /* BroadcastModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastModels.swift; path = MatchLiveTv/Streaming/BroadcastModels.swift; sourceTree = "<group>"; }; DCC6F79099C641DF9EA6F36F /* StreamVideoPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2074992AD4E94430B9524FB3 /* StreamVideoPreset.swift */; };
87A9A8AEACEA48FABD06BD52 /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = "<group>"; }; CA82A2382C4A45BE88276507 /* BroadcastControlsOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51507FF41FB4B80831D70E3 /* BroadcastControlsOverlay.swift */; };
8992D152CF7F478BA6469E36 /* StepMatchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepMatchScreen.swift; path = MatchLiveTv/UI/Wizard/StepMatchScreen.swift; sourceTree = "<group>"; }; 37E8D25C18014E0C8139D84D /* BroadcastScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EA7E4972BB74FDD884370D0 /* BroadcastScreen.swift */; };
8A279824047D4B7EB08A3F30 /* ScreenOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScreenOrientation.swift; path = MatchLiveTv/UI/System/ScreenOrientation.swift; sourceTree = "<group>"; }; 67A37D0BB28F4BD1872BACA6 /* LiveScoreActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2B124ACD024538B20863B8 /* LiveScoreActions.swift */; };
8F71C110A07B4F71AB71E6ED /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = "<group>"; }; 7AA96446B33C4ECCB90DDFD0 /* MatchLiveWordmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31B0AEBA3A0140D2BC9698DC /* MatchLiveWordmark.swift */; };
94EDDD3FE78C4DC288424A6E /* StepTransmissionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepTransmissionScreen.swift; path = MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift; sourceTree = "<group>"; }; 599290557AE4430EA30582D7 /* MatchPrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84054AB3574CC5B8056694 /* MatchPrimaryButton.swift */; };
973C4EEE1DCD40448D7F2FAD /* WizardSessionHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardSessionHolder.swift; path = MatchLiveTv/Data/WizardSessionHolder.swift; sourceTree = "<group>"; }; D71CB837FE844932AA590D30 /* MatchScreenScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */; };
985215153D0A44DC8B429731 /* BroadcastScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastScreen.swift; path = MatchLiveTv/UI/Broadcast/BroadcastScreen.swift; sourceTree = "<group>"; }; 52806453AD4C4E59BA2F05DE /* MatchSecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */; };
9889D83B0787422397EF3181 /* MatchLiveAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveAPI.swift; path = MatchLiveTv/Data/API/MatchLiveAPI.swift; sourceTree = "<group>"; }; FA601701F8684EE2BFDBBBFD /* MatchStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.swift */; };
A1D75DB685D64B93A65E9FC8 /* KeepScreenOn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KeepScreenOn.swift; path = MatchLiveTv/UI/System/KeepScreenOn.swift; sourceTree = "<group>"; }; 8C0225C7F87A4F9A8A0C2919 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA51BC65490E421AA3D16F02 /* LoginScreen.swift */; };
A224E4FD5069421AAB0C4CF6 /* LiveScoreActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveScoreActions.swift; path = MatchLiveTv/UI/Broadcast/LiveScoreActions.swift; sourceTree = "<group>"; }; 7293CD3C1B634333A59FA782 /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */; };
A23B62DE25C94F47B560D742 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ShareSheet.swift; path = MatchLiveTv/UI/System/ShareSheet.swift; sourceTree = "<group>"; }; 465980490F2D417E87241EBA /* AppNavHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD00428364442C6B8C55DDF /* AppNavHost.swift */; };
A3D8CA843FF54424B181675B /* SessionCableService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionCableService.swift; path = MatchLiveTv/Data/Cable/SessionCableService.swift; sourceTree = "<group>"; }; 044E04EEE71D472EA524C555 /* ModalRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F958AB307244896BD8948D2 /* ModalRoutes.swift */; };
A3F2049B408743AA82AF43F3 /* LiveBroadcastCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastCoordinator.swift; path = MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift; sourceTree = "<group>"; }; 52CEBF8B18834CB8B650FE16 /* Routes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21C2A6AB0E164C8CBDF9CF7F /* Routes.swift */; };
A5964C8AF73D4617BB437E4A /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; }; 94ADCCD464DD43149D0C0E60 /* BroadcastPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1702AF57020940DEBF0F9BA0 /* BroadcastPermissions.swift */; };
AB1B3BC7CA8B4B25BCC77F6E /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AuthRepository.swift; path = MatchLiveTv/Data/Repository/AuthRepository.swift; sourceTree = "<group>"; }; F23C2967ED7140EB8DCB2D2A /* SplashScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3004C07E608644AFB636C0C5 /* SplashScreen.swift */; };
ABC6373E955749DF9AB5DB5B /* TokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TokenStore.swift; path = MatchLiveTv/Core/TokenStore.swift; sourceTree = "<group>"; }; 47AD4ADC4ACE4F6DA7AD708C /* KeepScreenOn.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC39718A2EC74B078A0C5DE9 /* KeepScreenOn.swift */; };
AC36378EDDDF44F29ACAD3C6 /* CompactScoreboardElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CompactScoreboardElement.swift; path = MatchLiveTv/Streaming/Overlay/CompactScoreboardElement.swift; sourceTree = "<group>"; }; 8B0A9A9DBAF346199EBA21B3 /* ScreenOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCEB22897600469E93762209 /* ScreenOrientation.swift */; };
B2B9108C739D484BAD4FF40F /* ScoreboardElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreboardElement.swift; path = MatchLiveTv/Streaming/Overlay/ScoreboardElement.swift; sourceTree = "<group>"; }; D826E98233304F5FB27B27C3 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13BC17CE70034FEE97707B21 /* ShareSheet.swift */; };
B8736A8A330D4815A3D56104 /* MatchSecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSecondaryButton.swift; path = MatchLiveTv/UI/Components/MatchSecondaryButton.swift; sourceTree = "<group>"; }; 579A3A8D49DF43E7B11942C6 /* MatchColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAD219E4FA94CF885472711 /* MatchColors.swift */; };
BA288DB764E24438AE8826DC /* OverlayState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayState.swift; path = MatchLiveTv/Streaming/Overlay/OverlayState.swift; sourceTree = "<group>"; }; D0BEA6E726C04A95A8CC0018 /* StepMatchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29F84FF3F1E74E49A3EA5101 /* StepMatchScreen.swift */; };
BC8B7397C0BD4EC59851185D /* LiveBroadcastEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastEngine.swift; path = MatchLiveTv/Streaming/LiveBroadcastEngine.swift; sourceTree = "<group>"; }; 15DA64BB40B14C9B899A3729 /* StepNetworkTestScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1875AE0A77B94DF0921EE457 /* StepNetworkTestScreen.swift */; };
A1B2C3D4E5F6478990ABCDEF /* StreamVideoPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamVideoPreset.swift; path = MatchLiveTv/Streaming/StreamVideoPreset.swift; sourceTree = "<group>"; }; 4DF6B21EFDD54DF999385511 /* StepTransmissionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3168221266004A33BA35042C /* StepTransmissionScreen.swift */; };
BCA50054BF694B9D82EA97ED /* BroadcastVideoOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastVideoOrientation.swift; path = MatchLiveTv/Streaming/BroadcastVideoOrientation.swift; sourceTree = "<group>"; }; AEF63662B6B5454F92986710 /* TeamBrandingEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97603C3A54342799CBD0C03 /* TeamBrandingEditor.swift */; };
BDC3D4B19E974898AA9CAC76 /* SponsorElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SponsorElement.swift; path = MatchLiveTv/Streaming/Overlay/SponsorElement.swift; sourceTree = "<group>"; }; 78179BA533074F1ABBB7391E /* TeamColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFEAFB3E8F047B9A2B0DD04 /* TeamColorPicker.swift */; };
BFE4871BCA51457CB720047B /* BroadcastOrientationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastOrientationPolicy.swift; path = MatchLiveTv/Streaming/BroadcastOrientationPolicy.swift; sourceTree = "<group>"; }; 45E14846E79845CE9C20C5EC /* WizardComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7A5A0E6D9A49EA95918E79 /* WizardComponents.swift */; };
C2AF23C6CDE24E1AB227F019 /* MatchRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchRepository.swift; path = MatchLiveTv/Data/Repository/MatchRepository.swift; sourceTree = "<group>"; }; 2049733B63794135A4BFBB53 /* WizardShellScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68CB1416AC24491CAA4CE87A /* WizardShellScreen.swift */; };
C44E182032F64EBBA55ADCEB /* ModalRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ModalRoutes.swift; path = MatchLiveTv/UI/Navigation/ModalRoutes.swift; sourceTree = "<group>"; }; 6B75AFC14BBF4E2B97762ECB /* ApiInstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73BB032A0DD74458B0E716C6 /* ApiInstantTests.swift */; };
C7E9EFA6947F4A799570A271 /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; }; 9BA38C8D635F4770A232AE85 /* BroadcastOrientationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C186E88B35A0443494AE6E8E /* BroadcastOrientationPolicyTests.swift */; };
C8F7A645B14A44C69304E136 /* ScoreControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreControllerTests.swift; path = MatchLiveTvTests/ScoreControllerTests.swift; sourceTree = "<group>"; }; 1889CDB168794D81A5B24699 /* LiveBroadcastCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5751DB993C52460D960B8067 /* LiveBroadcastCoordinatorTests.swift */; };
CB3CE35029F144899C81993B /* SplashScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SplashScreen.swift; path = MatchLiveTv/UI/Splash/SplashScreen.swift; sourceTree = "<group>"; }; E25CB4FB944141D0AAB57EDF /* LiveScoreDialogHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E243C00009048F6A778323C /* LiveScoreDialogHostTests.swift */; };
CE709C8660614B9DA5A2D657 /* ScoreRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreRepository.swift; path = MatchLiveTv/Data/Repository/ScoreRepository.swift; sourceTree = "<group>"; }; 6BE3B2533E6A4E3DA32EA972 /* MatchScoringRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 634AD5C2E53B4C098582284A /* MatchScoringRulesTests.swift */; };
D48D1A4C43A24A1E815A646D /* MatchScoringRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRules.swift; path = MatchLiveTv/Domain/MatchScoringRules.swift; sourceTree = "<group>"; }; B88859D9B34E4F289913DDFC /* ScoreActionDecodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7DE055717014393B3F62D5A /* ScoreActionDecodeTests.swift */; };
DE0BD001D8384E1CAD5A327B /* MediaUrl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MediaUrl.swift; path = MatchLiveTv/Core/MediaUrl.swift; sourceTree = "<group>"; }; B8CC446DB3AE4BABB4D739D1 /* ScoreControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C1AC6562ED4A908D7C8B0A /* ScoreControllerTests.swift */; };
E3F630293570453BB85BE288 /* LivePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LivePreviewView.swift; path = MatchLiveTv/Streaming/Preview/LivePreviewView.swift; sourceTree = "<group>"; }; 6CCB67439127403180D9C697 /* ThermalAdaptationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 610A4D03FA384F0287AAF825 /* ThermalAdaptationTests.swift */; };
E8E91F93272E41A78E4CBECC /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = "<group>"; }; C44DD19CDB6545A381B61D20 /* Assets in Resources */ = {isa = PBXBuildFile; fileRef = F68654552B254000B6F0BE97; };
F0707542005B4DCC90E3A072 /* ApiInstantTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstantTests.swift; path = MatchLiveTvTests/ApiInstantTests.swift; sourceTree = "<group>"; }; 7C8A2DFFAB6B4CB8AA3E4FE8 /* it.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 6DFD25C0855F482DBEFD8757; };
F41CF197E5D543CE9A949497 /* ScoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreController.swift; path = MatchLiveTv/Data/Scoring/ScoreController.swift; sourceTree = "<group>"; }; 8D479F8873F94073938D737E /* en.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 12C3FA28B28146B9A8E3A70A; };
F7FCFC922786459EAF4D1813 /* MatchLiveTv.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MatchLiveTv.app; sourceTree = BUILT_PRODUCTS_DIR; }; 9A894DBC632C4904BC5D9A78 /* fr.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 5D760AAFB9C34DACBFC2387A; };
F961133AE74D4ECE9FDC3EBF /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoginScreen.swift; path = MatchLiveTv/UI/Login/LoginScreen.swift; sourceTree = "<group>"; }; 9246A8532B66443A83535192 /* de.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 85B0F928887C44128434974E; };
/* End PBXFileReference section */ 5DA941A5928A4F638223030C /* es.lproj in Resources */ = {isa = PBXBuildFile; fileRef = E907C350C3E5410FB4A2038D; };
B6A69164398F4F9FB84EC475 = {isa = PBXGroup; children = (86BA4622CE11450BAD7902B9, E068E1D4709041789B1E8F5A, 2165E0C34A8B43F28AC2B21C); sourceTree = "<group>"; };
/* Begin PBXFrameworksBuildPhase section */ 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 = "<group>"; };
3793049D651342FCB0DCB8E7 /* Frameworks */ = { E068E1D4709041789B1E8F5A = {isa = PBXGroup; children = (73BB032A0DD74458B0E716C6, C186E88B35A0443494AE6E8E, 5751DB993C52460D960B8067, 0E243C00009048F6A778323C, 634AD5C2E53B4C098582284A, B7DE055717014393B3F62D5A, C3C1AC6562ED4A908D7C8B0A, 610A4D03FA384F0287AAF825); name = MatchLiveTvTests; sourceTree = "<group>"; };
isa = PBXFrameworksBuildPhase; 2165E0C34A8B43F28AC2B21C = {isa = PBXGroup; children = (6FF24BB96E9C40F6A9F6E25A, 8F679C9C03AE47D6A028DBAC); name = Products; sourceTree = "<group>"; };
buildActionMask = 2147483647; 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; };
files = ( 729C230D5EB64A76BCD517B4 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (6B75AFC14BBF4E2B97762ECB, 9BA38C8D635F4770A232AE85, 1889CDB168794D81A5B24699, E25CB4FB944141D0AAB57EDF, 6BE3B2533E6A4E3DA32EA972, B88859D9B34E4F289913DDFC, B8CC446DB3AE4BABB4D739D1, 6CCB67439127403180D9C697); runOnlyForDeploymentPostprocessing = 0; };
4C2639492FE6DA960015AAF3 /* RTMPHaishinKit in Frameworks */, 6B4D5383389142E7AE9C4F41 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (C44DD19CDB6545A381B61D20, 7C8A2DFFAB6B4CB8AA3E4FE8, 8D479F8873F94073938D737E, 9A894DBC632C4904BC5D9A78, 9246A8532B66443A83535192, 5DA941A5928A4F638223030C); runOnlyForDeploymentPostprocessing = 0; };
4C2639482FE6DA960015AAF3 /* HaishinKit in Frameworks */, AA7CFC56BCC040A4AB487E2C = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
); 6E9DDC30CE734AC1AB90CC24 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
runOnlyForDeploymentPostprocessing = 0; D3DDF0C54D1744C09B415888 = {isa = XCBuildConfiguration; buildSettings = {
};
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 = "<group>";
};
3295305662B540D3B837C583 = {
isa = PBXGroup;
children = (
957E89149C624576B613E912 /* MatchLiveTv */,
F3A8835D1D314FBC9DE71AF9 /* MatchLiveTvTests */,
043A9CA61B924E8AA7DD8BAC /* Products */,
);
sourceTree = "<group>";
};
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 = "<group>";
};
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 = "<group>";
};
/* 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; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 21; CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = S8Q9TWBRG5;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist; INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2.0.0; MARKETING_VERSION = 2.0.5;
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv"; PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; API_BASE_URL = "https://www.matchlivetv.it";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; 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; 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; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist; INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2.0.0; MARKETING_VERSION = 2.0.5;
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv"; 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)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; 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); };
}; };
name = Debug; rootObject = 1906C5F192A44C6284A9FC4C /* Project object */;
};
/* 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 */
};
rootObject = 456AEB32689E48DEA025761E /* Project object */;
} }
@@ -3,28 +3,28 @@
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES"> <BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES"> <BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="C1A6F02007F74525A4124BD0" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/> <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="768EC47B451F4731A05BE1F9" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</BuildActionEntry> </BuildActionEntry>
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO"> <BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D2DB6900C94F48F0A49F19BB" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/> <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F6079500E62048DB89AE3866" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</BuildActionEntry> </BuildActionEntry>
</BuildActionEntries> </BuildActionEntries>
</BuildAction> </BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES"> <TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
<Testables> <Testables>
<TestableReference skipped="NO"> <TestableReference skipped="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D2DB6900C94F48F0A49F19BB" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/> <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F6079500E62048DB89AE3866" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</TestableReference> </TestableReference>
</Testables> </Testables>
</TestAction> </TestAction>
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES"> <LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
<BuildableProductRunnable runnableDebuggingMode="0"> <BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="C1A6F02007F74525A4124BD0" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/> <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="768EC47B451F4731A05BE1F9" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</BuildableProductRunnable> </BuildableProductRunnable>
</LaunchAction> </LaunchAction>
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES"> <ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
<BuildableProductRunnable runnableDebuggingMode="0"> <BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="C1A6F02007F74525A4124BD0" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/> <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="768EC47B451F4731A05BE1F9" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</BuildableProductRunnable> </BuildableProductRunnable>
</ProfileAction> </ProfileAction>
<AnalyzeAction buildConfiguration="Debug"/> <AnalyzeAction buildConfiguration="Debug"/>
File diff suppressed because it is too large Load Diff
@@ -4,48 +4,33 @@ import Network
struct DeviceHealth: Sendable { struct DeviceHealth: Sendable {
let batteryPercent: Int let batteryPercent: Int
let batteryTempC: Double? let thermalState: ThermalState
let thermalLevel: Int
let thermalLabel: String
let networkType: String let networkType: String
} }
enum DeviceTelemetry { enum DeviceTelemetry {
static func snapshot() -> DeviceHealth { static func snapshot(thermalState: ThermalState? = nil) -> DeviceHealth {
UIDevice.current.isBatteryMonitoringEnabled = true UIDevice.current.isBatteryMonitoringEnabled = true
let level = Int((UIDevice.current.batteryLevel >= 0 ? UIDevice.current.batteryLevel : 1) * 100) let level = Int((UIDevice.current.batteryLevel >= 0 ? UIDevice.current.batteryLevel : 1) * 100)
let thermal = ProcessInfo.processInfo.thermalState let thermal = thermalState ?? ThermalState.from(ProcessInfo.processInfo.thermalState)
let (thermalLevel, label) = thermalInfo(thermal)
return DeviceHealth( return DeviceHealth(
batteryPercent: level, batteryPercent: level,
batteryTempC: nil, thermalState: thermal,
thermalLevel: thermalLevel,
thermalLabel: label,
networkType: currentNetworkType() 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 { private static func currentNetworkType() -> String {
let monitor = NWPathMonitor() let monitor = NWPathMonitor()
let semaphore = DispatchSemaphore(value: 0) let semaphore = DispatchSemaphore(value: 0)
var result = "Sconosciuto" var result = L10n.t("network.type.unknown")
monitor.pathUpdateHandler = { path in monitor.pathUpdateHandler = { path in
if path.usesInterfaceType(.wifi) { if path.usesInterfaceType(.wifi) {
result = "WiFi" result = L10n.t("network.type.wifi")
} else if path.usesInterfaceType(.cellular) { } else if path.usesInterfaceType(.cellular) {
result = "4G" result = L10n.t("network.type.cellular")
} else if path.usesInterfaceType(.wiredEthernet) { } else if path.usesInterfaceType(.wiredEthernet) {
result = "Ethernet" result = L10n.t("network.type.ethernet")
} }
semaphore.signal() semaphore.signal()
} }
@@ -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 "🔴"
}
}
}
@@ -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<Void, Never>?
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
}
}
}
@@ -8,7 +8,8 @@ enum UserFacingError {
return ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled 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 isCancellation(error) { return nil }
if let rtmp = rtmpMessage(for: error) { return rtmp } if let rtmp = rtmpMessage(for: error) { return rtmp }
if let api = error as? LocalizedError, let description = api.errorDescription, !description.isEmpty { if let api = error as? LocalizedError, let description = api.errorDescription, !description.isEmpty {
@@ -16,9 +17,9 @@ enum UserFacingError {
} }
let text = error.localizedDescription let text = error.localizedDescription
if text.contains("RTMPConnection.Error") || text.contains("RTMPHaishinKit") { 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? { private static func rtmpMessage(for error: Error) -> String? {
@@ -26,14 +27,14 @@ enum UserFacingError {
let localized = error.localizedDescription let localized = error.localizedDescription
guard text.contains("RTMPConnection") || localized.contains("RTMPConnection") else { return nil } guard text.contains("RTMPConnection") || localized.contains("RTMPConnection") else { return nil }
if localized.contains("unsupportedCommand") || text.contains("unsupportedCommand") { 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") { 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") { 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")
} }
} }
@@ -8,10 +8,10 @@ enum APIError: LocalizedError {
var errorDescription: String? { var errorDescription: String? {
switch self { 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 .http(let code, let body): return Self.friendlyHttpMessage(code: code, body: body)
case .decoding: return "Risposta del server non valida" case .decoding: return L10n.t("api.error.decoding")
case .unauthorized: return "Sessione scaduta. Accedi di nuovo." case .unauthorized: return L10n.t("api.error.unauthorized")
} }
} }
@@ -23,7 +23,7 @@ enum APIError: LocalizedError {
return message return message
} }
if let error = json["error"] as? String, !error.isEmpty, !error.hasPrefix("{") { 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 return error
} }
} }
@@ -31,10 +31,10 @@ enum APIError: LocalizedError {
return body return body
} }
switch code { switch code {
case 500, 502, 503: return "Errore del server. Riprova tra qualche istante." case 500, 502, 503: return L10n.t("api.error.server")
case 404: return "Risorsa non trovata" case 404: return L10n.t("api.error.not.found")
case 403: return "Operazione non consentita" case 403: return L10n.t("api.error.forbidden")
default: return "Errore HTTP \(code)" default: return L10n.t("api.error.http", code)
} }
} }
} }
@@ -54,7 +54,7 @@ final class MatchRepository {
func createQuickMatch(teamId: String) async throws -> Match { func createQuickMatch(teamId: String) async throws -> Match {
try await api.createMatch( try await api.createMatch(
teamId: teamId, 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() ).toDomain()
} }
@@ -7,7 +7,7 @@ enum MatchSessionLauncher {
sessionRepository: SessionRepository sessionRepository: SessionRepository
) async throws -> String { ) async throws -> String {
guard let sessionId = match.activeSessionId else { 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) _ = try await sessionRepository.fetchSession(id: sessionId)
return sessionId return sessionId
@@ -92,7 +92,7 @@ final class ScoreController: ObservableObject {
syncGeneration += 1 syncGeneration += 1
guard let sessionId else { guard let sessionId else {
score = previous score = previous
lastActionError = "Sessione non collegata" lastActionError = L10n.t("session.error.not.connected")
return false return false
} }
do { do {
@@ -103,7 +103,7 @@ final class ScoreController: ObservableObject {
return true return true
} }
score = previous score = previous
lastActionError = "Risposta punteggio non valida" lastActionError = L10n.t("api.error.score.invalid")
return false return false
} catch { } catch {
score = previous score = previous
@@ -223,7 +223,7 @@ final class ScoreController: ObservableObject {
sessionCable.sendScoreUpdate(score) sessionCable.sendScoreUpdate(score)
} else { } else {
guard generation == syncGeneration else { return } guard generation == syncGeneration else { return }
lastActionError = "Risposta punteggio non valida" lastActionError = L10n.t("api.error.score.invalid")
} }
} catch { } catch {
guard generation == syncGeneration else { return } guard generation == syncGeneration else { return }
@@ -35,9 +35,9 @@ enum MatchHubFilter {
enum MatchPresentation { enum MatchPresentation {
static func statusLabel(for match: Match) -> String { static func statusLabel(for match: Match) -> String {
if match.canResumeCamera { return "RIPRENDI" } if match.canResumeCamera { return L10n.t("match.status.resume") }
if match.hasActiveSession { return "IN CORSO" } if match.hasActiveSession { return L10n.t("match.status.live") }
if ApiInstant.isScheduledFuture(match.scheduledAt) { return "PROGRAMMATA" } if ApiInstant.isScheduledFuture(match.scheduledAt) { return L10n.t("match.status.scheduled") }
return "AVVIA" return L10n.t("match.status.start")
} }
} }
+3 -3
View File
@@ -54,11 +54,11 @@ struct Team: Identifiable, Equatable, Sendable {
} }
var youtubeDestinationLabel: String { 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 let name = youtubeTeamChannelTitle?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
?? clubName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? clubName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
?? "società" ?? L10n.t("youtube.channel.club.fallback")
return "Canale società · \(name)" return L10n.t("youtube.channel.club", name)
} }
} }
+2 -2
View File
@@ -19,7 +19,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2.0.0</string> <string>2.0.5</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
@@ -32,7 +32,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>21</string> <string>26</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
@@ -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.";
@@ -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.";
@@ -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.";
@@ -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.";
@@ -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.";
@@ -179,10 +179,22 @@ final class LiveBroadcastEngine: ObservableObject {
await stopBroadcast() 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 { private func configurePipeline(_ config: BroadcastConfig) async throws {
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized, guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized,
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { 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 { if pipelineConfigured {
await attachPreviewIfNeeded() await attachPreviewIfNeeded()
@@ -252,7 +264,7 @@ final class LiveBroadcastEngine: ObservableObject {
guard previewAttached, generation == broadcastGeneration else { guard previewAttached, generation == broadcastGeneration else {
if generation == broadcastGeneration { if generation == broadcastGeneration {
publishPending = false publishPending = false
setPhase(.error, error: "Anteprima camera non pronta") setPhase(.error, error: L10n.t("broadcast.error.camera.preview.not.ready"))
} }
return return
} }
@@ -274,14 +286,14 @@ final class LiveBroadcastEngine: ObservableObject {
scheduleReconnect() scheduleReconnect()
return 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) setPhase(.error, error: message)
} }
} }
private func publish(config: BroadcastConfig, generation: Int) async throws { private func publish(config: BroadcastConfig, generation: Int) async throws {
guard let url = URL(string: config.rtmpUrl) else { 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 Self.ensureRTMPFactoryRegistered()
await applyVideoOrientationIfNeeded(force: true) await applyVideoOrientationIfNeeded(force: true)
@@ -291,7 +303,7 @@ final class LiveBroadcastEngine: ObservableObject {
.setMode(.publish) .setMode(.publish)
.build() .build()
guard let session else { 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 let stream = await session.stream
@@ -395,7 +407,7 @@ final class LiveBroadcastEngine: ObservableObject {
reconnectAttempts += 1 reconnectAttempts += 1
if reconnectAttempts > config.maxReconnectAttempts { if reconnectAttempts > config.maxReconnectAttempts {
reconnectTask = nil reconnectTask = nil
setPhase(.error, error: "Connessione RTMP interrotta") setPhase(.error, error: L10n.t("error.rtmp.interrupted"))
return return
} }
@@ -173,13 +173,6 @@ struct ScoreboardElement: OverlayElement {
} }
private func ordinalSetLabel(_ setNumber: Int) -> String { private func ordinalSetLabel(_ setNumber: Int) -> String {
switch setNumber { L10n.t("overlay.set.label", 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)"
}
} }
} }
@@ -60,7 +60,7 @@ struct BroadcastControlsOverlay: View {
VStack(alignment: .trailing, spacing: 6) { VStack(alignment: .trailing, spacing: 6) {
SideIconButton( SideIconButton(
systemName: controlsVisible ? "eye.slash.fill" : "eye.fill", 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 action: onToggleControls
) )
BroadcastTelemetryPanel( BroadcastTelemetryPanel(
@@ -94,11 +94,11 @@ struct BroadcastControlsOverlay: View {
.padding(.bottom, 8) .padding(.bottom, 8)
} }
} }
.alert("Terminare la diretta?", isPresented: $showTerminateConfirm) { .alert(L10n.t("broadcast.terminate.title"), isPresented: $showTerminateConfirm) {
Button("Annulla", role: .cancel) {} Button(L10n.t("action.cancel"), role: .cancel) {}
Button("TERMINA", role: .destructive, action: onTerminate) Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: onTerminate)
} message: { } 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) { VStack(spacing: 6) {
SideIconButton( SideIconButton(
systemName: "square.and.arrow.up", systemName: "square.and.arrow.up",
accessibilityLabel: "Condividi diretta", accessibilityLabel: L10n.t("broadcast.share.live.cd"),
action: onShareLive, action: onShareLive,
enabled: shareLiveEnabled enabled: shareLiveEnabled
) )
SideIconButton( SideIconButton(
systemName: "video.fill", systemName: "video.fill",
accessibilityLabel: "Condividi link regia", accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
action: onShareRegia action: onShareRegia
) )
if let onCloseSet { if let onCloseSet {
SideIconButton( SideIconButton(
systemName: "checkmark", systemName: "checkmark",
accessibilityLabel: "Chiudi set", accessibilityLabel: L10n.t("score.action.close.set"),
action: onCloseSet action: onCloseSet
) )
} }
if let onAdvancePeriod { if let onAdvancePeriod {
SideIconButton( SideIconButton(
systemName: "forward.end.fill", systemName: "forward.end.fill",
accessibilityLabel: "Periodo successivo", accessibilityLabel: L10n.t("broadcast.next.period.cd"),
action: onAdvancePeriod action: onAdvancePeriod
) )
} }
@@ -138,13 +138,13 @@ struct BroadcastControlsOverlay: View {
VStack(spacing: 6) { VStack(spacing: 6) {
SideIconButton( SideIconButton(
systemName: isPaused ? "play.fill" : "pause.fill", 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, action: onPauseOrResume,
highlighted: isPaused highlighted: isPaused
) )
SideIconButton( SideIconButton(
systemName: "stop.fill", systemName: "stop.fill",
accessibilityLabel: "Termina diretta", accessibilityLabel: L10n.t("broadcast.terminate.cd"),
action: { showTerminateConfirm = true }, action: { showTerminateConfirm = true },
danger: true danger: true
) )
@@ -154,7 +154,7 @@ struct BroadcastControlsOverlay: View {
private var scoreControlsRow: some View { private var scoreControlsRow: some View {
HStack(alignment: .bottom, spacing: 0) { HStack(alignment: .bottom, spacing: 0) {
TeamScoreColumn( TeamScoreColumn(
teamLabel: "CASA", teamLabel: L10n.t("broadcast.team.home.label"),
teamName: homeName, teamName: homeName,
accentColor: homeAccentColor, accentColor: homeAccentColor,
logoUrl: homeLogoUrl, logoUrl: homeLogoUrl,
@@ -171,7 +171,7 @@ struct BroadcastControlsOverlay: View {
ScoreCenterPanel(score: score, boardType: boardType, pointsTarget: pointsTarget) ScoreCenterPanel(score: score, boardType: boardType, pointsTarget: pointsTarget)
TeamScoreColumn( TeamScoreColumn(
teamLabel: "OSPITE", teamLabel: L10n.t("broadcast.team.away.label"),
teamName: awayName, teamName: awayName,
accentColor: awayAccentColor, accentColor: awayAccentColor,
logoUrl: awayLogoUrl, logoUrl: awayLogoUrl,
@@ -198,7 +198,7 @@ private struct BroadcastTelemetryPanel: View {
var body: some View { var body: some View {
VStack(alignment: .trailing, spacing: 2) { 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)) .font(.system(size: 11, weight: .medium))
.foregroundStyle(cableConnected ? MatchColors.successGreen : MatchColors.textSecondary) .foregroundStyle(cableConnected ? MatchColors.successGreen : MatchColors.textSecondary)
let fpsLabel = fps > 0 ? "\(fps) fps" : "— fps" let fpsLabel = fps > 0 ? "\(fps) fps" : "— fps"
@@ -214,11 +214,7 @@ private struct BroadcastTelemetryPanel: View {
Text("\(networkType) · \(deviceHealth.batteryPercent)%") Text("\(networkType) · \(deviceHealth.batteryPercent)%")
.font(.system(size: 11, weight: .medium)) .font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
ThermalIndicator( ThermalIndicator(state: deviceHealth.thermalState)
tempC: deviceHealth.batteryTempC,
level: deviceHealth.thermalLevel,
label: deviceHealth.thermalLabel
)
} }
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 6) .padding(.vertical, 6)
@@ -227,31 +223,27 @@ private struct BroadcastTelemetryPanel: View {
} }
private struct ThermalIndicator: View { private struct ThermalIndicator: View {
let tempC: Double? let state: ThermalState
let level: Int
let label: String
private var color: Color { private var color: Color {
switch level { switch state {
case 0: return MatchColors.successGreen case .nominal: return MatchColors.successGreen
case 1: return MatchColors.accentYellow case .fair: return MatchColors.accentYellow
case 2: return Color.orange case .serious: return Color.orange
default: return MatchColors.primaryRed case .critical: return MatchColors.primaryRed
} }
} }
var body: some View { var body: some View {
let tempText = tempC.map { "\(Int($0))°C" } ?? "—°C" let warningBg = state >= .fair ? color.opacity(0.18) : Color.clear
let warningBg = level >= 1 ? color.opacity(0.18) : Color.clear
HStack(spacing: 4) { HStack(spacing: 4) {
Text(tempText) Text(state.indicatorSymbol)
.font(.system(size: 11, weight: level >= 1 ? .bold : .regular)) .font(.system(size: 11))
Text(state.displayLabel)
.font(.system(size: 11, weight: state >= .fair ? .bold : .medium))
.foregroundStyle(color) .foregroundStyle(color)
if level >= 1 { .lineLimit(1)
Text(label) .minimumScaleFactor(0.85)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(color)
}
} }
.padding(.horizontal, 4) .padding(.horizontal, 4)
.padding(.vertical, 1) .padding(.vertical, 1)
@@ -272,17 +264,17 @@ private struct ScoreCenterPanel: View {
.fontWeight(.bold) .fontWeight(.bold)
switch boardType { switch boardType {
case "basket", "timed": 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)) .font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
case "generic": case "generic":
EmptyView() EmptyView()
default: default:
Text("Set \(score.currentSet) · \(pointsTarget) pt") Text(L10n.t("broadcast.set.progress", score.currentSet, pointsTarget))
.font(.system(size: 11, weight: .medium)) .font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
if score.homeSets > 0 || score.awaySets > 0 { 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)) .font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
} }
@@ -322,28 +314,28 @@ private struct TeamScoreColumn: View {
.fontWeight(.bold) .fontWeight(.bold)
.padding(.vertical, 4) .padding(.vertical, 4)
Spacer().frame(height: 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) { HStack(spacing: 6) {
if alignEnd { if alignEnd {
if showBasketButtons { if showBasketButtons {
if let onPlus3 { 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 { 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: "+1", tooltip: L10n.t("broadcast.tooltip.add.point", teamSide), action: onPlus, primary: !showBasketButtons)
ScoreIconButton(label: "", tooltip: "Togli punto \(teamSide)", action: onMinus) ScoreIconButton(label: "", tooltip: L10n.t("broadcast.tooltip.remove.point", teamSide), action: onMinus)
} else { } else {
ScoreIconButton(label: "", tooltip: "Togli punto \(teamSide)", action: onMinus) ScoreIconButton(label: "", tooltip: L10n.t("broadcast.tooltip.remove.point", teamSide), action: onMinus)
ScoreIconButton(label: "+1", tooltip: "Aggiungi punto \(teamSide)", action: onPlus, primary: !showBasketButtons) ScoreIconButton(label: "+1", tooltip: L10n.t("broadcast.tooltip.add.point", teamSide), action: onPlus, primary: !showBasketButtons)
if showBasketButtons { if showBasketButtons {
if let onPlus2 { 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 { if let onPlus3 {
ScoreIconButton(label: "+3", tooltip: "+3 \(teamSide)", action: onPlus3, primary: true) ScoreIconButton(label: "+3", tooltip: L10n.t("broadcast.tooltip.plus.side", 3, teamSide), action: onPlus3, primary: true)
} }
} }
} }
@@ -10,6 +10,7 @@ struct BroadcastScreen: View {
@StateObject private var permissions = BroadcastPermissions() @StateObject private var permissions = BroadcastPermissions()
@StateObject private var scoreDialogHost = LiveScoreDialogHost() @StateObject private var scoreDialogHost = LiveScoreDialogHost()
@StateObject private var thermalManager = ThermalStateManager()
init(container: AppContainer, sessionId: String, onFinished: @escaping () -> Void) { init(container: AppContainer, sessionId: String, onFinished: @escaping () -> Void) {
self.container = container self.container = container
@@ -30,6 +31,7 @@ struct BroadcastScreen: View {
@State private var snackbarMessage: String? @State private var snackbarMessage: String?
@State private var shareItem: ShareItem? @State private var shareItem: ShareItem?
@State private var bootstrapGeneration = 0 @State private var bootstrapGeneration = 0
@State private var languageTick = 0
var body: some View { var body: some View {
ZStack { ZStack {
@@ -44,11 +46,11 @@ struct BroadcastScreen: View {
ProgressView().tint(MatchColors.primaryRed) ProgressView().tint(MatchColors.primaryRed)
} else if !permissions.allGranted { } else if !permissions.allGranted {
VStack(spacing: 12) { VStack(spacing: 12) {
Text("Consenti camera e microfono per andare in diretta") Text(L10n.t("broadcast.permissions.required"))
.font(MatchTypography.bodyMedium) .font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.accentYellow) .foregroundStyle(MatchColors.accentYellow)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
MatchPrimaryButton(label: "CONCEDI PERMESSI") { MatchPrimaryButton(label: L10n.t("broadcast.permissions.grant.action")) {
Task { await permissions.requestAll() } Task { await permissions.requestAll() }
} }
.padding(.horizontal, 40) .padding(.horizontal, 40)
@@ -56,7 +58,7 @@ struct BroadcastScreen: View {
.padding(24) .padding(24)
} else if let session, let match { } else if let session, let match {
broadcastOverlay(session: session, match: match) broadcastOverlay(session: session, match: match)
.id(scoreController.score.progressKey()) .id("\(scoreController.score.progressKey())-\(languageTick)")
.zIndex(1) .zIndex(1)
} }
} }
@@ -89,7 +91,7 @@ struct BroadcastScreen: View {
.task(id: sessionId) { .task(id: sessionId) {
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 2_000_000_000) try? await Task.sleep(nanoseconds: 2_000_000_000)
deviceHealth = DeviceTelemetry.snapshot() deviceHealth = DeviceTelemetry.snapshot(thermalState: thermalManager.state)
} }
} }
.task(id: sessionId) { .task(id: sessionId) {
@@ -116,11 +118,35 @@ struct BroadcastScreen: View {
self.error = message self.error = message
} }
} }
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { .onChange(of: thermalManager.state) { _ in
Button("Riprova") { 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() } Task { await retryBroadcast() }
} }
Button("Esci", role: .destructive) { onFinished() } Button(L10n.t("action.exit"), role: .destructive) { onFinished() }
} message: { } message: {
Text(error ?? "") Text(error ?? "")
} }
@@ -264,7 +290,7 @@ struct BroadcastScreen: View {
targetFps: session.targetFps, targetFps: session.targetFps,
bitrateKbps: metrics.bitrateKbps, bitrateKbps: metrics.bitrateKbps,
networkType: deviceHealth.networkType, 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 { private func broadcastStatusText(isPaused: Bool, metrics: BroadcastMetrics) -> String {
if isPaused { return "PAUSA" } if isPaused { return L10n.t("broadcast.status.paused") }
switch metrics.phase { switch metrics.phase {
case .live: return "IN DIRETTA" case .live: return L10n.t("broadcast.status.live")
case .connecting: return "CONNESSIONE…" case .connecting: return L10n.t("broadcast.status.connecting")
case .reconnecting: return "RICONNESSIONE…" case .reconnecting: return L10n.t("broadcast.status.reconnecting")
case .error: return metrics.lastError ?? "ERRORE" case .error: return metrics.lastError ?? L10n.t("broadcast.status.error.fallback")
default: return "PREVIEW" default: return L10n.t("broadcast.status.preview")
} }
} }
@@ -302,7 +328,7 @@ struct BroadcastScreen: View {
private func shareLiveLink(session: StreamSession, subject: String) { private func shareLiveLink(session: StreamSession, subject: String) {
let urlString = session.watchShareUrl() ?? "\(AppConfig.apiBaseUrl)/live/\(session.id)" let urlString = session.watchShareUrl() ?? "\(AppConfig.apiBaseUrl)/live/\(session.id)"
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
snackbarMessage = "Link diretta non ancora disponibile" snackbarMessage = L10n.t("broadcast.share.link.unavailable")
return return
} }
shareItem = ShareItem(items: [url], subject: subject) shareItem = ShareItem(items: [url], subject: subject)
@@ -313,12 +339,12 @@ struct BroadcastScreen: View {
do { do {
let urlString = try await container.sessionRepository.createRegiaLink(sessionId: sessionId) let urlString = try await container.sessionRepository.createRegiaLink(sessionId: sessionId)
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
snackbarMessage = "Link regia non valido" snackbarMessage = L10n.t("broadcast.error.regia.link.invalid")
return return
} }
shareItem = ShareItem(items: [url], subject: "Link regia — \(subject)") shareItem = ShareItem(items: [url], subject: L10n.t("broadcast.share.regia.subject", subject))
} catch { } 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 loading = false
guard generation == bootstrapGeneration else { return } guard generation == bootstrapGeneration else { return }
guard await container.broadcastCoordinator.waitForPreviewSurface() else { 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 } guard generation == bootstrapGeneration else { return }
if let url = loaded.rtmpIngestUrl, !url.isEmpty { if let url = loaded.rtmpIngestUrl, !url.isEmpty {
let config = broadcastConfig(for: loaded, rtmpUrl: url) let config = broadcastConfig(for: loaded, rtmpUrl: url)
thermalManager.bind(engine: container.broadcastCoordinator.engine)
thermalManager.updateBaseline(config)
thermalManager.start()
try await container.broadcastCoordinator.prepareBroadcast( try await container.broadcastCoordinator.prepareBroadcast(
sessionId: sessionId, sessionId: sessionId,
config: config, config: config,
@@ -408,7 +437,7 @@ struct BroadcastScreen: View {
case .none: case .none:
state = OverlayState(overlayKind: .none, watermarkVisible: false, broadcastStatus: container.broadcastCoordinator.metrics.phase.toOverlayStatus()) state = OverlayState(overlayKind: .none, watermarkVisible: false, broadcastStatus: container.broadcastCoordinator.metrics.phase.toOverlayStatus())
case .basket, .timed: 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( state = OverlayState(
overlayKind: overlayKind, overlayKind: overlayKind,
compactScoreboard: CompactScoreboardState( compactScoreboard: CompactScoreboardState(
@@ -450,9 +479,9 @@ struct BroadcastScreen: View {
let updated = try await container.sessionRepository.pauseSession(id: sessionId) let updated = try await container.sessionRepository.pauseSession(id: sessionId)
session = updated session = updated
await container.broadcastCoordinator.pauseBroadcast() await container.broadcastCoordinator.pauseBroadcast()
snackbarMessage = "Diretta in pausa" snackbarMessage = L10n.t("broadcast.snackbar.paused")
} catch { } 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) try await container.broadcastCoordinator.resumeBroadcast(config: config)
updated = try await container.sessionRepository.fetchSession(id: sessionId) updated = try await container.sessionRepository.fetchSession(id: sessionId)
session = updated session = updated
snackbarMessage = "Diretta ripresa" snackbarMessage = L10n.t("broadcast.snackbar.resumed")
} catch { } 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) { if let fetched = try? await container.sessionRepository.fetchSession(id: sessionId) {
session = fetched session = fetched
} }
snackbarMessage = "Pausa dalla regia" snackbarMessage = L10n.t("broadcast.snackbar.paused.remote")
} }
/// Ripresa dalla regia / echo cable: solo RTMP locale, senza PATCH resume. /// Ripresa dalla regia / echo cable: solo RTMP locale, senza PATCH resume.
@@ -497,10 +526,11 @@ struct BroadcastScreen: View {
session = current session = current
guard let url = current.rtmpIngestUrl, !url.isEmpty else { return } guard let url = current.rtmpIngestUrl, !url.isEmpty else { return }
let config = broadcastConfig(for: current, rtmpUrl: url) let config = broadcastConfig(for: current, rtmpUrl: url)
thermalManager.updateBaseline(config)
try await container.broadcastCoordinator.resumeBroadcast(config: config) try await container.broadcastCoordinator.resumeBroadcast(config: config)
snackbarMessage = "Diretta ripresa" snackbarMessage = L10n.t("broadcast.snackbar.resumed")
} catch { } 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 error = nil
guard let session, let url = session.rtmpIngestUrl, !url.isEmpty else { return } guard let session, let url = session.rtmpIngestUrl, !url.isEmpty else { return }
let config = broadcastConfig(for: session, rtmpUrl: url) let config = broadcastConfig(for: session, rtmpUrl: url)
thermalManager.updateBaseline(config)
do { do {
try await container.broadcastCoordinator.resumeBroadcast(config: config) try await container.broadcastCoordinator.resumeBroadcast(config: config)
snackbarMessage = "Riconnessione avviata" snackbarMessage = L10n.t("broadcast.snackbar.reconnect.started")
} catch { } 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 { private func teardown() async {
thermalManager.stop()
container.scoreController.onScoreDidChange = nil container.scoreController.onScoreDidChange = nil
container.sessionCable.onScoreUpdate = nil container.sessionCable.onScoreUpdate = nil
container.sessionCable.onPauseStream = nil container.sessionCable.onPauseStream = nil
@@ -144,25 +144,25 @@ struct ScoreDialogRouter: View {
case .setWon: case .setWon:
let winner = dialog.winnerSide.map { scoringSideName($0, homeName: homeName, awayName: awayName) } ?? "" let winner = dialog.winnerSide.map { scoringSideName($0, homeName: homeName, awayName: awayName) } ?? ""
return Alert( return Alert(
title: Text("Set concluso"), title: Text(L10n.t("score.set.won.title")),
message: Text("\(winner) vince il set \(dialog.homePoints)-\(dialog.awayPoints).\n\nChiudere il set e passare al successivo?"), message: Text(L10n.t("score.set.won.message", winner, dialog.homePoints, dialog.awayPoints)),
primaryButton: .default(Text("Chiudi set")) { host.resolve(true) }, primaryButton: .default(Text(L10n.t("score.action.close.set"))) { host.resolve(true) },
secondaryButton: .cancel(Text("Continua a segnare")) { host.resolve(false) } secondaryButton: .cancel(Text(L10n.t("score.action.continue.scoring"))) { host.resolve(false) }
) )
case .closeSetAnyway: case .closeSetAnyway:
return Alert( return Alert(
title: Text("Chiudi set"), title: Text(L10n.t("score.action.close.set")),
message: Text("Il punteggio non soddisfa ancora le regole del torneo. Chiudere il set comunque?"), message: Text(L10n.t("score.close.set.anyway.message")),
primaryButton: .default(Text("Chiudi comunque")) { host.resolve(true) }, primaryButton: .default(Text(L10n.t("score.action.close.anyway"))) { host.resolve(true) },
secondaryButton: .cancel(Text("Annulla")) { host.resolve(false) } secondaryButton: .cancel(Text(L10n.t("action.cancel"))) { host.resolve(false) }
) )
case .matchWon: case .matchWon:
let winner = dialog.winnerSide.map { scoringSideName($0, homeName: homeName, awayName: awayName) } ?? "" let winner = dialog.winnerSide.map { scoringSideName($0, homeName: homeName, awayName: awayName) } ?? ""
return Alert( return Alert(
title: Text("Partita terminata"), title: Text(L10n.t("score.match.won.title")),
message: Text("\(winner) vince la partita (\(dialog.homeSets)-\(dialog.awaySets) set).\n\nChiudere definitivamente la diretta?"), message: Text(L10n.t("score.match.won.message", winner, dialog.homeSets, dialog.awaySets)),
primaryButton: .default(Text("Chiudi diretta")) { host.resolve(true) }, primaryButton: .default(Text(L10n.t("score.action.close.live"))) { host.resolve(true) },
secondaryButton: .cancel(Text("Continua in onda")) { host.resolve(false) } secondaryButton: .cancel(Text(L10n.t("score.action.continue.live"))) { host.resolve(false) }
) )
} }
} }
@@ -29,7 +29,7 @@ struct MatchLiveWordmark: View {
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
} }
if showSlogan { if showSlogan {
Text("OGNI PARTITA, OGNI EVENTO, PER I TUOI TIFOSI.") Text(L10n.t("app.slogan").uppercased())
.font(.system(size: 12, weight: .medium)) .font(.system(size: 12, weight: .medium))
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@@ -16,18 +16,24 @@ struct LoginScreen: View {
MatchScreenScaffold { MatchScreenScaffold {
ScrollView { ScrollView {
VStack(spacing: 0) { VStack(spacing: 0) {
MatchLiveWordmark(showSlogan: true) HStack {
.padding(.top, 32) Spacer()
Button(L10n.t("language.label")) { Button {
showLanguagePicker = true showLanguagePicker = true
} } label: {
Image(systemName: "globe")
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
}
.accessibilityLabel(L10n.t("language.label"))
}
.padding(.top, 8)
MatchLiveWordmark(showSlogan: true)
.padding(.top, 16) .padding(.top, 16)
Text(L10n.t("login.submit").uppercased()) Text(L10n.t("login.submit").uppercased())
.font(MatchTypography.headlineMedium) .font(MatchTypography.headlineMedium)
.padding(.top, 32) .padding(.top, 32)
VStack(spacing: 16) { 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) MatchSecureField(title: L10n.t("login.password"), text: $password, visible: $passwordVisible)
} }
.padding(.top, 32) .padding(.top, 32)
@@ -36,14 +36,17 @@ struct MatchesScreen: View {
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
} }
.accessibilityLabel(L10n.t("language.label")) .accessibilityLabel(L10n.t("language.label"))
Button(L10n.t("action.logout")) { Button {
Task { Task {
await container.authRepository.logout() await container.authRepository.logout()
onLogout() onLogout()
} }
} } label: {
Image(systemName: "rectangle.portrait.and.arrow.right")
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
} }
.accessibilityLabel(L10n.t("action.logout"))
}
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 8) .padding(.vertical, 8)
}, },
@@ -53,29 +56,33 @@ struct MatchesScreen: View {
ProgressView().tint(MatchColors.primaryRed) ProgressView().tint(MatchColors.primaryRed)
} else if teams.isEmpty { } else if teams.isEmpty {
VStack(spacing: 16) { VStack(spacing: 16) {
Text("Nessuna squadra disponibile") Text(L10n.t("matches.no.team.title"))
.foregroundStyle(MatchColors.textSecondary) .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) .padding(24)
} else if let error { } else if let error {
VStack(spacing: 16) { VStack(spacing: 16) {
Text(error).foregroundStyle(MatchColors.primaryRed).multilineTextAlignment(.center) 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) .padding(24)
} else { } else {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 16) { 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) .font(MatchTypography.headlineMedium)
Text("Riprendi una diretta in corso o avvia una partita programmata.") Text(L10n.t("matches.subtitle"))
.font(MatchTypography.bodyMedium) .font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
HStack(spacing: 12) { HStack(spacing: 12) {
MatchSecondaryButton(label: "PARTITA PROGRAMMATA", action: { showSchedule = true }) MatchSecondaryButton(label: L10n.t("matches.schedule").uppercased(), action: { showSchedule = true })
MatchPrimaryButton(label: "NUOVA PARTITA", action: { showNewMatch = true }) MatchPrimaryButton(label: L10n.t("matches.new").uppercased(), action: { showNewMatch = true })
} }
if let activeTeam { if let activeTeam {
TeamPickerBar( TeamPickerBar(
@@ -108,7 +115,7 @@ struct MatchesScreen: View {
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.vertical, 12) .padding(.vertical, 12)
} else if calendarMatches.isEmpty { } else if calendarMatches.isEmpty {
Text("Nessuna altra partita in calendario.") Text(L10n.t("matches.no.other"))
.font(MatchTypography.bodyMedium) .font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@@ -136,27 +143,41 @@ struct MatchesScreen: View {
} }
) )
.task(id: refreshToken) { reload(showSpinner: refreshToken == 0) } .task(id: refreshToken) { reload(showSpinner: refreshToken == 0) }
.alert("Riprendi diretta?", isPresented: Binding(get: { resumeMatch != nil }, set: { if !$0 { resumeMatch = nil } })) { .alert(L10n.t("sheet.configure.now.title"), isPresented: Binding(get: { resumeMatch != nil }, set: { if !$0 { resumeMatch = nil } })) {
Button("Riprendi") { Button(L10n.t("sheet.resume.camera")) {
if let match = resumeMatch { resumeBroadcast(match) } if let match = resumeMatch { resumeBroadcast(match) }
resumeMatch = nil resumeMatch = nil
} }
Button("Configura", role: .cancel) { Button(L10n.t("sheet.configure"), role: .cancel) {
if let match = resumeMatch { onOpenSetup(match.id) } if let match = resumeMatch { onOpenSetup(match.id) }
resumeMatch = nil resumeMatch = nil
} }
} message: {
Text(L10n.t("sheet.configure.now.body"))
} }
.alert("Elimina partita?", isPresented: Binding(get: { deleteMatch != nil }, set: { if !$0 { deleteMatch = nil } })) { .alert(
Button("Elimina", role: .destructive) { 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 { if let match = deleteMatch {
Task { Task {
try? await container.matchRepository.deleteMatch(matchId: match.id) do {
try await container.matchRepository.deleteMatch(matchId: match.id)
snackbar = L10n.t("matches.msg.deleted")
reload(showSpinner: false) reload(showSpinner: false)
} catch {
snackbar = L10n.t("matches.msg.delete.failed")
}
} }
} }
deleteMatch = nil 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) { .sheet(isPresented: $showNewMatch) {
NewMatchSheet( NewMatchSheet(
@@ -229,21 +250,21 @@ struct MatchesScreen: View {
private var calendarSectionTitle: String { private var calendarSectionTitle: String {
if calendarMatches.isEmpty && activeMatch == nil { if calendarMatches.isEmpty && activeMatch == nil {
return "Nessuna partita in calendario" return L10n.t("matches.empty.title")
} }
if !scheduledMatches.isEmpty { 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 { 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 { if let teamName = activeTeam?.name {
message += "\n\nSquadra attiva: \(teamName)." message += "\n\n" + L10n.t("matches.active.team", teamName)
} }
if teams.count > 1 { if teams.count > 1 {
message += "\nHai più squadre: verifica quella selezionata sopra." message += "\n" + L10n.t("matches.multi.team.hint")
} }
return message return message
} }
@@ -260,7 +281,7 @@ struct MatchesScreen: View {
matches = try await container.matchRepository.fetchMatchesForTeam(teamId: team.id) matches = try await container.matchRepository.fetchMatchesForTeam(teamId: team.id)
} }
} catch { } catch {
self.error = UserFacingError.message(for: error) self.error = UserFacingError.message(for: error) ?? L10n.t("matches.msg.load.error")
} }
loading = false loading = false
refreshing = false refreshing = false
@@ -279,7 +300,7 @@ struct MatchesScreen: View {
let sessionId = try await MatchSessionLauncher.resumeBroadcastSession(match: match, sessionRepository: container.sessionRepository) let sessionId = try await MatchSessionLauncher.resumeBroadcastSession(match: match, sessionRepository: container.sessionRepository)
onOpenBroadcast(sessionId) onOpenBroadcast(sessionId)
} catch { } catch {
snackbar = UserFacingError.message(for: error) snackbar = UserFacingError.message(for: error) ?? L10n.t("matches.msg.resume.failed")
} }
actionLoading = false actionLoading = false
} }
@@ -294,7 +315,7 @@ struct MatchesScreen: View {
reload(showSpinner: false) reload(showSpinner: false)
onOpenSetup(match.id) onOpenSetup(match.id)
} catch { } catch {
snackbar = UserFacingError.message(for: error) snackbar = UserFacingError.message(for: error) ?? L10n.t("matches.msg.create.failed")
} }
actionLoading = false actionLoading = false
} }
@@ -335,7 +356,7 @@ private struct ActiveSessionBanner: View {
Image(systemName: "video.fill") Image(systemName: "video.fill")
.foregroundStyle(MatchColors.primaryRed) .foregroundStyle(MatchColors.primaryRed)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Riprendi diretta in corso") Text(L10n.t("sheet.resume.banner"))
.font(MatchTypography.titleMedium) .font(MatchTypography.titleMedium)
.foregroundStyle(MatchColors.primaryRed) .foregroundStyle(MatchColors.primaryRed)
Text("\(match.teamName) vs \(match.opponentName)") Text("\(match.teamName) vs \(match.opponentName)")
@@ -409,23 +430,23 @@ private struct NewMatchSheet: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text("Nuova partita") Text(L10n.t("sheet.new.match.title"))
.font(MatchTypography.headlineMedium) .font(MatchTypography.headlineMedium)
Text("Programma in anticipo o avvia la configurazione diretta subito.") Text(L10n.t("sheet.new.match.lead"))
.font(MatchTypography.bodyMedium) .font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
.padding(.top, 6) .padding(.top, 6)
VStack(spacing: 10) { VStack(spacing: 10) {
SheetOptionTile( SheetOptionTile(
systemImage: "calendar.badge.clock", systemImage: "calendar.badge.clock",
title: "Programma partita", title: L10n.t("sheet.schedule.option"),
subtitle: "Data, ora e avversario — visibile anche sul sito", subtitle: L10n.t("sheet.schedule.option.sub"),
action: onSchedule action: onSchedule
) )
SheetOptionTile( SheetOptionTile(
systemImage: "play.circle", systemImage: "play.circle",
title: "Avvia subito", title: L10n.t("sheet.quick.option"),
subtitle: "Crea la partita e passa al wizard senza orario", subtitle: L10n.t("sheet.quick.option.sub"),
action: onQuickStart action: onQuickStart
) )
} }
@@ -481,15 +502,18 @@ private struct ScheduleMatchSheet: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
TextField("Avversario", text: $opponent) TextField(L10n.t("sheet.opponent"), text: $opponent)
TextField("Luogo", text: $location) TextField(L10n.t("sheet.location.optional"), text: $location)
DatePicker("Data", selection: $date) DatePicker(L10n.t("sheet.date.time"), selection: $date)
} }
.navigationTitle("Partita programmata") .navigationTitle(L10n.t("sheet.schedule.title"))
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Chiudi") { dismiss() } } ToolbarItem(placement: .cancellationAction) {
Button(L10n.t("common.close")) { dismiss() }
}
ToolbarItem(placement: .confirmationAction) { 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() { private func create() {
guard let teamId else { return } guard let teamId else { return }
Task { Task {
if let match = try? await container.matchRepository.createScheduledMatch( do {
let match = try await container.matchRepository.createScheduledMatch(
teamId: teamId, teamId: teamId,
opponentName: opponent, opponentName: opponent,
scheduledAt: date, scheduledAt: date,
location: location.nilIfEmpty location: location.nilIfEmpty
) { )
onCreated(match) 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"))
} }
} }
} }
@@ -7,6 +7,13 @@ struct AppNavHost: View {
@State private var broadcastRoute: BroadcastRoute? @State private var broadcastRoute: BroadcastRoute?
/// Incrementato al ritorno da wizard/broadcast per ricaricare l'hub partite. /// Incrementato al ritorno da wizard/broadcast per ricaricare l'hub partite.
@State private var matchesRefreshToken = 0 @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 { var body: some View {
NavigationStack(path: $path) { NavigationStack(path: $path) {
@@ -39,6 +46,10 @@ struct AppNavHost: View {
} }
} }
} }
.environment(\.locale, appLocale)
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
languageTick += 1
}
.lockPortraitOrientation() .lockPortraitOrientation()
.fullScreenCover(item: $wizardRoute) { route in .fullScreenCover(item: $wizardRoute) { route in
WizardShellScreen( WizardShellScreen(
@@ -124,12 +124,12 @@ struct StepMatchScreen: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text("Dettagli partita") Text(L10n.t("wizard.match.details.title"))
.font(MatchTypography.headlineMedium) .font(MatchTypography.headlineMedium)
.padding(.bottom, 20) .padding(.bottom, 20)
TeamBrandingRow( TeamBrandingRow(
sectionLabel: "Squadra di casa", sectionLabel: L10n.t("wizard.match.home.team.label"),
teamName: .constant(match.teamName), teamName: .constant(match.teamName),
nameEditable: false, nameEditable: false,
remoteLogoUrl: homeLogoUrl, remoteLogoUrl: homeLogoUrl,
@@ -140,7 +140,7 @@ struct StepMatchScreen: View {
.padding(.bottom, 16) .padding(.bottom, 16)
TeamBrandingRow( TeamBrandingRow(
sectionLabel: "Squadra avversaria", sectionLabel: L10n.t("wizard.match.away.team.label"),
teamName: $opponent, teamName: $opponent,
nameEditable: true, nameEditable: true,
remoteLogoUrl: opponentLogoUrl, remoteLogoUrl: opponentLogoUrl,
@@ -150,25 +150,25 @@ struct StepMatchScreen: View {
) )
.padding(.bottom, 16) .padding(.bottom, 16)
WizardOutlinedField(label: "Luogo", text: $location) WizardOutlinedField(label: L10n.t("wizard.match.location.label"), text: $location)
.padding(.bottom, 12) .padding(.bottom, 12)
WizardOutlinedField(label: "Campionato (facoltativo)", text: $campionato) WizardOutlinedField(label: L10n.t("wizard.match.category.label"), text: $campionato)
Text("Es. Serie C, torneo estivo — lo useremo in descrizione e overlay.") Text(L10n.t("wizard.match.category.hint"))
.font(MatchTypography.bodyMedium) .font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
.padding(.top, 4) .padding(.top, 4)
if isScheduledMatch { if isScheduledMatch {
WizardReadOnlyField( WizardReadOnlyField(
label: "Programmata per", label: L10n.t("wizard.match.scheduled.label"),
value: ApiInstant.formatMatchDate(match.scheduledAt) ?? "" value: ApiInstant.formatMatchDate(match.scheduledAt) ?? ""
) )
.padding(.top, 16) .padding(.top, 16)
} }
if !allowedOverlays.isEmpty { if !allowedOverlays.isEmpty {
Toggle("Overlay video personalizzato", isOn: $customOverlay) Toggle(L10n.t("wizard.match.custom.overlay.label"), isOn: $customOverlay)
.padding(.top, 20) .padding(.top, 20)
if customOverlay { if customOverlay {
VStack(spacing: 6) { VStack(spacing: 6) {
@@ -186,7 +186,7 @@ struct StepMatchScreen: View {
Toggle(isOn: $customRules) { Toggle(isOn: $customRules) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Regole punteggio personalizzate") Text(L10n.t("wizard.match.custom.rules.label"))
Text(customRulesDescription) Text(customRulesDescription)
.font(MatchTypography.bodyMedium) .font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
@@ -202,7 +202,7 @@ struct StepMatchScreen: View {
volleyRulesSection 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(.top, 32)
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
@@ -244,20 +244,20 @@ struct StepMatchScreen: View {
private var customRulesDescription: String { private var customRulesDescription: String {
if !customRules && ["basket", "timed"].contains(boardType) { 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) { if customRules && ["basket", "timed"].contains(boardType) {
return "Torneo non standard: tempi e periodi personalizzati." return L10n.t("wizard.match.rules.custom.timed")
} }
if customRules { 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 { private var periodRulesSection: some View {
VStack(alignment: .leading, spacing: 12) { 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) .font(MatchTypography.bodyMedium)
.padding(.top, 16) .padding(.top, 16)
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -271,7 +271,7 @@ struct StepMatchScreen: View {
} }
} }
WizardOutlinedField( 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 text: $periodDurationText
) )
.onChange(of: periodDurationText) { text in .onChange(of: periodDurationText) { text in
@@ -279,7 +279,7 @@ struct StepMatchScreen: View {
if filtered != text { periodDurationText = filtered } if filtered != text { periodDurationText = filtered }
if let value = Int(filtered) { periodDurationMins = min(max(value, 1), 120) } 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 .onChange(of: overtimeDurationText) { text in
let filtered = String(text.filter(\.isNumber).prefix(3)) let filtered = String(text.filter(\.isNumber).prefix(3))
if filtered != text { overtimeDurationText = filtered } if filtered != text { overtimeDurationText = filtered }
@@ -290,7 +290,7 @@ struct StepMatchScreen: View {
private var volleyRulesSection: some View { private var volleyRulesSection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Set da vincere la partita") Text(L10n.t("wizard.match.sets.to.win.label"))
.font(MatchTypography.bodyMedium) .font(MatchTypography.bodyMedium)
.padding(.top, 16) .padding(.top, 16)
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -303,13 +303,13 @@ struct StepMatchScreen: View {
.frame(maxWidth: .infinity) .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 .onChange(of: pointsPerSetText) { text in
let filtered = String(text.filter(\.isNumber).prefix(2)) let filtered = String(text.filter(\.isNumber).prefix(2))
if filtered != text { pointsPerSetText = filtered } if filtered != text { pointsPerSetText = filtered }
if let value = Int(filtered) { pointsPerSet = min(max(value, 1), 99) } 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 .onChange(of: pointsDecidingSetText) { text in
let filtered = String(text.filter(\.isNumber).prefix(2)) let filtered = String(text.filter(\.isNumber).prefix(2))
if filtered != text { pointsDecidingSetText = filtered } if filtered != text { pointsDecidingSetText = filtered }
@@ -321,17 +321,17 @@ struct StepMatchScreen: View {
private func saveAndContinue() { private func saveAndContinue() {
let trimmedOpponent = opponent.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedOpponent = opponent.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedOpponent.isEmpty else { guard !trimmedOpponent.isEmpty else {
onError("Inserisci il nome avversario") onError(L10n.t("wizard.error.opponent.name.required"))
return return
} }
if customRules && ["volley", "racket"].contains(boardType) { if customRules && ["volley", "racket"].contains(boardType) {
guard let perSet = Int(pointsPerSetText), perSet >= 1 else { 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 return
} }
guard let deciding = Int(pointsDecidingSetText), deciding >= 1 else { guard let deciding = Int(pointsDecidingSetText), deciding >= 1 else {
onError("Inserisci i punti del tie-break") onError(L10n.t("wizard.error.tiebreak.points.required"))
return return
} }
pointsPerSet = perSet pointsPerSet = perSet
@@ -340,11 +340,11 @@ struct StepMatchScreen: View {
if customRules && ["basket", "timed"].contains(boardType) { if customRules && ["basket", "timed"].contains(boardType) {
guard let periodMins = Int(periodDurationText), periodMins >= 1 else { 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 return
} }
guard let overtimeMins = Int(overtimeDurationText), overtimeMins >= 1 else { 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 return
} }
periodDurationMins = periodMins periodDurationMins = periodMins
@@ -45,35 +45,35 @@ struct StepNetworkTestScreen: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text("Test rete") Text(L10n.t("wizard.network.test.title"))
.font(MatchTypography.headlineMedium) .font(MatchTypography.headlineMedium)
Text("Verifica che la connessione regga l'upload della diretta.") Text(L10n.t("wizard.network.test.subtitle"))
.font(MatchTypography.bodyMedium) .font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
.padding(.top, 8) .padding(.top, 8)
HStack(spacing: 8) { HStack(spacing: 8) {
MetricCard( MetricCard(
label: "Download", label: L10n.t("wizard.network.download.label"),
value: testing ? "..." : String(format: "%.1f Mbps", downloadMbps) value: testing ? "..." : String(format: "%.1f Mbps", downloadMbps)
) )
MetricCard( MetricCard(
label: "Upload", label: L10n.t("wizard.network.upload.label"),
value: testing ? "..." : String(format: "%.1f Mbps", uploadMbps), value: testing ? "..." : String(format: "%.1f Mbps", uploadMbps),
highlight: ready highlight: ready
) )
MetricCard( MetricCard(
label: "Latenza", label: L10n.t("wizard.network.latency.label"),
value: testing ? "..." : "\(latencyMs) ms" value: testing ? "..." : "\(latencyMs) ms"
) )
} }
.padding(.top, 24) .padding(.top, 24)
MetricCard(label: "Tipo rete", value: networkType) MetricCard(label: L10n.t("wizard.network.type.label"), value: networkType)
.padding(.top, 12) .padding(.top, 12)
if testCompleted && ready { if testCompleted && ready {
Text("PRONTO PER ANDARE IN DIRETTA") Text(L10n.t("wizard.network.ready.label"))
.font(MatchTypography.titleMedium) .font(MatchTypography.titleMedium)
.foregroundStyle(MatchColors.successGreen) .foregroundStyle(MatchColors.successGreen)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -82,7 +82,7 @@ struct StepNetworkTestScreen: View {
if let quality = selectedQualityLabel { if let quality = selectedQualityLabel {
WizardReadOnlyField( WizardReadOnlyField(
label: "Qualità streaming (automatica)", label: L10n.t("wizard.network.quality.label"),
value: quality value: quality
) )
.padding(.top, 12) .padding(.top, 12)
@@ -91,20 +91,20 @@ struct StepNetworkTestScreen: View {
if testCompleted, let shareUrl { if testCompleted, let shareUrl {
WizardReadOnlyField( 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 value: shareUrl
) )
.padding(.top, 16) .padding(.top, 16)
HStack(spacing: 8) { 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) { if let url = URL(string: shareUrl) {
ShareLink( ShareLink(
item: url, 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) message: Text(shareUrl)
) { ) {
Text("CONDIVIDI") Text(L10n.t("wizard.action.share"))
.font(MatchTypography.labelLarge) .font(MatchTypography.labelLarge)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 52) .frame(height: 52)
@@ -115,13 +115,13 @@ struct StepNetworkTestScreen: View {
} }
.padding(.top, 8) .padding(.top, 8)
MatchSecondaryButton(label: "CONDIVIDI LINK REGIA", action: shareRegiaLink) MatchSecondaryButton(label: L10n.t("wizard.action.share.regia.link"), action: shareRegiaLink)
.padding(.top, 8) .padding(.top, 8)
} }
if !testCompleted { if !testCompleted {
MatchSecondaryButton( 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, action: runTest,
enabled: !testing enabled: !testing
) )
@@ -133,10 +133,10 @@ struct StepNetworkTestScreen: View {
let backWidth = (proxy.size.width - spacing) / 3 let backWidth = (proxy.size.width - spacing) / 3
let forwardWidth = (proxy.size.width - spacing) * 2 / 3 let forwardWidth = (proxy.size.width - spacing) * 2 / 3
HStack(spacing: spacing) { HStack(spacing: spacing) {
MatchSecondaryButton(label: "Indietro", action: onBack, enabled: !starting) MatchSecondaryButton(label: L10n.t("wizard.action.back"), action: onBack, enabled: !starting)
.frame(width: backWidth) .frame(width: backWidth)
MatchPrimaryButton( MatchPrimaryButton(
label: "INIZIA >", label: L10n.t("wizard.action.start"),
action: startLive, action: startLive,
enabled: ready, enabled: ready,
loading: starting loading: starting
@@ -230,12 +230,12 @@ struct StepNetworkTestScreen: View {
do { do {
let url = try await container.sessionRepository.createRegiaLink(sessionId: currentSession.id) let url = try await container.sessionRepository.createRegiaLink(sessionId: currentSession.id)
guard let link = URL(string: url) else { guard let link = URL(string: url) else {
onError("Link regia non valido") onError(L10n.t("wizard.error.regia.link"))
return return
} }
shareItem = ShareItem( shareItem = ShareItem(
items: [link], items: [link],
subject: "Link regia — \(match.teamName) vs \(match.opponentName)" subject: L10n.t("broadcast.share.regia.subject", "\(match.teamName) vs \(match.opponentName)")
) )
} catch { } catch {
if let message = UserFacingError.message(for: error) { if let message = UserFacingError.message(for: error) {
@@ -17,9 +17,9 @@ struct StepTransmissionScreen: View {
} }
private var youtubeSubtitle: String { private var youtubeSubtitle: String {
guard let team else { return "Canale in attivazione" } guard let team else { return L10n.t("wizard.transmission.youtube.activating") }
if !team.canUseYoutube { return "Premium Light o Full" } if !team.canUseYoutube { return L10n.t("wizard.transmission.youtube.premium.required") }
if !team.isYoutubeReady { return "Canale in attivazione" } if !team.isYoutubeReady { return L10n.t("wizard.transmission.youtube.activating") }
return team.youtubeDestinationLabel return team.youtubeDestinationLabel
} }
@@ -27,18 +27,18 @@ struct StepTransmissionScreen: View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
if let plan = team?.planName { if let plan = team?.planName {
Text("Piano \(plan)") Text(L10n.t("wizard.transmission.plan.label", plan))
.font(MatchTypography.bodyMedium) .font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
.padding(.bottom, 12) .padding(.bottom, 12)
} }
Text("Piattaforma") Text(L10n.t("wizard.transmission.platform.title"))
.font(MatchTypography.headlineMedium) .font(MatchTypography.headlineMedium)
WizardPlatformCard( WizardPlatformCard(
title: "Match Live TV", title: "Match Live TV",
subtitle: "Diretta sul nostro sito (incluso)", subtitle: L10n.t("wizard.transmission.platform.site.subtitle"),
selected: platform == "matchlivetv", selected: platform == "matchlivetv",
onClick: { platform = "matchlivetv" } onClick: { platform = "matchlivetv" }
) )
@@ -49,24 +49,24 @@ struct StepTransmissionScreen: View {
subtitle: youtubeSubtitle, subtitle: youtubeSubtitle,
selected: platform == "youtube", selected: platform == "youtube",
enabled: youtubeReady, enabled: youtubeReady,
badge: team?.canUseYoutube == false ? "Premium" : nil, badge: team?.canUseYoutube == false ? L10n.t("wizard.transmission.youtube.badge") : nil,
onClick: selectYoutube onClick: selectYoutube
) )
.padding(.top, 8) .padding(.top, 8)
Text("Visibilità") Text(L10n.t("wizard.transmission.visibility.title"))
.font(MatchTypography.headlineMedium) .font(MatchTypography.headlineMedium)
.padding(.top, 24) .padding(.top, 24)
HStack(spacing: 8) { HStack(spacing: 8) {
WizardChoiceButton( WizardChoiceButton(
label: "PUBBLICO", label: L10n.t("wizard.transmission.public.label"),
selected: privacy == "public", selected: privacy == "public",
action: { privacy = "public" } action: { privacy = "public" }
) )
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
WizardChoiceButton( WizardChoiceButton(
label: "NON IN ELENCO", label: L10n.t("wizard.transmission.unlisted.label"),
selected: privacy == "unlisted", selected: privacy == "unlisted",
action: { privacy = "unlisted" } action: { privacy = "unlisted" }
) )
@@ -80,7 +80,7 @@ struct StepTransmissionScreen: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding(.top, 8) .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) .font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -88,7 +88,7 @@ struct StepTransmissionScreen: View {
WizardFooterButtons( WizardFooterButtons(
onBack: onBack, onBack: onBack,
forwardLabel: "AVANTI >", forwardLabel: L10n.t("wizard.action.next"),
forwardLoading: creating, forwardLoading: creating,
onForward: createSessionAndContinue onForward: createSessionAndContinue
) )
@@ -111,16 +111,16 @@ struct StepTransmissionScreen: View {
private var visibilityDescription: String { private var visibilityDescription: String {
if privacy == "public" { 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() { private func selectYoutube() {
if youtubeReady { if youtubeReady {
platform = "youtube" platform = "youtube"
} else { } else {
onError("YouTube non disponibile per questa squadra") onError(L10n.t("wizard.error.youtube.unavailable"))
} }
} }
@@ -34,18 +34,18 @@ struct TeamBrandingRow: View {
if isConfigured { if isConfigured {
TeamLogoImage(remoteLogoUrl: remoteLogoUrl, localLogoImage: localLogoImage, size: 44) TeamLogoImage(remoteLogoUrl: remoteLogoUrl, localLogoImage: localLogoImage, size: 44)
ColorAccentBar(color: ColorHex.swiftUIColor(displayColorHex), height: 36) 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) .font(MatchTypography.titleMedium)
.lineLimit(1) .lineLimit(1)
} else { } else {
ColorAccentBar(color: ColorHex.swiftUIColor(displayColorHex), height: 32) ColorAccentBar(color: ColorHex.swiftUIColor(displayColorHex), height: 32)
if nameEditable { if nameEditable {
TextField("Nome avversario", text: $teamName) TextField(L10n.t("wizard.branding.opponent.name.placeholder"), text: $teamName)
.font(MatchTypography.titleMedium) .font(MatchTypography.titleMedium)
.padding(8) .padding(8)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
} else { } else {
Text(teamName.isEmpty ? "Squadra" : teamName) Text(teamName.isEmpty ? L10n.t("wizard.branding.team.fallback.name") : teamName)
.font(MatchTypography.titleMedium) .font(MatchTypography.titleMedium)
.lineLimit(1) .lineLimit(1)
} }
@@ -57,6 +57,7 @@ struct TeamBrandingRow: View {
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(L10n.t("wizard.branding.edit.team.cd"))
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -100,29 +101,29 @@ private struct TeamBrandingCustomizeSheet: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text("Personalizza").font(MatchTypography.headlineMedium) Text(L10n.t("wizard.branding.customize.title")).font(MatchTypography.headlineMedium)
Text(title) Text(title)
.font(MatchTypography.bodyMedium) .font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
.padding(.top, 4) .padding(.top, 4)
if nameEditable { if nameEditable {
Text("Nome squadra").font(MatchTypography.labelLarge).padding(.top, 20) Text(L10n.t("wizard.branding.team.name.label")).font(MatchTypography.labelLarge).padding(.top, 20)
TextField("Nome avversario", text: $draftName) TextField(L10n.t("wizard.branding.opponent.name.placeholder"), text: $draftName)
.padding(12) .padding(12)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
.padding(.top, 8) .padding(.top, 8)
} else { } 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(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) { HStack(spacing: 14) {
TeamLogoImage(remoteLogoUrl: remoteLogoUrl, localLogoImage: localLogoImage, size: 72) TeamLogoImage(remoteLogoUrl: remoteLogoUrl, localLogoImage: localLogoImage, size: 72)
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
PhotosPicker(selection: $logoPickerItem, matching: .images) { PhotosPicker(selection: $logoPickerItem, matching: .images) {
Text("CARICA LOGO") Text(L10n.t("wizard.branding.upload.logo"))
.font(MatchTypography.labelLarge) .font(MatchTypography.labelLarge)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 52) .frame(height: 52)
@@ -130,7 +131,7 @@ private struct TeamBrandingCustomizeSheet: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
if hasLogo { if hasLogo {
MatchSecondaryButton(label: "RIMUOVI", action: { MatchSecondaryButton(label: L10n.t("wizard.branding.remove.logo"), action: {
localLogoImage = nil localLogoImage = nil
logoPickerItem = nil logoPickerItem = nil
}) })
@@ -139,13 +140,13 @@ private struct TeamBrandingCustomizeSheet: View {
} }
.padding(.top, 10) .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 } TeamColorPickerPanel(initialColorHex: draftColor.isEmpty ? fallbackColorHex : draftColor) { draftColor = $0 }
.padding(.top, 12) .padding(.top, 12)
MatchPrimaryButton(label: "SALVA", action: save) MatchPrimaryButton(label: L10n.t("wizard.branding.save"), action: save)
.padding(.top, 24) .padding(.top, 24)
MatchSecondaryButton(label: "ANNULLA", action: onDismiss) MatchSecondaryButton(label: L10n.t("wizard.branding.cancel"), action: onDismiss)
.padding(.top, 8) .padding(.top, 8)
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
@@ -205,7 +206,7 @@ private struct TeamLogoImage: View {
private var placeholder: some View { private var placeholder: some View {
ZStack { ZStack {
MatchColors.surface MatchColors.surface
Text("Nessun logo") Text(L10n.t("wizard.branding.no.logo"))
.font(.caption) .font(.caption)
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
} }
@@ -19,7 +19,7 @@ struct TeamColorPickerPanel: View {
.overlay(Circle().stroke(MatchColors.outline, lineWidth: 2)) .overlay(Circle().stroke(MatchColors.outline, lineWidth: 2))
saturationBrightnessPicker saturationBrightnessPicker
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Tonalità") Text(L10n.t("wizard.color.hue"))
.font(MatchTypography.bodyMedium) .font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
hueGradient hueGradient
@@ -1,9 +1,12 @@
import SwiftUI import SwiftUI
private let wizardStepTitles = ["01 · Partita", "02 · Trasmissione", "03 · Test rete"]
func wizardStepTitle(_ step: Int) -> String { 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 { struct WizardStepIndicator: View {
@@ -32,7 +35,7 @@ struct WizardFooterButtons: View {
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
if showBack { if showBack {
MatchSecondaryButton(label: "Indietro", action: onBack) MatchSecondaryButton(label: L10n.t("wizard.action.back"), action: onBack)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
MatchPrimaryButton(label: forwardLabel, action: onForward, loading: forwardLoading) MatchPrimaryButton(label: forwardLabel, action: onForward, loading: forwardLoading)
@@ -11,6 +11,7 @@ struct WizardShellScreen: View {
@State private var team: Team? @State private var team: Team?
@State private var currentStep: Int @State private var currentStep: Int
@State private var error: String? @State private var error: String?
@State private var languageTick = 0
init(container: AppContainer, matchId: String, step: Int, onClose: @escaping () -> Void, onStartLive: @escaping (String) -> Void) { init(container: AppContainer, matchId: String, step: Int, onClose: @escaping () -> Void, onStartLive: @escaping (String) -> Void) {
self.container = container self.container = container
@@ -28,6 +29,7 @@ struct WizardShellScreen: View {
Button(action: onClose) { Button(action: onClose) {
Image(systemName: "xmark").foregroundStyle(.white) Image(systemName: "xmark").foregroundStyle(.white)
} }
.accessibilityLabel(L10n.t("common.close"))
Text(wizardStepTitle(currentStep)) Text(wizardStepTitle(currentStep))
.font(MatchTypography.titleMedium) .font(MatchTypography.titleMedium)
Spacer(minLength: 0) Spacer(minLength: 0)
@@ -70,7 +72,7 @@ struct WizardShellScreen: View {
onError: { presentError($0) } onError: { presentError($0) }
) )
} else { } else {
Text("Completa lo step Trasmissione") Text(L10n.t("wizard.complete.transmission.step"))
.font(MatchTypography.bodyMedium) .font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -99,11 +101,15 @@ struct WizardShellScreen: View {
} }
} }
} }
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { .alert(L10n.t("common.error.generic").capitalized, isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK", role: .cancel) {} Button(L10n.t("action.ok"), role: .cancel) {}
} message: { } message: {
Text(error ?? "") Text(error ?? "")
} }
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
languageTick += 1
}
.id(languageTick)
} }
private func presentError(_ message: String) { private func presentError(_ message: String) {
@@ -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
}
}
+21 -6
View File
@@ -17,6 +17,9 @@ ids = {f: uid() for f in app_files + test_files}
bf = {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() assets_ref, assets_bf = uid(), uid()
info_ref = 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_product, test_product = uid(), uid()
app_target, test_target = uid(), uid() app_target, test_target = uid(), uid()
app_sources, app_frameworks, app_resources = uid(), uid(), uid() app_sources, app_frameworks, app_resources = uid(), uid(), uid()
@@ -51,6 +54,12 @@ for f, fid in ids.items():
lines += [ lines += [
f'\t\t{assets_ref} /* Assets.xcassets */ = {{isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MatchLiveTv/Resources/Assets.xcassets; sourceTree = "<group>"; }};', f'\t\t{assets_ref} /* Assets.xcassets */ = {{isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MatchLiveTv/Resources/Assets.xcassets; sourceTree = "<group>"; }};',
f'\t\t{info_ref} /* Info.plist */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = MatchLiveTv/Resources/Info.plist; sourceTree = "<group>"; }};', f'\t\t{info_ref} /* Info.plist */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = MatchLiveTv/Resources/Info.plist; sourceTree = "<group>"; }};',
]
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 = "<group>"; }};'
)
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{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{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; }};', f'\t\t{hk} /* HaishinKit */ = {{isa = XCSwiftPackageProductDependency; package = {pkg}; productName = HaishinKit; }};',
@@ -62,9 +71,15 @@ for f, bid in bf.items():
lines += [ lines += [
f'\t\t{assets_bf} /* Assets in Resources */ = {{isa = PBXBuildFile; fileRef = {assets_ref}; }};', 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) test_children = ", ".join(ids[f] for f in test_files)
resource_bfs = ", ".join([assets_bf] + [lproj_bfs[lang] for lang in lproj_langs])
lines += [ lines += [
f'\t\t{main_group} = {{isa = PBXGroup; children = ({app_group}, {test_group}, {products_group}); sourceTree = "<group>"; }};', f'\t\t{main_group} = {{isa = PBXGroup; children = ({app_group}, {test_group}, {products_group}); sourceTree = "<group>"; }};',
@@ -73,7 +88,7 @@ lines += [
f'\t\t{products_group} = {{isa = PBXGroup; children = ({app_product}, {test_product}); name = Products; sourceTree = "<group>"; }};', f'\t\t{products_group} = {{isa = PBXGroup; children = ({app_product}, {test_product}); name = Products; sourceTree = "<group>"; }};',
f'\t\t{app_sources} = {{isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ({", ".join(bf[f] for f in app_files)}); runOnlyForDeploymentPostprocessing = 0; }};', 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{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{app_frameworks} = {{isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }};',
f'\t\t{test_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 = """ app_settings = """
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 21; CURRENT_PROJECT_VERSION = 26;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist; INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
@@ -92,7 +107,7 @@ app_settings = """
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2.0.0; MARKETING_VERSION = 2.0.5;
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv; PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
API_BASE_URL = "https://www.matchlivetv.it"; API_BASE_URL = "https://www.matchlivetv.it";
@@ -107,10 +122,10 @@ app_debug_settings = app_settings + """
test_settings = """ test_settings = """
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 21; CURRENT_PROJECT_VERSION = 26;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; 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_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;