Aggiunge app iOS nativa con parità multi-sport e test unitari.
Porting SwiftUI da Android (auth, hub, wizard, broadcast RTMP, overlay, scoring board-aware), reload hub al ritorno da diretta, decodifica score tollerante e documentazione allineata. Co-authored-by: Cursor <cursoragent@cursor.com>
42
backend/spec/services/scoring/volley_close_set_spec.rb
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Volley close_set" do
|
||||||
|
let!(:user) { User.create!(email: "volley-close@test.it", name: "U", password: "password123", role: "coach") }
|
||||||
|
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
|
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||||
|
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball", sets_to_win: 3) }
|
||||||
|
let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live") }
|
||||||
|
|
||||||
|
before do
|
||||||
|
session.create_score_state!(
|
||||||
|
home_sets: 0,
|
||||||
|
away_sets: 0,
|
||||||
|
home_points: 26,
|
||||||
|
away_points: 12,
|
||||||
|
current_set: 1,
|
||||||
|
board_type: "volley"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "chiude il set 26-12 e apre il set 2" do
|
||||||
|
result = Scoring::ApplyAction.new(session: session, action: "close_set").call
|
||||||
|
score = result.score.reload
|
||||||
|
|
||||||
|
expect(score.home_sets).to eq(1)
|
||||||
|
expect(score.away_sets).to eq(0)
|
||||||
|
expect(score.home_points).to eq(0)
|
||||||
|
expect(score.away_points).to eq(0)
|
||||||
|
expect(score.current_set).to eq(2)
|
||||||
|
expect(score.set_partials).to eq([{ "set" => 1, "home" => 26, "away" => 12 }])
|
||||||
|
expect(result.set_won).to be(true)
|
||||||
|
expect(result.match_won).to be(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rileva vincitore set al 25-23 dopo home_point" do
|
||||||
|
session.score_state.update!(home_points: 24, away_points: 23)
|
||||||
|
result = Scoring::ApplyAction.new(session: session, action: "home_point").call
|
||||||
|
expect(result.score.home_points).to eq(25)
|
||||||
|
expect(result.set_won).to be(true)
|
||||||
|
expect(result.winner).to eq(:home)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -35,7 +35,7 @@ Match Live TV è una piattaforma mobile-first per streaming di partite sportive
|
|||||||
| Ingest video | MediaMTX (RTMP :1935) |
|
| Ingest video | MediaMTX (RTMP :1935) |
|
||||||
| Playback live | HLS (MediaMTX :8888) |
|
| Playback live | HLS (MediaMTX :8888) |
|
||||||
| Replay | Garage (S3-compatible) + MP4 |
|
| Replay | Garage (S3-compatible) + MP4 |
|
||||||
| App mobile | Android nativo (Kotlin + Compose, RTMP) |
|
| App mobile | Android nativo (Kotlin + Compose, RTMP); iOS nativo (SwiftUI + HaishinKit, RTMP) — parità UI/API |
|
||||||
| Edge LAN | Nginx (`edge`) sulla porta host **3000** |
|
| Edge LAN | Nginx (`edge`) sulla porta host **3000** |
|
||||||
| HTTPS pubblico | Nginx Proxy Manager (NPM) → `192.168.1.146:3000` |
|
| HTTPS pubblico | Nginx Proxy Manager (NPM) → `192.168.1.146:3000` |
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ Definito in [`backend/config/sports.yml`](../backend/config/sports.yml), caricat
|
|||||||
|
|
||||||
Scelto per partita (`overlay_kind`), limitato da `allowed_overlays` dello sport.
|
Scelto per partita (`overlay_kind`), limitato da `allowed_overlays` dello sport.
|
||||||
|
|
||||||
Rendering Android: `OverlayKind` in `OverlayState` → `ScoreboardElement`, `CompactScoreboardElement`, `TimerElement`.
|
Rendering app mobile: `OverlayKind` in `OverlayState` → `ScoreboardElement` (volley/racket), `CompactScoreboardElement` (basket/timed), `WatermarkElement`. Il cronometro **non** è renderizzato sull'overlay video (solo in regia).
|
||||||
|
|
||||||
|
Percorsi: Android `native/android/.../streaming/overlay/`, iOS `native/ios/MatchLiveTv/Streaming/Overlay/`.
|
||||||
|
|
||||||
## Migrazione
|
## Migrazione
|
||||||
|
|
||||||
|
|||||||
@@ -160,13 +160,13 @@ Definiti in `Sports::OverlayKind`: `none`, `volley`, `basket`, `timed`, `racket`
|
|||||||
|
|
||||||
Esempio: il basket ammette `[basket, none]` — si può trasmettere con tabellone o solo watermark.
|
Esempio: il basket ammette `[basket, none]` — si può trasmettere con tabellone o solo watermark.
|
||||||
|
|
||||||
### Layout Android (pipeline video)
|
### Layout app mobile (Android e iOS)
|
||||||
|
|
||||||
Sull’app nativa l’overlay è composito in GPU senza ricodifica aggiuntiva:
|
Sull’app nativa l’overlay è composito in GPU senza ricodifica aggiuntiva:
|
||||||
|
|
||||||
1. `BroadcastScreen` costruisce un `OverlayState` a partire da `effectiveOverlayKind` e `ScoreState`.
|
1. `BroadcastScreen` costruisce un `OverlayState` a partire da `effectiveOverlayKind` e `ScoreState`.
|
||||||
2. `OverlayRenderer` → `OverlayCanvasRenderer` disegna gli elementi su bitmap trasparente.
|
2. `OverlayRenderer` → `OverlayCanvasRenderer` disegna gli elementi su bitmap trasparente.
|
||||||
3. Il bitmap viene applicato come filtro OpenGL sul flusso RTMP.
|
3. Il bitmap viene applicato come filtro sul flusso RTMP (OpenGL su Android, HaishinKit su iOS).
|
||||||
|
|
||||||
Elementi grafici (`OverlayCanvasRenderer`):
|
Elementi grafici (`OverlayCanvasRenderer`):
|
||||||
|
|
||||||
@@ -334,7 +334,9 @@ Questa separazione è intenzionale: il regolamento alimenta sempre il **modello
|
|||||||
| Modello partita | `backend/app/models/match.rb` |
|
| Modello partita | `backend/app/models/match.rb` |
|
||||||
| Stato punteggio | `backend/app/models/score_state.rb` |
|
| Stato punteggio | `backend/app/models/score_state.rb` |
|
||||||
| Overlay Android | `native/android/.../streaming/overlay/*` |
|
| Overlay Android | `native/android/.../streaming/overlay/*` |
|
||||||
| Schermata broadcast | `native/android/.../ui/broadcast/BroadcastScreen.kt` |
|
| Overlay iOS | `native/ios/MatchLiveTv/Streaming/Overlay/*` |
|
||||||
|
| Schermata broadcast Android | `native/android/.../ui/broadcast/BroadcastScreen.kt` |
|
||||||
|
| Schermata broadcast iOS | `native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift` |
|
||||||
| Regia web | `backend/app/views/public/regia/_*.html.erb` |
|
| Regia web | `backend/app/views/public/regia/_*.html.erb` |
|
||||||
|
|
||||||
Documento correlato: [`MULTI_SPORT.md`](./MULTI_SPORT.md) (panoramica più sintetica della migrazione multi-sport).
|
Documento correlato: [`MULTI_SPORT.md`](./MULTI_SPORT.md) (panoramica più sintetica della migrazione multi-sport).
|
||||||
|
|||||||
4
native/ios/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcuserstate
|
||||||
|
xcuserdata/
|
||||||
234
native/ios/MatchLiveTv.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {};
|
||||||
|
objectVersion = 60;
|
||||||
|
objects = {
|
||||||
|
5B23ED0F23434BC3A23B142F /* MatchLiveTvApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveTvApp.swift; path = MatchLiveTv/App/MatchLiveTvApp.swift; sourceTree = "<group>"; };
|
||||||
|
D7A94757D18C46C9821833B7 /* ApiInstant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstant.swift; path = MatchLiveTv/Core/ApiInstant.swift; sourceTree = "<group>"; };
|
||||||
|
1443F18A02BB4890A01A56F9 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppConfig.swift; path = MatchLiveTv/Core/AppConfig.swift; sourceTree = "<group>"; };
|
||||||
|
E717B8A6A51E499490C0334B /* ColorHex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ColorHex.swift; path = MatchLiveTv/Core/ColorHex.swift; sourceTree = "<group>"; };
|
||||||
|
6E6A356C81724C51BF41C25D /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = "<group>"; };
|
||||||
|
DC18E1BC596E42DBB8BBBBFD /* MediaUrl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MediaUrl.swift; path = MatchLiveTv/Core/MediaUrl.swift; sourceTree = "<group>"; };
|
||||||
|
C3DCAD32C3F24439A75920C1 /* TokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TokenStore.swift; path = MatchLiveTv/Core/TokenStore.swift; sourceTree = "<group>"; };
|
||||||
|
B4B5CF0BD5534CF7BE51E3CB /* UserFacingError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = UserFacingError.swift; path = MatchLiveTv/Core/UserFacingError.swift; sourceTree = "<group>"; };
|
||||||
|
E14CA2E05DE7400C852121AD /* ApiDtos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiDtos.swift; path = MatchLiveTv/Data/API/ApiDtos.swift; sourceTree = "<group>"; };
|
||||||
|
BD1CEFBFF4D54361A4C3B4D2 /* MatchLiveAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveAPI.swift; path = MatchLiveTv/Data/API/MatchLiveAPI.swift; sourceTree = "<group>"; };
|
||||||
|
66C7207107804520B8B87462 /* AppContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppContainer.swift; path = MatchLiveTv/Data/AppContainer.swift; sourceTree = "<group>"; };
|
||||||
|
BE6E2D81675945268458DEE8 /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = "<group>"; };
|
||||||
|
E902041CF4A04479B72760FE /* SessionCableService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionCableService.swift; path = MatchLiveTv/Data/Cable/SessionCableService.swift; sourceTree = "<group>"; };
|
||||||
|
4AE94D0FE137473D8821B0E3 /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AuthRepository.swift; path = MatchLiveTv/Data/Repository/AuthRepository.swift; sourceTree = "<group>"; };
|
||||||
|
8D20E18A8913489FABFBFFB5 /* MatchRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchRepository.swift; path = MatchLiveTv/Data/Repository/MatchRepository.swift; sourceTree = "<group>"; };
|
||||||
|
BDDCAF6027D24CB2B273A3D6 /* MatchSessionLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSessionLauncher.swift; path = MatchLiveTv/Data/Repository/MatchSessionLauncher.swift; sourceTree = "<group>"; };
|
||||||
|
2A5CCF3B585A4095941A6B21 /* ScoreRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreRepository.swift; path = MatchLiveTv/Data/Repository/ScoreRepository.swift; sourceTree = "<group>"; };
|
||||||
|
4436BE62A6004577BE8356C1 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = "<group>"; };
|
||||||
|
CF167DF2CBC14C2A97D49C1D /* ScoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreController.swift; path = MatchLiveTv/Data/Scoring/ScoreController.swift; sourceTree = "<group>"; };
|
||||||
|
0BE371ACB29A48DF928B90DD /* WizardSessionHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardSessionHolder.swift; path = MatchLiveTv/Data/WizardSessionHolder.swift; sourceTree = "<group>"; };
|
||||||
|
73A8C9597075432FB3DFCFFA /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = "<group>"; };
|
||||||
|
116561D032ED4DF8A4174E59 /* MatchScoringRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRules.swift; path = MatchLiveTv/Domain/MatchScoringRules.swift; sourceTree = "<group>"; };
|
||||||
|
AC559FEC305D475CA3F1DB21 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Models.swift; path = MatchLiveTv/Domain/Models.swift; sourceTree = "<group>"; };
|
||||||
|
3247EDBBC6114F269C89736C /* ScoreState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreState.swift; path = MatchLiveTv/Domain/ScoreState.swift; sourceTree = "<group>"; };
|
||||||
|
15B20863445A4EC28321DBFF /* BroadcastModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastModels.swift; path = MatchLiveTv/Streaming/BroadcastModels.swift; sourceTree = "<group>"; };
|
||||||
|
51B7115C3000422A8DCA41ED /* LiveBroadcastCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastCoordinator.swift; path = MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift; sourceTree = "<group>"; };
|
||||||
|
7A433A01B5BB4FE0B8E51234 /* LiveBroadcastEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastEngine.swift; path = MatchLiveTv/Streaming/LiveBroadcastEngine.swift; sourceTree = "<group>"; };
|
||||||
|
C296D6280BCD4DC0BB467CC1 /* CompactScoreboardElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CompactScoreboardElement.swift; path = MatchLiveTv/Streaming/Overlay/CompactScoreboardElement.swift; sourceTree = "<group>"; };
|
||||||
|
3F851B63647B4122A2753EBD /* OverlayCanvasRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayCanvasRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayCanvasRenderer.swift; sourceTree = "<group>"; };
|
||||||
|
420794A1247149B7B931DAEA /* OverlayLogoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayLogoCache.swift; path = MatchLiveTv/Streaming/Overlay/OverlayLogoCache.swift; sourceTree = "<group>"; };
|
||||||
|
48C5D1CE81864C2FBB3188EA /* OverlayMappings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayMappings.swift; path = MatchLiveTv/Streaming/Overlay/OverlayMappings.swift; sourceTree = "<group>"; };
|
||||||
|
228DEA0D7B0D48D6BED86141 /* OverlayRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayRenderer.swift; sourceTree = "<group>"; };
|
||||||
|
0F9F57E16B374D3AB379C1FF /* OverlayState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayState.swift; path = MatchLiveTv/Streaming/Overlay/OverlayState.swift; sourceTree = "<group>"; };
|
||||||
|
0ED2AA45ECFA4193ACC31063 /* ScoreboardElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreboardElement.swift; path = MatchLiveTv/Streaming/Overlay/ScoreboardElement.swift; sourceTree = "<group>"; };
|
||||||
|
1F2B2A3AC4C948038C03E8BB /* SponsorElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SponsorElement.swift; path = MatchLiveTv/Streaming/Overlay/SponsorElement.swift; sourceTree = "<group>"; };
|
||||||
|
3465550280DE4F5E8D485F69 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = "<group>"; };
|
||||||
|
42A4881EE59B42FEB7749122 /* LivePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LivePreviewView.swift; path = MatchLiveTv/Streaming/Preview/LivePreviewView.swift; sourceTree = "<group>"; };
|
||||||
|
2B7C7A905479455F98F00DC3 /* BroadcastControlsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastControlsOverlay.swift; path = MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift; sourceTree = "<group>"; };
|
||||||
|
8EBF7DD17E77419A83DC0496 /* BroadcastScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastScreen.swift; path = MatchLiveTv/UI/Broadcast/BroadcastScreen.swift; sourceTree = "<group>"; };
|
||||||
|
2F0BE30BA75440D5B0A0F9A6 /* LiveScoreActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveScoreActions.swift; path = MatchLiveTv/UI/Broadcast/LiveScoreActions.swift; sourceTree = "<group>"; };
|
||||||
|
1EDDB8ABF0ED4F24A5932D18 /* MatchLiveWordmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveWordmark.swift; path = MatchLiveTv/UI/Components/MatchLiveWordmark.swift; sourceTree = "<group>"; };
|
||||||
|
F2D8C0FDD5EB4A3792E4D32E /* MatchPrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchPrimaryButton.swift; path = MatchLiveTv/UI/Components/MatchPrimaryButton.swift; sourceTree = "<group>"; };
|
||||||
|
BDCE69667BE74DB9AF3E29E8 /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; };
|
||||||
|
6E72DCB8E06D42038B6E8B69 /* MatchSecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSecondaryButton.swift; path = MatchLiveTv/UI/Components/MatchSecondaryButton.swift; sourceTree = "<group>"; };
|
||||||
|
DC49A86D45D745E297107891 /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; };
|
||||||
|
456228041D2F44E99258698B /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoginScreen.swift; path = MatchLiveTv/UI/Login/LoginScreen.swift; sourceTree = "<group>"; };
|
||||||
|
6C792176D0544EED87B303C0 /* MatchesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchesScreen.swift; path = MatchLiveTv/UI/Matches/MatchesScreen.swift; sourceTree = "<group>"; };
|
||||||
|
ED410204E5694627AEA787DD /* AppNavHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppNavHost.swift; path = MatchLiveTv/UI/Navigation/AppNavHost.swift; sourceTree = "<group>"; };
|
||||||
|
EFF711C0619445C3944D3442 /* ModalRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ModalRoutes.swift; path = MatchLiveTv/UI/Navigation/ModalRoutes.swift; sourceTree = "<group>"; };
|
||||||
|
98EA33F31CE34BC88EC99E64 /* Routes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Routes.swift; path = MatchLiveTv/UI/Navigation/Routes.swift; sourceTree = "<group>"; };
|
||||||
|
097406CB65A24473B7F8A915 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = "<group>"; };
|
||||||
|
F18D3BA8A2604783AFA7BADC /* SplashScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SplashScreen.swift; path = MatchLiveTv/UI/Splash/SplashScreen.swift; sourceTree = "<group>"; };
|
||||||
|
8CF22D513753479E96E37546 /* KeepScreenOn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KeepScreenOn.swift; path = MatchLiveTv/UI/System/KeepScreenOn.swift; sourceTree = "<group>"; };
|
||||||
|
3C42C3E8D4D24A6EBC8A140F /* ScreenOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScreenOrientation.swift; path = MatchLiveTv/UI/System/ScreenOrientation.swift; sourceTree = "<group>"; };
|
||||||
|
AED45F6291AC4F7FB7B31393 /* MatchColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchColors.swift; path = MatchLiveTv/UI/Theme/MatchColors.swift; sourceTree = "<group>"; };
|
||||||
|
E4A2CDEC380147D89E36B359 /* StepMatchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepMatchScreen.swift; path = MatchLiveTv/UI/Wizard/StepMatchScreen.swift; sourceTree = "<group>"; };
|
||||||
|
4E0B262FA3774D72901529E8 /* StepNetworkTestScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepNetworkTestScreen.swift; path = MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift; sourceTree = "<group>"; };
|
||||||
|
00F0EEBEE0C642FAB6298D79 /* StepTransmissionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepTransmissionScreen.swift; path = MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift; sourceTree = "<group>"; };
|
||||||
|
64C5CD27F7354DDB9A7FCC5F /* TeamBrandingEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TeamBrandingEditor.swift; path = MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift; sourceTree = "<group>"; };
|
||||||
|
EC0F495A7A484769ADA89E96 /* TeamColorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TeamColorPicker.swift; path = MatchLiveTv/UI/Wizard/TeamColorPicker.swift; sourceTree = "<group>"; };
|
||||||
|
A768FA73B328428EAC69BF16 /* WizardComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardComponents.swift; path = MatchLiveTv/UI/Wizard/WizardComponents.swift; sourceTree = "<group>"; };
|
||||||
|
5B9EE57FDB9148A4B541B8CF /* WizardShellScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardShellScreen.swift; path = MatchLiveTv/UI/Wizard/WizardShellScreen.swift; sourceTree = "<group>"; };
|
||||||
|
2540D3CA40CC4181B3D4AFF3 /* ApiInstantTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstantTests.swift; path = MatchLiveTvTests/ApiInstantTests.swift; sourceTree = "<group>"; };
|
||||||
|
CD155C0D547D4495B9E6308A /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = "<group>"; };
|
||||||
|
42D318A6B43D4D4A898DBBE0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MatchLiveTv/Resources/Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
82B310B2B49243A296C9C581 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = MatchLiveTv/Resources/Info.plist; sourceTree = "<group>"; };
|
||||||
|
A42C110EE8DB4ECC893951AD /* MatchLiveTv.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MatchLiveTv.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
CA19230D3F7D4C6A95406F87 /* MatchLiveTvTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MatchLiveTvTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
0B182AE2B64440FBAD820078 /* HaishinKit */ = {isa = XCSwiftPackageProductDependency; package = F80AB1FECA6A40798C87634A; productName = HaishinKit; };
|
||||||
|
F0E1D34F0B3545A99C3EF141 /* RTMPHaishinKit */ = {isa = XCSwiftPackageProductDependency; package = F80AB1FECA6A40798C87634A; productName = RTMPHaishinKit; };
|
||||||
|
C43733BB396340269131C87F /* MatchLiveTvApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B23ED0F23434BC3A23B142F /* MatchLiveTvApp.swift */; };
|
||||||
|
3879FE4999AD42D29DD88BC9 /* ApiInstant.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A94757D18C46C9821833B7 /* ApiInstant.swift */; };
|
||||||
|
F9B0B951CC0E45F3AD9E17AE /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1443F18A02BB4890A01A56F9 /* AppConfig.swift */; };
|
||||||
|
FDC36E1F2673422E9F09A84D /* ColorHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = E717B8A6A51E499490C0334B /* ColorHex.swift */; };
|
||||||
|
9BF13DD9192942E8A69CD50B /* DeviceTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6A356C81724C51BF41C25D /* DeviceTelemetry.swift */; };
|
||||||
|
82F9B0A9BD714835A41E20CE /* MediaUrl.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC18E1BC596E42DBB8BBBBFD /* MediaUrl.swift */; };
|
||||||
|
6A6460E2BBBA44C6964B2281 /* TokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DCAD32C3F24439A75920C1 /* TokenStore.swift */; };
|
||||||
|
09C3F7CBE03C479FB3E4D484 /* UserFacingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B5CF0BD5534CF7BE51E3CB /* UserFacingError.swift */; };
|
||||||
|
882F9D01445F454BBBF95AAE /* ApiDtos.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14CA2E05DE7400C852121AD /* ApiDtos.swift */; };
|
||||||
|
A9C47B24D37C48D59221AB15 /* MatchLiveAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1CEFBFF4D54361A4C3B4D2 /* MatchLiveAPI.swift */; };
|
||||||
|
E45FC9EC93854268B13B2814 /* AppContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C7207107804520B8B87462 /* AppContainer.swift */; };
|
||||||
|
035C3532E9224C11A63DE8D0 /* ActionCableClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6E2D81675945268458DEE8 /* ActionCableClient.swift */; };
|
||||||
|
BD0F196EE104494FA4959E43 /* SessionCableService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E902041CF4A04479B72760FE /* SessionCableService.swift */; };
|
||||||
|
E16AD6EDD2E34A1BB16942CC /* AuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE94D0FE137473D8821B0E3 /* AuthRepository.swift */; };
|
||||||
|
4D58338481074C47B177E866 /* MatchRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D20E18A8913489FABFBFFB5 /* MatchRepository.swift */; };
|
||||||
|
5B0F3899F7B14F84AEF5CADA /* MatchSessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDDCAF6027D24CB2B273A3D6 /* MatchSessionLauncher.swift */; };
|
||||||
|
535A5DC767124965A7E6ED4D /* ScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5CCF3B585A4095941A6B21 /* ScoreRepository.swift */; };
|
||||||
|
1C27C8DFDE544D75B1D49507 /* SessionRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4436BE62A6004577BE8356C1 /* SessionRepository.swift */; };
|
||||||
|
8F6BCEEF9168476FBA147FD3 /* ScoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF167DF2CBC14C2A97D49C1D /* ScoreController.swift */; };
|
||||||
|
A2BFF50DA9294532A2F6D52B /* WizardSessionHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BE371ACB29A48DF928B90DD /* WizardSessionHolder.swift */; };
|
||||||
|
A5EA10DEDBB34A4A95243C8A /* MatchHubFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73A8C9597075432FB3DFCFFA /* MatchHubFilter.swift */; };
|
||||||
|
71857E5125D14E0BAC132C3B /* MatchScoringRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 116561D032ED4DF8A4174E59 /* MatchScoringRules.swift */; };
|
||||||
|
965F2CEB66984469BD3BB2F6 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC559FEC305D475CA3F1DB21 /* Models.swift */; };
|
||||||
|
6247A346FC674616A8833D87 /* ScoreState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247EDBBC6114F269C89736C /* ScoreState.swift */; };
|
||||||
|
21F2A3C208DA4D9A8D8A2AD0 /* BroadcastModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15B20863445A4EC28321DBFF /* BroadcastModels.swift */; };
|
||||||
|
C1967324B50641B1AAA7153D /* LiveBroadcastCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B7115C3000422A8DCA41ED /* LiveBroadcastCoordinator.swift */; };
|
||||||
|
6A39E943F02842819BA0F11D /* LiveBroadcastEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A433A01B5BB4FE0B8E51234 /* LiveBroadcastEngine.swift */; };
|
||||||
|
456274BB9DFA482EB52A77FF /* CompactScoreboardElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C296D6280BCD4DC0BB467CC1 /* CompactScoreboardElement.swift */; };
|
||||||
|
FFF0200A28BD4C55BF288A04 /* OverlayCanvasRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F851B63647B4122A2753EBD /* OverlayCanvasRenderer.swift */; };
|
||||||
|
EED95CFC9C044C46B8C748E8 /* OverlayLogoCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420794A1247149B7B931DAEA /* OverlayLogoCache.swift */; };
|
||||||
|
196A4353FEBF48FB8086B6C3 /* OverlayMappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48C5D1CE81864C2FBB3188EA /* OverlayMappings.swift */; };
|
||||||
|
73067AA8AEFA446C898CBB36 /* OverlayRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 228DEA0D7B0D48D6BED86141 /* OverlayRenderer.swift */; };
|
||||||
|
AD747FCF7E8A40FB8FB0C09D /* OverlayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9F57E16B374D3AB379C1FF /* OverlayState.swift */; };
|
||||||
|
4A2583194D364768910F0A2E /* ScoreboardElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ED2AA45ECFA4193ACC31063 /* ScoreboardElement.swift */; };
|
||||||
|
63FB60603C30400A9412B97A /* SponsorElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2B2A3AC4C948038C03E8BB /* SponsorElement.swift */; };
|
||||||
|
476CEF2BD80A4F5ABAF4BF8F /* WatermarkElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3465550280DE4F5E8D485F69 /* WatermarkElement.swift */; };
|
||||||
|
E2B229D77E7B4B0EB3E20F3A /* LivePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A4881EE59B42FEB7749122 /* LivePreviewView.swift */; };
|
||||||
|
455B81C72AAC40269A4B4EDE /* BroadcastControlsOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B7C7A905479455F98F00DC3 /* BroadcastControlsOverlay.swift */; };
|
||||||
|
2E49953E828A4F1A87AF47AF /* BroadcastScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EBF7DD17E77419A83DC0496 /* BroadcastScreen.swift */; };
|
||||||
|
87870520F2C5472DBEF4FAA0 /* LiveScoreActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0BE30BA75440D5B0A0F9A6 /* LiveScoreActions.swift */; };
|
||||||
|
7F20ADEFF13D42EB969FA227 /* MatchLiveWordmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EDDB8ABF0ED4F24A5932D18 /* MatchLiveWordmark.swift */; };
|
||||||
|
F05637D278B04B9E9F914685 /* MatchPrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2D8C0FDD5EB4A3792E4D32E /* MatchPrimaryButton.swift */; };
|
||||||
|
05E7701D345F47A48EB3B1A8 /* MatchScreenScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDCE69667BE74DB9AF3E29E8 /* MatchScreenScaffold.swift */; };
|
||||||
|
D0CA4D05E24D44B48662A467 /* MatchSecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E72DCB8E06D42038B6E8B69 /* MatchSecondaryButton.swift */; };
|
||||||
|
093B423F42B840AC8DC4BBFE /* MatchStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC49A86D45D745E297107891 /* MatchStatusBadge.swift */; };
|
||||||
|
6478C806699D4AF3AB895AC6 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 456228041D2F44E99258698B /* LoginScreen.swift */; };
|
||||||
|
E981731C73724090B93301FC /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C792176D0544EED87B303C0 /* MatchesScreen.swift */; };
|
||||||
|
D7EF5FD3160546A18B1F3268 /* AppNavHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED410204E5694627AEA787DD /* AppNavHost.swift */; };
|
||||||
|
DE2C6A8E89FF4C019599320F /* ModalRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFF711C0619445C3944D3442 /* ModalRoutes.swift */; };
|
||||||
|
FC4AD7E92B4248BF8D440AB5 /* Routes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98EA33F31CE34BC88EC99E64 /* Routes.swift */; };
|
||||||
|
08FB4930AAC1443B8A6AA17A /* BroadcastPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 097406CB65A24473B7F8A915 /* BroadcastPermissions.swift */; };
|
||||||
|
AA896C55DB9741B78A173671 /* SplashScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18D3BA8A2604783AFA7BADC /* SplashScreen.swift */; };
|
||||||
|
F2C1D718393C4A7695FE56CD /* KeepScreenOn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CF22D513753479E96E37546 /* KeepScreenOn.swift */; };
|
||||||
|
C06B35642D68409788DAC85F /* ScreenOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C42C3E8D4D24A6EBC8A140F /* ScreenOrientation.swift */; };
|
||||||
|
CA27CA0592C1459AA5895B03 /* MatchColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = AED45F6291AC4F7FB7B31393 /* MatchColors.swift */; };
|
||||||
|
0D790785EC384F4F999DF393 /* StepMatchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4A2CDEC380147D89E36B359 /* StepMatchScreen.swift */; };
|
||||||
|
33D5A9835E1C476FA513CD7B /* StepNetworkTestScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0B262FA3774D72901529E8 /* StepNetworkTestScreen.swift */; };
|
||||||
|
16773703D9B34B4D99E37DAB /* StepTransmissionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00F0EEBEE0C642FAB6298D79 /* StepTransmissionScreen.swift */; };
|
||||||
|
439C3F3C9E8E4512AAE9A6F9 /* TeamBrandingEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C5CD27F7354DDB9A7FCC5F /* TeamBrandingEditor.swift */; };
|
||||||
|
1895A7F0D8A04AB086C8B0E0 /* TeamColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC0F495A7A484769ADA89E96 /* TeamColorPicker.swift */; };
|
||||||
|
E606392321674632B0B5A9C3 /* WizardComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A768FA73B328428EAC69BF16 /* WizardComponents.swift */; };
|
||||||
|
437D99BC2B8446CA87766221 /* WizardShellScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B9EE57FDB9148A4B541B8CF /* WizardShellScreen.swift */; };
|
||||||
|
76DE047F93C0417FB8D60984 /* ApiInstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2540D3CA40CC4181B3D4AFF3 /* ApiInstantTests.swift */; };
|
||||||
|
AB9147175A564C9B848A5E43 /* MatchScoringRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD155C0D547D4495B9E6308A /* MatchScoringRulesTests.swift */; };
|
||||||
|
9392A07F3B5343F4BE8538C9 /* Assets in Resources */ = {isa = PBXBuildFile; fileRef = 42D318A6B43D4D4A898DBBE0; };
|
||||||
|
7AE60FABDB154D8286A2902F = {isa = PBXGroup; children = (25BA0F6BCD6248C8B1090B10, 9CDEE1193E594846AF90F5A3, CECA644368B34BD1AF81B7D4); sourceTree = "<group>"; };
|
||||||
|
25BA0F6BCD6248C8B1090B10 = {isa = PBXGroup; children = (5B23ED0F23434BC3A23B142F, D7A94757D18C46C9821833B7, 1443F18A02BB4890A01A56F9, E717B8A6A51E499490C0334B, 6E6A356C81724C51BF41C25D, DC18E1BC596E42DBB8BBBBFD, C3DCAD32C3F24439A75920C1, B4B5CF0BD5534CF7BE51E3CB, E14CA2E05DE7400C852121AD, BD1CEFBFF4D54361A4C3B4D2, 66C7207107804520B8B87462, BE6E2D81675945268458DEE8, E902041CF4A04479B72760FE, 4AE94D0FE137473D8821B0E3, 8D20E18A8913489FABFBFFB5, BDDCAF6027D24CB2B273A3D6, 2A5CCF3B585A4095941A6B21, 4436BE62A6004577BE8356C1, CF167DF2CBC14C2A97D49C1D, 0BE371ACB29A48DF928B90DD, 73A8C9597075432FB3DFCFFA, 116561D032ED4DF8A4174E59, AC559FEC305D475CA3F1DB21, 3247EDBBC6114F269C89736C, 15B20863445A4EC28321DBFF, 51B7115C3000422A8DCA41ED, 7A433A01B5BB4FE0B8E51234, C296D6280BCD4DC0BB467CC1, 3F851B63647B4122A2753EBD, 420794A1247149B7B931DAEA, 48C5D1CE81864C2FBB3188EA, 228DEA0D7B0D48D6BED86141, 0F9F57E16B374D3AB379C1FF, 0ED2AA45ECFA4193ACC31063, 1F2B2A3AC4C948038C03E8BB, 3465550280DE4F5E8D485F69, 42A4881EE59B42FEB7749122, 2B7C7A905479455F98F00DC3, 8EBF7DD17E77419A83DC0496, 2F0BE30BA75440D5B0A0F9A6, 1EDDB8ABF0ED4F24A5932D18, F2D8C0FDD5EB4A3792E4D32E, BDCE69667BE74DB9AF3E29E8, 6E72DCB8E06D42038B6E8B69, DC49A86D45D745E297107891, 456228041D2F44E99258698B, 6C792176D0544EED87B303C0, ED410204E5694627AEA787DD, EFF711C0619445C3944D3442, 98EA33F31CE34BC88EC99E64, 097406CB65A24473B7F8A915, F18D3BA8A2604783AFA7BADC, 8CF22D513753479E96E37546, 3C42C3E8D4D24A6EBC8A140F, AED45F6291AC4F7FB7B31393, E4A2CDEC380147D89E36B359, 4E0B262FA3774D72901529E8, 00F0EEBEE0C642FAB6298D79, 64C5CD27F7354DDB9A7FCC5F, EC0F495A7A484769ADA89E96, A768FA73B328428EAC69BF16, 5B9EE57FDB9148A4B541B8CF, 42D318A6B43D4D4A898DBBE0, 82B310B2B49243A296C9C581); name = MatchLiveTv; sourceTree = "<group>"; };
|
||||||
|
9CDEE1193E594846AF90F5A3 = {isa = PBXGroup; children = (2540D3CA40CC4181B3D4AFF3, CD155C0D547D4495B9E6308A); name = MatchLiveTvTests; sourceTree = "<group>"; };
|
||||||
|
CECA644368B34BD1AF81B7D4 = {isa = PBXGroup; children = (A42C110EE8DB4ECC893951AD, CA19230D3F7D4C6A95406F87); name = Products; sourceTree = "<group>"; };
|
||||||
|
75685F0D94844D25990466AB = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (C43733BB396340269131C87F, 3879FE4999AD42D29DD88BC9, F9B0B951CC0E45F3AD9E17AE, FDC36E1F2673422E9F09A84D, 9BF13DD9192942E8A69CD50B, 82F9B0A9BD714835A41E20CE, 6A6460E2BBBA44C6964B2281, 09C3F7CBE03C479FB3E4D484, 882F9D01445F454BBBF95AAE, A9C47B24D37C48D59221AB15, E45FC9EC93854268B13B2814, 035C3532E9224C11A63DE8D0, BD0F196EE104494FA4959E43, E16AD6EDD2E34A1BB16942CC, 4D58338481074C47B177E866, 5B0F3899F7B14F84AEF5CADA, 535A5DC767124965A7E6ED4D, 1C27C8DFDE544D75B1D49507, 8F6BCEEF9168476FBA147FD3, A2BFF50DA9294532A2F6D52B, A5EA10DEDBB34A4A95243C8A, 71857E5125D14E0BAC132C3B, 965F2CEB66984469BD3BB2F6, 6247A346FC674616A8833D87, 21F2A3C208DA4D9A8D8A2AD0, C1967324B50641B1AAA7153D, 6A39E943F02842819BA0F11D, 456274BB9DFA482EB52A77FF, FFF0200A28BD4C55BF288A04, EED95CFC9C044C46B8C748E8, 196A4353FEBF48FB8086B6C3, 73067AA8AEFA446C898CBB36, AD747FCF7E8A40FB8FB0C09D, 4A2583194D364768910F0A2E, 63FB60603C30400A9412B97A, 476CEF2BD80A4F5ABAF4BF8F, E2B229D77E7B4B0EB3E20F3A, 455B81C72AAC40269A4B4EDE, 2E49953E828A4F1A87AF47AF, 87870520F2C5472DBEF4FAA0, 7F20ADEFF13D42EB969FA227, F05637D278B04B9E9F914685, 05E7701D345F47A48EB3B1A8, D0CA4D05E24D44B48662A467, 093B423F42B840AC8DC4BBFE, 6478C806699D4AF3AB895AC6, E981731C73724090B93301FC, D7EF5FD3160546A18B1F3268, DE2C6A8E89FF4C019599320F, FC4AD7E92B4248BF8D440AB5, 08FB4930AAC1443B8A6AA17A, AA896C55DB9741B78A173671, F2C1D718393C4A7695FE56CD, C06B35642D68409788DAC85F, CA27CA0592C1459AA5895B03, 0D790785EC384F4F999DF393, 33D5A9835E1C476FA513CD7B, 16773703D9B34B4D99E37DAB, 439C3F3C9E8E4512AAE9A6F9, 1895A7F0D8A04AB086C8B0E0, E606392321674632B0B5A9C3, 437D99BC2B8446CA87766221); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
|
BC137E765FCA41FB85A944F8 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (76DE047F93C0417FB8D60984, AB9147175A564C9B848A5E43); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
|
E2036A7DDA8F4F7491399F71 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (9392A07F3B5343F4BE8538C9); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
|
ECE339972AA84978AE88BB46 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
|
1C9A61B330DB4A6AA59CFEF4 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
|
9773A4B6329B4CD2AA4CA51D = {isa = XCBuildConfiguration; buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 2.0.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
}; name = Debug; };
|
||||||
|
C101F6805BCD491CBA3F987D = {isa = XCBuildConfiguration; buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 2.0.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
}; name = Release; };
|
||||||
|
D8F8880914704BA9B087EE7B = {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; };
|
||||||
|
2627164A4CA343B2A4C15A2E = {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; };
|
||||||
|
7BD2F8EE33E8434AB17CE373 = {isa = XCBuildConfiguration; buildSettings = {IPHONEOS_DEPLOYMENT_TARGET = 16.0; SWIFT_VERSION = 5.0; }; name = Debug; };
|
||||||
|
68B5EA19B4E24E5D968BF94C = {isa = XCBuildConfiguration; buildSettings = {IPHONEOS_DEPLOYMENT_TARGET = 16.0; SWIFT_VERSION = 5.0; }; name = Release; };
|
||||||
|
6ED2FEB21E7E44408B6A0677 = {isa = XCConfigurationList; buildConfigurations = (9773A4B6329B4CD2AA4CA51D, C101F6805BCD491CBA3F987D); defaultConfigurationName = Release; };
|
||||||
|
EB832D1A0EA94AE69A6F2B98 = {isa = XCConfigurationList; buildConfigurations = (D8F8880914704BA9B087EE7B, 2627164A4CA343B2A4C15A2E); defaultConfigurationName = Release; };
|
||||||
|
8683854CF3914406BD918C9D = {isa = XCConfigurationList; buildConfigurations = (7BD2F8EE33E8434AB17CE373, 68B5EA19B4E24E5D968BF94C); defaultConfigurationName = Release; };
|
||||||
|
ACA5F32E253C473B9AD5D069 = {isa = PBXNativeTarget; buildConfigurationList = 6ED2FEB21E7E44408B6A0677; buildPhases = (75685F0D94844D25990466AB, ECE339972AA84978AE88BB46, E2036A7DDA8F4F7491399F71); buildRules = (); dependencies = (); name = MatchLiveTv; packageProductDependencies = (0B182AE2B64440FBAD820078, F0E1D34F0B3545A99C3EF141); productName = MatchLiveTv; productReference = A42C110EE8DB4ECC893951AD; productType = "com.apple.product-type.application"; };
|
||||||
|
8BE99E481DB24808AD87CDAA = {isa = PBXContainerItemProxy; containerPortal = 715925D32BC14CF8A5A9128B /* Project object */; proxyType = 1; remoteGlobalIDString = ACA5F32E253C473B9AD5D069; remoteInfo = MatchLiveTv; };
|
||||||
|
056ABE5A2CDA4D3E88485D37 = {isa = PBXTargetDependency; target = ACA5F32E253C473B9AD5D069 /* MatchLiveTv */; targetProxy = 8BE99E481DB24808AD87CDAA /* PBXContainerItemProxy */; };
|
||||||
|
A5EE823E98D34E7CB20F3F9E = {isa = PBXNativeTarget; buildConfigurationList = EB832D1A0EA94AE69A6F2B98; buildPhases = (BC137E765FCA41FB85A944F8, 1C9A61B330DB4A6AA59CFEF4); buildRules = (); dependencies = (056ABE5A2CDA4D3E88485D37); name = MatchLiveTvTests; productName = MatchLiveTvTests; productReference = CA19230D3F7D4C6A95406F87; productType = "com.apple.product-type.bundle.unit-test"; };
|
||||||
|
F80AB1FECA6A40798C87634A = {isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/shogo4405/HaishinKit.swift"; requirement = {kind = upToNextMajorVersion; minimumVersion = 2.0.0;}; };
|
||||||
|
715925D32BC14CF8A5A9128B = {isa = PBXProject; attributes = {BuildIndependentTargetsInParallel = 0; LastSwiftUpdateCheck = 1600;}; buildConfigurationList = 8683854CF3914406BD918C9D; compatibilityVersion = "Xcode 15.0"; developmentRegion = it; mainGroup = 7AE60FABDB154D8286A2902F; packageReferences = (F80AB1FECA6A40798C87634A); productRefGroup = CECA644368B34BD1AF81B7D4; targets = (ACA5F32E253C473B9AD5D069, A5EE823E98D34E7CB20F3F9E); };
|
||||||
|
};
|
||||||
|
rootObject = 715925D32BC14CF8A5A9128B /* Project object */;
|
||||||
|
}
|
||||||
4
native/ios/MatchLiveTv.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace version="1.0">
|
||||||
|
<FileRef location="self:"></FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "a02bd664b99b04b792f4c4ac4642c59cbac3f1660c60b2b1b9ca68761e3a17f7",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "haishinkit.swift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/shogo4405/HaishinKit.swift",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "dc880cb540b8feeb98f64e8b7dcfaaf320b6b2bd",
|
||||||
|
"version" : "2.2.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "logboard",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/shogo4405/Logboard.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "8f41c63afb903040b77049ee2efa8c257b8c0d50",
|
||||||
|
"version" : "2.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme LastUpgradeVersion="1600" version="1.7">
|
||||||
|
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
||||||
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="ACA5F32E253C473B9AD5D069" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
|
||||||
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5EE823E98D34E7CB20F3F9E" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
|
||||||
|
<Testables>
|
||||||
|
<TestableReference skipped="NO">
|
||||||
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5EE823E98D34E7CB20F3F9E" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</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">
|
||||||
|
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||||
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="ACA5F32E253C473B9AD5D069" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
|
||||||
|
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||||
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="ACA5F32E253C473B9AD5D069" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction buildConfiguration="Debug"/>
|
||||||
|
<ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
|
||||||
|
</Scheme>
|
||||||
14
native/ios/MatchLiveTv/App/MatchLiveTvApp.swift
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct MatchLiveTvApp: App {
|
||||||
|
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
MatchLiveTheme {
|
||||||
|
AppNavHost()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
native/ios/MatchLiveTv/Core/ApiInstant.swift
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ApiInstant {
|
||||||
|
static func parse(_ raw: String?) -> Date? {
|
||||||
|
let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !value.isEmpty else { return nil }
|
||||||
|
if let date = formatter.date(from: value) ?? fallbackFormatter.date(from: value) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
let normalized = value.replacingOccurrences(of: " ", with: "T")
|
||||||
|
if normalized != value {
|
||||||
|
return formatter.date(from: normalized) ?? fallbackFormatter.date(from: normalized)
|
||||||
|
}
|
||||||
|
return ISO8601DateFormatter().date(from: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isScheduledFuture(_ raw: String?, now: Date = Date()) -> Bool {
|
||||||
|
guard let date = parse(raw) else { return false }
|
||||||
|
return date > now
|
||||||
|
}
|
||||||
|
|
||||||
|
static func formatMatchDate(_ raw: String?) -> String? {
|
||||||
|
guard let date = parse(raw) else { return nil }
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "it_IT")
|
||||||
|
f.dateFormat = "EEE d MMM yyyy · HH:mm"
|
||||||
|
return f.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let formatter: ISO8601DateFormatter = {
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let fallbackFormatter: ISO8601DateFormatter = {
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime]
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
static let decoder: JSONDecoder = {
|
||||||
|
let d = JSONDecoder()
|
||||||
|
d.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
|
d.dateDecodingStrategy = .custom { decoder in
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let value = try container.decode(String.self)
|
||||||
|
if let date = formatter.date(from: value) ?? fallbackFormatter.date(from: value) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(value)")
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}()
|
||||||
|
|
||||||
|
static let encoder: JSONEncoder = {
|
||||||
|
let e = JSONEncoder()
|
||||||
|
e.keyEncodingStrategy = .convertToSnakeCase
|
||||||
|
return e
|
||||||
|
}()
|
||||||
|
}
|
||||||
23
native/ios/MatchLiveTv/Core/AppConfig.swift
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AppConfig {
|
||||||
|
static var apiBaseUrl: String {
|
||||||
|
let raw = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String
|
||||||
|
?? "https://www.matchlivetv.it"
|
||||||
|
return raw.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
static var apiV1: String { "\(apiBaseUrl)/api/v1" }
|
||||||
|
|
||||||
|
static var cableUrl: String {
|
||||||
|
guard let url = URL(string: apiBaseUrl),
|
||||||
|
let host = url.host else {
|
||||||
|
return "wss://www.matchlivetv.it/cable"
|
||||||
|
}
|
||||||
|
let wsScheme = url.scheme == "https" ? "wss" : "ws"
|
||||||
|
if let port = url.port, port != 80 && port != 443 {
|
||||||
|
return "\(wsScheme)://\(host):\(port)/cable"
|
||||||
|
}
|
||||||
|
return "\(wsScheme)://\(host)/cable"
|
||||||
|
}
|
||||||
|
}
|
||||||
58
native/ios/MatchLiveTv/Core/ColorHex.swift
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum ColorHex {
|
||||||
|
static func normalizeHexColor(_ input: String?, fallback: String = "#E53935") -> String {
|
||||||
|
guard let input, !input.isEmpty else { return fallback }
|
||||||
|
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let pattern = #"^#?[0-9A-Fa-f]{6}$"#
|
||||||
|
if trimmed.range(of: pattern, options: .regularExpression) != nil {
|
||||||
|
return trimmed.hasPrefix("#") ? trimmed : "#\(trimmed)"
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseColorHex(_ hex: String?, fallback: UIColor = UIColor(red: 1, green: 0.18, blue: 0.18, alpha: 1)) -> UIColor {
|
||||||
|
let normalized = normalizeHexColor(hex, fallback: "#FF2D2D")
|
||||||
|
let stripped = normalized.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||||||
|
guard stripped.count == 6, let value = Int(stripped, radix: 16) else { return fallback }
|
||||||
|
let r = CGFloat((value >> 16) & 0xFF) / 255
|
||||||
|
let g = CGFloat((value >> 8) & 0xFF) / 255
|
||||||
|
let b = CGFloat(value & 0xFF) / 255
|
||||||
|
return UIColor(red: r, green: g, blue: b, alpha: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func swiftUIColor(_ hex: String?, fallback: Color = MatchColors.primaryRed) -> Color {
|
||||||
|
let ui = parseColorHex(hex, fallback: UIColor(fallback))
|
||||||
|
return Color(ui)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func colorToHex(_ color: UIColor) -> String {
|
||||||
|
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||||
|
color.getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||||
|
return String(format: "#%02X%02X%02X", Int(r * 255), Int(g * 255), Int(b * 255))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func placeholderTeamColor(seed: String) -> String {
|
||||||
|
let palette = ["#FF2D2D", "#1E3A8A", "#22C55E", "#F5C518", "#8B5CF6", "#EC4899", "#06B6D4", "#F97316"]
|
||||||
|
let hash = abs(seed.hashValue)
|
||||||
|
return palette[hash % palette.count]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func hsvComponents(from hex: String) -> (hue: Double, saturation: Double, brightness: Double) {
|
||||||
|
let ui = parseColorHex(hex)
|
||||||
|
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||||
|
ui.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
|
||||||
|
return (Double(h * 360), Double(s), Double(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func hsvToHex(hue: Double, saturation: Double, brightness: Double) -> String {
|
||||||
|
let color = UIColor(
|
||||||
|
hue: CGFloat(hue / 360),
|
||||||
|
saturation: CGFloat(saturation),
|
||||||
|
brightness: CGFloat(brightness),
|
||||||
|
alpha: 1
|
||||||
|
)
|
||||||
|
return colorToHex(color)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
native/ios/MatchLiveTv/Core/DeviceTelemetry.swift
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
import Network
|
||||||
|
|
||||||
|
struct DeviceHealth: Sendable {
|
||||||
|
let batteryPercent: Int
|
||||||
|
let batteryTempC: Double?
|
||||||
|
let thermalLevel: Int
|
||||||
|
let thermalLabel: String
|
||||||
|
let networkType: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DeviceTelemetry {
|
||||||
|
static func snapshot() -> DeviceHealth {
|
||||||
|
UIDevice.current.isBatteryMonitoringEnabled = true
|
||||||
|
let level = Int((UIDevice.current.batteryLevel >= 0 ? UIDevice.current.batteryLevel : 1) * 100)
|
||||||
|
let thermal = ProcessInfo.processInfo.thermalState
|
||||||
|
let (thermalLevel, label) = thermalInfo(thermal)
|
||||||
|
return DeviceHealth(
|
||||||
|
batteryPercent: level,
|
||||||
|
batteryTempC: nil,
|
||||||
|
thermalLevel: thermalLevel,
|
||||||
|
thermalLabel: label,
|
||||||
|
networkType: currentNetworkType()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func thermalInfo(_ state: ProcessInfo.ThermalState) -> (Int, String) {
|
||||||
|
switch state {
|
||||||
|
case .nominal: return (0, "OK")
|
||||||
|
case .fair: return (1, "Caldo")
|
||||||
|
case .serious: return (2, "Surriscaldamento")
|
||||||
|
case .critical: return (3, "Troppo caldo")
|
||||||
|
@unknown default: return (0, "OK")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func currentNetworkType() -> String {
|
||||||
|
let monitor = NWPathMonitor()
|
||||||
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
|
var result = "Sconosciuto"
|
||||||
|
monitor.pathUpdateHandler = { path in
|
||||||
|
if path.usesInterfaceType(.wifi) {
|
||||||
|
result = "WiFi"
|
||||||
|
} else if path.usesInterfaceType(.cellular) {
|
||||||
|
result = "4G"
|
||||||
|
} else if path.usesInterfaceType(.wiredEthernet) {
|
||||||
|
result = "Ethernet"
|
||||||
|
}
|
||||||
|
semaphore.signal()
|
||||||
|
}
|
||||||
|
let queue = DispatchQueue(label: "network.type")
|
||||||
|
monitor.start(queue: queue)
|
||||||
|
_ = semaphore.wait(timeout: .now() + 0.5)
|
||||||
|
monitor.cancel()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
11
native/ios/MatchLiveTv/Core/MediaUrl.swift
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum MediaUrl {
|
||||||
|
static func resolve(_ path: String?) -> String? {
|
||||||
|
guard let path, !path.isEmpty else { return nil }
|
||||||
|
if path.hasPrefix("http://") || path.hasPrefix("https://") { return path }
|
||||||
|
let base = AppConfig.apiBaseUrl
|
||||||
|
if path.hasPrefix("/") { return base + path }
|
||||||
|
return "\(base)/\(path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
111
native/ios/MatchLiveTv/Core/TokenStore.swift
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
struct StoredSession: Equatable, Sendable {
|
||||||
|
let user: User
|
||||||
|
let accessToken: String
|
||||||
|
let refreshToken: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class TokenStore: ObservableObject {
|
||||||
|
@Published private(set) var session: StoredSession?
|
||||||
|
@Published private(set) var activeTeamId: String?
|
||||||
|
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
private let service = "com.matchlivetv.match-live-tv.auth"
|
||||||
|
|
||||||
|
init() {
|
||||||
|
session = loadSession()
|
||||||
|
activeTeamId = defaults.string(forKey: Keys.activeTeamId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveSession(_ session: StoredSession) {
|
||||||
|
self.session = session
|
||||||
|
saveKeychain(key: Keys.accessToken, value: session.accessToken)
|
||||||
|
saveKeychain(key: Keys.refreshToken, value: session.refreshToken)
|
||||||
|
defaults.set(session.user.id, forKey: Keys.userId)
|
||||||
|
defaults.set(session.user.email, forKey: Keys.userEmail)
|
||||||
|
defaults.set(session.user.name, forKey: Keys.userName)
|
||||||
|
defaults.set(session.user.role, forKey: Keys.userRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveActiveTeamId(_ teamId: String) {
|
||||||
|
activeTeamId = teamId
|
||||||
|
defaults.set(teamId, forKey: Keys.activeTeamId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
session = nil
|
||||||
|
activeTeamId = nil
|
||||||
|
deleteKeychain(key: Keys.accessToken)
|
||||||
|
deleteKeychain(key: Keys.refreshToken)
|
||||||
|
defaults.removeObject(forKey: Keys.userId)
|
||||||
|
defaults.removeObject(forKey: Keys.userEmail)
|
||||||
|
defaults.removeObject(forKey: Keys.userName)
|
||||||
|
defaults.removeObject(forKey: Keys.userRole)
|
||||||
|
defaults.removeObject(forKey: Keys.activeTeamId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadSession() -> StoredSession? {
|
||||||
|
guard let access = loadKeychain(key: Keys.accessToken),
|
||||||
|
let refresh = loadKeychain(key: Keys.refreshToken),
|
||||||
|
let id = defaults.string(forKey: Keys.userId) else { return nil }
|
||||||
|
return StoredSession(
|
||||||
|
user: User(
|
||||||
|
id: id,
|
||||||
|
email: defaults.string(forKey: Keys.userEmail) ?? "",
|
||||||
|
name: defaults.string(forKey: Keys.userName) ?? "",
|
||||||
|
role: defaults.string(forKey: Keys.userRole) ?? "coach"
|
||||||
|
),
|
||||||
|
accessToken: access,
|
||||||
|
refreshToken: refresh
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Keys {
|
||||||
|
static let accessToken = "access_token"
|
||||||
|
static let refreshToken = "refresh_token"
|
||||||
|
static let userId = "user_id"
|
||||||
|
static let userEmail = "user_email"
|
||||||
|
static let userName = "user_name"
|
||||||
|
static let userRole = "user_role"
|
||||||
|
static let activeTeamId = "active_team_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveKeychain(key: String, value: String) {
|
||||||
|
let data = Data(value.utf8)
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
]
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
var add = query
|
||||||
|
add[kSecValueData as String] = data
|
||||||
|
SecItemAdd(add as CFDictionary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadKeychain(key: String) -> String? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
|
]
|
||||||
|
var result: AnyObject?
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||||
|
let data = result as? Data else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteKeychain(key: String) {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
]
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
native/ios/MatchLiveTv/Core/UserFacingError.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum UserFacingError {
|
||||||
|
static func isCancellation(_ error: Error) -> Bool {
|
||||||
|
if error is CancellationError { return true }
|
||||||
|
if let urlError = error as? URLError, urlError.code == .cancelled { return true }
|
||||||
|
let ns = error as NSError
|
||||||
|
return ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
static func message(for error: Error, fallback: String = "Operazione non riuscita") -> String? {
|
||||||
|
if isCancellation(error) { return nil }
|
||||||
|
if let api = error as? LocalizedError, let description = api.errorDescription, !description.isEmpty {
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
let text = error.localizedDescription
|
||||||
|
return text.isEmpty ? fallback : text
|
||||||
|
}
|
||||||
|
}
|
||||||
475
native/ios/MatchLiveTv/Data/API/ApiDtos.swift
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct LoginRequest: Encodable {
|
||||||
|
let email: String
|
||||||
|
let password: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RefreshRequest: Encodable {
|
||||||
|
let refreshToken: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoginResponse: Decodable {
|
||||||
|
let user: UserDto
|
||||||
|
let accessToken: String
|
||||||
|
let refreshToken: String
|
||||||
|
|
||||||
|
func toDomain() -> (User, AuthTokens) {
|
||||||
|
(user.toDomain(), AuthTokens(accessToken: accessToken, refreshToken: refreshToken))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserDto: Decodable {
|
||||||
|
let id: String
|
||||||
|
let email: String
|
||||||
|
let name: String
|
||||||
|
let role: String?
|
||||||
|
|
||||||
|
func toDomain() -> User {
|
||||||
|
User(id: id, email: email, name: name, role: role ?? "coach")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SportDto: Decodable {
|
||||||
|
let key: String
|
||||||
|
let label: String
|
||||||
|
let board: String
|
||||||
|
let overlay: String
|
||||||
|
let allowedOverlays: [String]?
|
||||||
|
let defaultRules: [String: Int]?
|
||||||
|
|
||||||
|
func toDomain() -> SportOption {
|
||||||
|
SportOption(
|
||||||
|
key: key,
|
||||||
|
label: label,
|
||||||
|
board: board,
|
||||||
|
overlay: overlay,
|
||||||
|
allowedOverlays: allowedOverlays ?? [],
|
||||||
|
defaultRules: defaultRules ?? [:]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TeamDto: Decodable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let sport: String?
|
||||||
|
let sportKey: String?
|
||||||
|
let boardType: String?
|
||||||
|
let canStream: Bool?
|
||||||
|
let clubName: String?
|
||||||
|
let youtubeEnabled: Bool?
|
||||||
|
let youtubeConnected: Bool?
|
||||||
|
let youtubeSelectable: Bool?
|
||||||
|
let youtubeChannelTitle: String?
|
||||||
|
let youtubeUsesPlatformChannel: Bool?
|
||||||
|
let youtubeTeamChannelTitle: String?
|
||||||
|
let planName: String?
|
||||||
|
let recordingsEnabled: Bool?
|
||||||
|
let phoneDownloadEnabled: Bool?
|
||||||
|
let billingUrl: String?
|
||||||
|
let staffManageUrl: String?
|
||||||
|
let logoUrl: String?
|
||||||
|
let primaryColor: String?
|
||||||
|
let secondaryColor: String?
|
||||||
|
|
||||||
|
func toDomain() -> Team {
|
||||||
|
Team(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
sport: sportKey ?? sport ?? "pallavolo",
|
||||||
|
sportKey: sportKey ?? sport ?? "pallavolo",
|
||||||
|
boardType: boardType ?? "volley",
|
||||||
|
clubName: clubName,
|
||||||
|
logoUrl: logoUrl,
|
||||||
|
primaryColor: primaryColor,
|
||||||
|
secondaryColor: secondaryColor,
|
||||||
|
canStream: canStream ?? true,
|
||||||
|
youtubeEnabled: youtubeEnabled ?? false,
|
||||||
|
youtubeConnected: youtubeConnected ?? false,
|
||||||
|
youtubeSelectable: youtubeSelectable ?? false,
|
||||||
|
youtubeChannelTitle: youtubeChannelTitle,
|
||||||
|
youtubeUsesPlatformChannel: youtubeUsesPlatformChannel ?? false,
|
||||||
|
youtubeTeamChannelTitle: youtubeTeamChannelTitle,
|
||||||
|
planName: planName,
|
||||||
|
recordingsEnabled: recordingsEnabled ?? false,
|
||||||
|
phoneDownloadEnabled: phoneDownloadEnabled ?? false,
|
||||||
|
billingUrl: billingUrl,
|
||||||
|
staffManageUrl: staffManageUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MatchDto: Decodable {
|
||||||
|
let id: String
|
||||||
|
let teamId: String
|
||||||
|
let teamName: String?
|
||||||
|
let opponentName: String
|
||||||
|
let location: String?
|
||||||
|
let scheduledAt: String?
|
||||||
|
let setsToWin: Int?
|
||||||
|
let sportKey: String?
|
||||||
|
let sport: String?
|
||||||
|
let sportLabel: String?
|
||||||
|
let boardType: String?
|
||||||
|
let overlayKind: String?
|
||||||
|
let effectiveOverlayKind: String?
|
||||||
|
let category: String?
|
||||||
|
let scoringRules: ScoringRulesBody?
|
||||||
|
let activeSessionId: String?
|
||||||
|
let activeSessionStatus: String?
|
||||||
|
let streamCompleted: Bool?
|
||||||
|
let coachHubVisible: Bool?
|
||||||
|
let homePrimaryColor: String?
|
||||||
|
let homeSecondaryColor: String?
|
||||||
|
let homeLogoUrl: String?
|
||||||
|
let opponentPrimaryColor: String?
|
||||||
|
let opponentLogoUrl: String?
|
||||||
|
|
||||||
|
func toDomain() -> Match {
|
||||||
|
Match(
|
||||||
|
id: id,
|
||||||
|
teamId: teamId,
|
||||||
|
teamName: teamName ?? "",
|
||||||
|
opponentName: opponentName,
|
||||||
|
location: location,
|
||||||
|
scheduledAt: scheduledAt,
|
||||||
|
sportKey: sportKey ?? sport ?? "pallavolo",
|
||||||
|
sportLabel: sportLabel,
|
||||||
|
boardType: boardType ?? "volley",
|
||||||
|
overlayKind: overlayKind,
|
||||||
|
effectiveOverlayKind: effectiveOverlayKind ?? overlayKind ?? "volley",
|
||||||
|
setsToWin: setsToWin ?? 3,
|
||||||
|
category: category,
|
||||||
|
scoringRules: scoringRules?.toDomain(),
|
||||||
|
activeSessionId: activeSessionId,
|
||||||
|
activeSessionStatus: activeSessionStatus,
|
||||||
|
streamCompleted: streamCompleted ?? false,
|
||||||
|
coachHubVisible: coachHubVisible,
|
||||||
|
homePrimaryColor: homePrimaryColor,
|
||||||
|
homeSecondaryColor: homeSecondaryColor,
|
||||||
|
homeLogoUrl: homeLogoUrl,
|
||||||
|
opponentPrimaryColor: opponentPrimaryColor,
|
||||||
|
opponentLogoUrl: opponentLogoUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StreamSessionDto: Decodable {
|
||||||
|
let id: String
|
||||||
|
let matchId: String
|
||||||
|
let status: String?
|
||||||
|
let platform: String?
|
||||||
|
let rtmpIngestUrl: String?
|
||||||
|
let hlsPlaybackUrl: String?
|
||||||
|
let watchPageUrl: String?
|
||||||
|
let shareUrl: String?
|
||||||
|
let youtubeWatchUrl: String?
|
||||||
|
let youtubeReady: Bool?
|
||||||
|
let privacyStatus: String?
|
||||||
|
let targetBitrate: Int?
|
||||||
|
let targetFps: Int?
|
||||||
|
let startedAt: String?
|
||||||
|
let score: ScoreStateDto?
|
||||||
|
|
||||||
|
func toDomain() -> StreamSession {
|
||||||
|
StreamSession(
|
||||||
|
id: id,
|
||||||
|
matchId: matchId,
|
||||||
|
status: status ?? "idle",
|
||||||
|
platform: platform ?? "matchlivetv",
|
||||||
|
rtmpIngestUrl: rtmpIngestUrl,
|
||||||
|
hlsPlaybackUrl: hlsPlaybackUrl,
|
||||||
|
watchPageUrl: watchPageUrl,
|
||||||
|
shareUrl: shareUrl,
|
||||||
|
youtubeWatchUrl: youtubeWatchUrl,
|
||||||
|
youtubeReady: youtubeReady ?? false,
|
||||||
|
privacyStatus: privacyStatus ?? "public",
|
||||||
|
targetBitrate: targetBitrate ?? 2_500_000,
|
||||||
|
targetFps: targetFps ?? 30,
|
||||||
|
startedAt: startedAt,
|
||||||
|
score: score?.toDomain()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SetPartialDto: Codable {
|
||||||
|
let set: Int?
|
||||||
|
let home: Int?
|
||||||
|
let away: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodifica tollerante per campi JSONB (`score_states.data`).
|
||||||
|
private enum FlexibleJSON: Decodable {
|
||||||
|
case int(Int)
|
||||||
|
case bool(Bool)
|
||||||
|
case string(String)
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if let v = try? container.decode(Bool.self) { self = .bool(v); return }
|
||||||
|
if let v = try? container.decode(Int.self) { self = .int(v); return }
|
||||||
|
if let v = try? container.decode(String.self) { self = .string(v); return }
|
||||||
|
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
|
||||||
|
}
|
||||||
|
|
||||||
|
var intValue: Int? {
|
||||||
|
switch self {
|
||||||
|
case .int(let v): return v
|
||||||
|
case .string(let s): return Int(s)
|
||||||
|
case .bool(let v): return v ? 1 : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var boolValue: Bool? {
|
||||||
|
switch self {
|
||||||
|
case .bool(let v): return v
|
||||||
|
case .int(let v): return v != 0
|
||||||
|
case .string(let s): return (s as NSString).boolValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScoreStateDto: Decodable {
|
||||||
|
let boardType: String?
|
||||||
|
let homeSets: Int?
|
||||||
|
let awaySets: Int?
|
||||||
|
let homePoints: Int?
|
||||||
|
let awayPoints: Int?
|
||||||
|
let currentSet: Int?
|
||||||
|
let setPartials: [SetPartialDto]?
|
||||||
|
let timeoutHome: Bool?
|
||||||
|
let timeoutAway: Bool?
|
||||||
|
let period: String?
|
||||||
|
let clockSecs: Int?
|
||||||
|
let clockRunning: Bool?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case boardType = "board_type"
|
||||||
|
case homeSets = "home_sets"
|
||||||
|
case awaySets = "away_sets"
|
||||||
|
case homePoints = "home_points"
|
||||||
|
case awayPoints = "away_points"
|
||||||
|
case currentSet = "current_set"
|
||||||
|
case setPartials = "set_partials"
|
||||||
|
case timeoutHome = "timeout_home"
|
||||||
|
case timeoutAway = "timeout_away"
|
||||||
|
case period
|
||||||
|
case clockSecs = "clock_secs"
|
||||||
|
case clockRunning = "clock_running"
|
||||||
|
case data
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
boardType = try c.decodeIfPresent(String.self, forKey: .boardType)
|
||||||
|
homeSets = Self.decodeInt(c, key: .homeSets)
|
||||||
|
awaySets = Self.decodeInt(c, key: .awaySets)
|
||||||
|
homePoints = Self.decodeInt(c, key: .homePoints)
|
||||||
|
awayPoints = Self.decodeInt(c, key: .awayPoints)
|
||||||
|
currentSet = Self.decodeInt(c, key: .currentSet)
|
||||||
|
setPartials = try c.decodeIfPresent([SetPartialDto].self, forKey: .setPartials)
|
||||||
|
timeoutHome = Self.decodeBool(c, key: .timeoutHome)
|
||||||
|
timeoutAway = Self.decodeBool(c, key: .timeoutAway)
|
||||||
|
period = Self.decodeString(c, key: .period)
|
||||||
|
nestedData = try c.decodeIfPresent([String: FlexibleJSON].self, forKey: .data)
|
||||||
|
clockSecs = Self.decodeInt(c, key: .clockSecs) ?? nestedData?["clock_secs"]?.intValue
|
||||||
|
clockRunning = Self.decodeBool(c, key: .clockRunning) ?? nestedData?["clock_running"]?.boolValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private let nestedData: [String: FlexibleJSON]?
|
||||||
|
|
||||||
|
private static func decodeInt(_ c: KeyedDecodingContainer<CodingKeys>, key: CodingKeys) -> Int? {
|
||||||
|
if let v = try? c.decodeIfPresent(Int.self, forKey: key) { return v }
|
||||||
|
if let s = try? c.decodeIfPresent(String.self, forKey: key), let v = Int(s) { return v }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodeBool(_ c: KeyedDecodingContainer<CodingKeys>, key: CodingKeys) -> Bool? {
|
||||||
|
if let v = try? c.decodeIfPresent(Bool.self, forKey: key) { return v }
|
||||||
|
if let i = try? c.decodeIfPresent(Int.self, forKey: key) { return i != 0 }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodeString(_ c: KeyedDecodingContainer<CodingKeys>, key: CodingKeys) -> String? {
|
||||||
|
if let s = try? c.decodeIfPresent(String.self, forKey: key) { return s }
|
||||||
|
if let i = try? c.decodeIfPresent(Int.self, forKey: key) { return String(i) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomain() -> ScoreState {
|
||||||
|
let board = boardType ?? "volley"
|
||||||
|
let homePts = homePoints ?? nestedData?["home_score"]?.intValue ?? 0
|
||||||
|
let awayPts = awayPoints ?? nestedData?["away_score"]?.intValue ?? 0
|
||||||
|
let overtime = nestedData?["overtime"]?.boolValue ?? false
|
||||||
|
return ScoreState(
|
||||||
|
homeSets: homeSets ?? 0,
|
||||||
|
awaySets: awaySets ?? 0,
|
||||||
|
homePoints: homePts,
|
||||||
|
awayPoints: awayPts,
|
||||||
|
currentSet: max(currentSet ?? 1, 1),
|
||||||
|
setPartials: (setPartials ?? []).map {
|
||||||
|
SetPartial(set: $0.set ?? 1, home: $0.home ?? 0, away: $0.away ?? 0)
|
||||||
|
},
|
||||||
|
timeoutHome: timeoutHome ?? false,
|
||||||
|
timeoutAway: timeoutAway ?? false,
|
||||||
|
boardType: board,
|
||||||
|
period: parsePeriodNumber(period, currentSet: currentSet),
|
||||||
|
periodLabel: period,
|
||||||
|
clockSecs: clockSecs ?? 0,
|
||||||
|
clockRunning: clockRunning ?? false,
|
||||||
|
overtime: overtime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parsePeriodNumber(_ label: String?, currentSet: Int?) -> Int {
|
||||||
|
if let text = label {
|
||||||
|
if let m = text.range(of: #"Q(\d+)"#, options: .regularExpression) {
|
||||||
|
let num = text[m].dropFirst()
|
||||||
|
if let v = Int(num) { return v }
|
||||||
|
}
|
||||||
|
if let v = Int(text), v > 0 { return v }
|
||||||
|
}
|
||||||
|
return max(currentSet ?? 1, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScoreActionRequest: Encodable {
|
||||||
|
let scoreAction: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScoreSyncRequest: Encodable {
|
||||||
|
let homeSets: Int
|
||||||
|
let awaySets: Int
|
||||||
|
let homePoints: Int
|
||||||
|
let awayPoints: Int
|
||||||
|
let currentSet: Int
|
||||||
|
let setPartials: [SetPartialDto]
|
||||||
|
let clockSecs: Int?
|
||||||
|
let clockRunning: Bool?
|
||||||
|
let period: Int?
|
||||||
|
let homeScore: Int?
|
||||||
|
let awayScore: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateSessionRequest: Encodable {
|
||||||
|
let platform: String
|
||||||
|
let privacyStatus: String
|
||||||
|
let qualityPreset: String
|
||||||
|
let targetBitrate: Int
|
||||||
|
let targetFps: Int
|
||||||
|
let youtubeChannel: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScoringRulesBody: Codable {
|
||||||
|
let setsToWin: Int?
|
||||||
|
let pointsPerSet: Int?
|
||||||
|
let pointsDecidingSet: Int?
|
||||||
|
let minPointLead: Int?
|
||||||
|
let periods: Int?
|
||||||
|
let periodDurationSecs: Int?
|
||||||
|
let overtimeDurationSecs: Int?
|
||||||
|
|
||||||
|
func toDomain() -> ScoringRules {
|
||||||
|
ScoringRules(
|
||||||
|
pointsPerSet: pointsPerSet ?? 25,
|
||||||
|
pointsDecidingSet: pointsDecidingSet ?? 15,
|
||||||
|
minPointLead: minPointLead ?? 2,
|
||||||
|
periods: periods ?? 4,
|
||||||
|
periodDurationSecs: periodDurationSecs ?? 600,
|
||||||
|
overtimeDurationSecs: overtimeDurationSecs ?? 300
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateMatchBodyFull: Encodable {
|
||||||
|
let opponentName: String
|
||||||
|
let location: String?
|
||||||
|
let scheduledAt: String?
|
||||||
|
let setsToWin: Int
|
||||||
|
let sportKey: String?
|
||||||
|
let overlayKind: String?
|
||||||
|
let category: String?
|
||||||
|
let opponentPrimaryColor: String?
|
||||||
|
let scoringRules: [String: Int]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateMatchRequestFull: Encodable {
|
||||||
|
let match: UpdateMatchBodyFull
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateTeamBody: Encodable {
|
||||||
|
let primaryColor: String?
|
||||||
|
let secondaryColor: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateTeamRequest: Encodable {
|
||||||
|
let team: UpdateTeamBody
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NetworkTestResponse: Decodable {
|
||||||
|
let ready: Bool
|
||||||
|
let targetUploadMbps: Double?
|
||||||
|
let qualityPreset: String?
|
||||||
|
let targetBitrate: Int?
|
||||||
|
let targetFps: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RegiaLinkResponse: Decodable {
|
||||||
|
let regiaUrl: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateMatchBody: Encodable {
|
||||||
|
let opponentName: String
|
||||||
|
let sport: String
|
||||||
|
let setsToWin: Int
|
||||||
|
let scheduledAt: String?
|
||||||
|
let location: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateMatchRequest: Encodable {
|
||||||
|
let match: CreateMatchBody
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NetworkTestRequest: Encodable {
|
||||||
|
let downloadMbps: Double
|
||||||
|
let uploadMbps: Double
|
||||||
|
let latencyMs: Int
|
||||||
|
let networkType: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TelemetryRequest: Encodable {
|
||||||
|
let deviceRole: String
|
||||||
|
let batteryLevel: Int?
|
||||||
|
let networkType: String?
|
||||||
|
let signalStrength: Int?
|
||||||
|
let currentBitrate: Int?
|
||||||
|
let targetBitrate: Int?
|
||||||
|
let fps: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ScoringRules {
|
||||||
|
func toBody(setsToWin: Int? = nil) -> ScoringRulesBody {
|
||||||
|
ScoringRulesBody(
|
||||||
|
setsToWin: setsToWin,
|
||||||
|
pointsPerSet: pointsPerSet,
|
||||||
|
pointsDecidingSet: pointsDecidingSet,
|
||||||
|
minPointLead: minPointLead,
|
||||||
|
periods: periods,
|
||||||
|
periodDurationSecs: periodDurationSecs,
|
||||||
|
overtimeDurationSecs: overtimeDurationSecs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toMap(setsToWin: Int? = nil) -> [String: Int] {
|
||||||
|
var map: [String: Int] = [:]
|
||||||
|
if let setsToWin { map["sets_to_win"] = setsToWin }
|
||||||
|
map["points_per_set"] = pointsPerSet
|
||||||
|
map["points_deciding_set"] = pointsDecidingSet
|
||||||
|
map["min_point_lead"] = minPointLead
|
||||||
|
map["periods"] = periods
|
||||||
|
map["period_duration_secs"] = periodDurationSecs
|
||||||
|
map["overtime_duration_secs"] = overtimeDurationSecs
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
}
|
||||||
289
native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum APIError: LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case http(Int, String?)
|
||||||
|
case decoding(Error)
|
||||||
|
case unauthorized
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL: return "URL non valido"
|
||||||
|
case .http(let code, let body): return Self.friendlyHttpMessage(code: code, body: body)
|
||||||
|
case .decoding: return "Risposta del server non valida"
|
||||||
|
case .unauthorized: return "Sessione scaduta. Accedi di nuovo."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func friendlyHttpMessage(code: Int, body: String?) -> String {
|
||||||
|
if let body, !body.isEmpty,
|
||||||
|
let data = body.data(using: .utf8),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||||
|
if let message = json["message"] as? String, !message.isEmpty {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
if let error = json["error"] as? String, !error.isEmpty, !error.hasPrefix("{") {
|
||||||
|
if code >= 500 { return "Errore del server. Riprova tra qualche istante." }
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let body, !body.isEmpty, !body.hasPrefix("{") {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
switch code {
|
||||||
|
case 500, 502, 503: return "Errore del server. Riprova tra qualche istante."
|
||||||
|
case 404: return "Risorsa non trovata"
|
||||||
|
case 403: return "Operazione non consentita"
|
||||||
|
default: return "Errore HTTP \(code)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class MatchLiveAPI: @unchecked Sendable {
|
||||||
|
private let session: URLSession
|
||||||
|
private var accessToken: String?
|
||||||
|
private let baseURL: URL
|
||||||
|
|
||||||
|
init(baseURL: URL = URL(string: AppConfig.apiV1)!, session: URLSession = .shared) {
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.session = session
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAccessToken(_ token: String?) {
|
||||||
|
accessToken = token
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth
|
||||||
|
|
||||||
|
func login(email: String, password: String) async throws -> LoginResponse {
|
||||||
|
try await post("auth/login", body: LoginRequest(email: email, password: password))
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh(refreshToken: String) async throws -> LoginResponse {
|
||||||
|
try await post("auth/refresh", body: RefreshRequest(refreshToken: refreshToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
func me() async throws -> UserDto {
|
||||||
|
try await get("auth/me")
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout() async throws {
|
||||||
|
try await postVoid("auth/logout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sports & Teams
|
||||||
|
|
||||||
|
func sports() async throws -> [SportDto] {
|
||||||
|
try await get("sports")
|
||||||
|
}
|
||||||
|
|
||||||
|
func teams() async throws -> [TeamDto] {
|
||||||
|
try await get("teams")
|
||||||
|
}
|
||||||
|
|
||||||
|
func team(id: String) async throws -> TeamDto {
|
||||||
|
try await get("teams/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTeam(id: String, body: UpdateTeamRequest) async throws -> TeamDto {
|
||||||
|
try await patch("teams/\(id)", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTeamMultipart(
|
||||||
|
id: String,
|
||||||
|
primaryColor: String?,
|
||||||
|
secondaryColor: String?,
|
||||||
|
logoData: Data?,
|
||||||
|
logoFilename: String?
|
||||||
|
) async throws -> TeamDto {
|
||||||
|
var fields: [MultipartField] = []
|
||||||
|
if let primaryColor { fields.append(.text("team[primary_color]", primaryColor)) }
|
||||||
|
if let secondaryColor { fields.append(.text("team[secondary_color]", secondaryColor)) }
|
||||||
|
if let logoData, let logoFilename {
|
||||||
|
fields.append(.file("logo_file", logoData, filename: logoFilename, mime: "image/png"))
|
||||||
|
}
|
||||||
|
return try await multipartPatch("teams/\(id)", fields: fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Matches
|
||||||
|
|
||||||
|
func matches(teamId: String) async throws -> [MatchDto] {
|
||||||
|
try await get("teams/\(teamId)/matches")
|
||||||
|
}
|
||||||
|
|
||||||
|
func createMatch(teamId: String, body: CreateMatchRequest) async throws -> MatchDto {
|
||||||
|
try await post("teams/\(teamId)/matches", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func match(id: String) async throws -> MatchDto {
|
||||||
|
try await get("matches/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateMatch(id: String, body: UpdateMatchRequestFull) async throws -> MatchDto {
|
||||||
|
try await patch("matches/\(id)", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateMatchMultipart(id: String, fields: [MultipartField]) async throws -> MatchDto {
|
||||||
|
try await multipartPatch("matches/\(id)", fields: fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteMatch(id: String) async throws {
|
||||||
|
try await delete("matches/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sessions
|
||||||
|
|
||||||
|
func createSession(matchId: String, body: CreateSessionRequest) async throws -> StreamSessionDto {
|
||||||
|
try await post("matches/\(matchId)/sessions", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(id: String) async throws -> StreamSessionDto {
|
||||||
|
try await get("sessions/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func startSession(id: String) async throws -> StreamSessionDto {
|
||||||
|
try await patch("sessions/\(id)/start", body: EmptyBody())
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopSession(id: String) async throws -> StreamSessionDto {
|
||||||
|
try await patch("sessions/\(id)/stop", body: EmptyBody())
|
||||||
|
}
|
||||||
|
|
||||||
|
func pauseSession(id: String) async throws -> StreamSessionDto {
|
||||||
|
try await patch("sessions/\(id)/pause", body: EmptyBody())
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeSession(id: String) async throws -> StreamSessionDto {
|
||||||
|
try await patch("sessions/\(id)/resume", body: EmptyBody())
|
||||||
|
}
|
||||||
|
|
||||||
|
func networkTest(sessionId: String, body: NetworkTestRequest) async throws -> NetworkTestResponse {
|
||||||
|
try await post("sessions/\(sessionId)/network_test", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func regiaLink(sessionId: String) async throws -> RegiaLinkResponse {
|
||||||
|
try await post("sessions/\(sessionId)/regia_link", body: EmptyBody())
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncScore(sessionId: String, body: ScoreSyncRequest) async throws -> StreamSessionDto {
|
||||||
|
try await patch("sessions/\(sessionId)/score", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyScoreAction(sessionId: String, body: ScoreActionRequest) async throws -> StreamSessionDto {
|
||||||
|
try await post("sessions/\(sessionId)/score_action", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func postTelemetry(sessionId: String, body: TelemetryRequest) async throws {
|
||||||
|
try await postVoid("sessions/\(sessionId)/telemetry", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HTTP helpers
|
||||||
|
|
||||||
|
private struct EmptyBody: Encodable {}
|
||||||
|
|
||||||
|
private func get<T: Decodable>(_ path: String) async throws -> T {
|
||||||
|
try await request(path, method: "GET")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func post<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||||
|
try await request(path, method: "POST", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func postVoid<B: Encodable>(_ path: String, body: B = EmptyBody()) async throws {
|
||||||
|
let _: EmptyResponse = try await request(path, method: "POST", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func postVoid(_ path: String) async throws {
|
||||||
|
let _: EmptyResponse = try await request(path, method: "POST", body: EmptyBody())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func patch<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||||
|
try await request(path, method: "PATCH", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func delete(_ path: String) async throws {
|
||||||
|
let _: EmptyResponse = try await request(path, method: "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func multipartPatch<T: Decodable>(_ path: String, fields: [MultipartField]) async throws -> T {
|
||||||
|
let boundary = "Boundary-\(UUID().uuidString)"
|
||||||
|
var request = URLRequest(url: baseURL.appendingPathComponent(path))
|
||||||
|
request.httpMethod = "PATCH"
|
||||||
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
if let accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") }
|
||||||
|
request.httpBody = MultipartBuilder.build(fields: fields, boundary: boundary)
|
||||||
|
return try await execute(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func request<T: Decodable, B: Encodable>(
|
||||||
|
_ path: String,
|
||||||
|
method: String,
|
||||||
|
body: B? = nil as EmptyBody?
|
||||||
|
) async throws -> T {
|
||||||
|
var request = URLRequest(url: baseURL.appendingPathComponent(path))
|
||||||
|
request.httpMethod = method
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
if body != nil {
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = try ApiInstant.encoder.encode(body)
|
||||||
|
}
|
||||||
|
if let accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") }
|
||||||
|
return try await execute(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func execute<T: Decodable>(_ request: URLRequest) async throws -> T {
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse else { throw APIError.invalidURL }
|
||||||
|
if http.statusCode == 401 { throw APIError.unauthorized }
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
let body = String(data: data, encoding: .utf8)
|
||||||
|
throw APIError.http(http.statusCode, body)
|
||||||
|
}
|
||||||
|
if T.self == EmptyResponse.self {
|
||||||
|
return EmptyResponse() as! T
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try ApiInstant.decoder.decode(T.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw APIError.decoding(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EmptyResponse: Decodable {}
|
||||||
|
|
||||||
|
enum MultipartField {
|
||||||
|
case text(String, String)
|
||||||
|
case file(String, Data, filename: String, mime: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MultipartBuilder {
|
||||||
|
static func build(fields: [MultipartField], boundary: String) -> Data {
|
||||||
|
var data = Data()
|
||||||
|
let crlf = "\r\n"
|
||||||
|
for field in fields {
|
||||||
|
switch field {
|
||||||
|
case .text(let name, let value):
|
||||||
|
data.append("--\(boundary)\(crlf)")
|
||||||
|
data.append("Content-Disposition: form-data; name=\"\(name)\"\(crlf)\(crlf)")
|
||||||
|
data.append(value)
|
||||||
|
data.append(crlf)
|
||||||
|
case .file(let name, let fileData, let filename, let mime):
|
||||||
|
data.append("--\(boundary)\(crlf)")
|
||||||
|
data.append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\(crlf)")
|
||||||
|
data.append("Content-Type: \(mime)\(crlf)\(crlf)")
|
||||||
|
data.append(fileData)
|
||||||
|
data.append(crlf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.append("--\(boundary)--\(crlf)")
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Data {
|
||||||
|
mutating func append(_ string: String) {
|
||||||
|
if let d = string.data(using: .utf8) { append(d) }
|
||||||
|
}
|
||||||
|
}
|
||||||
37
native/ios/MatchLiveTv/Data/AppContainer.swift
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppContainer: ObservableObject {
|
||||||
|
let tokenStore: TokenStore
|
||||||
|
let api: MatchLiveAPI
|
||||||
|
let authRepository: AuthRepository
|
||||||
|
let matchRepository: MatchRepository
|
||||||
|
let sessionRepository: SessionRepository
|
||||||
|
let scoreRepository: ScoreRepository
|
||||||
|
let sessionCable = SessionCableService()
|
||||||
|
let scoreController: ScoreController
|
||||||
|
let wizardSession = WizardSessionHolder()
|
||||||
|
let broadcastCoordinator = LiveBroadcastCoordinator()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let api = MatchLiveAPI()
|
||||||
|
let tokenStore = TokenStore()
|
||||||
|
self.api = api
|
||||||
|
self.tokenStore = tokenStore
|
||||||
|
authRepository = AuthRepository(api: api, tokenStore: tokenStore) { token in
|
||||||
|
api.setAccessToken(token)
|
||||||
|
OverlayLogoCache.configure(token: token)
|
||||||
|
}
|
||||||
|
matchRepository = MatchRepository(api: api, tokenStore: tokenStore)
|
||||||
|
sessionRepository = SessionRepository(api: api)
|
||||||
|
scoreRepository = ScoreRepository(api: api)
|
||||||
|
scoreController = ScoreController(scoreRepository: scoreRepository, sessionCable: sessionCable)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootstrapAuth() {
|
||||||
|
if let session = tokenStore.session {
|
||||||
|
api.setAccessToken(session.accessToken)
|
||||||
|
OverlayLogoCache.configure(token: session.accessToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
native/ios/MatchLiveTv/Data/Cable/ActionCableClient.swift
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class ActionCableClient: NSObject, URLSessionWebSocketDelegate, @unchecked Sendable {
|
||||||
|
protocol Listener: AnyObject {
|
||||||
|
func onConnected()
|
||||||
|
func onDisconnected()
|
||||||
|
func onChannelMessage(_ payload: [String: Any])
|
||||||
|
}
|
||||||
|
|
||||||
|
private let cableUrl: URL
|
||||||
|
private let accessToken: String
|
||||||
|
private var webSocket: URLSessionWebSocketTask?
|
||||||
|
private var channelIdentifier: String?
|
||||||
|
private weak var listener: Listener?
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
init(cableUrl: String, accessToken: String) {
|
||||||
|
self.cableUrl = URL(string: cableUrl)!
|
||||||
|
self.accessToken = accessToken
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
self.session = URLSession(configuration: config)
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect(channel: String, params: [String: String], listener: Listener) {
|
||||||
|
disconnect()
|
||||||
|
self.listener = listener
|
||||||
|
var identifier: [String: Any] = ["channel": channel]
|
||||||
|
params.forEach { identifier[$0.key] = $0.value }
|
||||||
|
channelIdentifier = jsonString(identifier)
|
||||||
|
|
||||||
|
var request = URLRequest(url: cableUrl)
|
||||||
|
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
webSocket = session.webSocketTask(with: request)
|
||||||
|
webSocket?.resume()
|
||||||
|
receiveLoop()
|
||||||
|
sendSubscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
func performReceive(payload: [String: Any]) {
|
||||||
|
guard let identifier = channelIdentifier else { return }
|
||||||
|
var data = payload
|
||||||
|
data["action"] = "receive"
|
||||||
|
let envelope: [String: Any] = [
|
||||||
|
"command": "message",
|
||||||
|
"identifier": identifier,
|
||||||
|
"data": jsonString(data) ?? "{}",
|
||||||
|
]
|
||||||
|
send(json: envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() {
|
||||||
|
webSocket?.cancel(with: .goingAway, reason: nil)
|
||||||
|
webSocket = nil
|
||||||
|
channelIdentifier = nil
|
||||||
|
listener?.onDisconnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendSubscribe() {
|
||||||
|
guard let identifier = channelIdentifier else { return }
|
||||||
|
send(json: ["command": "subscribe", "identifier": identifier])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receiveLoop() {
|
||||||
|
webSocket?.receive { [weak self] result in
|
||||||
|
guard let self else { return }
|
||||||
|
switch result {
|
||||||
|
case .success(let message):
|
||||||
|
switch message {
|
||||||
|
case .string(let text):
|
||||||
|
self.handleIncoming(text)
|
||||||
|
case .data(let data):
|
||||||
|
if let text = String(data: data, encoding: .utf8) { self.handleIncoming(text) }
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
self.receiveLoop()
|
||||||
|
case .failure:
|
||||||
|
self.listener?.onDisconnected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleIncoming(_ text: String) {
|
||||||
|
guard let data = text.data(using: .utf8),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
||||||
|
|
||||||
|
if let type = json["type"] as? String, type == "ping" {
|
||||||
|
let pong: [String: Any] = ["type": "pong", "message": json["message"] ?? Date().timeIntervalSince1970]
|
||||||
|
send(json: pong)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if json["type"] as? String == "confirm_subscription" {
|
||||||
|
listener?.onConnected()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if json["type"] as? String == "disconnect" {
|
||||||
|
listener?.onDisconnected()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let message = json["message"] {
|
||||||
|
var payload: [String: Any]?
|
||||||
|
if let dict = message as? [String: Any] {
|
||||||
|
if let nested = dict["data"] as? [String: Any] {
|
||||||
|
payload = nested
|
||||||
|
} else if dict["method"] as? String == "receive_message",
|
||||||
|
let nested = dict["data"] as? [String: Any] {
|
||||||
|
payload = nested
|
||||||
|
} else {
|
||||||
|
payload = dict
|
||||||
|
}
|
||||||
|
} else if let str = message as? String,
|
||||||
|
let d = str.data(using: .utf8),
|
||||||
|
let dict = try? JSONSerialization.jsonObject(with: d) as? [String: Any] {
|
||||||
|
payload = dict
|
||||||
|
}
|
||||||
|
if let payload { listener?.onChannelMessage(payload) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(json: [String: Any]) {
|
||||||
|
guard let text = jsonString(json) else { return }
|
||||||
|
webSocket?.send(.string(text)) { _ in }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func jsonString(_ object: Any) -> String? {
|
||||||
|
guard let data = try? JSONSerialization.data(withJSONObject: object),
|
||||||
|
let text = String(data: data, encoding: .utf8) else { return nil }
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
107
native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SessionCableService: ObservableObject {
|
||||||
|
@Published private(set) var connected = false
|
||||||
|
|
||||||
|
var onScoreUpdate: ((ScoreState) -> Void)?
|
||||||
|
var onPauseStream: (() -> Void)?
|
||||||
|
var onResumeStream: (() -> Void)?
|
||||||
|
var onStopStream: (() -> Void)?
|
||||||
|
|
||||||
|
private var client: ActionCableClient?
|
||||||
|
private var intentionalDisconnect = false
|
||||||
|
private var reconnectAttempt = 0
|
||||||
|
private var reconnectTask: Task<Void, Never>?
|
||||||
|
private var sessionId: String?
|
||||||
|
private var accessToken: String?
|
||||||
|
private var deviceRole = "camera"
|
||||||
|
|
||||||
|
func connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
|
||||||
|
self.sessionId = sessionId
|
||||||
|
self.accessToken = accessToken
|
||||||
|
self.deviceRole = deviceRole
|
||||||
|
intentionalDisconnect = false
|
||||||
|
reconnectAttempt = 0
|
||||||
|
openSocket()
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() {
|
||||||
|
intentionalDisconnect = true
|
||||||
|
reconnectTask?.cancel()
|
||||||
|
client?.disconnect()
|
||||||
|
client = nil
|
||||||
|
connected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendScoreUpdate(_ score: ScoreState) {
|
||||||
|
client?.performReceive(payload: score.cablePayload())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openSocket() {
|
||||||
|
guard let sessionId, let accessToken else { return }
|
||||||
|
let cable = ActionCableClient(cableUrl: AppConfig.cableUrl, accessToken: accessToken)
|
||||||
|
cable.connect(
|
||||||
|
channel: "SessionChannel",
|
||||||
|
params: ["session_id": sessionId, "device_role": deviceRole],
|
||||||
|
listener: self
|
||||||
|
)
|
||||||
|
client = cable
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleReconnect() {
|
||||||
|
guard !intentionalDisconnect else { return }
|
||||||
|
reconnectAttempt += 1
|
||||||
|
let delay = min(30.0, pow(2.0, Double(min(reconnectAttempt - 1, 4))))
|
||||||
|
reconnectTask?.cancel()
|
||||||
|
reconnectTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||||
|
guard !Task.isCancelled, !intentionalDisconnect else { return }
|
||||||
|
openSocket()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SessionCableService: ActionCableClient.Listener {
|
||||||
|
nonisolated func onConnected() {
|
||||||
|
Task { @MainActor in
|
||||||
|
connected = true
|
||||||
|
reconnectAttempt = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func onDisconnected() {
|
||||||
|
Task { @MainActor in
|
||||||
|
connected = false
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func onChannelMessage(_ payload: [String: Any]) {
|
||||||
|
Task { @MainActor in
|
||||||
|
let type = payload["type"] as? String ?? ""
|
||||||
|
switch type {
|
||||||
|
case "score_update":
|
||||||
|
onScoreUpdate?(ScoreState.fromCablePayload(payload))
|
||||||
|
case "command":
|
||||||
|
let command = payload["command"] as? String ?? ""
|
||||||
|
switch command {
|
||||||
|
case "pause_stream": onPauseStream?()
|
||||||
|
case "resume_stream": onResumeStream?()
|
||||||
|
case "stop_stream": onStopStream?()
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
case "stream_event":
|
||||||
|
let event = payload["event"] as? String ?? ""
|
||||||
|
switch event {
|
||||||
|
case "paused": onPauseStream?()
|
||||||
|
case "resumed": onResumeStream?()
|
||||||
|
case "ended": onStopStream?()
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
native/ios/MatchLiveTv/Data/Repository/AuthRepository.swift
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AuthRepository {
|
||||||
|
private let api: MatchLiveAPI
|
||||||
|
private let tokenStore: TokenStore
|
||||||
|
private let onTokenChanged: (String?) -> Void
|
||||||
|
|
||||||
|
init(api: MatchLiveAPI, tokenStore: TokenStore, onTokenChanged: @escaping (String?) -> Void) {
|
||||||
|
self.api = api
|
||||||
|
self.tokenStore = tokenStore
|
||||||
|
self.onTokenChanged = onTokenChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentSession: StoredSession? { tokenStore.session }
|
||||||
|
|
||||||
|
func login(email: String, password: String) async throws -> StoredSession {
|
||||||
|
let response = try await api.login(email: email, password: password)
|
||||||
|
let (user, tokens) = response.toDomain()
|
||||||
|
let session = StoredSession(user: user, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken)
|
||||||
|
tokenStore.saveSession(session)
|
||||||
|
onTokenChanged(tokens.accessToken)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOrRefresh() async -> StoredSession? {
|
||||||
|
guard let stored = tokenStore.session else { return nil }
|
||||||
|
do {
|
||||||
|
_ = try await api.me()
|
||||||
|
return stored
|
||||||
|
} catch {
|
||||||
|
return try? await refresh(stored.refreshToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh(_ refreshToken: String) async throws -> StoredSession {
|
||||||
|
let response = try await api.refresh(refreshToken: refreshToken)
|
||||||
|
let (user, tokens) = response.toDomain()
|
||||||
|
let session = StoredSession(user: user, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken)
|
||||||
|
tokenStore.saveSession(session)
|
||||||
|
onTokenChanged(tokens.accessToken)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout() async {
|
||||||
|
try? await api.logout()
|
||||||
|
tokenStore.clear()
|
||||||
|
onTokenChanged(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
145
native/ios/MatchLiveTv/Data/Repository/MatchRepository.swift
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MatchRepository {
|
||||||
|
private let api: MatchLiveAPI
|
||||||
|
private let tokenStore: TokenStore
|
||||||
|
|
||||||
|
init(api: MatchLiveAPI, tokenStore: TokenStore) {
|
||||||
|
self.api = api
|
||||||
|
self.tokenStore = tokenStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSports() async throws -> [SportOption] {
|
||||||
|
try await api.sports().map { $0.toDomain() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchTeams() async throws -> [Team] {
|
||||||
|
try await api.teams().map { $0.toDomain() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchTeam(teamId: String) async -> Team? {
|
||||||
|
if let dto = try? await api.team(id: teamId) { return dto.toDomain() }
|
||||||
|
let teams = (try? await fetchTeams()) ?? []
|
||||||
|
return teams.first { $0.id == teamId }
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveActiveTeam(teams: [Team]) -> Team? {
|
||||||
|
guard !teams.isEmpty else { return nil }
|
||||||
|
let selected = teams.first { $0.id == tokenStore.activeTeamId } ?? teams[0]
|
||||||
|
tokenStore.saveActiveTeamId(selected.id)
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchMatch(matchId: String) async throws -> Match {
|
||||||
|
try await api.match(id: matchId).toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchMatchesForTeam(teamId: String) async throws -> [Match] {
|
||||||
|
let matches = try await api.matches(teamId: teamId).map { $0.toDomain() }
|
||||||
|
return MatchHubFilter.sortMatches(matches.filter { MatchHubFilter.coachHubVisible($0) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchMatches() async throws -> [Match] {
|
||||||
|
let teams = try await fetchTeams()
|
||||||
|
guard let team = resolveActiveTeam(teams: teams) else { return [] }
|
||||||
|
return try await fetchMatchesForTeam(teamId: team.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectTeam(teamId: String) {
|
||||||
|
tokenStore.saveActiveTeamId(teamId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createQuickMatch(teamId: String) async throws -> Match {
|
||||||
|
try await api.createMatch(
|
||||||
|
teamId: teamId,
|
||||||
|
body: CreateMatchRequest(match: CreateMatchBody(opponentName: "Avversario", sport: "volleyball", setsToWin: 3, scheduledAt: nil, location: nil))
|
||||||
|
).toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func createScheduledMatch(teamId: String, opponentName: String, scheduledAt: Date, location: String?) async throws -> Match {
|
||||||
|
let iso = ISO8601DateFormatter().string(from: scheduledAt)
|
||||||
|
return try await api.createMatch(
|
||||||
|
teamId: teamId,
|
||||||
|
body: CreateMatchRequest(
|
||||||
|
match: CreateMatchBody(
|
||||||
|
opponentName: opponentName,
|
||||||
|
sport: "volleyball",
|
||||||
|
setsToWin: 3,
|
||||||
|
scheduledAt: iso,
|
||||||
|
location: location?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTeamBranding(teamId: String, primaryColor: String, secondaryColor: String?, logoImage: UIImage?) async throws -> Team {
|
||||||
|
if let logoImage, let data = logoImage.pngData() {
|
||||||
|
return try await api.updateTeamMultipart(
|
||||||
|
id: teamId,
|
||||||
|
primaryColor: primaryColor,
|
||||||
|
secondaryColor: secondaryColor,
|
||||||
|
logoData: data,
|
||||||
|
logoFilename: "logo.png"
|
||||||
|
).toDomain()
|
||||||
|
}
|
||||||
|
return try await api.updateTeam(
|
||||||
|
id: teamId,
|
||||||
|
body: UpdateTeamRequest(team: UpdateTeamBody(primaryColor: primaryColor, secondaryColor: secondaryColor))
|
||||||
|
).toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateMatch(
|
||||||
|
matchId: String,
|
||||||
|
opponentName: String,
|
||||||
|
location: String?,
|
||||||
|
scheduledAt: String?,
|
||||||
|
setsToWin: Int,
|
||||||
|
category: String?,
|
||||||
|
opponentPrimaryColor: String?,
|
||||||
|
scoringRules: ScoringRules?,
|
||||||
|
sportKey: String? = nil,
|
||||||
|
overlayKind: String? = nil,
|
||||||
|
opponentLogoImage: UIImage? = nil
|
||||||
|
) async throws -> Match {
|
||||||
|
let rulesMap = scoringRules?.toMap(setsToWin: setsToWin)
|
||||||
|
let body = UpdateMatchBodyFull(
|
||||||
|
opponentName: opponentName,
|
||||||
|
location: location?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
|
||||||
|
scheduledAt: scheduledAt,
|
||||||
|
setsToWin: setsToWin,
|
||||||
|
sportKey: sportKey,
|
||||||
|
overlayKind: overlayKind,
|
||||||
|
category: category?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
|
||||||
|
opponentPrimaryColor: opponentPrimaryColor,
|
||||||
|
scoringRules: rulesMap
|
||||||
|
)
|
||||||
|
if let opponentLogoImage, let data = opponentLogoImage.pngData() {
|
||||||
|
var fields: [MultipartField] = [
|
||||||
|
.text("match[opponent_name]", opponentName),
|
||||||
|
.text("match[sets_to_win]", "\(setsToWin)"),
|
||||||
|
]
|
||||||
|
if let location = body.location { fields.append(.text("match[location]", location)) }
|
||||||
|
if let scheduledAt { fields.append(.text("match[scheduled_at]", scheduledAt)) }
|
||||||
|
if let category = body.category { fields.append(.text("match[category]", category)) }
|
||||||
|
if let color = opponentPrimaryColor { fields.append(.text("match[opponent_primary_color]", color)) }
|
||||||
|
if let rules = scoringRules {
|
||||||
|
fields.append(.text("match[scoring_rules][points_per_set]", "\(rules.pointsPerSet)"))
|
||||||
|
fields.append(.text("match[scoring_rules][points_deciding_set]", "\(rules.pointsDecidingSet)"))
|
||||||
|
fields.append(.text("match[scoring_rules][min_point_lead]", "\(rules.minPointLead)"))
|
||||||
|
}
|
||||||
|
fields.append(.file("opponent_logo_file", data, filename: "opponent.png", mime: "image/png"))
|
||||||
|
return try await api.updateMatchMultipart(id: matchId, fields: fields).toDomain()
|
||||||
|
}
|
||||||
|
return try await api.updateMatch(id: matchId, body: UpdateMatchRequestFull(match: body)).toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteMatch(matchId: String) async throws {
|
||||||
|
try await api.deleteMatch(id: matchId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum MatchSessionLauncher {
|
||||||
|
static func resumeBroadcastSession(
|
||||||
|
match: Match,
|
||||||
|
sessionRepository: SessionRepository
|
||||||
|
) async throws -> String {
|
||||||
|
guard let sessionId = match.activeSessionId else {
|
||||||
|
throw APIError.http(400, "Nessuna sessione attiva")
|
||||||
|
}
|
||||||
|
_ = try await sessionRepository.fetchSession(id: sessionId)
|
||||||
|
return sessionId
|
||||||
|
}
|
||||||
|
}
|
||||||
49
native/ios/MatchLiveTv/Data/Repository/ScoreRepository.swift
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ScoreRepository {
|
||||||
|
private let api: MatchLiveAPI
|
||||||
|
private static let periodBoards: Set<String> = ["basket", "timed"]
|
||||||
|
|
||||||
|
init(api: MatchLiveAPI) {
|
||||||
|
self.api = api
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncScore(sessionId: String, score: ScoreState) async throws -> StreamSession {
|
||||||
|
let partials = score.setPartials.map { SetPartialDto(set: $0.set, home: $0.home, away: $0.away) }
|
||||||
|
var request = ScoreSyncRequest(
|
||||||
|
homeSets: score.homeSets,
|
||||||
|
awaySets: score.awaySets,
|
||||||
|
homePoints: score.homePoints,
|
||||||
|
awayPoints: score.awayPoints,
|
||||||
|
currentSet: score.currentSet,
|
||||||
|
setPartials: partials,
|
||||||
|
clockSecs: nil,
|
||||||
|
clockRunning: nil,
|
||||||
|
period: nil,
|
||||||
|
homeScore: nil,
|
||||||
|
awayScore: nil
|
||||||
|
)
|
||||||
|
if Self.periodBoards.contains(score.boardType) {
|
||||||
|
request = ScoreSyncRequest(
|
||||||
|
homeSets: score.homeSets,
|
||||||
|
awaySets: score.awaySets,
|
||||||
|
homePoints: score.homePoints,
|
||||||
|
awayPoints: score.awayPoints,
|
||||||
|
currentSet: score.currentSet,
|
||||||
|
setPartials: partials,
|
||||||
|
clockSecs: score.clockSecs,
|
||||||
|
clockRunning: score.clockRunning,
|
||||||
|
period: score.period,
|
||||||
|
homeScore: score.homePoints,
|
||||||
|
awayScore: score.awayPoints
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return try await api.syncScore(sessionId: sessionId, body: request).toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyScoreAction(sessionId: String, action: String) async throws -> ScoreState? {
|
||||||
|
let response = try await api.applyScoreAction(sessionId: sessionId, body: ScoreActionRequest(scoreAction: action))
|
||||||
|
return response.score?.toDomain()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SessionRepository {
|
||||||
|
private let api: MatchLiveAPI
|
||||||
|
|
||||||
|
init(api: MatchLiveAPI) {
|
||||||
|
self.api = api
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSession(
|
||||||
|
matchId: String,
|
||||||
|
platform: String = "matchlivetv",
|
||||||
|
privacyStatus: String = "public",
|
||||||
|
qualityPreset: String = "720p_30_2.5mbps",
|
||||||
|
targetBitrate: Int = 2_500_000,
|
||||||
|
targetFps: Int = 30,
|
||||||
|
youtubeChannel: String? = nil
|
||||||
|
) async throws -> StreamSession {
|
||||||
|
try await api.createSession(
|
||||||
|
matchId: matchId,
|
||||||
|
body: CreateSessionRequest(
|
||||||
|
platform: platform,
|
||||||
|
privacyStatus: privacyStatus,
|
||||||
|
qualityPreset: qualityPreset,
|
||||||
|
targetBitrate: targetBitrate,
|
||||||
|
targetFps: targetFps,
|
||||||
|
youtubeChannel: youtubeChannel
|
||||||
|
)
|
||||||
|
).toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSession(id: String) async throws -> StreamSession {
|
||||||
|
try await api.session(id: id).toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startSession(id: String) async throws -> StreamSession {
|
||||||
|
try await api.startSession(id: id).toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopSession(id: String) async throws -> StreamSession {
|
||||||
|
try await api.stopSession(id: id).toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pauseSession(id: String) async throws -> StreamSession {
|
||||||
|
try await api.pauseSession(id: id).toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeSession(id: String) async throws -> StreamSession {
|
||||||
|
try await api.resumeSession(id: id).toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitNetworkTest(
|
||||||
|
sessionId: String,
|
||||||
|
downloadMbps: Double,
|
||||||
|
uploadMbps: Double,
|
||||||
|
latencyMs: Int,
|
||||||
|
networkType: String
|
||||||
|
) async throws -> NetworkTestResponse {
|
||||||
|
try await api.networkTest(
|
||||||
|
sessionId: sessionId,
|
||||||
|
body: NetworkTestRequest(
|
||||||
|
downloadMbps: downloadMbps,
|
||||||
|
uploadMbps: uploadMbps,
|
||||||
|
latencyMs: latencyMs,
|
||||||
|
networkType: networkType
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createRegiaLink(sessionId: String) async throws -> String {
|
||||||
|
try await api.regiaLink(sessionId: sessionId).regiaUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
func postTelemetry(
|
||||||
|
sessionId: String,
|
||||||
|
health: DeviceHealth,
|
||||||
|
currentBitrate: Int?,
|
||||||
|
targetBitrate: Int?,
|
||||||
|
fps: Int?
|
||||||
|
) async throws {
|
||||||
|
try await api.postTelemetry(
|
||||||
|
sessionId: sessionId,
|
||||||
|
body: TelemetryRequest(
|
||||||
|
deviceRole: "camera",
|
||||||
|
batteryLevel: health.batteryPercent,
|
||||||
|
networkType: health.networkType,
|
||||||
|
signalStrength: nil,
|
||||||
|
currentBitrate: currentBitrate,
|
||||||
|
targetBitrate: targetBitrate,
|
||||||
|
fps: fps
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
159
native/ios/MatchLiveTv/Data/Scoring/ScoreController.swift
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ScoreController: ObservableObject {
|
||||||
|
@Published private(set) var score = ScoreState()
|
||||||
|
|
||||||
|
private let scoreRepository: ScoreRepository
|
||||||
|
private let sessionCable: SessionCableService
|
||||||
|
private var sessionId: String?
|
||||||
|
private var suppressRemoteUntil: Date = .distantPast
|
||||||
|
private var syncTask: Task<Void, Never>?
|
||||||
|
private var syncGeneration = 0
|
||||||
|
|
||||||
|
init(scoreRepository: ScoreRepository, sessionCable: SessionCableService) {
|
||||||
|
self.scoreRepository = scoreRepository
|
||||||
|
self.sessionCable = sessionCable
|
||||||
|
}
|
||||||
|
|
||||||
|
func bind(sessionId: String, initial: ScoreState?) {
|
||||||
|
self.sessionId = sessionId
|
||||||
|
if let initial { score = initial }
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyRemote(_ remote: ScoreState) {
|
||||||
|
if remote == score { return }
|
||||||
|
if Date() < suppressRemoteUntil && remote.progressKey() <= score.progressKey() { return }
|
||||||
|
score = remote
|
||||||
|
}
|
||||||
|
|
||||||
|
func incrementHome() {
|
||||||
|
score.homePoints += 1
|
||||||
|
schedulePush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func incrementAway() {
|
||||||
|
score.awayPoints += 1
|
||||||
|
schedulePush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func decrementHome() {
|
||||||
|
if score.homePoints > 0 { score.homePoints -= 1 }
|
||||||
|
schedulePush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func decrementAway() {
|
||||||
|
if score.awayPoints > 0 { score.awayPoints -= 1 }
|
||||||
|
schedulePush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAction(_ action: String) {
|
||||||
|
applyOptimistic(action)
|
||||||
|
scheduleAction(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Azione punteggio via API (volley/racket): il server è fonte di verità.
|
||||||
|
func applyBoardAction(_ action: String) async {
|
||||||
|
syncTask?.cancel()
|
||||||
|
syncGeneration += 1
|
||||||
|
guard let sessionId else { return }
|
||||||
|
do {
|
||||||
|
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) {
|
||||||
|
suppressRemoteUntil = Date().addingTimeInterval(0.8)
|
||||||
|
score = updated
|
||||||
|
sessionCable.sendScoreUpdate(updated)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Mantieni lo stato locale; la UI può mostrare errore a livello schermata se serve.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chiude il set corrente tramite `close_set` sul motore backend.
|
||||||
|
func closeSetAsync() async -> Bool {
|
||||||
|
syncTask?.cancel()
|
||||||
|
syncGeneration += 1
|
||||||
|
guard let sessionId else { return false }
|
||||||
|
do {
|
||||||
|
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: "close_set") {
|
||||||
|
suppressRemoteUntil = Date().addingTimeInterval(1.2)
|
||||||
|
score = updated
|
||||||
|
sessionCable.sendScoreUpdate(updated)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyOptimistic(_ action: String) {
|
||||||
|
switch action {
|
||||||
|
case "home_point": score.homePoints += 1
|
||||||
|
case "away_point": score.awayPoints += 1
|
||||||
|
case "home_point_2": score.homePoints += 2
|
||||||
|
case "away_point_2": score.awayPoints += 2
|
||||||
|
case "home_point_3": score.homePoints += 3
|
||||||
|
case "away_point_3": score.awayPoints += 3
|
||||||
|
case "home_undo": score.homePoints = max(0, score.homePoints - 1)
|
||||||
|
case "away_undo": score.awayPoints = max(0, score.awayPoints - 1)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleAction(_ action: String) {
|
||||||
|
syncTask?.cancel()
|
||||||
|
syncGeneration += 1
|
||||||
|
let generation = syncGeneration
|
||||||
|
syncTask = Task {
|
||||||
|
guard let sessionId else { return }
|
||||||
|
if let updated = try? await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) {
|
||||||
|
guard generation == syncGeneration else { return }
|
||||||
|
suppressRemoteUntil = Date().addingTimeInterval(0.5)
|
||||||
|
score = updated
|
||||||
|
sessionCable.sendScoreUpdate(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeSet() {
|
||||||
|
let current = score
|
||||||
|
let homeWon = current.homePoints > current.awayPoints
|
||||||
|
var partials = current.setPartials
|
||||||
|
if current.homePoints > 0 || current.awayPoints > 0 {
|
||||||
|
partials.append(SetPartial(set: current.currentSet, home: current.homePoints, away: current.awayPoints))
|
||||||
|
}
|
||||||
|
score = ScoreState(
|
||||||
|
homeSets: homeWon ? current.homeSets + 1 : current.homeSets,
|
||||||
|
awaySets: homeWon ? current.awaySets : current.awaySets + 1,
|
||||||
|
homePoints: 0,
|
||||||
|
awayPoints: 0,
|
||||||
|
currentSet: current.currentSet + 1,
|
||||||
|
setPartials: partials,
|
||||||
|
timeoutHome: false,
|
||||||
|
timeoutAway: false,
|
||||||
|
boardType: current.boardType,
|
||||||
|
period: current.period,
|
||||||
|
periodLabel: current.periodLabel,
|
||||||
|
clockSecs: current.clockSecs,
|
||||||
|
clockRunning: current.clockRunning,
|
||||||
|
overtime: current.overtime
|
||||||
|
)
|
||||||
|
schedulePush()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func schedulePush() {
|
||||||
|
syncTask?.cancel()
|
||||||
|
syncGeneration += 1
|
||||||
|
let generation = syncGeneration
|
||||||
|
syncTask = Task {
|
||||||
|
guard let sessionId else { return }
|
||||||
|
let local = score
|
||||||
|
sessionCable.sendScoreUpdate(local)
|
||||||
|
if let remote = try? await scoreRepository.syncScore(sessionId: sessionId, score: local).score {
|
||||||
|
guard generation == syncGeneration else { return }
|
||||||
|
suppressRemoteUntil = Date().addingTimeInterval(0.5)
|
||||||
|
score = remote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
native/ios/MatchLiveTv/Data/WizardSessionHolder.swift
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WizardSessionHolder: ObservableObject {
|
||||||
|
@Published var match: Match?
|
||||||
|
@Published var session: StreamSession?
|
||||||
|
@Published var team: Team?
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
match = nil
|
||||||
|
session = nil
|
||||||
|
team = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
43
native/ios/MatchLiveTv/Domain/MatchHubFilter.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum MatchHubFilter {
|
||||||
|
private static let resumableStatuses: Set<String> = ["connecting", "live", "reconnecting", "paused"]
|
||||||
|
|
||||||
|
static func coachHubVisible(_ match: Match, now: Date = Date()) -> Bool {
|
||||||
|
if let visible = match.coachHubVisible { return visible }
|
||||||
|
|
||||||
|
if match.hasActiveSession {
|
||||||
|
let status = match.activeSessionStatus ?? ""
|
||||||
|
if resumableStatuses.contains(status) || status == "idle" { return true }
|
||||||
|
}
|
||||||
|
if match.streamCompleted { return false }
|
||||||
|
|
||||||
|
return ApiInstant.isScheduledFuture(match.scheduledAt, now: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isScheduledFuture(_ match: Match, now: Date = Date()) -> Bool {
|
||||||
|
ApiInstant.isScheduledFuture(match.scheduledAt, now: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isDraft(_ match: Match) -> Bool {
|
||||||
|
!match.hasActiveSession && (match.scheduledAt == nil || match.scheduledAt?.isEmpty == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sortMatches(_ matches: [Match]) -> [Match] {
|
||||||
|
matches.sorted { lhs, rhs in
|
||||||
|
let lDate = lhs.scheduledAt ?? ""
|
||||||
|
let rDate = rhs.scheduledAt ?? ""
|
||||||
|
if lDate != rDate { return lDate > rDate }
|
||||||
|
return lhs.id > rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MatchPresentation {
|
||||||
|
static func statusLabel(for match: Match) -> String {
|
||||||
|
if match.canResumeCamera { return "RIPRENDI" }
|
||||||
|
if match.hasActiveSession { return "IN CORSO" }
|
||||||
|
if ApiInstant.isScheduledFuture(match.scheduledAt) { return "PROGRAMMATA" }
|
||||||
|
return "AVVIA"
|
||||||
|
}
|
||||||
|
}
|
||||||
68
native/ios/MatchLiveTv/Domain/MatchScoringRules.swift
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ScoringSide: Sendable {
|
||||||
|
case home
|
||||||
|
case away
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MatchScoringContext: Sendable {
|
||||||
|
let setsToWin: Int
|
||||||
|
let pointsPerSet: Int
|
||||||
|
let pointsDecidingSet: Int
|
||||||
|
let minPointLead: Int
|
||||||
|
let boardType: String
|
||||||
|
|
||||||
|
var maxSets: Int { (setsToWin * 2) - 1 }
|
||||||
|
|
||||||
|
var usesSetLogic: Bool {
|
||||||
|
boardType == "volley" || boardType == "racket"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(match: Match) {
|
||||||
|
let rules = match.scoringRules ?? ScoringRules()
|
||||||
|
setsToWin = match.setsToWin
|
||||||
|
pointsPerSet = rules.pointsPerSet
|
||||||
|
pointsDecidingSet = rules.pointsDecidingSet
|
||||||
|
minPointLead = rules.minPointLead
|
||||||
|
boardType = match.boardType.isEmpty ? "volley" : match.boardType
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointsTarget(currentSet: Int) -> Int {
|
||||||
|
currentSet >= maxSets ? pointsDecidingSet : pointsPerSet
|
||||||
|
}
|
||||||
|
|
||||||
|
func setWinnerFromPoints(homePoints: Int, awayPoints: Int, currentSet: Int) -> ScoringSide? {
|
||||||
|
let target = pointsTarget(currentSet: currentSet)
|
||||||
|
if homePoints >= target, homePoints - awayPoints >= minPointLead { return .home }
|
||||||
|
if awayPoints >= target, awayPoints - homePoints >= minPointLead { return .away }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchWinner(homeSets: Int, awaySets: Int) -> ScoringSide? {
|
||||||
|
if homeSets >= setsToWin { return .home }
|
||||||
|
if awaySets >= setsToWin { return .away }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MatchScoringRules {
|
||||||
|
static func rules(for match: Match) -> ScoringRules {
|
||||||
|
match.scoringRules ?? ScoringRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setsToWin(for match: Match) -> Int {
|
||||||
|
match.setsToWin
|
||||||
|
}
|
||||||
|
|
||||||
|
static func maxSets(for match: Match) -> Int {
|
||||||
|
MatchScoringContext(match: match).maxSets
|
||||||
|
}
|
||||||
|
|
||||||
|
static func pointsTarget(for match: Match, currentSet: Int) -> Int {
|
||||||
|
MatchScoringContext(match: match).pointsTarget(currentSet: currentSet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoringSideName(_ side: ScoringSide, homeName: String, awayName: String) -> String {
|
||||||
|
side == .home ? homeName : awayName
|
||||||
|
}
|
||||||
137
native/ios/MatchLiveTv/Domain/Models.swift
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct User: Codable, Equatable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let email: String
|
||||||
|
let name: String
|
||||||
|
let role: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AuthTokens: Sendable {
|
||||||
|
let accessToken: String
|
||||||
|
let refreshToken: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SportOption: Identifiable, Equatable, Sendable {
|
||||||
|
var id: String { key }
|
||||||
|
let key: String
|
||||||
|
let label: String
|
||||||
|
let board: String
|
||||||
|
let overlay: String
|
||||||
|
let allowedOverlays: [String]
|
||||||
|
let defaultRules: [String: Int]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Team: Identifiable, Equatable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let sport: String
|
||||||
|
let sportKey: String
|
||||||
|
let boardType: String
|
||||||
|
let clubName: String?
|
||||||
|
let logoUrl: String?
|
||||||
|
let primaryColor: String?
|
||||||
|
let secondaryColor: String?
|
||||||
|
let canStream: Bool
|
||||||
|
let youtubeEnabled: Bool
|
||||||
|
let youtubeConnected: Bool
|
||||||
|
let youtubeSelectable: Bool
|
||||||
|
let youtubeChannelTitle: String?
|
||||||
|
let youtubeUsesPlatformChannel: Bool
|
||||||
|
let youtubeTeamChannelTitle: String?
|
||||||
|
let planName: String?
|
||||||
|
let recordingsEnabled: Bool
|
||||||
|
let phoneDownloadEnabled: Bool
|
||||||
|
let billingUrl: String?
|
||||||
|
let staffManageUrl: String?
|
||||||
|
|
||||||
|
var canUseYoutube: Bool { youtubeEnabled }
|
||||||
|
var isYoutubeReady: Bool { youtubeSelectable }
|
||||||
|
|
||||||
|
var effectiveYoutubeChannel: String? {
|
||||||
|
guard isYoutubeReady else { return nil }
|
||||||
|
return youtubeUsesPlatformChannel ? "platform" : "team"
|
||||||
|
}
|
||||||
|
|
||||||
|
var youtubeDestinationLabel: String {
|
||||||
|
if youtubeUsesPlatformChannel { return "Canale Match Live TV" }
|
||||||
|
let name = youtubeTeamChannelTitle?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||||
|
?? clubName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||||
|
?? "società"
|
||||||
|
return "Canale società · \(name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScoringRules: Equatable, Sendable {
|
||||||
|
var pointsPerSet: Int = 25
|
||||||
|
var pointsDecidingSet: Int = 15
|
||||||
|
var minPointLead: Int = 2
|
||||||
|
var periods: Int = 4
|
||||||
|
var periodDurationSecs: Int = 600
|
||||||
|
var overtimeDurationSecs: Int = 300
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Match: Identifiable, Equatable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let teamId: String
|
||||||
|
let teamName: String
|
||||||
|
let opponentName: String
|
||||||
|
let location: String?
|
||||||
|
let scheduledAt: String?
|
||||||
|
let sportKey: String
|
||||||
|
let sportLabel: String?
|
||||||
|
let boardType: String
|
||||||
|
let overlayKind: String?
|
||||||
|
let effectiveOverlayKind: String
|
||||||
|
let setsToWin: Int
|
||||||
|
let category: String?
|
||||||
|
let scoringRules: ScoringRules?
|
||||||
|
let activeSessionId: String?
|
||||||
|
let activeSessionStatus: String?
|
||||||
|
let streamCompleted: Bool
|
||||||
|
let coachHubVisible: Bool?
|
||||||
|
let homePrimaryColor: String?
|
||||||
|
let homeSecondaryColor: String?
|
||||||
|
let homeLogoUrl: String?
|
||||||
|
let opponentPrimaryColor: String?
|
||||||
|
let opponentLogoUrl: String?
|
||||||
|
|
||||||
|
var hasActiveSession: Bool { activeSessionId != nil }
|
||||||
|
|
||||||
|
var canResumeCamera: Bool {
|
||||||
|
guard let status = activeSessionStatus, activeSessionId != nil else { return false }
|
||||||
|
return ["connecting", "live", "reconnecting", "paused"].contains(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StreamSession: Identifiable, Equatable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let matchId: String
|
||||||
|
let status: String
|
||||||
|
let platform: String
|
||||||
|
let rtmpIngestUrl: String?
|
||||||
|
let hlsPlaybackUrl: String?
|
||||||
|
let watchPageUrl: String?
|
||||||
|
let shareUrl: String?
|
||||||
|
let youtubeWatchUrl: String?
|
||||||
|
let youtubeReady: Bool
|
||||||
|
let privacyStatus: String
|
||||||
|
let targetBitrate: Int
|
||||||
|
let targetFps: Int
|
||||||
|
let startedAt: String?
|
||||||
|
let score: ScoreState?
|
||||||
|
|
||||||
|
var isLive: Bool { status == "live" || status == "reconnecting" }
|
||||||
|
var isPaused: Bool { status == "paused" }
|
||||||
|
|
||||||
|
func watchShareUrl() -> String? {
|
||||||
|
if platform == "youtube", let url = youtubeWatchUrl, !url.isEmpty { return url }
|
||||||
|
if let url = shareUrl, !url.isEmpty { return url }
|
||||||
|
if let url = watchPageUrl, !url.isEmpty { return url }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||||
|
}
|
||||||
113
native/ios/MatchLiveTv/Domain/ScoreState.swift
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct SetPartial: Equatable, Codable, Sendable {
|
||||||
|
let set: Int
|
||||||
|
let home: Int
|
||||||
|
let away: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScoreState: Equatable, Sendable {
|
||||||
|
var homeSets: Int = 0
|
||||||
|
var awaySets: Int = 0
|
||||||
|
var homePoints: Int = 0
|
||||||
|
var awayPoints: Int = 0
|
||||||
|
var currentSet: Int = 1
|
||||||
|
var setPartials: [SetPartial] = []
|
||||||
|
var timeoutHome: Bool = false
|
||||||
|
var timeoutAway: Bool = false
|
||||||
|
var boardType: String = "volley"
|
||||||
|
var period: Int = 1
|
||||||
|
var periodLabel: String?
|
||||||
|
var clockSecs: Int = 0
|
||||||
|
var clockRunning: Bool = false
|
||||||
|
var overtime: Bool = false
|
||||||
|
|
||||||
|
func cablePayload() -> [String: Any] {
|
||||||
|
var payload: [String: Any] = [
|
||||||
|
"type": "score_update",
|
||||||
|
"board_type": boardType,
|
||||||
|
"home_sets": homeSets,
|
||||||
|
"away_sets": awaySets,
|
||||||
|
"home_points": homePoints,
|
||||||
|
"away_points": awayPoints,
|
||||||
|
"current_set": currentSet,
|
||||||
|
"set_partials": setPartials.map { ["set": $0.set, "home": $0.home, "away": $0.away] },
|
||||||
|
"timeout_home": timeoutHome,
|
||||||
|
"timeout_away": timeoutAway,
|
||||||
|
]
|
||||||
|
if Self.periodBoards.contains(boardType) {
|
||||||
|
payload["period"] = periodLabel ?? "\(period)"
|
||||||
|
payload["clock_secs"] = clockSecs
|
||||||
|
payload["clock_running"] = clockRunning
|
||||||
|
payload["home_score"] = homePoints
|
||||||
|
payload["away_score"] = awayPoints
|
||||||
|
payload["overtime"] = overtime
|
||||||
|
}
|
||||||
|
if boardType == "timer" {
|
||||||
|
payload["clock_secs"] = clockSecs
|
||||||
|
payload["clock_running"] = clockRunning
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func progressKey() -> Int64 {
|
||||||
|
Int64(currentSet) * 10_000_000_000
|
||||||
|
+ Int64(homeSets) * 1_000_000
|
||||||
|
+ Int64(awaySets) * 100_000
|
||||||
|
+ Int64(homePoints) * 1_000
|
||||||
|
+ Int64(awayPoints)
|
||||||
|
+ Int64(clockSecs)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let periodBoards: Set<String> = ["basket", "timed"]
|
||||||
|
|
||||||
|
static func fromCablePayload(_ data: [String: Any]) -> ScoreState {
|
||||||
|
let boardType = data["board_type"] as? String ?? "volley"
|
||||||
|
let nested = data["data"] as? [String: Any]
|
||||||
|
let partialsRaw = (data["set_partials"] as? [Any]) ?? (data["setPartials"] as? [Any]) ?? []
|
||||||
|
let periodNumber = intFrom(nested, key: "period").flatMap { $0 > 0 ? $0 : nil }
|
||||||
|
?? max(intField(data, snake: "current_set", camel: "currentSet"), 1)
|
||||||
|
|
||||||
|
return ScoreState(
|
||||||
|
homeSets: intField(data, snake: "home_sets", camel: "homeSets"),
|
||||||
|
awaySets: intField(data, snake: "away_sets", camel: "awaySets"),
|
||||||
|
homePoints: intField(data, snake: "home_points", camel: "homePoints"),
|
||||||
|
awayPoints: intField(data, snake: "away_points", camel: "awayPoints"),
|
||||||
|
currentSet: max(intField(data, snake: "current_set", camel: "currentSet"), 1),
|
||||||
|
setPartials: partialsRaw.compactMap { item -> SetPartial? in
|
||||||
|
guard let row = item as? [String: Any] else { return nil }
|
||||||
|
return SetPartial(
|
||||||
|
set: (row["set"] as? NSNumber)?.intValue ?? 1,
|
||||||
|
home: (row["home"] as? NSNumber)?.intValue ?? 0,
|
||||||
|
away: (row["away"] as? NSNumber)?.intValue ?? 0
|
||||||
|
)
|
||||||
|
},
|
||||||
|
timeoutHome: data["timeout_home"] as? Bool ?? false,
|
||||||
|
timeoutAway: data["timeout_away"] as? Bool ?? false,
|
||||||
|
boardType: boardType,
|
||||||
|
period: periodNumber,
|
||||||
|
periodLabel: data["period"] as? String,
|
||||||
|
clockSecs: {
|
||||||
|
let direct = intField(data, snake: "clock_secs", camel: "clockSecs")
|
||||||
|
if direct > 0 || data["clock_secs"] != nil || data["clockSecs"] != nil { return direct }
|
||||||
|
return intFrom(nested, key: "clock_secs") ?? 0
|
||||||
|
}(),
|
||||||
|
clockRunning: data["clock_running"] as? Bool ?? nested?["clock_running"] as? Bool ?? false,
|
||||||
|
overtime: nested?["overtime"] as? Bool ?? false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func intField(_ data: [String: Any], snake: String, camel: String) -> Int {
|
||||||
|
let raw = data[snake] ?? data[camel]
|
||||||
|
if let n = raw as? NSNumber { return n.intValue }
|
||||||
|
if let s = raw as? String, let v = Int(s) { return v }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func intFrom(_ data: [String: Any]?, key: String) -> Int? {
|
||||||
|
guard let data else { return nil }
|
||||||
|
if let n = data[key] as? NSNumber { return n.intValue }
|
||||||
|
if let s = data[key] as? String, let v = Int(s) { return v }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{ "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0.176", "green" : "0.176", "red" : "1.000" } }, "idiom" : "universal" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 257 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "info" : { "author" : "xcode", "version" : 1 } }
|
||||||
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "CopertinaCanale.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale.imageset/CopertinaCanale.png
vendored
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_2.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "CopertinaCanale_2.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_2.imageset/CopertinaCanale_2.png
vendored
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_3.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "CopertinaCanale_3.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_3.imageset/CopertinaCanale_3.png
vendored
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_4.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "CopertinaCanale_4.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_4.imageset/CopertinaCanale_4.png
vendored
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_5.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "CopertinaCanale_5.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_5.imageset/CopertinaCanale_5.png
vendored
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_6.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "CopertinaCanale_6.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_6.imageset/CopertinaCanale_6.png
vendored
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "logo-dark-gradient-256.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 23 KiB |
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-dark-gradient.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "logo-dark-gradient.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-dark-gradient.imageset/logo-dark-gradient.png
vendored
Normal file
|
After Width: | Height: | Size: 257 KiB |
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m-256.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "logo-white-m-256.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m-256.imageset/logo-white-m-256.png
vendored
Normal file
|
After Width: | Height: | Size: 24 KiB |
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "logo-white-m.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m.imageset/logo-white-m.png
vendored
Normal file
|
After Width: | Height: | Size: 245 KiB |
62
native/ios/MatchLiveTv/Resources/Info.plist
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>API_BASE_URL</key>
|
||||||
|
<string>https://www.matchlivetv.it</string>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>it</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Match Live TV</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>2.0.0</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.matchlivetv.match-live-tv</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>matchlivetv</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>21</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Match Live TV usa la fotocamera per trasmettere le partite in diretta.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Match Live TV usa il microfono per l'audio della diretta.</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>Match Live TV usa le foto per i loghi delle squadre.</string>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIColorName</key>
|
||||||
|
<string></string>
|
||||||
|
<key>UIImageName</key>
|
||||||
|
<string>logo-dark-gradient-256</string>
|
||||||
|
</dict>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
26
native/ios/MatchLiveTv/Streaming/BroadcastModels.swift
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BroadcastConfig: Sendable {
|
||||||
|
let rtmpUrl: String
|
||||||
|
var width: Int = 1280
|
||||||
|
var height: Int = 720
|
||||||
|
var videoBitrate: Int = 2_500_000
|
||||||
|
var audioBitrate: Int = 128_000
|
||||||
|
var fps: Int = 30
|
||||||
|
var rotation: Int = 0
|
||||||
|
var portrait: Bool = false
|
||||||
|
var maxReconnectAttempts: Int = 10
|
||||||
|
var reconnectDelayMs: Int = 5_000
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BroadcastPhase: String, Sendable {
|
||||||
|
case idle, preview, connecting, live, paused, reconnecting, error
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BroadcastMetrics: Sendable {
|
||||||
|
var phase: BroadcastPhase = .idle
|
||||||
|
var bitrateKbps: Int = 0
|
||||||
|
var fps: Int = 0
|
||||||
|
var connected: Bool = false
|
||||||
|
var lastError: String?
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LiveBroadcastCoordinator: ObservableObject {
|
||||||
|
let engine = LiveBroadcastEngine()
|
||||||
|
@Published private(set) var metrics = BroadcastMetrics()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
engine.setMetricsListener { [weak self] value in
|
||||||
|
Task { @MainActor in self?.metrics = value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateOverlay(_ state: OverlayState) {
|
||||||
|
engine.updateOverlay(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureBackgroundAudio() {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try? session.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
|
||||||
|
try? session.setActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startBroadcast(config: BroadcastConfig) async throws {
|
||||||
|
configureBackgroundAudio()
|
||||||
|
try await engine.startBroadcast(config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pauseBroadcast() async {
|
||||||
|
await engine.pauseBroadcast()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeBroadcast(config: BroadcastConfig) async throws {
|
||||||
|
try await engine.resumeBroadcast(config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopBroadcast() async {
|
||||||
|
await engine.stopBroadcast()
|
||||||
|
}
|
||||||
|
|
||||||
|
func release() async {
|
||||||
|
await engine.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
176
native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import HaishinKit
|
||||||
|
import RTMPHaishinKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct RTMPPublishTarget: Sendable {
|
||||||
|
let connectURL: String
|
||||||
|
let streamName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RTMPUrlParser {
|
||||||
|
static func parse(_ urlString: String) -> RTMPPublishTarget? {
|
||||||
|
guard let url = URL(string: urlString), url.scheme?.hasPrefix("rtmp") == true else { return nil }
|
||||||
|
let pathParts = url.path.split(separator: "/").map(String.init)
|
||||||
|
guard pathParts.count >= 2 else { return nil }
|
||||||
|
let app = pathParts.dropLast().joined(separator: "/")
|
||||||
|
let streamName = pathParts.last ?? ""
|
||||||
|
var connect = "rtmp://\(url.host ?? "")"
|
||||||
|
if let port = url.port { connect += ":\(port)" }
|
||||||
|
connect += "/\(app)"
|
||||||
|
return RTMPPublishTarget(connectURL: connect, streamName: streamName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LiveBroadcastEngine: ObservableObject {
|
||||||
|
@Published private(set) var metrics = BroadcastMetrics()
|
||||||
|
|
||||||
|
let mixer = MediaMixer()
|
||||||
|
let connection = RTMPConnection()
|
||||||
|
let stream: RTMPStream
|
||||||
|
private let overlayRenderer = OverlayRenderer()
|
||||||
|
|
||||||
|
private var config: BroadcastConfig?
|
||||||
|
private var phase: BroadcastPhase = .idle
|
||||||
|
private var metricsListener: ((BroadcastMetrics) -> Void)?
|
||||||
|
private var reconnectAttempts = 0
|
||||||
|
private var publishTask: Task<Void, Never>?
|
||||||
|
private var pipelineConfigured = false
|
||||||
|
private var isPortrait = false
|
||||||
|
|
||||||
|
init() {
|
||||||
|
stream = RTMPStream(connection: connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMetricsListener(_ block: ((BroadcastMetrics) -> Void)?) {
|
||||||
|
metricsListener = block
|
||||||
|
emitMetrics()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateOverlay(_ state: OverlayState) {
|
||||||
|
overlayRenderer.update(state: state, isPortrait: isPortrait)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindPreview(to view: MTHKView) async {
|
||||||
|
await stream.addOutput(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func preparePreview(config: BroadcastConfig) async throws {
|
||||||
|
self.config = config
|
||||||
|
try await configurePipeline(config)
|
||||||
|
setPhase(.preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startBroadcast(config: BroadcastConfig) async throws {
|
||||||
|
self.config = config
|
||||||
|
publishTask?.cancel()
|
||||||
|
publishTask = nil
|
||||||
|
try await configurePipeline(config)
|
||||||
|
setPhase(.connecting)
|
||||||
|
try await Task.sleep(nanoseconds: 350_000_000)
|
||||||
|
try await publish(config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pauseBroadcast() async {
|
||||||
|
try? await stream.close()
|
||||||
|
setPhase(.paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeBroadcast(config: BroadcastConfig) async throws {
|
||||||
|
self.config = config
|
||||||
|
if !pipelineConfigured {
|
||||||
|
try await configurePipeline(config)
|
||||||
|
}
|
||||||
|
setPhase(.connecting)
|
||||||
|
try await publish(config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopBroadcast() async {
|
||||||
|
publishTask?.cancel()
|
||||||
|
publishTask = nil
|
||||||
|
overlayRenderer.detach()
|
||||||
|
try? await stream.close()
|
||||||
|
try? await connection.close()
|
||||||
|
try? await mixer.stopRunning()
|
||||||
|
pipelineConfigured = false
|
||||||
|
setPhase(.idle)
|
||||||
|
reconnectAttempts = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func release() async {
|
||||||
|
await stopBroadcast()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configurePipeline(_ config: BroadcastConfig) async throws {
|
||||||
|
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized,
|
||||||
|
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||||
|
throw APIError.http(403, "Permessi camera e microfono richiesti")
|
||||||
|
}
|
||||||
|
if pipelineConfigured { return }
|
||||||
|
|
||||||
|
let audioSession = AVAudioSession.sharedInstance()
|
||||||
|
try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
|
||||||
|
try audioSession.setActive(true)
|
||||||
|
|
||||||
|
isPortrait = config.portrait
|
||||||
|
var videoSettings = await mixer.videoMixerSettings
|
||||||
|
videoSettings.mode = .offscreen
|
||||||
|
try await mixer.setVideoMixerSettings(videoSettings)
|
||||||
|
await configureScreenSize(width: config.width, height: config.height)
|
||||||
|
|
||||||
|
if let microphone = AVCaptureDevice.default(for: .audio) {
|
||||||
|
try await mixer.attachAudio(microphone, track: 0)
|
||||||
|
}
|
||||||
|
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
|
||||||
|
try await mixer.attachVideo(camera, track: 0)
|
||||||
|
}
|
||||||
|
try await mixer.addOutput(stream)
|
||||||
|
try await mixer.startRunning()
|
||||||
|
overlayRenderer.attach(mixer: mixer, width: config.width, height: config.height, isPortrait: config.portrait)
|
||||||
|
|
||||||
|
var streamSettings = await stream.videoSettings
|
||||||
|
streamSettings.videoSize = .init(width: config.width, height: config.height)
|
||||||
|
streamSettings.bitRate = config.videoBitrate
|
||||||
|
streamSettings.maxKeyFrameIntervalDuration = 2
|
||||||
|
try await stream.setVideoSettings(streamSettings)
|
||||||
|
var audioSettings = await stream.audioSettings
|
||||||
|
audioSettings.bitRate = config.audioBitrate
|
||||||
|
try await stream.setAudioSettings(audioSettings)
|
||||||
|
pipelineConfigured = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureScreenSize(width: Int, height: Int) async {
|
||||||
|
await Task { @ScreenActor in
|
||||||
|
mixer.screen.size = CGSize(width: width, height: height)
|
||||||
|
mixer.screen.backgroundColor = UIColor.clear.cgColor
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private func publish(config: BroadcastConfig) async throws {
|
||||||
|
guard let target = RTMPUrlParser.parse(config.rtmpUrl) else {
|
||||||
|
throw APIError.http(400, "URL RTMP non valido")
|
||||||
|
}
|
||||||
|
try await connection.connect(target.connectURL)
|
||||||
|
try await stream.publish(target.streamName)
|
||||||
|
reconnectAttempts = 0
|
||||||
|
setPhase(.live)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setPhase(_ newPhase: BroadcastPhase, error: String? = nil) {
|
||||||
|
phase = newPhase
|
||||||
|
metrics = BroadcastMetrics(
|
||||||
|
phase: newPhase,
|
||||||
|
bitrateKbps: metrics.bitrateKbps,
|
||||||
|
fps: config?.fps ?? metrics.fps,
|
||||||
|
connected: newPhase == .live || newPhase == .reconnecting,
|
||||||
|
lastError: error
|
||||||
|
)
|
||||||
|
metricsListener?(metrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emitMetrics() {
|
||||||
|
metricsListener?(metrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct CompactScoreboardElement: OverlayElement {
|
||||||
|
func draw(in context: CGContext, state: OverlayState, layout: OverlayLayout) {
|
||||||
|
guard let compact = state.compactScoreboard else { return }
|
||||||
|
let kind = state.overlayKind
|
||||||
|
guard kind == .basket || kind == .timed || kind == .racket || kind == .volley else { return }
|
||||||
|
|
||||||
|
let scale = CGFloat(layout.canvasHeight) / 720
|
||||||
|
let pad = max(8, Int((10 * scale).rounded()))
|
||||||
|
let barW = max(3, Int((4 * scale).rounded()))
|
||||||
|
let logoSize = max(20, Int((28 * scale).rounded()))
|
||||||
|
let gap = max(4, Int((6 * scale).rounded()))
|
||||||
|
let totalW = max(320, Int((CGFloat(layout.canvasWidth) * 0.62).rounded()))
|
||||||
|
let mainH = max(56, Int((CGFloat(layout.canvasHeight) * 0.09).rounded()))
|
||||||
|
let metaH = max(16, Int((CGFloat(layout.canvasHeight) * 0.028).rounded()))
|
||||||
|
let totalH = mainH + metaH + pad
|
||||||
|
let left = CGFloat(layout.marginPx)
|
||||||
|
let top = CGFloat(layout.marginPx)
|
||||||
|
let rect = CGRect(x: left, y: top, width: CGFloat(totalW), height: CGFloat(totalH))
|
||||||
|
|
||||||
|
context.setFillColor(UIColor(white: 0.047, alpha: 0.82).cgColor)
|
||||||
|
context.addPath(UIBezierPath(roundedRect: rect, cornerRadius: 10 * scale).cgPath)
|
||||||
|
context.fillPath()
|
||||||
|
|
||||||
|
let centerX = rect.midX
|
||||||
|
let rowCenterY = top + CGFloat(pad) + CGFloat(mainH) * 0.58
|
||||||
|
let sideMaxW = CGFloat(totalW) * 0.34
|
||||||
|
let scoreZoneHalf = CGFloat(totalW) * 0.11
|
||||||
|
let labelSize = max(13, CGFloat(mainH) * 0.28)
|
||||||
|
let scoreSize = max(24, CGFloat(mainH) * 0.46)
|
||||||
|
let labelFont = UIFont.boldSystemFont(ofSize: labelSize)
|
||||||
|
let scoreFont = UIFont.boldSystemFont(ofSize: scoreSize)
|
||||||
|
|
||||||
|
drawTeamSide(context: context, compact: compact, isHome: true, rectLeft: rect.minX + CGFloat(pad), rectRight: centerX - scoreZoneHalf, centerY: rowCenterY, barW: barW, logoSize: logoSize, gap: gap, maxWidth: sideMaxW, labelFont: labelFont)
|
||||||
|
drawTeamSide(context: context, compact: compact, isHome: false, rectLeft: centerX + scoreZoneHalf, rectRight: rect.maxX - CGFloat(pad), centerY: rowCenterY, barW: barW, logoSize: logoSize, gap: gap, maxWidth: sideMaxW, labelFont: labelFont)
|
||||||
|
|
||||||
|
let scoreText = "\(compact.homeScore) - \(compact.awayScore)"
|
||||||
|
let scoreAttrs: [NSAttributedString.Key: Any] = [.font: scoreFont, .foregroundColor: UIColor.white]
|
||||||
|
let scoreNSString = scoreText as NSString
|
||||||
|
let scoreTextSize = scoreNSString.size(withAttributes: scoreAttrs)
|
||||||
|
scoreNSString.draw(at: CGPoint(x: centerX - scoreTextSize.width / 2, y: rowCenterY + scoreSize * 0.32 - scoreTextSize.height), withAttributes: scoreAttrs)
|
||||||
|
|
||||||
|
if let period = compact.periodLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !period.isEmpty {
|
||||||
|
let metaSize = max(11, CGFloat(metaH) * 0.72)
|
||||||
|
let metaFont = UIFont.systemFont(ofSize: metaSize)
|
||||||
|
let metaAttrs: [NSAttributedString.Key: Any] = [.font: metaFont, .foregroundColor: UIColor(white: 0.67, alpha: 1)]
|
||||||
|
let metaNSString = period as NSString
|
||||||
|
let metaSize2 = metaNSString.size(withAttributes: metaAttrs)
|
||||||
|
metaNSString.draw(at: CGPoint(x: centerX - metaSize2.width / 2, y: rect.maxY - CGFloat(pad) * 0.45 - metaSize2.height), withAttributes: metaAttrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawTeamSide(
|
||||||
|
context: CGContext,
|
||||||
|
compact: CompactScoreboardState,
|
||||||
|
isHome: Bool,
|
||||||
|
rectLeft: CGFloat,
|
||||||
|
rectRight: CGFloat,
|
||||||
|
centerY: CGFloat,
|
||||||
|
barW: Int,
|
||||||
|
logoSize: Int,
|
||||||
|
gap: Int,
|
||||||
|
maxWidth: CGFloat,
|
||||||
|
labelFont: UIFont
|
||||||
|
) {
|
||||||
|
let accent = isHome ? compact.homeAccentColor : compact.awayAccentColor
|
||||||
|
let logoUrl = isHome ? compact.homeLogoUrl : compact.awayLogoUrl
|
||||||
|
let logo = OverlayLogoCache.get(logoUrl)
|
||||||
|
let rawName = isHome ? compact.homeTeamName : compact.awayTeamName
|
||||||
|
let name = ellipsize(rawName.trimmingCharacters(in: .whitespacesAndNewlines), font: labelFont, maxWidth: maxWidth * 0.55)
|
||||||
|
let attrs: [NSAttributedString.Key: Any] = [.font: labelFont, .foregroundColor: UIColor.white]
|
||||||
|
|
||||||
|
if isHome {
|
||||||
|
var x = rectLeft
|
||||||
|
context.setFillColor(accent.cgColor)
|
||||||
|
context.fill(CGRect(x: x, y: centerY - CGFloat(logoSize) * 0.45, width: CGFloat(barW), height: CGFloat(logoSize) * 0.9))
|
||||||
|
x += CGFloat(barW + gap)
|
||||||
|
if let logo, let cg = logo.cgImage {
|
||||||
|
context.draw(cg, in: CGRect(x: x, y: centerY - CGFloat(logoSize) / 2, width: CGFloat(logoSize), height: CGFloat(logoSize)))
|
||||||
|
x += CGFloat(logoSize + gap)
|
||||||
|
}
|
||||||
|
(name as NSString).draw(at: CGPoint(x: x, y: centerY + labelFont.pointSize * 0.35 - labelFont.lineHeight), withAttributes: attrs)
|
||||||
|
} else {
|
||||||
|
var x = rectRight
|
||||||
|
context.setFillColor(accent.cgColor)
|
||||||
|
context.fill(CGRect(x: x - CGFloat(barW), y: centerY - CGFloat(logoSize) * 0.45, width: CGFloat(barW), height: CGFloat(logoSize) * 0.9))
|
||||||
|
x -= CGFloat(barW + gap)
|
||||||
|
let nameSize = (name as NSString).size(withAttributes: attrs)
|
||||||
|
(name as NSString).draw(at: CGPoint(x: x - nameSize.width, y: centerY + labelFont.pointSize * 0.35 - labelFont.lineHeight), withAttributes: attrs)
|
||||||
|
x -= CGFloat(gap)
|
||||||
|
if let logo, let cg = logo.cgImage {
|
||||||
|
x -= CGFloat(logoSize)
|
||||||
|
context.draw(cg, in: CGRect(x: x, y: centerY - CGFloat(logoSize) / 2, width: CGFloat(logoSize), height: CGFloat(logoSize)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ellipsize(_ text: String, font: UIFont, maxWidth: CGFloat) -> String {
|
||||||
|
let safeMax = max(48, maxWidth)
|
||||||
|
let attrs: [NSAttributedString.Key: Any] = [.font: font]
|
||||||
|
if (text as NSString).size(withAttributes: attrs).width <= safeMax { return text }
|
||||||
|
var result = text
|
||||||
|
while result.count > 1 && (result as NSString).size(withAttributes: attrs).width > safeMax {
|
||||||
|
result = String(result.dropLast())
|
||||||
|
}
|
||||||
|
return result + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class OverlayCanvasRenderer {
|
||||||
|
private let elements: [OverlayElement] = [
|
||||||
|
WatermarkElement(),
|
||||||
|
ScoreboardElement(),
|
||||||
|
CompactScoreboardElement(),
|
||||||
|
SponsorElement(),
|
||||||
|
]
|
||||||
|
|
||||||
|
private var canvasWidth = 0
|
||||||
|
private var canvasHeight = 0
|
||||||
|
|
||||||
|
func setCanvasSize(width: Int, height: Int) {
|
||||||
|
canvasWidth = width
|
||||||
|
canvasHeight = height
|
||||||
|
}
|
||||||
|
|
||||||
|
func render(state: OverlayState, isPortraitContent: Bool) -> CGImage? {
|
||||||
|
guard canvasWidth > 0, canvasHeight > 0 else { return nil }
|
||||||
|
let size = CGSize(width: canvasWidth, height: canvasHeight)
|
||||||
|
let margin = max(12, Int((CGFloat(canvasWidth) * 0.012).rounded()))
|
||||||
|
let layout = OverlayLayout(
|
||||||
|
canvasWidth: canvasWidth,
|
||||||
|
canvasHeight: canvasHeight,
|
||||||
|
marginPx: margin,
|
||||||
|
isPortraitContent: isPortraitContent
|
||||||
|
)
|
||||||
|
|
||||||
|
// scale = 1.0: pixel dimensions must match HaishinKit screen (1280x720), not Retina points.
|
||||||
|
let format = UIGraphicsImageRendererFormat()
|
||||||
|
format.scale = 1.0
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: size, format: format)
|
||||||
|
let image = renderer.image { ctx in
|
||||||
|
let context = ctx.cgContext
|
||||||
|
context.clear(CGRect(origin: .zero, size: size))
|
||||||
|
elements.forEach { $0.draw(in: context, state: state, layout: layout) }
|
||||||
|
}
|
||||||
|
guard let cgImage = image.cgImage,
|
||||||
|
cgImage.width == canvasWidth,
|
||||||
|
cgImage.height == canvasHeight else { return nil }
|
||||||
|
return cgImage
|
||||||
|
}
|
||||||
|
|
||||||
|
func release() {
|
||||||
|
canvasWidth = 0
|
||||||
|
canvasHeight = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum OverlayLogoCache {
|
||||||
|
private static var authToken: String?
|
||||||
|
private static var cache: [String: UIImage] = [:]
|
||||||
|
private static let lock = NSLock()
|
||||||
|
|
||||||
|
static func configure(token: String?) {
|
||||||
|
lock.lock()
|
||||||
|
authToken = token
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func get(_ url: String?) -> UIImage? {
|
||||||
|
guard let url, !url.isEmpty else { return nil }
|
||||||
|
lock.lock()
|
||||||
|
if let cached = cache[url] {
|
||||||
|
lock.unlock()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
lock.unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func preload(_ url: String?) async {
|
||||||
|
guard let resolved = MediaUrl.resolve(url) else { return }
|
||||||
|
lock.lock()
|
||||||
|
if cache[resolved] != nil {
|
||||||
|
lock.unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lock.unlock()
|
||||||
|
guard let image = await download(resolved) else { return }
|
||||||
|
lock.lock()
|
||||||
|
cache[resolved] = image
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func preloadAll(_ urls: [String?]) async {
|
||||||
|
await withTaskGroup(of: Void.self) { group in
|
||||||
|
for url in urls {
|
||||||
|
group.addTask { await preload(url) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func clear() {
|
||||||
|
lock.lock()
|
||||||
|
cache.removeAll()
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func download(_ urlString: String) async -> UIImage? {
|
||||||
|
guard let url = URL(string: urlString) else { return nil }
|
||||||
|
var request = URLRequest(url: url, timeoutInterval: 20)
|
||||||
|
if let authToken { request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") }
|
||||||
|
guard let (data, response) = try? await URLSession.shared.data(for: request),
|
||||||
|
let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
||||||
|
let image = UIImage(data: data) else { return nil }
|
||||||
|
return scale(image, maxSide: 128)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func scale(_ image: UIImage, maxSide: CGFloat) -> UIImage {
|
||||||
|
let size = image.size
|
||||||
|
let ratio = min(maxSide / size.width, maxSide / size.height, 1)
|
||||||
|
if ratio >= 1 { return image }
|
||||||
|
let newSize = CGSize(width: size.width * ratio, height: size.height * ratio)
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: newSize)
|
||||||
|
return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: newSize)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension ScoreState {
|
||||||
|
func toScoreboardState(
|
||||||
|
homeTeamName: String,
|
||||||
|
awayTeamName: String,
|
||||||
|
homeAccentColor: UIColor = ColorHex.parseColorHex("#FF2D2D"),
|
||||||
|
awayAccentColor: UIColor = ColorHex.parseColorHex("#1E3A8A"),
|
||||||
|
homeLogoUrl: String? = nil,
|
||||||
|
awayLogoUrl: String? = nil
|
||||||
|
) -> ScoreboardState {
|
||||||
|
var columns: [ScoreboardSetColumn] = setPartials.map {
|
||||||
|
ScoreboardSetColumn(setNumber: $0.set, homePoints: $0.home, awayPoints: $0.away, isCurrent: false)
|
||||||
|
}
|
||||||
|
columns.append(
|
||||||
|
ScoreboardSetColumn(setNumber: currentSet, homePoints: homePoints, awayPoints: awayPoints, isCurrent: true)
|
||||||
|
)
|
||||||
|
return ScoreboardState(
|
||||||
|
homeTeamName: homeTeamName,
|
||||||
|
awayTeamName: awayTeamName,
|
||||||
|
columns: columns,
|
||||||
|
homeAccentColor: homeAccentColor,
|
||||||
|
awayAccentColor: awayAccentColor,
|
||||||
|
homeLogoUrl: homeLogoUrl,
|
||||||
|
awayLogoUrl: awayLogoUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatOverlayClock(_ secs: Int, countUp: Bool = false) -> String {
|
||||||
|
if secs <= 0 && !countUp { return "0:00" }
|
||||||
|
return String(format: "%d:%02d", secs / 60, secs % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BroadcastPhase {
|
||||||
|
func toOverlayStatus() -> BroadcastOverlayStatus {
|
||||||
|
switch self {
|
||||||
|
case .live: return .live
|
||||||
|
case .paused: return .paused
|
||||||
|
case .connecting, .reconnecting, .error: return .connecting
|
||||||
|
case .preview, .idle: return .preview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
native/ios/MatchLiveTv/Streaming/Overlay/OverlayRenderer.swift
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import UIKit
|
||||||
|
import HaishinKit
|
||||||
|
|
||||||
|
final class OverlayRenderer: @unchecked Sendable {
|
||||||
|
private let canvasRenderer = OverlayCanvasRenderer()
|
||||||
|
private var streamWidth = 0
|
||||||
|
private var streamHeight = 0
|
||||||
|
private var isPortraitContent = false
|
||||||
|
private var attached = false
|
||||||
|
private var lastFingerprint = Int.min
|
||||||
|
private var pendingState: OverlayState?
|
||||||
|
private weak var mixer: MediaMixer?
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var screenTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
@ScreenActor private var overlayObject: ImageScreenObject?
|
||||||
|
|
||||||
|
func attach(mixer: MediaMixer, width: Int, height: Int, isPortrait: Bool = false) {
|
||||||
|
lock.lock()
|
||||||
|
self.mixer = mixer
|
||||||
|
streamWidth = width
|
||||||
|
streamHeight = height
|
||||||
|
isPortraitContent = isPortrait
|
||||||
|
canvasRenderer.setCanvasSize(width: width, height: height)
|
||||||
|
attached = true
|
||||||
|
lastFingerprint = Int.min
|
||||||
|
let state = pendingState
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
enqueueScreen {
|
||||||
|
self.overlayObject = nil
|
||||||
|
}
|
||||||
|
if let state { pushState(state) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(state: OverlayState, isPortrait: Bool = false) {
|
||||||
|
lock.lock()
|
||||||
|
isPortraitContent = isPortrait
|
||||||
|
pendingState = state
|
||||||
|
let isAttached = attached
|
||||||
|
lock.unlock()
|
||||||
|
guard isAttached else { return }
|
||||||
|
pushState(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshOrientation(isPortrait: Bool) {
|
||||||
|
lock.lock()
|
||||||
|
isPortraitContent = isPortrait
|
||||||
|
lastFingerprint = Int.min
|
||||||
|
let state = pendingState
|
||||||
|
let isAttached = attached
|
||||||
|
lock.unlock()
|
||||||
|
guard isAttached, let state else { return }
|
||||||
|
pushState(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detach() {
|
||||||
|
lock.lock()
|
||||||
|
attached = false
|
||||||
|
lastFingerprint = Int.min
|
||||||
|
pendingState = nil
|
||||||
|
let mixerRef = mixer
|
||||||
|
mixer = nil
|
||||||
|
lock.unlock()
|
||||||
|
canvasRenderer.release()
|
||||||
|
|
||||||
|
enqueueScreen {
|
||||||
|
if let overlay = self.overlayObject {
|
||||||
|
mixerRef?.screen.removeChild(overlay)
|
||||||
|
self.overlayObject = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pushState(_ state: OverlayState) {
|
||||||
|
let fp = state.fingerprint()
|
||||||
|
lock.lock()
|
||||||
|
if fp == lastFingerprint {
|
||||||
|
lock.unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastFingerprint = fp
|
||||||
|
let portrait = isPortraitContent
|
||||||
|
let width = streamWidth
|
||||||
|
let height = streamHeight
|
||||||
|
guard let mixer else {
|
||||||
|
lock.unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
guard let cgImage = canvasRenderer.render(state: state, isPortraitContent: portrait) else { return }
|
||||||
|
guard cgImage.width == width, cgImage.height == height else { return }
|
||||||
|
|
||||||
|
enqueueScreen {
|
||||||
|
let size = CGSize(width: cgImage.width, height: cgImage.height)
|
||||||
|
if self.overlayObject == nil {
|
||||||
|
let overlay = ImageScreenObject()
|
||||||
|
overlay.horizontalAlignment = .left
|
||||||
|
overlay.verticalAlignment = .top
|
||||||
|
overlay.size = size
|
||||||
|
do {
|
||||||
|
try mixer.screen.addChild(overlay)
|
||||||
|
self.overlayObject = overlay
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let overlay = self.overlayObject else { return }
|
||||||
|
overlay.size = size
|
||||||
|
overlay.cgImage = cgImage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enqueueScreen(_ operation: @escaping @ScreenActor () async -> Void) {
|
||||||
|
let previous = screenTask
|
||||||
|
screenTask = Task {
|
||||||
|
await previous?.value
|
||||||
|
await operation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
129
native/ios/MatchLiveTv/Streaming/Overlay/OverlayState.swift
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct ScoreboardSetColumn: Equatable {
|
||||||
|
let setNumber: Int
|
||||||
|
let homePoints: Int
|
||||||
|
let awayPoints: Int
|
||||||
|
let isCurrent: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScoreboardState: Equatable {
|
||||||
|
let homeTeamName: String
|
||||||
|
let awayTeamName: String
|
||||||
|
let columns: [ScoreboardSetColumn]
|
||||||
|
var homeAccentColor: UIColor = UIColor(red: 1, green: 0.176, blue: 0.176, alpha: 1)
|
||||||
|
var awayAccentColor: UIColor = UIColor(red: 0.118, green: 0.227, blue: 0.541, alpha: 1)
|
||||||
|
var homeLogoUrl: String?
|
||||||
|
var awayLogoUrl: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CompactScoreboardState: Equatable {
|
||||||
|
let homeTeamName: String
|
||||||
|
let awayTeamName: String
|
||||||
|
let homeScore: Int
|
||||||
|
let awayScore: Int
|
||||||
|
var periodLabel: String?
|
||||||
|
var homeAccentColor: UIColor = UIColor(red: 1, green: 0.176, blue: 0.176, alpha: 1)
|
||||||
|
var awayAccentColor: UIColor = UIColor(red: 0.118, green: 0.227, blue: 0.541, alpha: 1)
|
||||||
|
var homeLogoUrl: String?
|
||||||
|
var awayLogoUrl: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OverlayKind: String, Equatable {
|
||||||
|
case none, volley, basket, timed, racket
|
||||||
|
|
||||||
|
static func fromApi(_ value: String?) -> OverlayKind {
|
||||||
|
switch value?.lowercased() {
|
||||||
|
case "none", "timer": return .none
|
||||||
|
case "basket": return .basket
|
||||||
|
case "timed": return .timed
|
||||||
|
case "racket": return .racket
|
||||||
|
default: return .volley
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BroadcastOverlayStatus: Equatable {
|
||||||
|
case preview, connecting, live, paused
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OverlayState: Equatable {
|
||||||
|
var overlayKind: OverlayKind = .volley
|
||||||
|
var scoreboard: ScoreboardState?
|
||||||
|
var compactScoreboard: CompactScoreboardState?
|
||||||
|
var watermarkVisible: Bool = true
|
||||||
|
var broadcastStatus: BroadcastOverlayStatus = .live
|
||||||
|
var sponsorText: String?
|
||||||
|
|
||||||
|
func fingerprint() -> Int {
|
||||||
|
var result = overlayKind.hashValue
|
||||||
|
result = mixHash(result, watermarkVisible.hashValue)
|
||||||
|
result = mixHash(result, broadcastStatus.hashValue)
|
||||||
|
result = mixHash(result, sponsorText?.hashValue ?? 0)
|
||||||
|
result = mixHash(result, scoreboard?.fingerprint() ?? 0)
|
||||||
|
result = mixHash(result, compactScoreboard?.fingerprint() ?? 0)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OverlayLayout {
|
||||||
|
let canvasWidth: Int
|
||||||
|
let canvasHeight: Int
|
||||||
|
let marginPx: Int
|
||||||
|
let isPortraitContent: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol OverlayElement {
|
||||||
|
func draw(in context: CGContext, state: OverlayState, layout: OverlayLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ScoreboardState {
|
||||||
|
func fingerprint() -> Int {
|
||||||
|
var result = homeTeamName.hashValue
|
||||||
|
result = mixHash(result, awayTeamName.hashValue)
|
||||||
|
result = mixHash(result, colorFingerprint(homeAccentColor))
|
||||||
|
result = mixHash(result, colorFingerprint(awayAccentColor))
|
||||||
|
result = mixHash(result, homeLogoUrl?.hashValue ?? 0)
|
||||||
|
result = mixHash(result, awayLogoUrl?.hashValue ?? 0)
|
||||||
|
result = mixHash(result, logoLoadedKey(homeLogoUrl))
|
||||||
|
result = mixHash(result, logoLoadedKey(awayLogoUrl))
|
||||||
|
for col in columns {
|
||||||
|
result = mixHash(result, col.setNumber)
|
||||||
|
result = mixHash(result, col.homePoints)
|
||||||
|
result = mixHash(result, col.awayPoints)
|
||||||
|
result = mixHash(result, col.isCurrent.hashValue)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension CompactScoreboardState {
|
||||||
|
func fingerprint() -> Int {
|
||||||
|
var result = homeTeamName.hashValue
|
||||||
|
result = mixHash(result, awayTeamName.hashValue)
|
||||||
|
result = mixHash(result, homeScore)
|
||||||
|
result = mixHash(result, awayScore)
|
||||||
|
result = mixHash(result, colorFingerprint(homeAccentColor))
|
||||||
|
result = mixHash(result, colorFingerprint(awayAccentColor))
|
||||||
|
result = mixHash(result, periodLabel?.hashValue ?? 0)
|
||||||
|
result = mixHash(result, logoLoadedKey(homeLogoUrl))
|
||||||
|
result = mixHash(result, logoLoadedKey(awayLogoUrl))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kotlin `Int` wraps on overflow; Swift debug traps unless we use wrapping ops.
|
||||||
|
private func mixHash(_ hash: Int, _ value: Int) -> Int {
|
||||||
|
31 &* hash &+ value
|
||||||
|
}
|
||||||
|
|
||||||
|
private func logoLoadedKey(_ url: String?) -> Int {
|
||||||
|
OverlayLogoCache.get(url) != nil ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func colorFingerprint(_ color: UIColor) -> Int {
|
||||||
|
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||||
|
color.getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||||
|
return Int(r * 255) << 16 | Int(g * 255) << 8 | Int(b * 255)
|
||||||
|
}
|
||||||
185
native/ios/MatchLiveTv/Streaming/Overlay/ScoreboardElement.swift
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct ScoreboardElement: OverlayElement {
|
||||||
|
private static let sizeBoost: CGFloat = 1.1
|
||||||
|
|
||||||
|
func draw(in context: CGContext, state: OverlayState, layout: OverlayLayout) {
|
||||||
|
guard state.overlayKind == .volley || state.overlayKind == .racket,
|
||||||
|
let board = state.scoreboard, !board.columns.isEmpty else { return }
|
||||||
|
|
||||||
|
let scale = CGFloat(layout.canvasHeight) / 720 * Self.sizeBoost
|
||||||
|
let pad = max(6, Int((8 * scale).rounded()))
|
||||||
|
let colorBarW = max(4, Int((5 * scale).rounded()))
|
||||||
|
let logoSize = max(16, Int((24 * scale).rounded()))
|
||||||
|
let logoGap = max(3, Int((4 * scale).rounded()))
|
||||||
|
let teamW = Int((158 * scale).rounded())
|
||||||
|
let colW = max(22, Int((28 * scale).rounded()))
|
||||||
|
let brandH = max(14, Int((18 * scale).rounded()))
|
||||||
|
let headH = max(12, Int((16 * scale).rounded()))
|
||||||
|
let rowH = max(18, Int((22 * scale).rounded()))
|
||||||
|
let footerH = max(14, Int((18 * scale).rounded()))
|
||||||
|
let n = board.columns.count
|
||||||
|
let logoSlotW = logoSlotWidth(board, logoSize: logoSize, logoGap: logoGap)
|
||||||
|
let boxW = pad * 2 + colorBarW + logoSlotW + teamW + n * colW
|
||||||
|
let boxH = pad * 2 + brandH + headH + rowH * 2 + footerH
|
||||||
|
let left = CGFloat(layout.marginPx)
|
||||||
|
let top = CGFloat(layout.marginPx)
|
||||||
|
|
||||||
|
let bgPath = UIBezierPath(roundedRect: CGRect(x: left, y: top, width: CGFloat(boxW), height: CGFloat(boxH)), cornerRadius: 6 * scale)
|
||||||
|
context.saveGState()
|
||||||
|
context.setFillColor(UIColor(white: 0.047, alpha: 0.82).cgColor)
|
||||||
|
context.addPath(bgPath.cgPath)
|
||||||
|
context.fillPath()
|
||||||
|
context.restoreGState()
|
||||||
|
|
||||||
|
drawBrandHeader(context: context, left: left, top: top, pad: pad, scale: scale, brandH: brandH)
|
||||||
|
|
||||||
|
let headerSize = max(8, 10 * scale)
|
||||||
|
let headerFont = UIFont.boldSystemFont(ofSize: headerSize)
|
||||||
|
let headerAttrs: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: headerFont,
|
||||||
|
.foregroundColor: UIColor(white: 0.53, alpha: 1),
|
||||||
|
]
|
||||||
|
let scoresLeft = left + CGFloat(pad + colorBarW + logoSlotW + teamW)
|
||||||
|
let headerBaseline = top + CGFloat(pad + brandH + headH) - 2 * scale
|
||||||
|
for (index, column) in board.columns.enumerated() {
|
||||||
|
let cx = scoresLeft + CGFloat(index * colW + colW / 2)
|
||||||
|
let text = "\(column.setNumber)" as NSString
|
||||||
|
let size = text.size(withAttributes: headerAttrs)
|
||||||
|
text.draw(at: CGPoint(x: cx - size.width / 2, y: headerBaseline - size.height), withAttributes: headerAttrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
let homeRowTop = top + CGFloat(pad + brandH + headH)
|
||||||
|
let awayRowTop = homeRowTop + CGFloat(rowH)
|
||||||
|
drawTeamRow(context: context, board: board, isHome: true, rowTop: homeRowTop, left: left, pad: pad, colorBarW: colorBarW, logoSize: logoSize, logoGap: logoGap, logoSlotW: logoSlotW, teamW: teamW, colW: colW, rowH: rowH, scoresLeft: scoresLeft, scale: scale)
|
||||||
|
drawTeamRow(context: context, board: board, isHome: false, rowTop: awayRowTop, left: left, pad: pad, colorBarW: colorBarW, logoSize: logoSize, logoGap: logoGap, logoSlotW: logoSlotW, teamW: teamW, colW: colW, rowH: rowH, scoresLeft: scoresLeft, scale: scale)
|
||||||
|
|
||||||
|
let currentSet = board.columns.last(where: { $0.isCurrent })?.setNumber ?? board.columns.count
|
||||||
|
let footerText = ordinalSetLabel(currentSet)
|
||||||
|
let footerSize = max(8, 10 * scale)
|
||||||
|
let footerAttrs: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: UIFont.systemFont(ofSize: footerSize),
|
||||||
|
.foregroundColor: UIColor(white: 0.8, alpha: 1),
|
||||||
|
]
|
||||||
|
let footerBaseline: CGFloat = top + CGFloat(boxH - pad) - 2 * scale
|
||||||
|
let footerX: CGFloat = left + CGFloat(pad + colorBarW) + 4 * scale
|
||||||
|
let footerY: CGFloat = footerBaseline - footerSize
|
||||||
|
let footerPoint = CGPoint(x: footerX, y: footerY)
|
||||||
|
(footerText as NSString).draw(at: footerPoint, withAttributes: footerAttrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func logoSlotWidth(_ board: ScoreboardState, logoSize: Int, logoGap: Int) -> Int {
|
||||||
|
let needs = !(board.homeLogoUrl?.isEmpty ?? true) || !(board.awayLogoUrl?.isEmpty ?? true)
|
||||||
|
return needs ? logoSize + logoGap : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawBrandHeader(context: CGContext, left: CGFloat, top: CGFloat, pad: Int, scale: CGFloat, brandH: Int) {
|
||||||
|
let textSize = max(7, 9 * scale)
|
||||||
|
let matchFont = UIFont.boldSystemFont(ofSize: textSize)
|
||||||
|
let tvFont = UIFont.boldSystemFont(ofSize: textSize)
|
||||||
|
let liveFont = UIFont.boldSystemFont(ofSize: textSize * 0.88)
|
||||||
|
var x = left + CGFloat(pad)
|
||||||
|
let baseline = top + CGFloat(pad) + CGFloat(brandH) * 0.72
|
||||||
|
("MATCH" as NSString).draw(at: CGPoint(x: x, y: baseline - textSize), withAttributes: [.font: matchFont, .foregroundColor: UIColor.white])
|
||||||
|
x += ("MATCH" as NSString).size(withAttributes: [.font: matchFont]).width + 4 * scale
|
||||||
|
let liveText = "LIVE"
|
||||||
|
let livePadH = 4 * scale
|
||||||
|
let liveW = (liveText as NSString).size(withAttributes: [.font: liveFont]).width + livePadH * 2
|
||||||
|
let liveH = textSize + 4 * scale
|
||||||
|
let liveTop = baseline - textSize - 2 * scale
|
||||||
|
let liveRect = CGRect(x: x, y: liveTop, width: liveW, height: liveH)
|
||||||
|
context.setFillColor(UIColor(red: 1, green: 0.176, blue: 0.176, alpha: 1).cgColor)
|
||||||
|
context.addPath(UIBezierPath(roundedRect: liveRect, cornerRadius: 3 * scale).cgPath)
|
||||||
|
context.fillPath()
|
||||||
|
(liveText as NSString).draw(at: CGPoint(x: x + livePadH, y: baseline - textSize), withAttributes: [.font: liveFont, .foregroundColor: UIColor.white])
|
||||||
|
x += liveW + 4 * scale
|
||||||
|
("TV" as NSString).draw(at: CGPoint(x: x, y: baseline - textSize), withAttributes: [.font: tvFont, .foregroundColor: UIColor(red: 0.612, green: 0.639, blue: 0.686, alpha: 1)])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawTeamRow(
|
||||||
|
context: CGContext,
|
||||||
|
board: ScoreboardState,
|
||||||
|
isHome: Bool,
|
||||||
|
rowTop: CGFloat,
|
||||||
|
left: CGFloat,
|
||||||
|
pad: Int,
|
||||||
|
colorBarW: Int,
|
||||||
|
logoSize: Int,
|
||||||
|
logoGap: Int,
|
||||||
|
logoSlotW: Int,
|
||||||
|
teamW: Int,
|
||||||
|
colW: Int,
|
||||||
|
rowH: Int,
|
||||||
|
scoresLeft: CGFloat,
|
||||||
|
scale: CGFloat
|
||||||
|
) {
|
||||||
|
let accent = isHome ? board.homeAccentColor : board.awayAccentColor
|
||||||
|
let barLeft = left + CGFloat(pad)
|
||||||
|
let barTop = rowTop + 2 * scale
|
||||||
|
let barBottom = rowTop + CGFloat(rowH) - 2 * scale
|
||||||
|
context.setFillColor(accent.cgColor)
|
||||||
|
let barRect = CGRect(x: barLeft, y: barTop, width: CGFloat(colorBarW), height: barBottom - barTop)
|
||||||
|
context.addRect(barRect)
|
||||||
|
context.fillPath()
|
||||||
|
|
||||||
|
let logoUrl = isHome ? board.homeLogoUrl : board.awayLogoUrl
|
||||||
|
let logo = OverlayLogoCache.get(logoUrl)
|
||||||
|
var nameLeft = barLeft + CGFloat(colorBarW + logoGap)
|
||||||
|
if logoSlotW > 0 {
|
||||||
|
if let logo, let cg = logo.cgImage {
|
||||||
|
let logoLeft = nameLeft
|
||||||
|
let logoTop = rowTop + (CGFloat(rowH) - CGFloat(logoSize)) / 2
|
||||||
|
context.draw(cg, in: CGRect(x: logoLeft, y: logoTop, width: CGFloat(logoSize), height: CGFloat(logoSize)))
|
||||||
|
}
|
||||||
|
nameLeft += CGFloat(logoSize + logoGap)
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawName = (isHome ? board.homeTeamName : board.awayTeamName).trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||||
|
let teamSize = max(9, 11 * scale)
|
||||||
|
let teamFont = UIFont.boldSystemFont(ofSize: teamSize)
|
||||||
|
let name = ellipsize(rawName, font: teamFont, maxWidth: CGFloat(teamW) - 6 * scale)
|
||||||
|
let nameBaseline = rowTop + CGFloat(rowH) * 0.72
|
||||||
|
(name as NSString).draw(at: CGPoint(x: nameLeft, y: nameBaseline - teamSize), withAttributes: [.font: teamFont, .foregroundColor: UIColor.white])
|
||||||
|
|
||||||
|
let scoreSize = max(10, 13 * scale)
|
||||||
|
let scoreFont = UIFont.boldSystemFont(ofSize: scoreSize)
|
||||||
|
let winColor = UIColor(red: 1, green: 0.176, blue: 0.176, alpha: 1)
|
||||||
|
for (index, column) in board.columns.enumerated() {
|
||||||
|
let cx = scoresLeft + CGFloat(index * colW + colW / 2)
|
||||||
|
let score = isHome ? column.homePoints : column.awayPoints
|
||||||
|
let color: UIColor
|
||||||
|
if column.isCurrent {
|
||||||
|
color = .white
|
||||||
|
} else {
|
||||||
|
let homeWon = column.homePoints > column.awayPoints
|
||||||
|
let awayWon = column.awayPoints > column.homePoints
|
||||||
|
color = (isHome && homeWon) || (!isHome && awayWon) ? winColor : .white
|
||||||
|
}
|
||||||
|
let text = "\(score)" as NSString
|
||||||
|
let size = text.size(withAttributes: [.font: scoreFont])
|
||||||
|
text.draw(at: CGPoint(x: cx - size.width / 2, y: nameBaseline - scoreSize), withAttributes: [.font: scoreFont, .foregroundColor: color])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ellipsize(_ text: String, font: UIFont, maxWidth: CGFloat) -> String {
|
||||||
|
let safeMax = max(48, maxWidth)
|
||||||
|
let attrs: [NSAttributedString.Key: Any] = [.font: font]
|
||||||
|
if (text as NSString).size(withAttributes: attrs).width <= safeMax { return text }
|
||||||
|
var result = text
|
||||||
|
while result.count > 1 && (result as NSString).size(withAttributes: attrs).width > safeMax {
|
||||||
|
result = String(result.dropLast())
|
||||||
|
}
|
||||||
|
return result + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ordinalSetLabel(_ setNumber: Int) -> String {
|
||||||
|
switch setNumber {
|
||||||
|
case 1: return "1° set"
|
||||||
|
case 2: return "2° set"
|
||||||
|
case 3: return "3° set"
|
||||||
|
case 4: return "4° set"
|
||||||
|
case 5: return "5° set"
|
||||||
|
default: return "Set \(setNumber)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct SponsorElement: OverlayElement {
|
||||||
|
func draw(in context: CGContext, state: OverlayState, layout: OverlayLayout) {
|
||||||
|
guard let text = state.sponsorText?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return }
|
||||||
|
let fontSize = max(12, CGFloat(layout.canvasHeight) * 0.045)
|
||||||
|
let font = UIFont.boldSystemFont(ofSize: fontSize)
|
||||||
|
let attrs: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: font,
|
||||||
|
.foregroundColor: UIColor(red: 0.961, green: 0.773, blue: 0.094, alpha: 0.86),
|
||||||
|
]
|
||||||
|
let size = (text as NSString).size(withAttributes: attrs)
|
||||||
|
let x = (CGFloat(layout.canvasWidth) - size.width) / 2
|
||||||
|
let y = CGFloat(layout.marginPx) * 2.2
|
||||||
|
(text as NSString).draw(at: CGPoint(x: x, y: y), withAttributes: attrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct WatermarkElement: OverlayElement {
|
||||||
|
private let logo = UIImage(named: "logo-white-m-256")
|
||||||
|
|
||||||
|
func draw(in context: CGContext, state: OverlayState, layout: OverlayLayout) {
|
||||||
|
guard state.overlayKind != .none, state.watermarkVisible, let logo, let cg = logo.cgImage else { return }
|
||||||
|
let targetWidth = min(72, max(28, Int((CGFloat(layout.canvasWidth) * 0.055).rounded())))
|
||||||
|
let scale = CGFloat(targetWidth) / logo.size.width
|
||||||
|
let targetHeight = Int(logo.size.height * scale)
|
||||||
|
let left = layout.canvasWidth - layout.marginPx - targetWidth
|
||||||
|
let top = layout.canvasHeight - layout.marginPx - targetHeight
|
||||||
|
context.draw(cg, in: CGRect(x: left, y: top, width: targetWidth, height: targetHeight))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import HaishinKit
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct LivePreviewView: UIViewRepresentable {
|
||||||
|
let engine: LiveBroadcastEngine
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> MTHKView {
|
||||||
|
let view = MTHKView(frame: .zero)
|
||||||
|
view.videoGravity = .resizeAspectFill
|
||||||
|
Task { await engine.bindPreview(to: view) }
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: MTHKView, context: Context) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
private let sideToolbarWidth: CGFloat = 44
|
||||||
|
private let iconButtonSize: CGFloat = 36
|
||||||
|
private let scoreButtonHeight: CGFloat = 32
|
||||||
|
|
||||||
|
struct BroadcastControlsOverlay: View {
|
||||||
|
let controlsVisible: Bool
|
||||||
|
let onToggleControls: () -> Void
|
||||||
|
let statusText: String
|
||||||
|
let statusColor: Color
|
||||||
|
let cableConnected: Bool
|
||||||
|
let isPaused: Bool
|
||||||
|
let homeName: String
|
||||||
|
let awayName: String
|
||||||
|
let homeAccentColor: Color
|
||||||
|
let awayAccentColor: Color
|
||||||
|
let homeLogoUrl: String?
|
||||||
|
let awayLogoUrl: String?
|
||||||
|
let score: ScoreState
|
||||||
|
let boardType: String
|
||||||
|
let pointsTarget: Int
|
||||||
|
let onPointHome: () -> Void
|
||||||
|
let onPointAway: () -> Void
|
||||||
|
let onMinusHome: () -> Void
|
||||||
|
let onMinusAway: () -> Void
|
||||||
|
var onPoint2Home: (() -> Void)?
|
||||||
|
var onPoint3Home: (() -> Void)?
|
||||||
|
var onPoint2Away: (() -> Void)?
|
||||||
|
var onPoint3Away: (() -> Void)?
|
||||||
|
var onCloseSet: (() -> Void)?
|
||||||
|
var onAdvancePeriod: (() -> Void)?
|
||||||
|
let onPauseOrResume: () -> Void
|
||||||
|
let onTerminate: () -> Void
|
||||||
|
let onShareLive: () -> Void
|
||||||
|
let onShareRegia: () -> Void
|
||||||
|
let shareLiveEnabled: Bool
|
||||||
|
let fps: Int
|
||||||
|
let targetFps: Int
|
||||||
|
let bitrateKbps: Int
|
||||||
|
let networkType: String
|
||||||
|
let deviceHealth: DeviceHealth
|
||||||
|
|
||||||
|
@State private var showTerminateConfirm = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Color.clear
|
||||||
|
.overlay(alignment: .topLeading) {
|
||||||
|
MatchStatusBadge(
|
||||||
|
text: statusText,
|
||||||
|
backgroundColor: MatchColors.background.opacity(0.78),
|
||||||
|
textColor: statusColor
|
||||||
|
)
|
||||||
|
.padding(.leading, 8)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
VStack(alignment: .trailing, spacing: 6) {
|
||||||
|
SideIconButton(
|
||||||
|
systemName: controlsVisible ? "eye.slash.fill" : "eye.fill",
|
||||||
|
accessibilityLabel: controlsVisible ? "Nascondi controlli" : "Mostra controlli",
|
||||||
|
action: onToggleControls
|
||||||
|
)
|
||||||
|
BroadcastTelemetryPanel(
|
||||||
|
cableConnected: cableConnected,
|
||||||
|
fps: fps,
|
||||||
|
targetFps: targetFps,
|
||||||
|
bitrateKbps: bitrateKbps,
|
||||||
|
networkType: networkType,
|
||||||
|
deviceHealth: deviceHealth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
.overlay(alignment: .leading) {
|
||||||
|
if controlsVisible {
|
||||||
|
leftToolbar
|
||||||
|
.padding(.leading, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .trailing) {
|
||||||
|
if controlsVisible {
|
||||||
|
rightToolbar
|
||||||
|
.padding(.trailing, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if controlsVisible {
|
||||||
|
scoreControlsRow
|
||||||
|
.padding(.horizontal, sideToolbarWidth)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Terminare la diretta?", isPresented: $showTerminateConfirm) {
|
||||||
|
Button("Annulla", role: .cancel) {}
|
||||||
|
Button("TERMINA", role: .destructive, action: onTerminate)
|
||||||
|
} message: {
|
||||||
|
Text("Lo streaming verrà chiuso per tutti gli spettatori.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var leftToolbar: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
SideIconButton(
|
||||||
|
systemName: "square.and.arrow.up",
|
||||||
|
accessibilityLabel: "Condividi diretta",
|
||||||
|
action: onShareLive,
|
||||||
|
enabled: shareLiveEnabled
|
||||||
|
)
|
||||||
|
SideIconButton(
|
||||||
|
systemName: "video.fill",
|
||||||
|
accessibilityLabel: "Condividi link regia",
|
||||||
|
action: onShareRegia
|
||||||
|
)
|
||||||
|
if let onCloseSet {
|
||||||
|
SideIconButton(
|
||||||
|
systemName: "checkmark",
|
||||||
|
accessibilityLabel: "Chiudi set",
|
||||||
|
action: onCloseSet
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if let onAdvancePeriod {
|
||||||
|
SideIconButton(
|
||||||
|
systemName: "forward.end.fill",
|
||||||
|
accessibilityLabel: "Periodo successivo",
|
||||||
|
action: onAdvancePeriod
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rightToolbar: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
SideIconButton(
|
||||||
|
systemName: isPaused ? "play.fill" : "pause.fill",
|
||||||
|
accessibilityLabel: isPaused ? "Riprendi diretta" : "Pausa diretta",
|
||||||
|
action: onPauseOrResume,
|
||||||
|
highlighted: isPaused
|
||||||
|
)
|
||||||
|
SideIconButton(
|
||||||
|
systemName: "stop.fill",
|
||||||
|
accessibilityLabel: "Termina diretta",
|
||||||
|
action: { showTerminateConfirm = true },
|
||||||
|
danger: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scoreControlsRow: some View {
|
||||||
|
HStack(alignment: .bottom, spacing: 0) {
|
||||||
|
TeamScoreColumn(
|
||||||
|
teamLabel: "CASA",
|
||||||
|
teamName: homeName,
|
||||||
|
accentColor: homeAccentColor,
|
||||||
|
logoUrl: homeLogoUrl,
|
||||||
|
points: score.homePoints,
|
||||||
|
onPlus: onPointHome,
|
||||||
|
onMinus: onMinusHome,
|
||||||
|
onPlus2: onPoint2Home,
|
||||||
|
onPlus3: onPoint3Home,
|
||||||
|
showBasketButtons: boardType == "basket",
|
||||||
|
alignEnd: false
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
ScoreCenterPanel(score: score, boardType: boardType, pointsTarget: pointsTarget)
|
||||||
|
|
||||||
|
TeamScoreColumn(
|
||||||
|
teamLabel: "OSPITE",
|
||||||
|
teamName: awayName,
|
||||||
|
accentColor: awayAccentColor,
|
||||||
|
logoUrl: awayLogoUrl,
|
||||||
|
points: score.awayPoints,
|
||||||
|
onPlus: onPointAway,
|
||||||
|
onMinus: onMinusAway,
|
||||||
|
onPlus2: onPoint2Away,
|
||||||
|
onPlus3: onPoint3Away,
|
||||||
|
showBasketButtons: boardType == "basket",
|
||||||
|
alignEnd: true
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct BroadcastTelemetryPanel: View {
|
||||||
|
let cableConnected: Bool
|
||||||
|
let fps: Int
|
||||||
|
let targetFps: Int
|
||||||
|
let bitrateKbps: Int
|
||||||
|
let networkType: String
|
||||||
|
let deviceHealth: DeviceHealth
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text(cableConnected ? "Tabellone OK" : "Tabellone offline")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(cableConnected ? MatchColors.successGreen : MatchColors.textSecondary)
|
||||||
|
let fpsLabel = fps > 0 ? "\(fps) fps" : "— fps"
|
||||||
|
let targetSuffix = targetFps > 0 ? " / \(targetFps)" : ""
|
||||||
|
Text(fpsLabel + targetSuffix)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
if bitrateKbps > 0 {
|
||||||
|
Text(formatBitrateKbps(bitrateKbps))
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
ThermalIndicator(
|
||||||
|
tempC: deviceHealth.batteryTempC,
|
||||||
|
level: deviceHealth.thermalLevel,
|
||||||
|
label: deviceHealth.thermalLabel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(MatchColors.background.opacity(0.78), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ThermalIndicator: View {
|
||||||
|
let tempC: Double?
|
||||||
|
let level: Int
|
||||||
|
let label: String
|
||||||
|
|
||||||
|
private var color: Color {
|
||||||
|
switch level {
|
||||||
|
case 0: return MatchColors.successGreen
|
||||||
|
case 1: return MatchColors.accentYellow
|
||||||
|
case 2: return Color.orange
|
||||||
|
default: return MatchColors.primaryRed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let tempText = tempC.map { "\(Int($0))°C" } ?? "—°C"
|
||||||
|
let warningBg = level >= 1 ? color.opacity(0.18) : Color.clear
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(tempText)
|
||||||
|
.font(.system(size: 11, weight: level >= 1 ? .bold : .regular))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
if level >= 1 {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.background(warningBg, in: RoundedRectangle(cornerRadius: 4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ScoreCenterPanel: View {
|
||||||
|
let score: ScoreState
|
||||||
|
let boardType: String
|
||||||
|
let pointsTarget: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("\(score.homePoints) - \(score.awayPoints)")
|
||||||
|
.font(MatchTypography.titleMedium)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
switch boardType {
|
||||||
|
case "basket", "timed":
|
||||||
|
Text(score.periodLabel ?? (boardType == "basket" ? "Q\(score.period)" : "\(score.period)° tempo"))
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
case "generic":
|
||||||
|
EmptyView()
|
||||||
|
default:
|
||||||
|
Text("Set \(score.currentSet) · \(pointsTarget) pt")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
if score.homeSets > 0 || score.awaySets > 0 {
|
||||||
|
Text("Set vinti \(score.homeSets)-\(score.awaySets)")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TeamScoreColumn: View {
|
||||||
|
let teamLabel: String
|
||||||
|
let teamName: String
|
||||||
|
let accentColor: Color
|
||||||
|
let logoUrl: String?
|
||||||
|
let points: Int
|
||||||
|
let onPlus: () -> Void
|
||||||
|
let onMinus: () -> Void
|
||||||
|
var onPlus2: (() -> Void)?
|
||||||
|
var onPlus3: (() -> Void)?
|
||||||
|
let showBasketButtons: Bool
|
||||||
|
let alignEnd: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: alignEnd ? .trailing : .leading, spacing: 0) {
|
||||||
|
TeamIdentityRow(
|
||||||
|
teamLabel: teamLabel,
|
||||||
|
teamName: teamName,
|
||||||
|
accentColor: accentColor,
|
||||||
|
logoUrl: logoUrl,
|
||||||
|
alignEnd: alignEnd
|
||||||
|
)
|
||||||
|
Spacer().frame(height: 6)
|
||||||
|
Text("\(points)")
|
||||||
|
.font(MatchTypography.headlineMedium)
|
||||||
|
.foregroundStyle(accentColor)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
Spacer().frame(height: 4)
|
||||||
|
let teamSide = alignEnd ? "ospite" : "casa"
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if alignEnd {
|
||||||
|
if showBasketButtons {
|
||||||
|
if let onPlus3 {
|
||||||
|
ScoreIconButton(label: "+3", tooltip: "+3 \(teamSide)", action: onPlus3, primary: true)
|
||||||
|
}
|
||||||
|
if let onPlus2 {
|
||||||
|
ScoreIconButton(label: "+2", tooltip: "+2 \(teamSide)", action: onPlus2, primary: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ScoreIconButton(label: "+1", tooltip: "Aggiungi punto \(teamSide)", action: onPlus, primary: !showBasketButtons)
|
||||||
|
ScoreIconButton(label: "−", tooltip: "Togli punto \(teamSide)", action: onMinus)
|
||||||
|
} else {
|
||||||
|
ScoreIconButton(label: "−", tooltip: "Togli punto \(teamSide)", action: onMinus)
|
||||||
|
ScoreIconButton(label: "+1", tooltip: "Aggiungi punto \(teamSide)", action: onPlus, primary: !showBasketButtons)
|
||||||
|
if showBasketButtons {
|
||||||
|
if let onPlus2 {
|
||||||
|
ScoreIconButton(label: "+2", tooltip: "+2 \(teamSide)", action: onPlus2, primary: true)
|
||||||
|
}
|
||||||
|
if let onPlus3 {
|
||||||
|
ScoreIconButton(label: "+3", tooltip: "+3 \(teamSide)", action: onPlus3, primary: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: alignEnd ? .trailing : .leading)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(MatchColors.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TeamIdentityRow: View {
|
||||||
|
let teamLabel: String
|
||||||
|
let teamName: String
|
||||||
|
let accentColor: Color
|
||||||
|
let logoUrl: String?
|
||||||
|
let alignEnd: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let resolvedLogoUrl = MediaUrl.resolve(logoUrl)
|
||||||
|
HStack(alignment: .center, spacing: 0) {
|
||||||
|
if alignEnd {
|
||||||
|
VStack(alignment: .trailing, spacing: 0) {
|
||||||
|
Text(teamLabel)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.kerning(1)
|
||||||
|
Text(teamName)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
if let resolvedLogoUrl, let url = URL(string: resolvedLogoUrl) {
|
||||||
|
Spacer().frame(width: 8)
|
||||||
|
TeamLogoThumbnail(url: url)
|
||||||
|
}
|
||||||
|
Spacer().frame(width: 6)
|
||||||
|
TeamColorBar(color: accentColor)
|
||||||
|
} else {
|
||||||
|
TeamColorBar(color: accentColor)
|
||||||
|
if let resolvedLogoUrl, let url = URL(string: resolvedLogoUrl) {
|
||||||
|
Spacer().frame(width: 6)
|
||||||
|
TeamLogoThumbnail(url: url)
|
||||||
|
}
|
||||||
|
Spacer().frame(width: 8)
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(teamLabel)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.kerning(1)
|
||||||
|
Text(teamName)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TeamColorBar: View {
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 4, height: 36)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TeamLogoThumbnail: View {
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let image):
|
||||||
|
image.resizable().scaledToFill()
|
||||||
|
default:
|
||||||
|
Color.clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 6).stroke(MatchColors.outline, lineWidth: 1))
|
||||||
|
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SideIconButton: View {
|
||||||
|
let systemName: String
|
||||||
|
let accessibilityLabel: String
|
||||||
|
let action: () -> Void
|
||||||
|
var enabled: Bool = true
|
||||||
|
var highlighted: Bool = false
|
||||||
|
var danger: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: systemName)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(enabled ? .white : MatchColors.textSecondary)
|
||||||
|
.frame(width: iconButtonSize, height: iconButtonSize)
|
||||||
|
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!enabled)
|
||||||
|
.accessibilityLabel(accessibilityLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
if danger { return MatchColors.primaryRed.opacity(0.88) }
|
||||||
|
if highlighted { return MatchColors.successGreen.opacity(0.55) }
|
||||||
|
return MatchColors.background.opacity(0.78)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ScoreIconButton: View {
|
||||||
|
let label: String
|
||||||
|
let tooltip: String
|
||||||
|
let action: () -> Void
|
||||||
|
var primary: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: label == "+1" ? 40 : scoreButtonHeight, height: scoreButtonHeight)
|
||||||
|
.background(primary ? MatchColors.primaryRed : MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(tooltip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatBitrateKbps(_ kbps: Int) -> String {
|
||||||
|
if kbps >= 1000 {
|
||||||
|
return String(format: "%.1f Mbps", Double(kbps) / 1000.0)
|
||||||
|
}
|
||||||
|
return "\(kbps) kbps"
|
||||||
|
}
|
||||||
448
native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct BroadcastScreen: View {
|
||||||
|
@ObservedObject var container: AppContainer
|
||||||
|
@ObservedObject private var scoreController: ScoreController
|
||||||
|
@ObservedObject private var broadcastCoordinator: LiveBroadcastCoordinator
|
||||||
|
let sessionId: String
|
||||||
|
let onFinished: () -> Void
|
||||||
|
|
||||||
|
@StateObject private var permissions = BroadcastPermissions()
|
||||||
|
@StateObject private var scoreDialogHost = LiveScoreDialogHost()
|
||||||
|
|
||||||
|
init(container: AppContainer, sessionId: String, onFinished: @escaping () -> Void) {
|
||||||
|
self.container = container
|
||||||
|
self.sessionId = sessionId
|
||||||
|
self.onFinished = onFinished
|
||||||
|
_scoreController = ObservedObject(wrappedValue: container.scoreController)
|
||||||
|
_broadcastCoordinator = ObservedObject(wrappedValue: container.broadcastCoordinator)
|
||||||
|
}
|
||||||
|
@State private var session: StreamSession?
|
||||||
|
@State private var match: Match?
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var loading = true
|
||||||
|
@State private var controlsVisible = true
|
||||||
|
@State private var logoReady = 0
|
||||||
|
@State private var pauseInFlight = false
|
||||||
|
@State private var deviceHealth = DeviceTelemetry.snapshot()
|
||||||
|
@State private var snackbarMessage: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
if loading {
|
||||||
|
ProgressView().tint(MatchColors.primaryRed)
|
||||||
|
} else if !permissions.allGranted {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("Consenti camera e microfono per andare in diretta")
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.accentYellow)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
MatchPrimaryButton(label: "CONCEDI PERMESSI") {
|
||||||
|
Task { await permissions.requestAll() }
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
} else if let session, let match {
|
||||||
|
LivePreviewView(engine: broadcastCoordinator.engine)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
broadcastOverlay(session: session, match: match)
|
||||||
|
.zIndex(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.statusBarHidden(true)
|
||||||
|
.lockLandscapeOrientation()
|
||||||
|
.onAppear {
|
||||||
|
UIDevice.current.isBatteryMonitoringEnabled = true
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
Task { await teardown() }
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await permissions.refresh()
|
||||||
|
if !permissions.allGranted {
|
||||||
|
await permissions.requestAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: permissions.allGranted) {
|
||||||
|
guard permissions.allGranted else {
|
||||||
|
loading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await bootstrap()
|
||||||
|
}
|
||||||
|
.onChange(of: scoreController.score) { _ in updateOverlay() }
|
||||||
|
.onChange(of: broadcastCoordinator.metrics.phase) { _ in updateOverlay() }
|
||||||
|
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||||
|
Button("OK") { onFinished() }
|
||||||
|
} message: {
|
||||||
|
Text(error ?? "")
|
||||||
|
}
|
||||||
|
.background {
|
||||||
|
if let match {
|
||||||
|
ScoreDialogRouter(
|
||||||
|
host: scoreDialogHost,
|
||||||
|
homeName: match.teamName,
|
||||||
|
awayName: match.opponentName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
if let snackbarMessage {
|
||||||
|
Text(snackbarMessage)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
.padding(.top, 48)
|
||||||
|
.onAppear {
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
|
self.snackbarMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func broadcastOverlay(session: StreamSession, match: Match) -> some View {
|
||||||
|
let metrics = broadcastCoordinator.metrics
|
||||||
|
let score = scoreController.score
|
||||||
|
let isPaused = session.isPaused || metrics.phase == .paused
|
||||||
|
let statusText = broadcastStatusText(isPaused: isPaused, metrics: metrics)
|
||||||
|
let statusColor = broadcastStatusColor(isPaused: isPaused, metrics: metrics)
|
||||||
|
let boardType = match.boardType.isEmpty ? score.boardType : match.boardType
|
||||||
|
let usesActionScoring = boardType == "basket" || boardType == "timed"
|
||||||
|
let usesSetScoring = boardType == "volley" || boardType == "racket"
|
||||||
|
let pointsTarget = MatchScoringRules.pointsTarget(for: match, currentSet: score.currentSet)
|
||||||
|
let shareSubject = "\(match.teamName) vs \(match.opponentName)"
|
||||||
|
|
||||||
|
BroadcastControlsOverlay(
|
||||||
|
controlsVisible: controlsVisible,
|
||||||
|
onToggleControls: { controlsVisible.toggle() },
|
||||||
|
statusText: statusText,
|
||||||
|
statusColor: statusColor,
|
||||||
|
cableConnected: container.sessionCable.connected,
|
||||||
|
isPaused: isPaused,
|
||||||
|
homeName: match.teamName,
|
||||||
|
awayName: match.opponentName,
|
||||||
|
homeAccentColor: ColorHex.swiftUIColor(match.homePrimaryColor, fallback: Color(red: 1, green: 0.176, blue: 0.176)),
|
||||||
|
awayAccentColor: ColorHex.swiftUIColor(match.opponentPrimaryColor, fallback: Color(red: 0.118, green: 0.227, blue: 0.541)),
|
||||||
|
homeLogoUrl: match.homeLogoUrl,
|
||||||
|
awayLogoUrl: match.opponentLogoUrl,
|
||||||
|
score: score,
|
||||||
|
boardType: boardType,
|
||||||
|
pointsTarget: pointsTarget,
|
||||||
|
onPointHome: {
|
||||||
|
Task {
|
||||||
|
if usesActionScoring {
|
||||||
|
scoreController.applyAction("home_point")
|
||||||
|
} else if usesSetScoring {
|
||||||
|
await scoreController.applyBoardAction("home_point")
|
||||||
|
await liveScoreActions(for: match).afterPointChange(score: scoreController.score)
|
||||||
|
} else {
|
||||||
|
scoreController.incrementHome()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPointAway: {
|
||||||
|
Task {
|
||||||
|
if usesActionScoring {
|
||||||
|
scoreController.applyAction("away_point")
|
||||||
|
} else if usesSetScoring {
|
||||||
|
await scoreController.applyBoardAction("away_point")
|
||||||
|
await liveScoreActions(for: match).afterPointChange(score: scoreController.score)
|
||||||
|
} else {
|
||||||
|
scoreController.incrementAway()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMinusHome: {
|
||||||
|
Task {
|
||||||
|
if usesActionScoring {
|
||||||
|
scoreController.applyAction("home_undo")
|
||||||
|
} else if usesSetScoring {
|
||||||
|
await scoreController.applyBoardAction("home_undo")
|
||||||
|
} else {
|
||||||
|
scoreController.decrementHome()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMinusAway: {
|
||||||
|
Task {
|
||||||
|
if usesActionScoring {
|
||||||
|
scoreController.applyAction("away_undo")
|
||||||
|
} else if usesSetScoring {
|
||||||
|
await scoreController.applyBoardAction("away_undo")
|
||||||
|
} else {
|
||||||
|
scoreController.decrementAway()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPoint2Home: boardType == "basket" ? { scoreController.applyAction("home_point_2") } : nil,
|
||||||
|
onPoint3Home: boardType == "basket" ? { scoreController.applyAction("home_point_3") } : nil,
|
||||||
|
onPoint2Away: boardType == "basket" ? { scoreController.applyAction("away_point_2") } : nil,
|
||||||
|
onPoint3Away: boardType == "basket" ? { scoreController.applyAction("away_point_3") } : nil,
|
||||||
|
onCloseSet: usesSetScoring ? {
|
||||||
|
Task { await liveScoreActions(for: match).requestCloseSet() }
|
||||||
|
} : nil,
|
||||||
|
onAdvancePeriod: usesActionScoring ? { scoreController.applyAction("advance_period") } : nil,
|
||||||
|
onPauseOrResume: {
|
||||||
|
if isPaused {
|
||||||
|
Task { await resumeStream() }
|
||||||
|
} else {
|
||||||
|
pauseStream()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTerminate: { Task { await stopStream() } },
|
||||||
|
onShareLive: { shareLiveLink(session: session, subject: shareSubject) },
|
||||||
|
onShareRegia: { shareRegiaLink(subject: shareSubject) },
|
||||||
|
shareLiveEnabled: session.watchShareUrl() != nil,
|
||||||
|
fps: metrics.fps,
|
||||||
|
targetFps: session.targetFps,
|
||||||
|
bitrateKbps: metrics.bitrateKbps,
|
||||||
|
networkType: deviceHealth.networkType,
|
||||||
|
deviceHealth: deviceHealth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func liveScoreActions(for match: Match) -> LiveScoreActions {
|
||||||
|
LiveScoreActions(
|
||||||
|
rules: MatchScoringContext(match: match),
|
||||||
|
scoreController: scoreController,
|
||||||
|
dialogHost: scoreDialogHost,
|
||||||
|
onStopStream: { await stopStream() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func broadcastStatusText(isPaused: Bool, metrics: BroadcastMetrics) -> String {
|
||||||
|
if isPaused { return "PAUSA" }
|
||||||
|
switch metrics.phase {
|
||||||
|
case .live: return "IN DIRETTA"
|
||||||
|
case .connecting: return "CONNESSIONE…"
|
||||||
|
case .reconnecting: return "RICONNESSIONE…"
|
||||||
|
case .error: return metrics.lastError ?? "ERRORE"
|
||||||
|
default: return "PREVIEW"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func broadcastStatusColor(isPaused: Bool, metrics: BroadcastMetrics) -> Color {
|
||||||
|
if isPaused { return MatchColors.accentYellow }
|
||||||
|
switch metrics.phase {
|
||||||
|
case .live: return MatchColors.successGreen
|
||||||
|
case .error: return MatchColors.primaryRed
|
||||||
|
default: return MatchColors.accentYellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shareLiveLink(session: StreamSession, subject: String) {
|
||||||
|
guard let urlString = session.watchShareUrl(), let url = URL(string: urlString) else {
|
||||||
|
snackbarMessage = "Link diretta non ancora disponibile"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
presentShare(items: [url], subject: subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shareRegiaLink(subject: String) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let urlString = try await container.sessionRepository.createRegiaLink(sessionId: sessionId)
|
||||||
|
guard let url = URL(string: urlString) else { return }
|
||||||
|
presentShare(items: [url], subject: "Link regia — \(subject)")
|
||||||
|
} catch {
|
||||||
|
snackbarMessage = UserFacingError.message(for: error) ?? "Errore link regia"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentShare(items: [Any], subject: String) {
|
||||||
|
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
|
let root = scene.windows.first?.rootViewController else { return }
|
||||||
|
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||||
|
controller.setValue(subject, forKey: "subject")
|
||||||
|
root.present(controller, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bootstrap() async {
|
||||||
|
loading = true
|
||||||
|
error = nil
|
||||||
|
await container.broadcastCoordinator.stopBroadcast()
|
||||||
|
do {
|
||||||
|
let loaded = try await container.sessionRepository.fetchSession(id: sessionId)
|
||||||
|
let loadedMatch = try await container.matchRepository.fetchMatch(matchId: loaded.matchId)
|
||||||
|
session = loaded
|
||||||
|
match = loadedMatch
|
||||||
|
container.scoreController.bind(sessionId: sessionId, initial: loaded.score)
|
||||||
|
wireCable()
|
||||||
|
if let url = loaded.rtmpIngestUrl, !url.isEmpty {
|
||||||
|
let config = broadcastConfig(for: loaded, rtmpUrl: url)
|
||||||
|
if loaded.isPaused {
|
||||||
|
try await container.broadcastCoordinator.engine.preparePreview(config: config)
|
||||||
|
} else {
|
||||||
|
try await container.broadcastCoordinator.startBroadcast(config: config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await preloadLogos(for: loadedMatch)
|
||||||
|
startPolling()
|
||||||
|
loading = false
|
||||||
|
updateOverlay()
|
||||||
|
} catch {
|
||||||
|
if let message = UserFacingError.message(for: error) {
|
||||||
|
self.error = message
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func wireCable() {
|
||||||
|
guard let token = container.tokenStore.session?.accessToken else { return }
|
||||||
|
container.sessionCable.onScoreUpdate = { [weak container] remote in
|
||||||
|
container?.scoreController.applyRemote(remote)
|
||||||
|
}
|
||||||
|
container.sessionCable.onPauseStream = { [weak container] in
|
||||||
|
Task { @MainActor in await container?.broadcastCoordinator.pauseBroadcast() }
|
||||||
|
}
|
||||||
|
container.sessionCable.onResumeStream = { [weak container] in
|
||||||
|
Task { @MainActor in
|
||||||
|
await resumeStream()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
container.sessionCable.onStopStream = { [weak container] in
|
||||||
|
Task { @MainActor in await stopStream() }
|
||||||
|
}
|
||||||
|
container.sessionCable.connect(sessionId: sessionId, accessToken: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func preloadLogos(for match: Match) async {
|
||||||
|
await OverlayLogoCache.preloadAll([match.homeLogoUrl, match.opponentLogoUrl])
|
||||||
|
logoReady += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateOverlay() {
|
||||||
|
guard let match, let session else { return }
|
||||||
|
let overlayKind = OverlayKind.fromApi(match.effectiveOverlayKind)
|
||||||
|
let homeColor = ColorHex.parseColorHex(match.homePrimaryColor ?? "#FF2D2D")
|
||||||
|
let awayColor = ColorHex.parseColorHex(match.opponentPrimaryColor ?? "#1E3A8A")
|
||||||
|
let score = container.scoreController.score
|
||||||
|
let state: OverlayState
|
||||||
|
switch overlayKind {
|
||||||
|
case .none:
|
||||||
|
state = OverlayState(overlayKind: .none, watermarkVisible: false, broadcastStatus: container.broadcastCoordinator.metrics.phase.toOverlayStatus())
|
||||||
|
case .basket, .timed:
|
||||||
|
let period = score.periodLabel ?? (overlayKind == .basket ? "Q\(score.period)" : "\(score.period)° tempo")
|
||||||
|
state = OverlayState(
|
||||||
|
overlayKind: overlayKind,
|
||||||
|
compactScoreboard: CompactScoreboardState(
|
||||||
|
homeTeamName: match.teamName,
|
||||||
|
awayTeamName: match.opponentName,
|
||||||
|
homeScore: score.homePoints,
|
||||||
|
awayScore: score.awayPoints,
|
||||||
|
periodLabel: period,
|
||||||
|
homeAccentColor: homeColor,
|
||||||
|
awayAccentColor: awayColor,
|
||||||
|
homeLogoUrl: match.homeLogoUrl,
|
||||||
|
awayLogoUrl: match.opponentLogoUrl
|
||||||
|
),
|
||||||
|
broadcastStatus: container.broadcastCoordinator.metrics.phase.toOverlayStatus()
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
state = OverlayState(
|
||||||
|
overlayKind: overlayKind,
|
||||||
|
scoreboard: score.toScoreboardState(
|
||||||
|
homeTeamName: match.teamName,
|
||||||
|
awayTeamName: match.opponentName,
|
||||||
|
homeAccentColor: homeColor,
|
||||||
|
awayAccentColor: awayColor,
|
||||||
|
homeLogoUrl: match.homeLogoUrl,
|
||||||
|
awayLogoUrl: match.opponentLogoUrl
|
||||||
|
),
|
||||||
|
broadcastStatus: container.broadcastCoordinator.metrics.phase.toOverlayStatus()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
container.broadcastCoordinator.updateOverlay(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startPolling() {
|
||||||
|
Task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||||
|
if let remote = try? await container.sessionRepository.fetchSession(id: sessionId).score {
|
||||||
|
container.scoreController.applyRemote(remote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
deviceHealth = DeviceTelemetry.snapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 10_000_000_000)
|
||||||
|
let health = DeviceTelemetry.snapshot()
|
||||||
|
let metrics = container.broadcastCoordinator.metrics
|
||||||
|
try? await container.sessionRepository.postTelemetry(
|
||||||
|
sessionId: sessionId,
|
||||||
|
health: health,
|
||||||
|
currentBitrate: metrics.bitrateKbps * 1000,
|
||||||
|
targetBitrate: session?.targetBitrate,
|
||||||
|
fps: metrics.fps
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pauseStream() {
|
||||||
|
guard !pauseInFlight else { return }
|
||||||
|
pauseInFlight = true
|
||||||
|
Task {
|
||||||
|
_ = try? await container.sessionRepository.pauseSession(id: sessionId)
|
||||||
|
await container.broadcastCoordinator.pauseBroadcast()
|
||||||
|
pauseInFlight = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resumeStream() async {
|
||||||
|
do {
|
||||||
|
var updated = try await container.sessionRepository.resumeSession(id: sessionId)
|
||||||
|
session = updated
|
||||||
|
guard let url = updated.rtmpIngestUrl, !url.isEmpty else { return }
|
||||||
|
let config = broadcastConfig(for: updated, rtmpUrl: url)
|
||||||
|
try await container.broadcastCoordinator.resumeBroadcast(config: config)
|
||||||
|
updated = try await container.sessionRepository.fetchSession(id: sessionId)
|
||||||
|
session = updated
|
||||||
|
} catch {
|
||||||
|
if let message = UserFacingError.message(for: error) {
|
||||||
|
self.error = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func broadcastConfig(for loaded: StreamSession, rtmpUrl: String) -> BroadcastConfig {
|
||||||
|
BroadcastConfig(
|
||||||
|
rtmpUrl: rtmpUrl,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
videoBitrate: loaded.targetBitrate,
|
||||||
|
audioBitrate: 128_000,
|
||||||
|
fps: loaded.targetFps
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopStream() async {
|
||||||
|
_ = try? await container.sessionRepository.stopSession(id: sessionId)
|
||||||
|
await teardown()
|
||||||
|
onFinished()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func teardown() async {
|
||||||
|
container.sessionCable.disconnect()
|
||||||
|
await container.broadcastCoordinator.stopBroadcast()
|
||||||
|
}
|
||||||
|
}
|
||||||
164
native/ios/MatchLiveTv/UI/Broadcast/LiveScoreActions.swift
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum ScoreDialogKind {
|
||||||
|
case setWon
|
||||||
|
case closeSetAnyway
|
||||||
|
case matchWon
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScoreDialogState: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let kind: ScoreDialogKind
|
||||||
|
var winnerSide: ScoringSide?
|
||||||
|
var homePoints: Int = 0
|
||||||
|
var awayPoints: Int = 0
|
||||||
|
var homeSets: Int = 0
|
||||||
|
var awaySets: Int = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LiveScoreDialogHost: ObservableObject {
|
||||||
|
@Published private(set) var pending: ScoreDialogState?
|
||||||
|
private var waiter: CheckedContinuation<Bool, Never>?
|
||||||
|
|
||||||
|
func show(_ state: ScoreDialogState) async -> Bool {
|
||||||
|
guard pending == nil else { return false }
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
pending = state
|
||||||
|
waiter = continuation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolve(_ value: Bool) {
|
||||||
|
pending = nil
|
||||||
|
waiter?.resume(returning: value)
|
||||||
|
waiter = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LiveScoreActions {
|
||||||
|
private let rules: MatchScoringContext
|
||||||
|
private let scoreController: ScoreController
|
||||||
|
private let dialogHost: LiveScoreDialogHost
|
||||||
|
private let onStopStream: () async -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
rules: MatchScoringContext,
|
||||||
|
scoreController: ScoreController,
|
||||||
|
dialogHost: LiveScoreDialogHost,
|
||||||
|
onStopStream: @escaping () async -> Void
|
||||||
|
) {
|
||||||
|
self.rules = rules
|
||||||
|
self.scoreController = scoreController
|
||||||
|
self.dialogHost = dialogHost
|
||||||
|
self.onStopStream = onStopStream
|
||||||
|
}
|
||||||
|
|
||||||
|
func afterPointChange(score: ScoreState) async {
|
||||||
|
guard rules.usesSetLogic else { return }
|
||||||
|
guard let winner = rules.setWinnerFromPoints(
|
||||||
|
homePoints: score.homePoints,
|
||||||
|
awayPoints: score.awayPoints,
|
||||||
|
currentSet: score.currentSet
|
||||||
|
) else { return }
|
||||||
|
|
||||||
|
let close = await dialogHost.show(
|
||||||
|
ScoreDialogState(
|
||||||
|
kind: .setWon,
|
||||||
|
winnerSide: winner,
|
||||||
|
homePoints: score.homePoints,
|
||||||
|
awayPoints: score.awayPoints
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if close {
|
||||||
|
guard await scoreController.closeSetAsync() else { return }
|
||||||
|
await afterCloseSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestCloseSet() async {
|
||||||
|
guard rules.usesSetLogic else { return }
|
||||||
|
|
||||||
|
let score = scoreController.score
|
||||||
|
let winner = rules.setWinnerFromPoints(
|
||||||
|
homePoints: score.homePoints,
|
||||||
|
awayPoints: score.awayPoints,
|
||||||
|
currentSet: score.currentSet
|
||||||
|
)
|
||||||
|
if winner == nil {
|
||||||
|
let ok = await dialogHost.show(ScoreDialogState(kind: .closeSetAnyway))
|
||||||
|
if !ok { return }
|
||||||
|
}
|
||||||
|
guard await scoreController.closeSetAsync() else { return }
|
||||||
|
await afterCloseSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func afterCloseSet() async {
|
||||||
|
let score = scoreController.score
|
||||||
|
guard let winner = rules.matchWinner(homeSets: score.homeSets, awaySets: score.awaySets) else { return }
|
||||||
|
let stop = await dialogHost.show(
|
||||||
|
ScoreDialogState(
|
||||||
|
kind: .matchWon,
|
||||||
|
winnerSide: winner,
|
||||||
|
homeSets: score.homeSets,
|
||||||
|
awaySets: score.awaySets
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if stop { await onStopStream() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScoreDialogRouter: View {
|
||||||
|
@ObservedObject var host: LiveScoreDialogHost
|
||||||
|
let homeName: String
|
||||||
|
let awayName: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Color.clear
|
||||||
|
.frame(width: 0, height: 0)
|
||||||
|
.alert(item: dialogBinding) { dialog in
|
||||||
|
alert(for: dialog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dialogBinding: Binding<ScoreDialogState?> {
|
||||||
|
Binding(
|
||||||
|
get: { host.pending },
|
||||||
|
set: { newValue in
|
||||||
|
if newValue == nil, host.pending != nil {
|
||||||
|
host.resolve(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func alert(for dialog: ScoreDialogState) -> Alert {
|
||||||
|
switch dialog.kind {
|
||||||
|
case .setWon:
|
||||||
|
let winner = dialog.winnerSide.map { scoringSideName($0, homeName: homeName, awayName: awayName) } ?? ""
|
||||||
|
return Alert(
|
||||||
|
title: Text("Set concluso"),
|
||||||
|
message: Text("\(winner) vince il set \(dialog.homePoints)-\(dialog.awayPoints).\n\nChiudere il set e passare al successivo?"),
|
||||||
|
primaryButton: .default(Text("Chiudi set")) { host.resolve(true) },
|
||||||
|
secondaryButton: .cancel(Text("Continua a segnare")) { host.resolve(false) }
|
||||||
|
)
|
||||||
|
case .closeSetAnyway:
|
||||||
|
return Alert(
|
||||||
|
title: Text("Chiudi set"),
|
||||||
|
message: Text("Il punteggio non soddisfa ancora le regole del torneo. Chiudere il set comunque?"),
|
||||||
|
primaryButton: .default(Text("Chiudi comunque")) { host.resolve(true) },
|
||||||
|
secondaryButton: .cancel(Text("Annulla")) { host.resolve(false) }
|
||||||
|
)
|
||||||
|
case .matchWon:
|
||||||
|
let winner = dialog.winnerSide.map { scoringSideName($0, homeName: homeName, awayName: awayName) } ?? ""
|
||||||
|
return Alert(
|
||||||
|
title: Text("Partita terminata"),
|
||||||
|
message: Text("\(winner) vince la partita (\(dialog.homeSets)-\(dialog.awaySets) set).\n\nChiudere definitivamente la diretta?"),
|
||||||
|
primaryButton: .default(Text("Chiudi diretta")) { host.resolve(true) },
|
||||||
|
secondaryButton: .cancel(Text("Continua in onda")) { host.resolve(false) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
native/ios/MatchLiveTv/UI/Components/MatchLiveWordmark.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MatchLiveWordmark: View {
|
||||||
|
var compact: Bool = false
|
||||||
|
var showSlogan: Bool = false
|
||||||
|
var showLogo: Bool { !compact }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if showLogo {
|
||||||
|
Image("logo-white-m")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: compact ? 40 : 80, height: compact ? 40 : 80)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("MATCH")
|
||||||
|
.font(MatchTypography.displaySmall)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text("LIVE")
|
||||||
|
.font(MatchTypography.labelLarge)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, compact ? 8 : 12)
|
||||||
|
.padding(.vertical, compact ? 2 : 4)
|
||||||
|
.background(MatchColors.primaryRed, in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
Text("TV")
|
||||||
|
.font(MatchTypography.displaySmall)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
if showSlogan {
|
||||||
|
Text("OGNI PARTITA, OGNI EVENTO, PER I TUOI TIFOSI.")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MatchPrimaryButton: View {
|
||||||
|
let label: String
|
||||||
|
let action: () -> Void
|
||||||
|
var enabled: Bool = true
|
||||||
|
var loading: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Group {
|
||||||
|
if loading {
|
||||||
|
ProgressView()
|
||||||
|
.tint(.white)
|
||||||
|
} else {
|
||||||
|
Text(label)
|
||||||
|
.font(MatchTypography.labelLarge)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 52)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.background(enabled && !loading ? MatchColors.primaryRed : MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
.foregroundStyle(enabled && !loading ? .white : MatchColors.textSecondary)
|
||||||
|
.disabled(!enabled || loading)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MatchScreenScaffold<Content: View, TopBar: View>: View {
|
||||||
|
@ViewBuilder var topBar: () -> TopBar
|
||||||
|
@ViewBuilder var content: () -> Content
|
||||||
|
|
||||||
|
init(
|
||||||
|
@ViewBuilder topBar: @escaping () -> TopBar = { EmptyView() },
|
||||||
|
@ViewBuilder content: @escaping () -> Content
|
||||||
|
) {
|
||||||
|
self.topBar = topBar
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
topBar()
|
||||||
|
content()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
.background(MatchColors.background)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MatchSecondaryButton: View {
|
||||||
|
let label: String
|
||||||
|
let action: () -> Void
|
||||||
|
var enabled: Bool = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(label)
|
||||||
|
.font(MatchTypography.labelLarge)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 52)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(enabled ? MatchColors.outline : MatchColors.surfaceElevated, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.foregroundStyle(enabled ? .white : MatchColors.textSecondary)
|
||||||
|
.disabled(!enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
native/ios/MatchLiveTv/UI/Components/MatchStatusBadge.swift
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MatchStatusBadge: View {
|
||||||
|
let text: String
|
||||||
|
var backgroundColor: Color = MatchColors.surfaceElevated
|
||||||
|
var textColor: Color = MatchColors.accentYellow
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(text)
|
||||||
|
.font(MatchTypography.labelLarge)
|
||||||
|
.foregroundStyle(textColor)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
}
|
||||||
125
native/ios/MatchLiveTv/UI/Login/LoginScreen.swift
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LoginScreen: View {
|
||||||
|
@ObservedObject var container: AppContainer
|
||||||
|
let onLoggedIn: () -> Void
|
||||||
|
|
||||||
|
@State private var email = ""
|
||||||
|
@State private var password = ""
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var loading = false
|
||||||
|
@State private var passwordVisible = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
MatchScreenScaffold {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
MatchLiveWordmark(showSlogan: true)
|
||||||
|
.padding(.top, 32)
|
||||||
|
Text("ACCEDI")
|
||||||
|
.font(MatchTypography.headlineMedium)
|
||||||
|
.padding(.top, 48)
|
||||||
|
Text("Gestisci le dirette della tua squadra")
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 8)
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
MatchTextField(title: "Email", text: $email, placeholder: "coach@squadra.it", keyboard: .emailAddress)
|
||||||
|
MatchSecureField(title: "Password", text: $password, visible: $passwordVisible)
|
||||||
|
}
|
||||||
|
.padding(.top, 32)
|
||||||
|
if let error {
|
||||||
|
Text(error)
|
||||||
|
.foregroundStyle(MatchColors.primaryRed)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
MatchPrimaryButton(
|
||||||
|
label: "ACCEDI",
|
||||||
|
action: submitLogin,
|
||||||
|
enabled: !email.isEmpty && !password.isEmpty,
|
||||||
|
loading: loading
|
||||||
|
)
|
||||||
|
.padding(.top, 32)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submitLogin() {
|
||||||
|
guard !loading, !email.isEmpty, !password.isEmpty else { return }
|
||||||
|
loading = true
|
||||||
|
error = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
_ = try await container.authRepository.login(email: email.trimmingCharacters(in: .whitespaces), password: password)
|
||||||
|
onLoggedIn()
|
||||||
|
} catch {
|
||||||
|
let message = error.localizedDescription
|
||||||
|
if message.contains("401") {
|
||||||
|
self.error = "Email o password non corretti"
|
||||||
|
} else if message.localizedCaseInsensitiveContains("timeout") {
|
||||||
|
self.error = "Server non raggiungibile. Verifica la connessione."
|
||||||
|
} else {
|
||||||
|
self.error = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MatchTextField: View {
|
||||||
|
let title: String
|
||||||
|
@Binding var text: String
|
||||||
|
var placeholder: String = ""
|
||||||
|
var keyboard: UIKeyboardType = .default
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
TextField(placeholder, text: $text)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.keyboardType(keyboard)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.padding(12)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MatchSecureField: View {
|
||||||
|
let title: String
|
||||||
|
@Binding var text: String
|
||||||
|
@Binding var visible: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
HStack {
|
||||||
|
Group {
|
||||||
|
if visible {
|
||||||
|
TextField("", text: $text)
|
||||||
|
} else {
|
||||||
|
SecureField("", text: $text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
Button(action: { visible.toggle() }) {
|
||||||
|
Image(systemName: visible ? "eye.slash" : "eye")
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
533
native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MatchesScreen: View {
|
||||||
|
@ObservedObject var container: AppContainer
|
||||||
|
var refreshToken: Int = 0
|
||||||
|
let onOpenSetup: (String) -> Void
|
||||||
|
let onOpenBroadcast: (String) -> Void
|
||||||
|
let onLogout: () -> Void
|
||||||
|
|
||||||
|
@State private var loading = true
|
||||||
|
@State private var refreshing = false
|
||||||
|
@State private var actionLoading = false
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var teams: [Team] = []
|
||||||
|
@State private var activeTeam: Team?
|
||||||
|
@State private var matches: [Match] = []
|
||||||
|
@State private var showNewMatch = false
|
||||||
|
@State private var showSchedule = false
|
||||||
|
@State private var showTeamPicker = false
|
||||||
|
@State private var resumeMatch: Match?
|
||||||
|
@State private var deleteMatch: Match?
|
||||||
|
@State private var snackbar: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
MatchScreenScaffold(
|
||||||
|
topBar: {
|
||||||
|
HStack {
|
||||||
|
MatchLiveWordmark(compact: true)
|
||||||
|
Spacer()
|
||||||
|
Button("Esci") {
|
||||||
|
Task {
|
||||||
|
await container.authRepository.logout()
|
||||||
|
onLogout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
Group {
|
||||||
|
if loading {
|
||||||
|
ProgressView().tint(MatchColors.primaryRed)
|
||||||
|
} else if teams.isEmpty {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("Nessuna squadra disponibile")
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
MatchPrimaryButton(label: "RIPROVA", action: { reload(showSpinner: false) })
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
} else if let error {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text(error).foregroundStyle(MatchColors.primaryRed).multilineTextAlignment(.center)
|
||||||
|
MatchPrimaryButton(label: "RIPROVA", action: { reload(showSpinner: false) })
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("Ciao, \(container.tokenStore.session?.user.name ?? "")")
|
||||||
|
.font(MatchTypography.headlineMedium)
|
||||||
|
Text("Riprendi una diretta in corso o avvia una partita programmata.")
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
MatchSecondaryButton(label: "PARTITA PROGRAMMATA", action: { showSchedule = true })
|
||||||
|
MatchPrimaryButton(label: "NUOVA PARTITA", action: { showNewMatch = true })
|
||||||
|
}
|
||||||
|
if let activeTeam {
|
||||||
|
TeamPickerBar(
|
||||||
|
team: activeTeam,
|
||||||
|
showPicker: teams.count > 1,
|
||||||
|
onTap: { if teams.count > 1 { showTeamPicker = true } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if let active = activeMatch {
|
||||||
|
ActiveSessionBanner(match: active) {
|
||||||
|
resumeMatch = active
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
Text(calendarSectionTitle)
|
||||||
|
.font(MatchTypography.labelLarge)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
if calendarMatches.isEmpty && activeMatch == nil {
|
||||||
|
Text(emptyCalendarMessage)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
} else if calendarMatches.isEmpty {
|
||||||
|
Text("Nessuna altra partita in calendario.")
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
} else {
|
||||||
|
ForEach(calendarMatches) { match in
|
||||||
|
MatchListCard(
|
||||||
|
match: match,
|
||||||
|
onTap: { openMatch(match) },
|
||||||
|
onDelete: (!match.hasActiveSession && !match.streamCompleted)
|
||||||
|
? { deleteMatch = match }
|
||||||
|
: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
.refreshable { reload(showSpinner: false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.task(id: refreshToken) { reload(showSpinner: refreshToken == 0) }
|
||||||
|
.alert("Riprendi diretta?", isPresented: Binding(get: { resumeMatch != nil }, set: { if !$0 { resumeMatch = nil } })) {
|
||||||
|
Button("Riprendi") {
|
||||||
|
if let match = resumeMatch { resumeBroadcast(match) }
|
||||||
|
resumeMatch = nil
|
||||||
|
}
|
||||||
|
Button("Configura", role: .cancel) {
|
||||||
|
if let match = resumeMatch { onOpenSetup(match.id) }
|
||||||
|
resumeMatch = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Elimina partita?", isPresented: Binding(get: { deleteMatch != nil }, set: { if !$0 { deleteMatch = nil } })) {
|
||||||
|
Button("Elimina", role: .destructive) {
|
||||||
|
if let match = deleteMatch {
|
||||||
|
Task {
|
||||||
|
try? await container.matchRepository.deleteMatch(matchId: match.id)
|
||||||
|
reload(showSpinner: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deleteMatch = nil
|
||||||
|
}
|
||||||
|
Button("Annulla", role: .cancel) { deleteMatch = nil }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showNewMatch) {
|
||||||
|
NewMatchSheet(
|
||||||
|
onSchedule: {
|
||||||
|
showNewMatch = false
|
||||||
|
showSchedule = true
|
||||||
|
},
|
||||||
|
onQuickStart: {
|
||||||
|
showNewMatch = false
|
||||||
|
createQuickMatch()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showSchedule) {
|
||||||
|
ScheduleMatchSheet(container: container, teamId: activeTeam?.id) { match in
|
||||||
|
showSchedule = false
|
||||||
|
onOpenSetup(match.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showTeamPicker) {
|
||||||
|
TeamPickerSheet(teams: teams, activeTeamId: activeTeam?.id) { team in
|
||||||
|
container.matchRepository.selectTeam(teamId: team.id)
|
||||||
|
activeTeam = team
|
||||||
|
reload(showSpinner: false)
|
||||||
|
showTeamPicker = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if actionLoading {
|
||||||
|
Color.black.opacity(0.35)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
ProgressView().tint(MatchColors.primaryRed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if let snackbar {
|
||||||
|
Text(snackbar)
|
||||||
|
.padding()
|
||||||
|
.background(MatchColors.surfaceElevated)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.padding()
|
||||||
|
.onAppear {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.snackbar = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activeMatch: Match? {
|
||||||
|
matches.first { $0.canResumeCamera || ($0.hasActiveSession && $0.activeSessionStatus == "idle") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scheduledMatches: [Match] {
|
||||||
|
matches.filter { !$0.hasActiveSession && MatchHubFilter.isScheduledFuture($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var calendarMatches: [Match] {
|
||||||
|
let drafts = matches.filter { MatchHubFilter.isDraft($0) }
|
||||||
|
return (scheduledMatches + drafts).uniqued(by: \.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var calendarSectionTitle: String {
|
||||||
|
if calendarMatches.isEmpty && activeMatch == nil {
|
||||||
|
return "Nessuna partita in calendario"
|
||||||
|
}
|
||||||
|
if !scheduledMatches.isEmpty {
|
||||||
|
return "Partite programmate"
|
||||||
|
}
|
||||||
|
return "Pronte da avviare"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyCalendarMessage: String {
|
||||||
|
var message = "Programma una partita o avviane una nuova con «Nuova partita»."
|
||||||
|
if let teamName = activeTeam?.name {
|
||||||
|
message += "\n\nSquadra attiva: \(teamName)."
|
||||||
|
}
|
||||||
|
if teams.count > 1 {
|
||||||
|
message += "\nHai più squadre: verifica quella selezionata sopra."
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reload(showSpinner: Bool) {
|
||||||
|
if showSpinner { loading = true } else { refreshing = true }
|
||||||
|
error = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let loadedTeams = try await container.matchRepository.fetchTeams()
|
||||||
|
teams = loadedTeams
|
||||||
|
activeTeam = container.matchRepository.resolveActiveTeam(teams: loadedTeams)
|
||||||
|
if let team = activeTeam {
|
||||||
|
matches = try await container.matchRepository.fetchMatchesForTeam(teamId: team.id)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.error = UserFacingError.message(for: error)
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
|
refreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openMatch(_ match: Match) {
|
||||||
|
if match.hasActiveSession { resumeMatch = match } else { onOpenSetup(match.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resumeBroadcast(_ match: Match) {
|
||||||
|
guard !actionLoading else { return }
|
||||||
|
actionLoading = true
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let sessionId = try await MatchSessionLauncher.resumeBroadcastSession(match: match, sessionRepository: container.sessionRepository)
|
||||||
|
onOpenBroadcast(sessionId)
|
||||||
|
} catch {
|
||||||
|
snackbar = UserFacingError.message(for: error)
|
||||||
|
}
|
||||||
|
actionLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createQuickMatch() {
|
||||||
|
guard let teamId = activeTeam?.id, !actionLoading else { return }
|
||||||
|
actionLoading = true
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let match = try await container.matchRepository.createQuickMatch(teamId: teamId)
|
||||||
|
reload(showSpinner: false)
|
||||||
|
onOpenSetup(match.id)
|
||||||
|
} catch {
|
||||||
|
snackbar = UserFacingError.message(for: error)
|
||||||
|
}
|
||||||
|
actionLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TeamPickerBar: View {
|
||||||
|
let team: Team
|
||||||
|
var showPicker: Bool = true
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack {
|
||||||
|
Text(team.name).font(MatchTypography.titleMedium)
|
||||||
|
Spacer()
|
||||||
|
MatchStatusBadge(text: team.sportKey.uppercased())
|
||||||
|
if showPicker {
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!showPicker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ActiveSessionBanner: View {
|
||||||
|
let match: Match
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "video.fill")
|
||||||
|
.foregroundStyle(MatchColors.primaryRed)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Riprendi diretta in corso")
|
||||||
|
.font(MatchTypography.titleMedium)
|
||||||
|
.foregroundStyle(MatchColors.primaryRed)
|
||||||
|
Text("\(match.teamName) vs \(match.opponentName)")
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundStyle(MatchColors.primaryRed)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(MatchColors.primaryRed.opacity(0.15), in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MatchListCard: View {
|
||||||
|
let match: Match
|
||||||
|
let onTap: () -> Void
|
||||||
|
let onDelete: (() -> Void)?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("\(match.teamName) vs \(match.opponentName)")
|
||||||
|
.font(MatchTypography.titleMedium)
|
||||||
|
if let date = ApiInstant.formatMatchDate(match.scheduledAt) {
|
||||||
|
Text(date)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
if let location = match.location?.trimmingCharacters(in: .whitespacesAndNewlines), !location.isEmpty {
|
||||||
|
Text(location)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text(MatchPresentation.statusLabel(for: match))
|
||||||
|
.font(MatchTypography.labelLarge)
|
||||||
|
.foregroundStyle(match.hasActiveSession ? MatchColors.primaryRed : MatchColors.textSecondary)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
match.hasActiveSession
|
||||||
|
? MatchColors.primaryRed.opacity(0.2)
|
||||||
|
: MatchColors.surfaceElevated,
|
||||||
|
in: RoundedRectangle(cornerRadius: 8)
|
||||||
|
)
|
||||||
|
if let onDelete {
|
||||||
|
Button(action: onDelete) {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NewMatchSheet: View {
|
||||||
|
let onSchedule: () -> Void
|
||||||
|
let onQuickStart: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text("Nuova partita")
|
||||||
|
.font(MatchTypography.headlineMedium)
|
||||||
|
Text("Programma in anticipo o avvia la configurazione diretta subito.")
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.padding(.top, 6)
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
SheetOptionTile(
|
||||||
|
systemImage: "calendar.badge.clock",
|
||||||
|
title: "Programma partita",
|
||||||
|
subtitle: "Data, ora e avversario — visibile anche sul sito",
|
||||||
|
action: onSchedule
|
||||||
|
)
|
||||||
|
SheetOptionTile(
|
||||||
|
systemImage: "play.circle",
|
||||||
|
title: "Avvia subito",
|
||||||
|
subtitle: "Crea la partita e passa al wizard senza orario",
|
||||||
|
action: onQuickStart
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.top, 20)
|
||||||
|
Spacer(minLength: 24)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.background(MatchColors.surface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SheetOptionTile: View {
|
||||||
|
let systemImage: String
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(MatchColors.primaryRed)
|
||||||
|
.frame(width: 28)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title).font(MatchTypography.titleMedium)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ScheduleMatchSheet: View {
|
||||||
|
@ObservedObject var container: AppContainer
|
||||||
|
let teamId: String?
|
||||||
|
let onCreated: (Match) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var opponent = ""
|
||||||
|
@State private var location = ""
|
||||||
|
@State private var date = Date().addingTimeInterval(86400)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
TextField("Avversario", text: $opponent)
|
||||||
|
TextField("Luogo", text: $location)
|
||||||
|
DatePicker("Data", selection: $date)
|
||||||
|
}
|
||||||
|
.navigationTitle("Partita programmata")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) { Button("Chiudi") { dismiss() } }
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Crea") { create() }.disabled(opponent.isEmpty || teamId == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func create() {
|
||||||
|
guard let teamId else { return }
|
||||||
|
Task {
|
||||||
|
if let match = try? await container.matchRepository.createScheduledMatch(
|
||||||
|
teamId: teamId,
|
||||||
|
opponentName: opponent,
|
||||||
|
scheduledAt: date,
|
||||||
|
location: location.nilIfEmpty
|
||||||
|
) {
|
||||||
|
onCreated(match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TeamPickerSheet: View {
|
||||||
|
let teams: [Team]
|
||||||
|
let activeTeamId: String?
|
||||||
|
let onSelect: (Team) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List(teams) { team in
|
||||||
|
Button {
|
||||||
|
onSelect(team)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(team.name)
|
||||||
|
Spacer()
|
||||||
|
if team.id == activeTeamId {
|
||||||
|
Image(systemName: "checkmark").foregroundStyle(MatchColors.primaryRed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Squadra")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Array {
|
||||||
|
func uniqued<ID: Hashable>(by keyPath: KeyPath<Element, ID>) -> [Element] {
|
||||||
|
var seen = Set<ID>()
|
||||||
|
return filter { seen.insert($0[keyPath: keyPath]).inserted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||||
|
}
|
||||||
79
native/ios/MatchLiveTv/UI/Navigation/AppNavHost.swift
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AppNavHost: View {
|
||||||
|
@StateObject private var container = AppContainer()
|
||||||
|
@State private var path: [Routes] = []
|
||||||
|
@State private var wizardRoute: WizardRoute?
|
||||||
|
@State private var broadcastRoute: BroadcastRoute?
|
||||||
|
/// Incrementato al ritorno da wizard/broadcast per ricaricare l'hub partite.
|
||||||
|
@State private var matchesRefreshToken = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack(path: $path) {
|
||||||
|
SplashScreen(
|
||||||
|
container: container,
|
||||||
|
onAuthenticated: { path = [.matches] },
|
||||||
|
onUnauthenticated: { path = [.login] }
|
||||||
|
)
|
||||||
|
.navigationDestination(for: Routes.self) { route in
|
||||||
|
switch route {
|
||||||
|
case .splash:
|
||||||
|
EmptyView()
|
||||||
|
case .login:
|
||||||
|
LoginScreen(container: container) { path = [.matches] }
|
||||||
|
case .matches:
|
||||||
|
MatchesScreen(
|
||||||
|
container: container,
|
||||||
|
refreshToken: matchesRefreshToken,
|
||||||
|
onOpenSetup: { matchId in
|
||||||
|
wizardRoute = WizardRoute(matchId: matchId, step: 1)
|
||||||
|
},
|
||||||
|
onOpenBroadcast: { sessionId in
|
||||||
|
broadcastRoute = BroadcastRoute(sessionId: sessionId)
|
||||||
|
},
|
||||||
|
onLogout: { path = [.login] }
|
||||||
|
)
|
||||||
|
.lockPortraitOrientation()
|
||||||
|
case .setup, .broadcast:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.lockPortraitOrientation()
|
||||||
|
.fullScreenCover(item: $wizardRoute) { route in
|
||||||
|
WizardShellScreen(
|
||||||
|
container: container,
|
||||||
|
matchId: route.matchId,
|
||||||
|
step: route.step,
|
||||||
|
onClose: {
|
||||||
|
container.wizardSession.reset()
|
||||||
|
wizardRoute = nil
|
||||||
|
matchesRefreshToken += 1
|
||||||
|
},
|
||||||
|
onStartLive: { sessionId in
|
||||||
|
wizardRoute = nil
|
||||||
|
broadcastRoute = BroadcastRoute(sessionId: sessionId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.lockPortraitOrientation()
|
||||||
|
}
|
||||||
|
.fullScreenCover(item: $broadcastRoute, onDismiss: {
|
||||||
|
AppOrientation.lockPortrait()
|
||||||
|
matchesRefreshToken += 1
|
||||||
|
}) { route in
|
||||||
|
BroadcastScreen(container: container, sessionId: route.sessionId) {
|
||||||
|
AppOrientation.lockPortrait()
|
||||||
|
container.wizardSession.reset()
|
||||||
|
broadcastRoute = nil
|
||||||
|
path = [.matches]
|
||||||
|
}
|
||||||
|
.keepScreenOn()
|
||||||
|
}
|
||||||
|
.onChange(of: broadcastRoute?.sessionId) { sessionId in
|
||||||
|
if sessionId == nil {
|
||||||
|
AppOrientation.lockPortrait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environmentObject(container)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
native/ios/MatchLiveTv/UI/Navigation/ModalRoutes.swift
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct WizardRoute: Identifiable, Equatable {
|
||||||
|
let matchId: String
|
||||||
|
let step: Int
|
||||||
|
|
||||||
|
var id: String { "\(matchId)-\(step)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BroadcastRoute: Identifiable, Equatable {
|
||||||
|
let sessionId: String
|
||||||
|
|
||||||
|
var id: String { sessionId }
|
||||||
|
}
|
||||||
9
native/ios/MatchLiveTv/UI/Navigation/Routes.swift
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Routes: Hashable {
|
||||||
|
case splash
|
||||||
|
case login
|
||||||
|
case matches
|
||||||
|
case setup(matchId: String, step: Int)
|
||||||
|
case broadcast(sessionId: String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class BroadcastPermissions: ObservableObject {
|
||||||
|
@Published var cameraGranted = false
|
||||||
|
@Published var microphoneGranted = false
|
||||||
|
|
||||||
|
var allGranted: Bool { cameraGranted && microphoneGranted }
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
cameraGranted = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||||
|
microphoneGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAll() async {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try? session.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
|
||||||
|
let video = await AVCaptureDevice.requestAccess(for: .video)
|
||||||
|
cameraGranted = video
|
||||||
|
let audio = await AVCaptureDevice.requestAccess(for: .audio)
|
||||||
|
microphoneGranted = audio
|
||||||
|
}
|
||||||
|
}
|
||||||
27
native/ios/MatchLiveTv/UI/Splash/SplashScreen.swift
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SplashScreen: View {
|
||||||
|
@ObservedObject var container: AppContainer
|
||||||
|
let onAuthenticated: () -> Void
|
||||||
|
let onUnauthenticated: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
MatchScreenScaffold {
|
||||||
|
ZStack {
|
||||||
|
MatchLiveWordmark(showSlogan: true)
|
||||||
|
ProgressView()
|
||||||
|
.tint(MatchColors.primaryRed)
|
||||||
|
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||||
|
.padding(.bottom, 48)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
container.bootstrapAuth()
|
||||||
|
if await container.authRepository.validateOrRefresh() != nil {
|
||||||
|
onAuthenticated()
|
||||||
|
} else {
|
||||||
|
onUnauthenticated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
native/ios/MatchLiveTv/UI/System/KeepScreenOn.swift
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct KeepScreenOnModifier: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.onAppear { UIApplication.shared.isIdleTimerDisabled = true }
|
||||||
|
.onDisappear { UIApplication.shared.isIdleTimerDisabled = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func keepScreenOn() -> some View {
|
||||||
|
modifier(KeepScreenOnModifier())
|
||||||
|
}
|
||||||
|
}
|
||||||
114
native/ios/MatchLiveTv/UI/System/ScreenOrientation.swift
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum AppOrientation {
|
||||||
|
enum Mode {
|
||||||
|
case portrait
|
||||||
|
case landscape
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) static var mode: Mode = .portrait
|
||||||
|
|
||||||
|
static func lockPortrait() {
|
||||||
|
apply(.portrait)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func lockLandscape() {
|
||||||
|
apply(.landscape)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Torna al portrait (alias di `lockPortrait` per compatibilità).
|
||||||
|
static func unlock() {
|
||||||
|
lockPortrait()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func apply(_ newMode: Mode) {
|
||||||
|
mode = newMode
|
||||||
|
refreshSupportedOrientations()
|
||||||
|
requestOrientationUpdate(for: newMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var interfaceOrientationMask: UIInterfaceOrientationMask {
|
||||||
|
mode == .landscape ? .landscape : .portrait
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func activeWindowScene() -> UIWindowScene? {
|
||||||
|
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||||||
|
return scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func refreshSupportedOrientations() {
|
||||||
|
guard let scene = activeWindowScene() else { return }
|
||||||
|
for window in scene.windows {
|
||||||
|
window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||||
|
topViewController(from: window.rootViewController)?.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func requestOrientationUpdate(for mode: Mode) {
|
||||||
|
guard let scene = activeWindowScene() else { return }
|
||||||
|
let mask: UIInterfaceOrientationMask = mode == .landscape ? .landscape : .portrait
|
||||||
|
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { _ in }
|
||||||
|
|
||||||
|
if mode == .portrait, scene.interfaceOrientation.isLandscape {
|
||||||
|
UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")
|
||||||
|
} else if mode == .landscape, scene.interfaceOrientation.isPortrait {
|
||||||
|
UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func topViewController(from root: UIViewController?) -> UIViewController? {
|
||||||
|
if let presented = root?.presentedViewController {
|
||||||
|
return topViewController(from: presented)
|
||||||
|
}
|
||||||
|
if let nav = root as? UINavigationController {
|
||||||
|
return topViewController(from: nav.visibleViewController)
|
||||||
|
}
|
||||||
|
if let tab = root as? UITabBarController {
|
||||||
|
return topViewController(from: tab.selectedViewController)
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
|
) -> Bool {
|
||||||
|
AppOrientation.lockPortrait()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
supportedInterfaceOrientationsFor window: UIWindow?
|
||||||
|
) -> UIInterfaceOrientationMask {
|
||||||
|
AppOrientation.interfaceOrientationMask
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PortraitOrientationModifier: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.onAppear { AppOrientation.lockPortrait() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LandscapeOrientationModifier: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.onAppear { AppOrientation.lockLandscape() }
|
||||||
|
.onDisappear { AppOrientation.lockPortrait() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func lockPortraitOrientation() -> some View {
|
||||||
|
modifier(PortraitOrientationModifier())
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockLandscapeOrientation() -> some View {
|
||||||
|
modifier(LandscapeOrientationModifier())
|
||||||
|
}
|
||||||
|
}
|
||||||
30
native/ios/MatchLiveTv/UI/Theme/MatchColors.swift
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum MatchColors {
|
||||||
|
static let primaryRed = Color(red: 1, green: 0.176, blue: 0.176)
|
||||||
|
static let background = Color(red: 0.039, green: 0.039, blue: 0.039)
|
||||||
|
static let surface = Color(red: 0.118, green: 0.118, blue: 0.118)
|
||||||
|
static let surfaceElevated = Color(red: 0.165, green: 0.165, blue: 0.165)
|
||||||
|
static let accentYellow = Color(red: 0.961, green: 0.773, blue: 0.094)
|
||||||
|
static let successGreen = Color(red: 0.133, green: 0.773, blue: 0.369)
|
||||||
|
static let textSecondary = Color(red: 0.612, green: 0.639, blue: 0.686)
|
||||||
|
static let outline = Color(red: 0.251, green: 0.251, blue: 0.251)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MatchTypography {
|
||||||
|
static let displaySmall = Font.system(size: 28, weight: .black).leading(.loose)
|
||||||
|
static let headlineMedium = Font.system(size: 24, weight: .bold)
|
||||||
|
static let titleMedium = Font.system(size: 18, weight: .semibold)
|
||||||
|
static let bodyMedium = Font.system(size: 14, weight: .regular)
|
||||||
|
static let labelLarge = Font.system(size: 14, weight: .heavy).leading(.loose)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MatchLiveTheme<Content: View>: View {
|
||||||
|
@ViewBuilder let content: () -> Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
content()
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
.tint(MatchColors.primaryRed)
|
||||||
|
}
|
||||||
|
}
|
||||||
438
native/ios/MatchLiveTv/UI/Wizard/StepMatchScreen.swift
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
private enum WizardDefaults {
|
||||||
|
static let setsToWin = 3
|
||||||
|
static let pointsPerSet = 25
|
||||||
|
static let pointsDecidingSet = 15
|
||||||
|
static let minPointLead = 2
|
||||||
|
static let periods = 4
|
||||||
|
static let periodDurationSecs = 600
|
||||||
|
static let overtimeDurationSecs = 300
|
||||||
|
static let opponentColor = "#1E3A8A"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func overlayLabel(_ key: String) -> String {
|
||||||
|
let name = OverlayKind.fromApi(key).rawValue
|
||||||
|
return name.prefix(1).uppercased() + name.dropFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matchHasCustomOverlay(_ match: Match, sportDefaultOverlay: String?) -> Bool {
|
||||||
|
guard let defaultOverlay = sportDefaultOverlay, let current = match.overlayKind else { return false }
|
||||||
|
return current != defaultOverlay
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matchHasCustomScoring(_ match: Match, boardType: String) -> Bool {
|
||||||
|
guard let rules = match.scoringRules else { return false }
|
||||||
|
switch boardType {
|
||||||
|
case "basket", "timed":
|
||||||
|
return rules.periods != WizardDefaults.periods
|
||||||
|
|| rules.periodDurationSecs != WizardDefaults.periodDurationSecs
|
||||||
|
|| rules.overtimeDurationSecs != WizardDefaults.overtimeDurationSecs
|
||||||
|
default:
|
||||||
|
if match.setsToWin != WizardDefaults.setsToWin { return true }
|
||||||
|
return rules.pointsPerSet != WizardDefaults.pointsPerSet
|
||||||
|
|| rules.pointsDecidingSet != WizardDefaults.pointsDecidingSet
|
||||||
|
|| rules.minPointLead != WizardDefaults.minPointLead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StepMatchScreen: View {
|
||||||
|
@ObservedObject var container: AppContainer
|
||||||
|
let match: Match
|
||||||
|
let onNext: () -> Void
|
||||||
|
let onError: (String) -> Void
|
||||||
|
|
||||||
|
@State private var opponent: String
|
||||||
|
@State private var location: String
|
||||||
|
@State private var campionato: String
|
||||||
|
@State private var homeTeam: Team?
|
||||||
|
@State private var homeLogoUrl: String?
|
||||||
|
@State private var opponentLogoUrl: String?
|
||||||
|
@State private var homePrimaryColor: String
|
||||||
|
@State private var opponentPrimaryColor: String
|
||||||
|
@State private var homeLogoImage: UIImage?
|
||||||
|
@State private var opponentLogoImage: UIImage?
|
||||||
|
@State private var saving = false
|
||||||
|
@State private var sports: [SportOption] = []
|
||||||
|
@State private var customRules = false
|
||||||
|
@State private var setsToWin: Int
|
||||||
|
@State private var pointsPerSet: Int
|
||||||
|
@State private var pointsDecidingSet: Int
|
||||||
|
@State private var pointsPerSetText: String
|
||||||
|
@State private var pointsDecidingSetText: String
|
||||||
|
@State private var periods: Int
|
||||||
|
@State private var periodDurationMins: Int
|
||||||
|
@State private var overtimeDurationMins: Int
|
||||||
|
@State private var periodDurationText: String
|
||||||
|
@State private var overtimeDurationText: String
|
||||||
|
@State private var customOverlay = false
|
||||||
|
@State private var selectedOverlay: String
|
||||||
|
|
||||||
|
private var isScheduledMatch: Bool { match.scheduledAt != nil }
|
||||||
|
|
||||||
|
init(container: AppContainer, match: Match, onNext: @escaping () -> Void, onError: @escaping (String) -> Void) {
|
||||||
|
self.container = container
|
||||||
|
self.match = match
|
||||||
|
self.onNext = onNext
|
||||||
|
self.onError = onError
|
||||||
|
let rules = match.scoringRules
|
||||||
|
_opponent = State(initialValue: match.opponentName)
|
||||||
|
_location = State(initialValue: match.location ?? "")
|
||||||
|
_campionato = State(initialValue: match.category ?? "")
|
||||||
|
_homeLogoUrl = State(initialValue: match.homeLogoUrl)
|
||||||
|
_opponentLogoUrl = State(initialValue: match.opponentLogoUrl)
|
||||||
|
_homePrimaryColor = State(initialValue: ColorHex.normalizeHexColor(match.homePrimaryColor))
|
||||||
|
_opponentPrimaryColor = State(initialValue: ColorHex.normalizeHexColor(match.opponentPrimaryColor, fallback: WizardDefaults.opponentColor))
|
||||||
|
_setsToWin = State(initialValue: min(max(match.setsToWin, 2), 3))
|
||||||
|
_pointsPerSet = State(initialValue: rules?.pointsPerSet ?? WizardDefaults.pointsPerSet)
|
||||||
|
_pointsDecidingSet = State(initialValue: rules?.pointsDecidingSet ?? WizardDefaults.pointsDecidingSet)
|
||||||
|
_pointsPerSetText = State(initialValue: "\(rules?.pointsPerSet ?? WizardDefaults.pointsPerSet)")
|
||||||
|
_pointsDecidingSetText = State(initialValue: "\(rules?.pointsDecidingSet ?? WizardDefaults.pointsDecidingSet)")
|
||||||
|
_periods = State(initialValue: rules?.periods ?? WizardDefaults.periods)
|
||||||
|
_periodDurationMins = State(initialValue: (rules?.periodDurationSecs ?? WizardDefaults.periodDurationSecs) / 60)
|
||||||
|
_overtimeDurationMins = State(initialValue: (rules?.overtimeDurationSecs ?? WizardDefaults.overtimeDurationSecs) / 60)
|
||||||
|
_periodDurationText = State(initialValue: "\((rules?.periodDurationSecs ?? WizardDefaults.periodDurationSecs) / 60)")
|
||||||
|
_overtimeDurationText = State(initialValue: "\((rules?.overtimeDurationSecs ?? WizardDefaults.overtimeDurationSecs) / 60)")
|
||||||
|
_selectedOverlay = State(initialValue: match.effectiveOverlayKind)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var teamSportKey: String? {
|
||||||
|
homeTeam?.sportKey.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private var effectiveSportKey: String {
|
||||||
|
teamSportKey ?? match.sportKey
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentSport: SportOption? {
|
||||||
|
sports.first { $0.key == effectiveSportKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var allowedOverlays: [String] {
|
||||||
|
currentSport?.allowedOverlays ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private var defaultOverlay: String {
|
||||||
|
currentSport?.overlay ?? match.effectiveOverlayKind
|
||||||
|
}
|
||||||
|
|
||||||
|
private var boardType: String {
|
||||||
|
currentSport?.board ?? homeTeam?.boardType ?? match.boardType
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text("Dettagli partita")
|
||||||
|
.font(MatchTypography.headlineMedium)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
|
||||||
|
TeamBrandingRow(
|
||||||
|
sectionLabel: "Squadra di casa",
|
||||||
|
teamName: .constant(match.teamName),
|
||||||
|
nameEditable: false,
|
||||||
|
remoteLogoUrl: homeLogoUrl,
|
||||||
|
localLogoImage: $homeLogoImage,
|
||||||
|
primaryColorHex: $homePrimaryColor,
|
||||||
|
placeholderColorSeed: "home-\(match.teamId)"
|
||||||
|
)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
|
||||||
|
TeamBrandingRow(
|
||||||
|
sectionLabel: "Squadra avversaria",
|
||||||
|
teamName: $opponent,
|
||||||
|
nameEditable: true,
|
||||||
|
remoteLogoUrl: opponentLogoUrl,
|
||||||
|
localLogoImage: $opponentLogoImage,
|
||||||
|
primaryColorHex: $opponentPrimaryColor,
|
||||||
|
placeholderColorSeed: "away-\(match.id)"
|
||||||
|
)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
|
||||||
|
WizardOutlinedField(label: "Luogo", text: $location)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
|
||||||
|
WizardOutlinedField(label: "Campionato (facoltativo)", text: $campionato)
|
||||||
|
Text("Es. Serie C, torneo estivo — lo useremo in descrizione e overlay.")
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
if isScheduledMatch {
|
||||||
|
WizardReadOnlyField(
|
||||||
|
label: "Programmata per",
|
||||||
|
value: ApiInstant.formatMatchDate(match.scheduledAt) ?? "—"
|
||||||
|
)
|
||||||
|
.padding(.top, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allowedOverlays.isEmpty {
|
||||||
|
Toggle("Overlay video personalizzato", isOn: $customOverlay)
|
||||||
|
.padding(.top, 20)
|
||||||
|
if customOverlay {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
ForEach(allowedOverlays, id: \.self) { overlayKey in
|
||||||
|
WizardChoiceButton(
|
||||||
|
label: overlayLabel(overlayKey),
|
||||||
|
selected: selectedOverlay == overlayKey,
|
||||||
|
action: { selectedOverlay = overlayKey }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle(isOn: $customRules) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Regole punteggio personalizzate")
|
||||||
|
Text(customRulesDescription)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 20)
|
||||||
|
|
||||||
|
if customRules && ["basket", "timed"].contains(boardType) {
|
||||||
|
periodRulesSection
|
||||||
|
}
|
||||||
|
|
||||||
|
if customRules && ["volley", "racket"].contains(boardType) {
|
||||||
|
volleyRulesSection
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchPrimaryButton(label: "AVANTI >", action: saveAndContinue, enabled: !saving, loading: saving)
|
||||||
|
.padding(.top, 32)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
.task(id: match.id) {
|
||||||
|
if let cached = container.wizardSession.team {
|
||||||
|
applyHomeTeam(cached)
|
||||||
|
} else if let team = await container.matchRepository.fetchTeam(teamId: match.teamId) {
|
||||||
|
container.wizardSession.team = team
|
||||||
|
applyHomeTeam(team)
|
||||||
|
}
|
||||||
|
if let loaded = try? await container.matchRepository.fetchSports() {
|
||||||
|
sports = loaded
|
||||||
|
}
|
||||||
|
customRules = matchHasCustomScoring(match, boardType: boardType)
|
||||||
|
customOverlay = matchHasCustomOverlay(match, sportDefaultOverlay: defaultOverlay)
|
||||||
|
if customOverlay {
|
||||||
|
selectedOverlay = match.overlayKind ?? match.effectiveOverlayKind
|
||||||
|
} else {
|
||||||
|
selectedOverlay = defaultOverlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: customOverlay) { enabled in
|
||||||
|
if !enabled {
|
||||||
|
selectedOverlay = defaultOverlay
|
||||||
|
} else if !allowedOverlays.contains(selectedOverlay) {
|
||||||
|
selectedOverlay = defaultOverlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: defaultOverlay) { overlay in
|
||||||
|
if !customOverlay {
|
||||||
|
selectedOverlay = overlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var customRulesDescription: String {
|
||||||
|
if !customRules && ["basket", "timed"].contains(boardType) {
|
||||||
|
return "Regole standard dello sport selezionato."
|
||||||
|
}
|
||||||
|
if customRules && ["basket", "timed"].contains(boardType) {
|
||||||
|
return "Torneo non standard: tempi e periodi personalizzati."
|
||||||
|
}
|
||||||
|
if customRules {
|
||||||
|
return "Torneo non standard: imposta set e punteggi."
|
||||||
|
}
|
||||||
|
return "Standard FIPAV: 3 set per vincere, set a 25, tie-break a 15."
|
||||||
|
}
|
||||||
|
|
||||||
|
private var periodRulesSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text(boardType == "basket" ? "Quarti" : "Tempi")
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.padding(.top, 16)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach([2, 4], id: \.self) { value in
|
||||||
|
WizardChoiceButton(
|
||||||
|
label: "\(value)",
|
||||||
|
selected: periods == value,
|
||||||
|
action: { periods = value }
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WizardOutlinedField(
|
||||||
|
label: boardType == "basket" ? "Minuti per quarto" : "Minuti per tempo",
|
||||||
|
text: $periodDurationText
|
||||||
|
)
|
||||||
|
.onChange(of: periodDurationText) { text in
|
||||||
|
let filtered = String(text.filter(\.isNumber).prefix(3))
|
||||||
|
if filtered != text { periodDurationText = filtered }
|
||||||
|
if let value = Int(filtered) { periodDurationMins = min(max(value, 1), 120) }
|
||||||
|
}
|
||||||
|
WizardOutlinedField(label: "Minuti supplementari", text: $overtimeDurationText)
|
||||||
|
.onChange(of: overtimeDurationText) { text in
|
||||||
|
let filtered = String(text.filter(\.isNumber).prefix(3))
|
||||||
|
if filtered != text { overtimeDurationText = filtered }
|
||||||
|
if let value = Int(filtered) { overtimeDurationMins = min(max(value, 1), 60) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var volleyRulesSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Set da vincere la partita")
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.padding(.top, 16)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach([2, 3], id: \.self) { value in
|
||||||
|
WizardChoiceButton(
|
||||||
|
label: "\(value)",
|
||||||
|
selected: setsToWin == value,
|
||||||
|
action: { setsToWin = value }
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WizardOutlinedField(label: "Punti per vincere un set", text: $pointsPerSetText)
|
||||||
|
.onChange(of: pointsPerSetText) { text in
|
||||||
|
let filtered = String(text.filter(\.isNumber).prefix(2))
|
||||||
|
if filtered != text { pointsPerSetText = filtered }
|
||||||
|
if let value = Int(filtered) { pointsPerSet = min(max(value, 1), 99) }
|
||||||
|
}
|
||||||
|
WizardOutlinedField(label: "Punti tie-break (ultimo set)", text: $pointsDecidingSetText)
|
||||||
|
.onChange(of: pointsDecidingSetText) { text in
|
||||||
|
let filtered = String(text.filter(\.isNumber).prefix(2))
|
||||||
|
if filtered != text { pointsDecidingSetText = filtered }
|
||||||
|
if let value = Int(filtered) { pointsDecidingSet = min(max(value, 1), 99) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveAndContinue() {
|
||||||
|
let trimmedOpponent = opponent.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedOpponent.isEmpty else {
|
||||||
|
onError("Inserisci il nome avversario")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if customRules && ["volley", "racket"].contains(boardType) {
|
||||||
|
guard let perSet = Int(pointsPerSetText), perSet >= 1 else {
|
||||||
|
onError("Inserisci i punti per vincere un set")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let deciding = Int(pointsDecidingSetText), deciding >= 1 else {
|
||||||
|
onError("Inserisci i punti del tie-break")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pointsPerSet = perSet
|
||||||
|
pointsDecidingSet = deciding
|
||||||
|
}
|
||||||
|
|
||||||
|
if customRules && ["basket", "timed"].contains(boardType) {
|
||||||
|
guard let periodMins = Int(periodDurationText), periodMins >= 1 else {
|
||||||
|
onError("Inserisci la durata del periodo in minuti")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let overtimeMins = Int(overtimeDurationText), overtimeMins >= 1 else {
|
||||||
|
onError("Inserisci la durata dei supplementari in minuti")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
periodDurationMins = periodMins
|
||||||
|
overtimeDurationMins = overtimeMins
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedSets: Int
|
||||||
|
if customRules && ["volley", "racket"].contains(boardType) {
|
||||||
|
resolvedSets = setsToWin
|
||||||
|
} else {
|
||||||
|
resolvedSets = WizardDefaults.setsToWin
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedRules: ScoringRules?
|
||||||
|
if customRules && ["volley", "racket"].contains(boardType) {
|
||||||
|
resolvedRules = ScoringRules(
|
||||||
|
pointsPerSet: pointsPerSet,
|
||||||
|
pointsDecidingSet: pointsDecidingSet,
|
||||||
|
minPointLead: match.scoringRules?.minPointLead ?? WizardDefaults.minPointLead,
|
||||||
|
periods: WizardDefaults.periods,
|
||||||
|
periodDurationSecs: WizardDefaults.periodDurationSecs,
|
||||||
|
overtimeDurationSecs: WizardDefaults.overtimeDurationSecs
|
||||||
|
)
|
||||||
|
} else if customRules && ["basket", "timed"].contains(boardType) {
|
||||||
|
resolvedRules = ScoringRules(
|
||||||
|
pointsPerSet: WizardDefaults.pointsPerSet,
|
||||||
|
pointsDecidingSet: WizardDefaults.pointsDecidingSet,
|
||||||
|
minPointLead: WizardDefaults.minPointLead,
|
||||||
|
periods: periods,
|
||||||
|
periodDurationSecs: periodDurationMins * 60,
|
||||||
|
overtimeDurationSecs: overtimeDurationMins * 60
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
resolvedRules = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialHomeColor = ColorHex.normalizeHexColor(homeTeam?.primaryColor ?? match.homePrimaryColor)
|
||||||
|
let homeBrandingChanged =
|
||||||
|
ColorHex.normalizeHexColor(homePrimaryColor) != initialHomeColor || homeLogoImage != nil
|
||||||
|
|
||||||
|
let overlayKind: String? = {
|
||||||
|
if customOverlay && selectedOverlay != defaultOverlay { return selectedOverlay }
|
||||||
|
return ""
|
||||||
|
}()
|
||||||
|
|
||||||
|
guard !saving else { return }
|
||||||
|
saving = true
|
||||||
|
Task { @MainActor in
|
||||||
|
defer { saving = false }
|
||||||
|
do {
|
||||||
|
if homeBrandingChanged, let team = homeTeam {
|
||||||
|
_ = try await container.matchRepository.updateTeamBranding(
|
||||||
|
teamId: team.id,
|
||||||
|
primaryColor: ColorHex.normalizeHexColor(homePrimaryColor),
|
||||||
|
secondaryColor: team.secondaryColor,
|
||||||
|
logoImage: homeLogoImage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let updated = try await container.matchRepository.updateMatch(
|
||||||
|
matchId: match.id,
|
||||||
|
opponentName: trimmedOpponent,
|
||||||
|
location: location.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
|
||||||
|
scheduledAt: match.scheduledAt,
|
||||||
|
setsToWin: resolvedSets,
|
||||||
|
category: campionato.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
|
||||||
|
opponentPrimaryColor: opponentPrimaryColor,
|
||||||
|
scoringRules: resolvedRules,
|
||||||
|
sportKey: effectiveSportKey,
|
||||||
|
overlayKind: overlayKind,
|
||||||
|
opponentLogoImage: opponentLogoImage
|
||||||
|
)
|
||||||
|
container.wizardSession.match = updated
|
||||||
|
onNext()
|
||||||
|
} catch {
|
||||||
|
if let message = UserFacingError.message(for: error) {
|
||||||
|
onError(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyHomeTeam(_ team: Team) {
|
||||||
|
homeTeam = team
|
||||||
|
homePrimaryColor = ColorHex.normalizeHexColor(team.primaryColor ?? match.homePrimaryColor)
|
||||||
|
homeLogoUrl = team.logoUrl ?? match.homeLogoUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||||
|
}
|
||||||
249
native/ios/MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct StepNetworkTestScreen: View {
|
||||||
|
@ObservedObject var container: AppContainer
|
||||||
|
let match: Match
|
||||||
|
let session: StreamSession
|
||||||
|
let onBack: () -> Void
|
||||||
|
let onStartLive: (String) -> Void
|
||||||
|
let onError: (String) -> Void
|
||||||
|
|
||||||
|
@State private var testing = false
|
||||||
|
@State private var testCompleted = false
|
||||||
|
@State private var ready = false
|
||||||
|
@State private var downloadMbps = 0.0
|
||||||
|
@State private var uploadMbps = 0.0
|
||||||
|
@State private var latencyMs = 0
|
||||||
|
@State private var networkType = "—"
|
||||||
|
@State private var selectedQualityLabel: String?
|
||||||
|
@State private var starting = false
|
||||||
|
@State private var currentSession: StreamSession
|
||||||
|
|
||||||
|
init(
|
||||||
|
container: AppContainer,
|
||||||
|
match: Match,
|
||||||
|
session: StreamSession,
|
||||||
|
onBack: @escaping () -> Void,
|
||||||
|
onStartLive: @escaping (String) -> Void,
|
||||||
|
onError: @escaping (String) -> Void
|
||||||
|
) {
|
||||||
|
self.container = container
|
||||||
|
self.match = match
|
||||||
|
self.session = session
|
||||||
|
self.onBack = onBack
|
||||||
|
self.onStartLive = onStartLive
|
||||||
|
self.onError = onError
|
||||||
|
_currentSession = State(initialValue: session)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shareUrl: String? {
|
||||||
|
currentSession.watchShareUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text("Test rete")
|
||||||
|
.font(MatchTypography.headlineMedium)
|
||||||
|
Text("Verifica che la connessione regga l'upload della diretta.")
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
MetricCard(
|
||||||
|
label: "Download",
|
||||||
|
value: testing ? "..." : String(format: "%.1f Mbps", downloadMbps)
|
||||||
|
)
|
||||||
|
MetricCard(
|
||||||
|
label: "Upload",
|
||||||
|
value: testing ? "..." : String(format: "%.1f Mbps", uploadMbps),
|
||||||
|
highlight: ready
|
||||||
|
)
|
||||||
|
MetricCard(
|
||||||
|
label: "Latenza",
|
||||||
|
value: testing ? "..." : "\(latencyMs) ms"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.top, 24)
|
||||||
|
|
||||||
|
MetricCard(label: "Tipo rete", value: networkType)
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
if testCompleted && ready {
|
||||||
|
Text("PRONTO PER ANDARE IN DIRETTA")
|
||||||
|
.font(MatchTypography.titleMedium)
|
||||||
|
.foregroundStyle(MatchColors.successGreen)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 20)
|
||||||
|
|
||||||
|
if let quality = selectedQualityLabel {
|
||||||
|
WizardReadOnlyField(
|
||||||
|
label: "Qualità streaming (automatica)",
|
||||||
|
value: quality
|
||||||
|
)
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if testCompleted, let shareUrl {
|
||||||
|
WizardReadOnlyField(
|
||||||
|
label: currentSession.platform == "youtube" ? "Link YouTube" : "Link diretta",
|
||||||
|
value: shareUrl
|
||||||
|
)
|
||||||
|
.padding(.top, 16)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
MatchSecondaryButton(label: "COPIA", action: { copyToClipboard(shareUrl) })
|
||||||
|
if let url = URL(string: shareUrl) {
|
||||||
|
ShareLink(
|
||||||
|
item: url,
|
||||||
|
subject: Text("Diretta — \(match.teamName) vs \(match.opponentName)"),
|
||||||
|
message: Text(shareUrl)
|
||||||
|
) {
|
||||||
|
Text("CONDIVIDI")
|
||||||
|
.font(MatchTypography.labelLarge)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 52)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
MatchSecondaryButton(label: "CONDIVIDI LINK REGIA", action: shareRegiaLink)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !testCompleted {
|
||||||
|
MatchSecondaryButton(
|
||||||
|
label: testing ? "TEST IN CORSO..." : "AVVIA TEST RETE",
|
||||||
|
action: runTest,
|
||||||
|
enabled: !testing
|
||||||
|
)
|
||||||
|
.padding(.top, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
GeometryReader { proxy in
|
||||||
|
let spacing: CGFloat = 12
|
||||||
|
let backWidth = (proxy.size.width - spacing) / 3
|
||||||
|
let forwardWidth = (proxy.size.width - spacing) * 2 / 3
|
||||||
|
HStack(spacing: spacing) {
|
||||||
|
MatchSecondaryButton(label: "Indietro", action: onBack, enabled: !starting)
|
||||||
|
.frame(width: backWidth)
|
||||||
|
MatchPrimaryButton(
|
||||||
|
label: "INIZIA >",
|
||||||
|
action: startLive,
|
||||||
|
enabled: ready,
|
||||||
|
loading: starting
|
||||||
|
)
|
||||||
|
.frame(width: forwardWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 52)
|
||||||
|
.padding(.top, 32)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
.task(id: session.id) {
|
||||||
|
await pollYoutubeIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runTest() {
|
||||||
|
testing = true
|
||||||
|
ready = false
|
||||||
|
Task { @MainActor in
|
||||||
|
networkType = DeviceTelemetry.snapshot().networkType
|
||||||
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
downloadMbps = Double.random(in: 8...20)
|
||||||
|
uploadMbps = Double.random(in: 2...6)
|
||||||
|
latencyMs = Int.random(in: 20...99)
|
||||||
|
|
||||||
|
let result = try? await container.sessionRepository.submitNetworkTest(
|
||||||
|
sessionId: currentSession.id,
|
||||||
|
downloadMbps: downloadMbps,
|
||||||
|
uploadMbps: uploadMbps,
|
||||||
|
latencyMs: latencyMs,
|
||||||
|
networkType: networkType
|
||||||
|
)
|
||||||
|
if let result {
|
||||||
|
selectedQualityLabel = formatQualityLabel(result)
|
||||||
|
if let updated = try? await container.sessionRepository.fetchSession(id: currentSession.id) {
|
||||||
|
currentSession = updated
|
||||||
|
container.wizardSession.session = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ready = result?.ready ?? (uploadMbps >= 2.0)
|
||||||
|
testCompleted = true
|
||||||
|
testing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startLive() {
|
||||||
|
guard ready, !starting else { return }
|
||||||
|
starting = true
|
||||||
|
Task { @MainActor in
|
||||||
|
defer { starting = false }
|
||||||
|
do {
|
||||||
|
let started = try await container.sessionRepository.startSession(id: currentSession.id)
|
||||||
|
currentSession = started
|
||||||
|
container.wizardSession.session = started
|
||||||
|
onStartLive(started.id)
|
||||||
|
} catch {
|
||||||
|
if let message = UserFacingError.message(for: error) {
|
||||||
|
onError(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pollYoutubeIfNeeded() async {
|
||||||
|
guard currentSession.platform == "youtube", !currentSession.youtubeReady else { return }
|
||||||
|
for _ in 0..<20 {
|
||||||
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
|
guard let updated = try? await container.sessionRepository.fetchSession(id: currentSession.id) else { continue }
|
||||||
|
currentSession = updated
|
||||||
|
container.wizardSession.session = updated
|
||||||
|
if updated.youtubeReady || !(updated.youtubeWatchUrl?.isEmpty ?? true) { return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copyToClipboard(_ text: String) {
|
||||||
|
UIPasteboard.general.string = text
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shareRegiaLink() {
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
let url = try await container.sessionRepository.createRegiaLink(sessionId: currentSession.id)
|
||||||
|
guard let link = URL(string: url) else { return }
|
||||||
|
presentShare(items: [link])
|
||||||
|
} catch {
|
||||||
|
if let message = UserFacingError.message(for: error) {
|
||||||
|
onError(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentShare(items: [Any]) {
|
||||||
|
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
|
let root = scene.windows.first?.rootViewController else { return }
|
||||||
|
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||||
|
root.present(controller, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatQualityLabel(_ result: NetworkTestResponse) -> String {
|
||||||
|
let bitrateMbps = Double(result.targetBitrate ?? 2_500_000) / 1_000_000.0
|
||||||
|
let fps = result.targetFps ?? 30
|
||||||
|
let resolution = result.qualityPreset?.hasPrefix("1080p") == true ? "1080p" : "720p"
|
||||||
|
return "\(resolution) · \(fps)fps · \(String(format: "%.1f", bitrateMbps)) Mbps"
|
||||||
|
}
|
||||||
|
}
|
||||||
147
native/ios/MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct StepTransmissionScreen: View {
|
||||||
|
@ObservedObject var container: AppContainer
|
||||||
|
let match: Match
|
||||||
|
let onBack: () -> Void
|
||||||
|
let onNext: () -> Void
|
||||||
|
let onError: (String) -> Void
|
||||||
|
|
||||||
|
@State private var team: Team?
|
||||||
|
@State private var platform = "matchlivetv"
|
||||||
|
@State private var privacy = "public"
|
||||||
|
@State private var creating = false
|
||||||
|
|
||||||
|
private var youtubeReady: Bool {
|
||||||
|
team?.isYoutubeReady == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private var youtubeSubtitle: String {
|
||||||
|
guard let team else { return "Canale in attivazione" }
|
||||||
|
if !team.canUseYoutube { return "Premium Light o Full" }
|
||||||
|
if !team.isYoutubeReady { return "Canale in attivazione" }
|
||||||
|
return team.youtubeDestinationLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
if let plan = team?.planName {
|
||||||
|
Text("Piano \(plan)")
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Piattaforma")
|
||||||
|
.font(MatchTypography.headlineMedium)
|
||||||
|
|
||||||
|
WizardPlatformCard(
|
||||||
|
title: "Match Live TV",
|
||||||
|
subtitle: "Diretta sul nostro sito (incluso)",
|
||||||
|
selected: platform == "matchlivetv",
|
||||||
|
onClick: { platform = "matchlivetv" }
|
||||||
|
)
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
WizardPlatformCard(
|
||||||
|
title: "YouTube Live",
|
||||||
|
subtitle: youtubeSubtitle,
|
||||||
|
selected: platform == "youtube",
|
||||||
|
enabled: youtubeReady,
|
||||||
|
badge: team?.canUseYoutube == false ? "Premium" : nil,
|
||||||
|
onClick: selectYoutube
|
||||||
|
)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
Text("Visibilità")
|
||||||
|
.font(MatchTypography.headlineMedium)
|
||||||
|
.padding(.top, 24)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
WizardChoiceButton(
|
||||||
|
label: "PUBBLICO",
|
||||||
|
selected: privacy == "public",
|
||||||
|
action: { privacy = "public" }
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
WizardChoiceButton(
|
||||||
|
label: "NON IN ELENCO",
|
||||||
|
selected: privacy == "unlisted",
|
||||||
|
action: { privacy = "unlisted" }
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
Text(visibilityDescription)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
Text("La partita resta sempre visibile nel backend della squadra per tutta la durata dell'abbonamento.")
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.padding(.top, 6)
|
||||||
|
|
||||||
|
WizardFooterButtons(
|
||||||
|
onBack: onBack,
|
||||||
|
forwardLabel: "AVANTI >",
|
||||||
|
forwardLoading: creating,
|
||||||
|
onForward: createSessionAndContinue
|
||||||
|
)
|
||||||
|
.padding(.top, 32)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
.task {
|
||||||
|
if let cached = container.wizardSession.team {
|
||||||
|
team = cached
|
||||||
|
} else {
|
||||||
|
team = await container.matchRepository.fetchTeam(teamId: match.teamId)
|
||||||
|
container.wizardSession.team = team
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var visibilityDescription: String {
|
||||||
|
if privacy == "public" {
|
||||||
|
return "Compare nell'elenco dirette su MatchLiveTV.it, nelle ricerche e sul canale YouTube (se selezionato)."
|
||||||
|
}
|
||||||
|
return "Non compare negli elenchi pubblici né nelle ricerche. Solo chi ha il link può guardare."
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectYoutube() {
|
||||||
|
if youtubeReady {
|
||||||
|
platform = "youtube"
|
||||||
|
} else {
|
||||||
|
onError("YouTube non disponibile per questa squadra")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createSessionAndContinue() {
|
||||||
|
creating = true
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let session = try await container.sessionRepository.createSession(
|
||||||
|
matchId: match.id,
|
||||||
|
platform: platform,
|
||||||
|
privacyStatus: privacy,
|
||||||
|
youtubeChannel: platform == "youtube" ? team?.effectiveYoutubeChannel : nil
|
||||||
|
)
|
||||||
|
container.wizardSession.session = session
|
||||||
|
onNext()
|
||||||
|
} catch {
|
||||||
|
if let message = UserFacingError.message(for: error) {
|
||||||
|
onError(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
creating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
224
native/ios/MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import PhotosUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TeamBrandingRow: View {
|
||||||
|
let sectionLabel: String
|
||||||
|
@Binding var teamName: String
|
||||||
|
let nameEditable: Bool
|
||||||
|
let remoteLogoUrl: String?
|
||||||
|
@Binding var localLogoImage: UIImage?
|
||||||
|
@Binding var primaryColorHex: String
|
||||||
|
let placeholderColorSeed: String
|
||||||
|
|
||||||
|
@State private var showCustomize = false
|
||||||
|
|
||||||
|
private var hasLogo: Bool {
|
||||||
|
localLogoImage != nil || !(remoteLogoUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isConfigured: Bool {
|
||||||
|
hasLogo && !primaryColorHex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private var displayColorHex: String {
|
||||||
|
let trimmed = primaryColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty ? ColorHex.placeholderTeamColor(seed: placeholderColorSeed) : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(sectionLabel)
|
||||||
|
.font(MatchTypography.labelLarge)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if isConfigured {
|
||||||
|
TeamLogoImage(remoteLogoUrl: remoteLogoUrl, localLogoImage: localLogoImage, size: 44)
|
||||||
|
ColorAccentBar(color: ColorHex.swiftUIColor(displayColorHex), height: 36)
|
||||||
|
Text(teamName.isEmpty ? "Squadra" : teamName)
|
||||||
|
.font(MatchTypography.titleMedium)
|
||||||
|
.lineLimit(1)
|
||||||
|
} else {
|
||||||
|
ColorAccentBar(color: ColorHex.swiftUIColor(displayColorHex), height: 32)
|
||||||
|
if nameEditable {
|
||||||
|
TextField("Nome avversario", text: $teamName)
|
||||||
|
.font(MatchTypography.titleMedium)
|
||||||
|
.padding(8)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
|
||||||
|
} else {
|
||||||
|
Text(teamName.isEmpty ? "Squadra" : teamName)
|
||||||
|
.font(MatchTypography.titleMedium)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Button { showCustomize = true } label: {
|
||||||
|
Image(systemName: "ellipsis")
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showCustomize) {
|
||||||
|
TeamBrandingCustomizeSheet(
|
||||||
|
title: sectionLabel,
|
||||||
|
teamName: $teamName,
|
||||||
|
nameEditable: nameEditable,
|
||||||
|
remoteLogoUrl: remoteLogoUrl,
|
||||||
|
localLogoImage: $localLogoImage,
|
||||||
|
primaryColorHex: $primaryColorHex,
|
||||||
|
fallbackColorHex: ColorHex.placeholderTeamColor(seed: placeholderColorSeed),
|
||||||
|
onDismiss: { showCustomize = false }
|
||||||
|
)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TeamBrandingCustomizeSheet: View {
|
||||||
|
let title: String
|
||||||
|
@Binding var teamName: String
|
||||||
|
let nameEditable: Bool
|
||||||
|
let remoteLogoUrl: String?
|
||||||
|
@Binding var localLogoImage: UIImage?
|
||||||
|
@Binding var primaryColorHex: String
|
||||||
|
let fallbackColorHex: String
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
@State private var draftName: String = ""
|
||||||
|
@State private var draftColor: String = ""
|
||||||
|
@State private var logoPickerItem: PhotosPickerItem?
|
||||||
|
|
||||||
|
private var hasLogo: Bool {
|
||||||
|
localLogoImage != nil || !(remoteLogoUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text("Personalizza").font(MatchTypography.headlineMedium)
|
||||||
|
Text(title)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
if nameEditable {
|
||||||
|
Text("Nome squadra").font(MatchTypography.labelLarge).padding(.top, 20)
|
||||||
|
TextField("Nome avversario", text: $draftName)
|
||||||
|
.padding(12)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
|
||||||
|
.padding(.top, 8)
|
||||||
|
} else {
|
||||||
|
Text("Nome squadra").font(MatchTypography.labelLarge).padding(.top, 20)
|
||||||
|
Text(teamName).font(MatchTypography.titleMedium).padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Logo").font(MatchTypography.labelLarge).padding(.top, 20)
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
TeamLogoImage(remoteLogoUrl: remoteLogoUrl, localLogoImage: localLogoImage, size: 72)
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
PhotosPicker(selection: $logoPickerItem, matching: .images) {
|
||||||
|
Text("CARICA LOGO")
|
||||||
|
.font(MatchTypography.labelLarge)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 52)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
if hasLogo {
|
||||||
|
MatchSecondaryButton(label: "RIMUOVI", action: {
|
||||||
|
localLogoImage = nil
|
||||||
|
logoPickerItem = nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 10)
|
||||||
|
|
||||||
|
Text("Colore squadra").font(MatchTypography.labelLarge).padding(.top, 20)
|
||||||
|
TeamColorPickerPanel(initialColorHex: draftColor.isEmpty ? fallbackColorHex : draftColor) { draftColor = $0 }
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
MatchPrimaryButton(label: "SALVA", action: save)
|
||||||
|
.padding(.top, 24)
|
||||||
|
MatchSecondaryButton(label: "ANNULLA", action: onDismiss)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
.background(MatchColors.surfaceElevated)
|
||||||
|
.onAppear {
|
||||||
|
draftName = teamName
|
||||||
|
draftColor = primaryColorHex.isEmpty ? fallbackColorHex : primaryColorHex
|
||||||
|
}
|
||||||
|
.onChange(of: logoPickerItem) { item in
|
||||||
|
guard let item else { return }
|
||||||
|
Task {
|
||||||
|
if let data = try? await item.loadTransferable(type: Data.self),
|
||||||
|
let image = UIImage(data: data) {
|
||||||
|
localLogoImage = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
if nameEditable { teamName = draftName.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
primaryColorHex = ColorHex.normalizeHexColor(draftColor, fallback: fallbackColorHex)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TeamLogoImage: View {
|
||||||
|
let remoteLogoUrl: String?
|
||||||
|
let localLogoImage: UIImage?
|
||||||
|
let size: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let localLogoImage {
|
||||||
|
Image(uiImage: localLogoImage)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
} else if let urlString = MediaUrl.resolve(remoteLogoUrl), let url = URL(string: urlString) {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
if let image = phase.image {
|
||||||
|
image.resizable().scaledToFill()
|
||||||
|
} else {
|
||||||
|
placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: size > 50 ? 12 : 8))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: size > 50 ? 12 : 8).stroke(MatchColors.outline, lineWidth: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var placeholder: some View {
|
||||||
|
ZStack {
|
||||||
|
MatchColors.surface
|
||||||
|
Text("Nessun logo")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ColorAccentBar: View {
|
||||||
|
let color: Color
|
||||||
|
let height: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 5, height: height)
|
||||||
|
}
|
||||||
|
}
|
||||||
91
native/ios/MatchLiveTv/UI/Wizard/TeamColorPicker.swift
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TeamColorPickerPanel: View {
|
||||||
|
let initialColorHex: String
|
||||||
|
let onColorChange: (String) -> Void
|
||||||
|
|
||||||
|
@State private var hue: Double = 0
|
||||||
|
@State private var saturation: Double = 1
|
||||||
|
@State private var brightness: Double = 1
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let selected = ColorHex.swiftUIColor(
|
||||||
|
ColorHex.hsvToHex(hue: hue, saturation: saturation, brightness: brightness)
|
||||||
|
)
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
Circle()
|
||||||
|
.fill(selected)
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.overlay(Circle().stroke(MatchColors.outline, lineWidth: 2))
|
||||||
|
saturationBrightnessPicker
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Tonalità")
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
hueGradient
|
||||||
|
Slider(value: $hue, in: 0...360) { _ in emit() }
|
||||||
|
.tint(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
let hsv = ColorHex.hsvComponents(from: initialColorHex)
|
||||||
|
hue = hsv.hue
|
||||||
|
saturation = hsv.saturation
|
||||||
|
brightness = hsv.brightness
|
||||||
|
}
|
||||||
|
.onChange(of: hue) { _ in emit() }
|
||||||
|
.onChange(of: saturation) { _ in emit() }
|
||||||
|
.onChange(of: brightness) { _ in emit() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hueGradient: some View {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.red, .yellow, .green, .cyan, .blue, .purple, .red],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
.frame(height: 16)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var saturationBrightnessPicker: some View {
|
||||||
|
let baseHue = Color(
|
||||||
|
uiColor: UIColor(
|
||||||
|
hue: CGFloat(hue / 360),
|
||||||
|
saturation: 1,
|
||||||
|
brightness: 1,
|
||||||
|
alpha: 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return GeometryReader { geo in
|
||||||
|
ZStack {
|
||||||
|
Rectangle().fill(.white)
|
||||||
|
Rectangle().fill(LinearGradient(colors: [.white, baseHue], startPoint: .leading, endPoint: .trailing))
|
||||||
|
Rectangle().fill(LinearGradient(colors: [.clear, .black], startPoint: .top, endPoint: .bottom))
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(.white, lineWidth: 3)
|
||||||
|
.background(Circle().stroke(Color.black.opacity(0.35), lineWidth: 1.5))
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
.position(
|
||||||
|
x: saturation * geo.size.width,
|
||||||
|
y: (1 - brightness) * geo.size.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(MatchColors.outline, lineWidth: 1))
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.onChanged { value in
|
||||||
|
saturation = min(max(value.location.x / geo.size.width, 0), 1)
|
||||||
|
brightness = 1 - min(max(value.location.y / geo.size.height, 0), 1)
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(height: 140)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emit() {
|
||||||
|
onColorChange(ColorHex.hsvToHex(hue: hue, saturation: saturation, brightness: brightness))
|
||||||
|
}
|
||||||
|
}
|
||||||
166
native/ios/MatchLiveTv/UI/Wizard/WizardComponents.swift
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
private let wizardStepTitles = ["01 · Partita", "02 · Trasmissione", "03 · Test rete"]
|
||||||
|
|
||||||
|
func wizardStepTitle(_ step: Int) -> String {
|
||||||
|
wizardStepTitles[min(max(step, 1), 3) - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WizardStepIndicator: View {
|
||||||
|
let currentStep: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(1...3, id: \.self) { step in
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(step <= currentStep ? MatchColors.primaryRed : MatchColors.surfaceElevated)
|
||||||
|
.frame(height: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WizardFooterButtons: View {
|
||||||
|
var showBack: Bool = true
|
||||||
|
let onBack: () -> Void
|
||||||
|
let forwardLabel: String
|
||||||
|
var forwardLoading: Bool = false
|
||||||
|
let onForward: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if showBack {
|
||||||
|
MatchSecondaryButton(label: "Indietro", action: onBack)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
MatchPrimaryButton(label: forwardLabel, action: onForward, loading: forwardLoading)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WizardOutlinedField: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(label)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
TextField("", text: $text)
|
||||||
|
.padding(12)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(MatchColors.outline, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WizardReadOnlyField: View {
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(label).font(MatchTypography.bodyMedium)
|
||||||
|
Text(value).font(MatchTypography.titleMedium)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(14)
|
||||||
|
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WizardChoiceButton: View {
|
||||||
|
let label: String
|
||||||
|
let selected: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if selected {
|
||||||
|
MatchPrimaryButton(label: label, action: action)
|
||||||
|
} else {
|
||||||
|
MatchSecondaryButton(label: label, action: action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MetricCard: View {
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
var highlight: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(label)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
Text(value)
|
||||||
|
.font(MatchTypography.titleMedium)
|
||||||
|
.foregroundStyle(highlight ? MatchColors.successGreen : .white)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
highlight ? MatchColors.successGreen.opacity(0.12) : MatchColors.surfaceElevated,
|
||||||
|
in: RoundedRectangle(cornerRadius: 10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WizardPlatformCard: View {
|
||||||
|
let title: String
|
||||||
|
let subtitle: String?
|
||||||
|
let selected: Bool
|
||||||
|
var enabled: Bool = true
|
||||||
|
var badge: String?
|
||||||
|
let onClick: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onClick) {
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
Text(selected ? "●" : "○")
|
||||||
|
.foregroundStyle(selected ? MatchColors.primaryRed : MatchColors.textSecondary)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(MatchTypography.titleMedium)
|
||||||
|
.foregroundStyle(.white.opacity(enabled ? 1 : 0.55))
|
||||||
|
if let subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary.opacity(enabled ? 1 : 0.55))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
if let badge {
|
||||||
|
Text(badge)
|
||||||
|
.font(MatchTypography.labelLarge)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(
|
||||||
|
selected ? MatchColors.primaryRed.opacity(0.15) : MatchColors.surfaceElevated,
|
||||||
|
in: RoundedRectangle(cornerRadius: 10)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(selected ? MatchColors.primaryRed : .clear, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!enabled)
|
||||||
|
.opacity(enabled ? 1 : 0.55)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
113
native/ios/MatchLiveTv/UI/Wizard/WizardShellScreen.swift
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WizardShellScreen: View {
|
||||||
|
@ObservedObject var container: AppContainer
|
||||||
|
let matchId: String
|
||||||
|
let initialStep: Int
|
||||||
|
let onClose: () -> Void
|
||||||
|
let onStartLive: (String) -> Void
|
||||||
|
|
||||||
|
@State private var match: Match?
|
||||||
|
@State private var team: Team?
|
||||||
|
@State private var currentStep: Int
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
init(container: AppContainer, matchId: String, step: Int, onClose: @escaping () -> Void, onStartLive: @escaping (String) -> Void) {
|
||||||
|
self.container = container
|
||||||
|
self.matchId = matchId
|
||||||
|
self.initialStep = step
|
||||||
|
self.onClose = onClose
|
||||||
|
self.onStartLive = onStartLive
|
||||||
|
_currentStep = State(initialValue: min(max(step, 1), 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
MatchScreenScaffold(
|
||||||
|
topBar: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: onClose) {
|
||||||
|
Image(systemName: "xmark").foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
Text(wizardStepTitle(currentStep))
|
||||||
|
.font(MatchTypography.titleMedium)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if currentStep == 1, let team {
|
||||||
|
MatchStatusBadge(text: team.sportKey.uppercased())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
if let match {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
WizardStepIndicator(currentStep: currentStep)
|
||||||
|
Group {
|
||||||
|
switch currentStep {
|
||||||
|
case 1:
|
||||||
|
StepMatchScreen(
|
||||||
|
container: container,
|
||||||
|
match: match,
|
||||||
|
onNext: { currentStep = 2 },
|
||||||
|
onError: { presentError($0) }
|
||||||
|
)
|
||||||
|
case 2:
|
||||||
|
StepTransmissionScreen(
|
||||||
|
container: container,
|
||||||
|
match: match,
|
||||||
|
onBack: { currentStep = 1 },
|
||||||
|
onNext: { currentStep = 3 },
|
||||||
|
onError: { presentError($0) }
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
if let session = container.wizardSession.session {
|
||||||
|
StepNetworkTestScreen(
|
||||||
|
container: container,
|
||||||
|
match: match,
|
||||||
|
session: session,
|
||||||
|
onBack: { currentStep = 2 },
|
||||||
|
onStartLive: onStartLive,
|
||||||
|
onError: { presentError($0) }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Completa lo step Trasmissione")
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
} else {
|
||||||
|
ProgressView().tint(MatchColors.primaryRed)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.task {
|
||||||
|
do {
|
||||||
|
let loaded = try await container.matchRepository.fetchMatch(matchId: matchId)
|
||||||
|
match = loaded
|
||||||
|
container.wizardSession.match = loaded
|
||||||
|
team = await container.matchRepository.fetchTeam(teamId: loaded.teamId)
|
||||||
|
container.wizardSession.team = team
|
||||||
|
} catch {
|
||||||
|
if let message = UserFacingError.message(for: error) {
|
||||||
|
presentError(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(error ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentError(_ message: String) {
|
||||||
|
guard !message.isEmpty else { return }
|
||||||
|
error = message
|
||||||
|
}
|
||||||
|
}
|
||||||
47
native/ios/MatchLiveTvTests/ApiInstantTests.swift
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Smoke test eseguibile senza @testable import (validazione logica pura).
|
||||||
|
final class ApiInstantTests: XCTestCase {
|
||||||
|
func testOverlayKindMapping() {
|
||||||
|
XCTAssertEqual(OverlayKindMapping.fromApi("basket"), "basket")
|
||||||
|
XCTAssertEqual(OverlayKindMapping.fromApi("none"), "none")
|
||||||
|
XCTAssertEqual(OverlayKindMapping.fromApi(nil), "volley")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRTMPUrlParser() {
|
||||||
|
let target = RTMPUrlParserLogic.parse("rtmp://stream.example.com/live/match_abc-123")
|
||||||
|
XCTAssertEqual(target?.connectURL, "rtmp://stream.example.com/live")
|
||||||
|
XCTAssertEqual(target?.streamName, "match_abc-123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OverlayKindMapping {
|
||||||
|
static func fromApi(_ value: String?) -> String {
|
||||||
|
switch value?.lowercased() {
|
||||||
|
case "none", "timer": return "none"
|
||||||
|
case "basket": return "basket"
|
||||||
|
case "timed": return "timed"
|
||||||
|
case "racket": return "racket"
|
||||||
|
default: return "volley"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RTMPUrlParserLogic {
|
||||||
|
struct Target {
|
||||||
|
let connectURL: String
|
||||||
|
let streamName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parse(_ urlString: String) -> Target? {
|
||||||
|
guard let url = URL(string: urlString), url.scheme?.hasPrefix("rtmp") == true else { return nil }
|
||||||
|
let pathParts = url.path.split(separator: "/").map(String.init)
|
||||||
|
guard pathParts.count >= 2 else { return nil }
|
||||||
|
let app = pathParts.dropLast().joined(separator: "/")
|
||||||
|
let streamName = pathParts.last ?? ""
|
||||||
|
var connect = "rtmp://\(url.host ?? "")"
|
||||||
|
if let port = url.port { connect += ":\(port)" }
|
||||||
|
connect += "/\(app)"
|
||||||
|
return Target(connectURL: connect, streamName: streamName)
|
||||||
|
}
|
||||||
|
}
|
||||||
140
native/ios/MatchLiveTvTests/MatchScoringRulesTests.swift
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import MatchLiveTv
|
||||||
|
|
||||||
|
final class MatchScoringRulesTests: XCTestCase {
|
||||||
|
private func volleyMatch(setsToWin: Int = 3, rules: ScoringRules? = nil) -> Match {
|
||||||
|
Match(
|
||||||
|
id: "match-1",
|
||||||
|
teamId: "team-1",
|
||||||
|
teamName: "Tigers Volley",
|
||||||
|
opponentName: "Avversario",
|
||||||
|
location: nil,
|
||||||
|
scheduledAt: nil,
|
||||||
|
sportKey: "pallavolo",
|
||||||
|
sportLabel: "Pallavolo",
|
||||||
|
boardType: "volley",
|
||||||
|
overlayKind: "volley",
|
||||||
|
effectiveOverlayKind: "volley",
|
||||||
|
setsToWin: setsToWin,
|
||||||
|
category: nil,
|
||||||
|
scoringRules: rules,
|
||||||
|
activeSessionId: nil,
|
||||||
|
activeSessionStatus: nil,
|
||||||
|
streamCompleted: false,
|
||||||
|
coachHubVisible: nil,
|
||||||
|
homePrimaryColor: "#FF2D2D",
|
||||||
|
homeSecondaryColor: nil,
|
||||||
|
homeLogoUrl: nil,
|
||||||
|
opponentPrimaryColor: "#1E3A8A",
|
||||||
|
opponentLogoUrl: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSetWinnerAt26_12() {
|
||||||
|
let rules = MatchScoringContext(match: volleyMatch())
|
||||||
|
XCTAssertEqual(rules.setWinnerFromPoints(homePoints: 26, awayPoints: 12, currentSet: 1), .home)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSetWinnerRequiresMinLead() {
|
||||||
|
let rules = MatchScoringContext(match: volleyMatch())
|
||||||
|
XCTAssertNil(rules.setWinnerFromPoints(homePoints: 25, awayPoints: 24, currentSet: 1))
|
||||||
|
XCTAssertEqual(rules.setWinnerFromPoints(homePoints: 25, awayPoints: 23, currentSet: 1), .home)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecidingSetUses15Points() {
|
||||||
|
let rules = MatchScoringContext(match: volleyMatch(setsToWin: 3))
|
||||||
|
XCTAssertEqual(rules.maxSets, 5)
|
||||||
|
XCTAssertEqual(rules.pointsTarget(currentSet: 5), 15)
|
||||||
|
XCTAssertEqual(rules.setWinnerFromPoints(homePoints: 15, awayPoints: 13, currentSet: 5), .home)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMatchWinnerAfterThreeSets() {
|
||||||
|
let rules = MatchScoringContext(match: volleyMatch(setsToWin: 3))
|
||||||
|
XCTAssertEqual(rules.matchWinner(homeSets: 3, awaySets: 1), .home)
|
||||||
|
XCTAssertNil(rules.matchWinner(homeSets: 2, awaySets: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCustomScoringRulesFromMatch() {
|
||||||
|
let custom = ScoringRules(pointsPerSet: 21, pointsDecidingSet: 15, minPointLead: 2)
|
||||||
|
let rules = MatchScoringContext(match: volleyMatch(setsToWin: 2, rules: custom))
|
||||||
|
XCTAssertEqual(rules.pointsTarget(currentSet: 1), 21)
|
||||||
|
XCTAssertEqual(rules.setWinnerFromPoints(homePoints: 21, awayPoints: 19, currentSet: 1), .home)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUsesSetLogicOnlyForVolleyAndRacket() {
|
||||||
|
XCTAssertTrue(MatchScoringContext(match: volleyMatch()).usesSetLogic)
|
||||||
|
var basket = volleyMatch()
|
||||||
|
basket = Match(
|
||||||
|
id: basket.id,
|
||||||
|
teamId: basket.teamId,
|
||||||
|
teamName: basket.teamName,
|
||||||
|
opponentName: basket.opponentName,
|
||||||
|
location: basket.location,
|
||||||
|
scheduledAt: basket.scheduledAt,
|
||||||
|
sportKey: "basket",
|
||||||
|
sportLabel: "Basket",
|
||||||
|
boardType: "basket",
|
||||||
|
overlayKind: "basket",
|
||||||
|
effectiveOverlayKind: "basket",
|
||||||
|
setsToWin: 0,
|
||||||
|
category: nil,
|
||||||
|
scoringRules: nil,
|
||||||
|
activeSessionId: nil,
|
||||||
|
activeSessionStatus: nil,
|
||||||
|
streamCompleted: false,
|
||||||
|
coachHubVisible: nil,
|
||||||
|
homePrimaryColor: nil,
|
||||||
|
homeSecondaryColor: nil,
|
||||||
|
homeLogoUrl: nil,
|
||||||
|
opponentPrimaryColor: nil,
|
||||||
|
opponentLogoUrl: nil
|
||||||
|
)
|
||||||
|
XCTAssertFalse(MatchScoringContext(match: basket).usesSetLogic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecodesFlexibleScorePayload() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"board_type": "basket",
|
||||||
|
"home_points": 42,
|
||||||
|
"away_points": 38,
|
||||||
|
"current_set": 1,
|
||||||
|
"period": "Q3",
|
||||||
|
"clock_secs": 312,
|
||||||
|
"clock_running": 1,
|
||||||
|
"data": { "home_score": 42, "away_score": 38, "period": 3, "overtime": false }
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
let dto = try JSONDecoder().decode(ScoreStateDto.self, from: json)
|
||||||
|
let score = dto.toDomain()
|
||||||
|
XCTAssertEqual(score.boardType, "basket")
|
||||||
|
XCTAssertEqual(score.homePoints, 42)
|
||||||
|
XCTAssertEqual(score.periodLabel, "Q3")
|
||||||
|
XCTAssertEqual(score.clockSecs, 312)
|
||||||
|
XCTAssertTrue(score.clockRunning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ScoreboardOverlayTests: XCTestCase {
|
||||||
|
func testScoreboardColumnsAfterSetClosed() {
|
||||||
|
let score = ScoreState(
|
||||||
|
homeSets: 1,
|
||||||
|
awaySets: 0,
|
||||||
|
homePoints: 0,
|
||||||
|
awayPoints: 0,
|
||||||
|
currentSet: 2,
|
||||||
|
setPartials: [SetPartial(set: 1, home: 26, away: 12)],
|
||||||
|
boardType: "volley"
|
||||||
|
)
|
||||||
|
let board = score.toScoreboardState(homeTeamName: "Casa", awayTeamName: "Ospite")
|
||||||
|
XCTAssertEqual(board.columns.count, 2)
|
||||||
|
XCTAssertEqual(board.columns[0].setNumber, 1)
|
||||||
|
XCTAssertEqual(board.columns[0].homePoints, 26)
|
||||||
|
XCTAssertEqual(board.columns[0].awayPoints, 12)
|
||||||
|
XCTAssertFalse(board.columns[0].isCurrent)
|
||||||
|
XCTAssertEqual(board.columns[1].setNumber, 2)
|
||||||
|
XCTAssertTrue(board.columns[1].isCurrent)
|
||||||
|
XCTAssertEqual(board.columns[1].homePoints, 0)
|
||||||
|
XCTAssertEqual(board.columns[1].awayPoints, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
84
native/ios/README.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Match Live TV — app iOS nativa
|
||||||
|
|
||||||
|
Porting SwiftUI dell'app Android con stessa UI, brand e contratti API backend.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- UI: SwiftUI, NavigationStack
|
||||||
|
- Rete: URLSession + Codable
|
||||||
|
- Auth: Keychain
|
||||||
|
- Streaming: HaishinKit (RTMP) + AVFoundation
|
||||||
|
- Overlay: Core Graphics (bitmap composito su pipeline video)
|
||||||
|
|
||||||
|
## Struttura
|
||||||
|
|
||||||
|
```
|
||||||
|
MatchLiveTv/
|
||||||
|
App/ — entry point
|
||||||
|
Core/ — config, token store, utility
|
||||||
|
Data/ — API, repository, ActionCable
|
||||||
|
Domain/ — modelli
|
||||||
|
Streaming/ — motore RTMP, overlay nativi
|
||||||
|
UI/ — schermate SwiftUI
|
||||||
|
Resources/ — Assets.xcassets (brand), Info.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parità multi-sport (riferimento: `docs/TABELLONI_E_OVERLAY.md`)
|
||||||
|
|
||||||
|
| Board | Controlli broadcast | Overlay video | Stato iOS |
|
||||||
|
|-------|---------------------|---------------|-----------|
|
||||||
|
| volley / racket | +1, undo, chiudi set (API `close_set`) | Tabellone a colonne | Completo |
|
||||||
|
| basket | +1/+2/+3, undo, `advance_period` | Compact scoreboard | Completo |
|
||||||
|
| timed | +1, undo, `advance_period` | Compact scoreboard | Completo |
|
||||||
|
| generic | +1 / undo locale | Watermark | Completo |
|
||||||
|
| timer | — | Solo watermark | Completo |
|
||||||
|
|
||||||
|
Il cronometro basket/timed è gestito in **regia web**, non sull'overlay video (come Android).
|
||||||
|
|
||||||
|
## Asset brand
|
||||||
|
|
||||||
|
Gli asset in `brand/` sono importati in `Resources/Assets.xcassets`:
|
||||||
|
- `logo-white-m`, `logo-white-m-256` — wordmark e watermark
|
||||||
|
- `logo-dark-gradient`, `logo-dark-gradient-256` — app icon
|
||||||
|
- `CopertinaCanale*.png` — riferimenti visivi brand
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 generate_xcodeproj.py # rigenera MatchLiveTv.xcodeproj
|
||||||
|
./scripts/build_ios_release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Override API: `API_BASE_URL=https://staging.example.com ./scripts/build_ios_release.sh`
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd native/ios
|
||||||
|
python3 generate_xcodeproj.py
|
||||||
|
xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||||
|
-derivedDataPath build/DerivedData \
|
||||||
|
ONLY_ACTIVE_ARCH=YES ARCHS=arm64 EXCLUDED_ARCHS=x86_64 \
|
||||||
|
test -only-testing:MatchLiveTvTests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requisiti
|
||||||
|
|
||||||
|
- Xcode 16+
|
||||||
|
- iOS 16+
|
||||||
|
- Mac con Apple Silicon o Intel
|
||||||
|
|
||||||
|
## Funzionalità
|
||||||
|
|
||||||
|
- Login JWT, hub partite (reload al ritorno da diretta), wizard 3 step
|
||||||
|
- Diretta RTMP 1280×720 con overlay tabellone/watermark
|
||||||
|
- ActionCable SessionChannel per punteggio e comandi regia
|
||||||
|
- Telemetria dispositivo periodica
|
||||||
|
- Orientamento: portrait ovunque, landscape solo in broadcast
|
||||||
|
|
||||||
|
## Documentazione correlata
|
||||||
|
|
||||||
|
- [`docs/TABELLONI_E_OVERLAY.md`](../../docs/TABELLONI_E_OVERLAY.md) — regolamenti, board, overlay
|
||||||
|
- [`docs/MULTI_SPORT.md`](../../docs/MULTI_SPORT.md) — catalogo sport e engine scoring
|
||||||
|
- [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) — infrastruttura e flussi live/HLS
|
||||||
183
native/ios/generate_xcodeproj.py
Executable file
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from pathlib import Path
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parent
|
||||||
|
APP_DIR = ROOT / "MatchLiveTv"
|
||||||
|
TEST_DIR = ROOT / "MatchLiveTvTests"
|
||||||
|
OUT = ROOT / "MatchLiveTv.xcodeproj" / "project.pbxproj"
|
||||||
|
|
||||||
|
def uid():
|
||||||
|
return uuid.uuid4().hex[:24].upper()
|
||||||
|
|
||||||
|
app_files = sorted(APP_DIR.rglob("*.swift"))
|
||||||
|
test_files = sorted(TEST_DIR.rglob("*.swift"))
|
||||||
|
|
||||||
|
ids = {f: uid() for f in app_files + test_files}
|
||||||
|
bf = {f: uid() for f in app_files + test_files}
|
||||||
|
assets_ref, assets_bf = uid(), uid()
|
||||||
|
info_ref = uid()
|
||||||
|
app_product, test_product = uid(), uid()
|
||||||
|
app_target, test_target = uid(), uid()
|
||||||
|
app_sources, app_frameworks, app_resources = uid(), uid(), uid()
|
||||||
|
test_sources, test_frameworks = uid(), uid()
|
||||||
|
main_group, app_group, test_group, products_group = uid(), uid(), uid(), uid()
|
||||||
|
proj = uid()
|
||||||
|
pkg = uid()
|
||||||
|
hk, rtmp = uid(), uid()
|
||||||
|
hk_bf, rtmp_bf = uid(), uid()
|
||||||
|
bcl_proj_dbg, bcl_proj_rel = uid(), uid()
|
||||||
|
bcl_app_dbg, bcl_app_rel = uid(), uid()
|
||||||
|
bcl_test_dbg, bcl_test_rel = uid(), uid()
|
||||||
|
cfg_app_dbg, cfg_app_rel = uid(), uid()
|
||||||
|
cfg_test_dbg, cfg_test_rel = uid(), uid()
|
||||||
|
cfg_proj_dbg, cfg_proj_rel = uid(), uid()
|
||||||
|
bcl_app, bcl_test, bcl_proj = uid(), uid(), uid()
|
||||||
|
test_dep, test_dep_proxy = uid(), uid()
|
||||||
|
scheme_id = uid()
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"// !$*UTF8*$!",
|
||||||
|
"{",
|
||||||
|
"\tarchiveVersion = 1;",
|
||||||
|
"\tclasses = {};",
|
||||||
|
"\tobjectVersion = 60;",
|
||||||
|
"\tobjects = {",
|
||||||
|
]
|
||||||
|
|
||||||
|
for f, fid in ids.items():
|
||||||
|
lines.append(f'\t\t{fid} /* {f.name} */ = {{isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = {f.name}; path = {f.relative_to(ROOT)}; sourceTree = "<group>"; }};')
|
||||||
|
|
||||||
|
lines += [
|
||||||
|
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{app_product} /* MatchLiveTv.app */ = {{isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MatchLiveTv.app; sourceTree = BUILT_PRODUCTS_DIR; }};',
|
||||||
|
f'\t\t{test_product} /* MatchLiveTvTests.xctest */ = {{isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MatchLiveTvTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }};',
|
||||||
|
f'\t\t{hk} /* HaishinKit */ = {{isa = XCSwiftPackageProductDependency; package = {pkg}; productName = HaishinKit; }};',
|
||||||
|
f'\t\t{rtmp} /* RTMPHaishinKit */ = {{isa = XCSwiftPackageProductDependency; package = {pkg}; productName = RTMPHaishinKit; }};',
|
||||||
|
]
|
||||||
|
|
||||||
|
for f, bid in bf.items():
|
||||||
|
lines.append(f'\t\t{bid} /* {f.name} in Sources */ = {{isa = PBXBuildFile; fileRef = {ids[f]} /* {f.name} */; }};')
|
||||||
|
lines += [
|
||||||
|
f'\t\t{assets_bf} /* Assets in Resources */ = {{isa = PBXBuildFile; fileRef = {assets_ref}; }};',
|
||||||
|
]
|
||||||
|
|
||||||
|
app_children = ", ".join(ids[f] for f in app_files) + f", {assets_ref}, {info_ref}"
|
||||||
|
test_children = ", ".join(ids[f] for f in test_files)
|
||||||
|
|
||||||
|
lines += [
|
||||||
|
f'\t\t{main_group} = {{isa = PBXGroup; children = ({app_group}, {test_group}, {products_group}); sourceTree = "<group>"; }};',
|
||||||
|
f'\t\t{app_group} = {{isa = PBXGroup; children = ({app_children}); name = MatchLiveTv; sourceTree = "<group>"; }};',
|
||||||
|
f'\t\t{test_group} = {{isa = PBXGroup; children = ({test_children}); name = MatchLiveTvTests; 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{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_frameworks} = {{isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }};',
|
||||||
|
f'\t\t{test_frameworks} = {{isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }};',
|
||||||
|
]
|
||||||
|
|
||||||
|
app_settings = """
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 2.0.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
"""
|
||||||
|
|
||||||
|
app_debug_settings = app_settings + """
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
"""
|
||||||
|
|
||||||
|
test_settings = """
|
||||||
|
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";
|
||||||
|
"""
|
||||||
|
|
||||||
|
lines += [
|
||||||
|
f'\t\t{cfg_app_dbg} = {{isa = XCBuildConfiguration; buildSettings = {{{app_debug_settings}\t\t\t}}; name = Debug; }};',
|
||||||
|
f'\t\t{cfg_app_rel} = {{isa = XCBuildConfiguration; buildSettings = {{{app_settings}\t\t\t}}; name = Release; }};',
|
||||||
|
f'\t\t{cfg_test_dbg} = {{isa = XCBuildConfiguration; buildSettings = {{{test_settings}\t\t\t}}; name = Debug; }};',
|
||||||
|
f'\t\t{cfg_test_rel} = {{isa = XCBuildConfiguration; buildSettings = {{{test_settings}\t\t\t}}; name = Release; }};',
|
||||||
|
f'\t\t{cfg_proj_dbg} = {{isa = XCBuildConfiguration; buildSettings = {{IPHONEOS_DEPLOYMENT_TARGET = 16.0; SWIFT_VERSION = 5.0; }}; name = Debug; }};',
|
||||||
|
f'\t\t{cfg_proj_rel} = {{isa = XCBuildConfiguration; buildSettings = {{IPHONEOS_DEPLOYMENT_TARGET = 16.0; SWIFT_VERSION = 5.0; }}; name = Release; }};',
|
||||||
|
f'\t\t{bcl_app} = {{isa = XCConfigurationList; buildConfigurations = ({cfg_app_dbg}, {cfg_app_rel}); defaultConfigurationName = Release; }};',
|
||||||
|
f'\t\t{bcl_test} = {{isa = XCConfigurationList; buildConfigurations = ({cfg_test_dbg}, {cfg_test_rel}); defaultConfigurationName = Release; }};',
|
||||||
|
f'\t\t{bcl_proj} = {{isa = XCConfigurationList; buildConfigurations = ({cfg_proj_dbg}, {cfg_proj_rel}); defaultConfigurationName = Release; }};',
|
||||||
|
f'\t\t{app_target} = {{isa = PBXNativeTarget; buildConfigurationList = {bcl_app}; buildPhases = ({app_sources}, {app_frameworks}, {app_resources}); buildRules = (); dependencies = (); name = MatchLiveTv; packageProductDependencies = ({hk}, {rtmp}); productName = MatchLiveTv; productReference = {app_product}; productType = "com.apple.product-type.application"; }};',
|
||||||
|
f'\t\t{test_dep_proxy} = {{isa = PBXContainerItemProxy; containerPortal = {proj} /* Project object */; proxyType = 1; remoteGlobalIDString = {app_target}; remoteInfo = MatchLiveTv; }};',
|
||||||
|
f'\t\t{test_dep} = {{isa = PBXTargetDependency; target = {app_target} /* MatchLiveTv */; targetProxy = {test_dep_proxy} /* PBXContainerItemProxy */; }};',
|
||||||
|
f'\t\t{test_target} = {{isa = PBXNativeTarget; buildConfigurationList = {bcl_test}; buildPhases = ({test_sources}, {test_frameworks}); buildRules = (); dependencies = ({test_dep}); name = MatchLiveTvTests; productName = MatchLiveTvTests; productReference = {test_product}; productType = "com.apple.product-type.bundle.unit-test"; }};',
|
||||||
|
f'\t\t{pkg} = {{isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/shogo4405/HaishinKit.swift"; requirement = {{kind = upToNextMajorVersion; minimumVersion = 2.0.0;}}; }};',
|
||||||
|
f'\t\t{proj} = {{isa = PBXProject; attributes = {{BuildIndependentTargetsInParallel = 0; LastSwiftUpdateCheck = 1600;}}; buildConfigurationList = {bcl_proj}; compatibilityVersion = "Xcode 15.0"; developmentRegion = it; mainGroup = {main_group}; packageReferences = ({pkg}); productRefGroup = {products_group}; targets = ({app_target}, {test_target}); }};',
|
||||||
|
"\t};",
|
||||||
|
f"\trootObject = {proj} /* Project object */;",
|
||||||
|
"}",
|
||||||
|
]
|
||||||
|
|
||||||
|
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
OUT.write_text("\n".join(lines) + "\n")
|
||||||
|
(OUT.parent / "project.xcworkspace").mkdir(exist_ok=True)
|
||||||
|
(OUT.parent / "project.xcworkspace" / "contents.xcworkspacedata").write_text(
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n<Workspace version="1.0">\n <FileRef location="self:"></FileRef>\n</Workspace>\n'
|
||||||
|
)
|
||||||
|
scheme_dir = OUT.parent / "xcshareddata" / "xcschemes"
|
||||||
|
scheme_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(scheme_dir / "MatchLiveTv.xcscheme").write_text(f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme LastUpgradeVersion="1600" version="1.7">
|
||||||
|
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
||||||
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{app_target}" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
|
||||||
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{test_target}" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
|
||||||
|
<Testables>
|
||||||
|
<TestableReference skipped="NO">
|
||||||
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{test_target}" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</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">
|
||||||
|
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||||
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{app_target}" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
|
||||||
|
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||||
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{app_target}" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction buildConfiguration="Debug"/>
|
||||||
|
<ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
|
||||||
|
</Scheme>
|
||||||
|
""")
|
||||||
|
print(f"Wrote {OUT} ({len(app_files)} app sources, {len(test_files)} test sources)")
|
||||||