diff --git a/backend/spec/services/scoring/volley_close_set_spec.rb b/backend/spec/services/scoring/volley_close_set_spec.rb new file mode 100644 index 0000000..5bce276 --- /dev/null +++ b/backend/spec/services/scoring/volley_close_set_spec.rb @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6a2d101..06f17a6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,7 +35,7 @@ Match Live TV è una piattaforma mobile-first per streaming di partite sportive | Ingest video | MediaMTX (RTMP :1935) | | Playback live | HLS (MediaMTX :8888) | | 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** | | HTTPS pubblico | Nginx Proxy Manager (NPM) → `192.168.1.146:3000` | diff --git a/docs/MULTI_SPORT.md b/docs/MULTI_SPORT.md index 3d84a98..e58abc5 100644 --- a/docs/MULTI_SPORT.md +++ b/docs/MULTI_SPORT.md @@ -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. -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 diff --git a/docs/TABELLONI_E_OVERLAY.md b/docs/TABELLONI_E_OVERLAY.md index 5a8abf0..8ddf5b2 100644 --- a/docs/TABELLONI_E_OVERLAY.md +++ b/docs/TABELLONI_E_OVERLAY.md @@ -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. -### Layout Android (pipeline video) +### Layout app mobile (Android e iOS) Sull’app nativa l’overlay è composito in GPU senza ricodifica aggiuntiva: 1. `BroadcastScreen` costruisce un `OverlayState` a partire da `effectiveOverlayKind` e `ScoreState`. 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`): @@ -334,7 +334,9 @@ Questa separazione è intenzionale: il regolamento alimenta sempre il **modello | Modello partita | `backend/app/models/match.rb` | | Stato punteggio | `backend/app/models/score_state.rb` | | 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` | Documento correlato: [`MULTI_SPORT.md`](./MULTI_SPORT.md) (panoramica più sintetica della migrazione multi-sport). diff --git a/native/ios/.gitignore b/native/ios/.gitignore new file mode 100644 index 0000000..3d59e72 --- /dev/null +++ b/native/ios/.gitignore @@ -0,0 +1,4 @@ +build/ +DerivedData/ +*.xcuserstate +xcuserdata/ diff --git a/native/ios/MatchLiveTv.xcodeproj/project.pbxproj b/native/ios/MatchLiveTv.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e2a7ff5 --- /dev/null +++ b/native/ios/MatchLiveTv.xcodeproj/project.pbxproj @@ -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 = ""; }; + D7A94757D18C46C9821833B7 /* ApiInstant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstant.swift; path = MatchLiveTv/Core/ApiInstant.swift; sourceTree = ""; }; + 1443F18A02BB4890A01A56F9 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppConfig.swift; path = MatchLiveTv/Core/AppConfig.swift; sourceTree = ""; }; + E717B8A6A51E499490C0334B /* ColorHex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ColorHex.swift; path = MatchLiveTv/Core/ColorHex.swift; sourceTree = ""; }; + 6E6A356C81724C51BF41C25D /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = ""; }; + DC18E1BC596E42DBB8BBBBFD /* MediaUrl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MediaUrl.swift; path = MatchLiveTv/Core/MediaUrl.swift; sourceTree = ""; }; + C3DCAD32C3F24439A75920C1 /* TokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TokenStore.swift; path = MatchLiveTv/Core/TokenStore.swift; sourceTree = ""; }; + B4B5CF0BD5534CF7BE51E3CB /* UserFacingError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = UserFacingError.swift; path = MatchLiveTv/Core/UserFacingError.swift; sourceTree = ""; }; + E14CA2E05DE7400C852121AD /* ApiDtos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiDtos.swift; path = MatchLiveTv/Data/API/ApiDtos.swift; sourceTree = ""; }; + BD1CEFBFF4D54361A4C3B4D2 /* MatchLiveAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveAPI.swift; path = MatchLiveTv/Data/API/MatchLiveAPI.swift; sourceTree = ""; }; + 66C7207107804520B8B87462 /* AppContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppContainer.swift; path = MatchLiveTv/Data/AppContainer.swift; sourceTree = ""; }; + BE6E2D81675945268458DEE8 /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = ""; }; + E902041CF4A04479B72760FE /* SessionCableService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionCableService.swift; path = MatchLiveTv/Data/Cable/SessionCableService.swift; sourceTree = ""; }; + 4AE94D0FE137473D8821B0E3 /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AuthRepository.swift; path = MatchLiveTv/Data/Repository/AuthRepository.swift; sourceTree = ""; }; + 8D20E18A8913489FABFBFFB5 /* MatchRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchRepository.swift; path = MatchLiveTv/Data/Repository/MatchRepository.swift; sourceTree = ""; }; + BDDCAF6027D24CB2B273A3D6 /* MatchSessionLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSessionLauncher.swift; path = MatchLiveTv/Data/Repository/MatchSessionLauncher.swift; sourceTree = ""; }; + 2A5CCF3B585A4095941A6B21 /* ScoreRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreRepository.swift; path = MatchLiveTv/Data/Repository/ScoreRepository.swift; sourceTree = ""; }; + 4436BE62A6004577BE8356C1 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = ""; }; + CF167DF2CBC14C2A97D49C1D /* ScoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreController.swift; path = MatchLiveTv/Data/Scoring/ScoreController.swift; sourceTree = ""; }; + 0BE371ACB29A48DF928B90DD /* WizardSessionHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardSessionHolder.swift; path = MatchLiveTv/Data/WizardSessionHolder.swift; sourceTree = ""; }; + 73A8C9597075432FB3DFCFFA /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = ""; }; + 116561D032ED4DF8A4174E59 /* MatchScoringRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRules.swift; path = MatchLiveTv/Domain/MatchScoringRules.swift; sourceTree = ""; }; + AC559FEC305D475CA3F1DB21 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Models.swift; path = MatchLiveTv/Domain/Models.swift; sourceTree = ""; }; + 3247EDBBC6114F269C89736C /* ScoreState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreState.swift; path = MatchLiveTv/Domain/ScoreState.swift; sourceTree = ""; }; + 15B20863445A4EC28321DBFF /* BroadcastModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastModels.swift; path = MatchLiveTv/Streaming/BroadcastModels.swift; sourceTree = ""; }; + 51B7115C3000422A8DCA41ED /* LiveBroadcastCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastCoordinator.swift; path = MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift; sourceTree = ""; }; + 7A433A01B5BB4FE0B8E51234 /* LiveBroadcastEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastEngine.swift; path = MatchLiveTv/Streaming/LiveBroadcastEngine.swift; sourceTree = ""; }; + C296D6280BCD4DC0BB467CC1 /* CompactScoreboardElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CompactScoreboardElement.swift; path = MatchLiveTv/Streaming/Overlay/CompactScoreboardElement.swift; sourceTree = ""; }; + 3F851B63647B4122A2753EBD /* OverlayCanvasRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayCanvasRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayCanvasRenderer.swift; sourceTree = ""; }; + 420794A1247149B7B931DAEA /* OverlayLogoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayLogoCache.swift; path = MatchLiveTv/Streaming/Overlay/OverlayLogoCache.swift; sourceTree = ""; }; + 48C5D1CE81864C2FBB3188EA /* OverlayMappings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayMappings.swift; path = MatchLiveTv/Streaming/Overlay/OverlayMappings.swift; sourceTree = ""; }; + 228DEA0D7B0D48D6BED86141 /* OverlayRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayRenderer.swift; sourceTree = ""; }; + 0F9F57E16B374D3AB379C1FF /* OverlayState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayState.swift; path = MatchLiveTv/Streaming/Overlay/OverlayState.swift; sourceTree = ""; }; + 0ED2AA45ECFA4193ACC31063 /* ScoreboardElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreboardElement.swift; path = MatchLiveTv/Streaming/Overlay/ScoreboardElement.swift; sourceTree = ""; }; + 1F2B2A3AC4C948038C03E8BB /* SponsorElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SponsorElement.swift; path = MatchLiveTv/Streaming/Overlay/SponsorElement.swift; sourceTree = ""; }; + 3465550280DE4F5E8D485F69 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = ""; }; + 42A4881EE59B42FEB7749122 /* LivePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LivePreviewView.swift; path = MatchLiveTv/Streaming/Preview/LivePreviewView.swift; sourceTree = ""; }; + 2B7C7A905479455F98F00DC3 /* BroadcastControlsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastControlsOverlay.swift; path = MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift; sourceTree = ""; }; + 8EBF7DD17E77419A83DC0496 /* BroadcastScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastScreen.swift; path = MatchLiveTv/UI/Broadcast/BroadcastScreen.swift; sourceTree = ""; }; + 2F0BE30BA75440D5B0A0F9A6 /* LiveScoreActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveScoreActions.swift; path = MatchLiveTv/UI/Broadcast/LiveScoreActions.swift; sourceTree = ""; }; + 1EDDB8ABF0ED4F24A5932D18 /* MatchLiveWordmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveWordmark.swift; path = MatchLiveTv/UI/Components/MatchLiveWordmark.swift; sourceTree = ""; }; + F2D8C0FDD5EB4A3792E4D32E /* MatchPrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchPrimaryButton.swift; path = MatchLiveTv/UI/Components/MatchPrimaryButton.swift; sourceTree = ""; }; + BDCE69667BE74DB9AF3E29E8 /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = ""; }; + 6E72DCB8E06D42038B6E8B69 /* MatchSecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSecondaryButton.swift; path = MatchLiveTv/UI/Components/MatchSecondaryButton.swift; sourceTree = ""; }; + DC49A86D45D745E297107891 /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = ""; }; + 456228041D2F44E99258698B /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoginScreen.swift; path = MatchLiveTv/UI/Login/LoginScreen.swift; sourceTree = ""; }; + 6C792176D0544EED87B303C0 /* MatchesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchesScreen.swift; path = MatchLiveTv/UI/Matches/MatchesScreen.swift; sourceTree = ""; }; + ED410204E5694627AEA787DD /* AppNavHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppNavHost.swift; path = MatchLiveTv/UI/Navigation/AppNavHost.swift; sourceTree = ""; }; + EFF711C0619445C3944D3442 /* ModalRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ModalRoutes.swift; path = MatchLiveTv/UI/Navigation/ModalRoutes.swift; sourceTree = ""; }; + 98EA33F31CE34BC88EC99E64 /* Routes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Routes.swift; path = MatchLiveTv/UI/Navigation/Routes.swift; sourceTree = ""; }; + 097406CB65A24473B7F8A915 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = ""; }; + F18D3BA8A2604783AFA7BADC /* SplashScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SplashScreen.swift; path = MatchLiveTv/UI/Splash/SplashScreen.swift; sourceTree = ""; }; + 8CF22D513753479E96E37546 /* KeepScreenOn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KeepScreenOn.swift; path = MatchLiveTv/UI/System/KeepScreenOn.swift; sourceTree = ""; }; + 3C42C3E8D4D24A6EBC8A140F /* ScreenOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScreenOrientation.swift; path = MatchLiveTv/UI/System/ScreenOrientation.swift; sourceTree = ""; }; + AED45F6291AC4F7FB7B31393 /* MatchColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchColors.swift; path = MatchLiveTv/UI/Theme/MatchColors.swift; sourceTree = ""; }; + E4A2CDEC380147D89E36B359 /* StepMatchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepMatchScreen.swift; path = MatchLiveTv/UI/Wizard/StepMatchScreen.swift; sourceTree = ""; }; + 4E0B262FA3774D72901529E8 /* StepNetworkTestScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepNetworkTestScreen.swift; path = MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift; sourceTree = ""; }; + 00F0EEBEE0C642FAB6298D79 /* StepTransmissionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepTransmissionScreen.swift; path = MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift; sourceTree = ""; }; + 64C5CD27F7354DDB9A7FCC5F /* TeamBrandingEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TeamBrandingEditor.swift; path = MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift; sourceTree = ""; }; + EC0F495A7A484769ADA89E96 /* TeamColorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TeamColorPicker.swift; path = MatchLiveTv/UI/Wizard/TeamColorPicker.swift; sourceTree = ""; }; + A768FA73B328428EAC69BF16 /* WizardComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardComponents.swift; path = MatchLiveTv/UI/Wizard/WizardComponents.swift; sourceTree = ""; }; + 5B9EE57FDB9148A4B541B8CF /* WizardShellScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardShellScreen.swift; path = MatchLiveTv/UI/Wizard/WizardShellScreen.swift; sourceTree = ""; }; + 2540D3CA40CC4181B3D4AFF3 /* ApiInstantTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstantTests.swift; path = MatchLiveTvTests/ApiInstantTests.swift; sourceTree = ""; }; + CD155C0D547D4495B9E6308A /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = ""; }; + 42D318A6B43D4D4A898DBBE0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MatchLiveTv/Resources/Assets.xcassets; sourceTree = ""; }; + 82B310B2B49243A296C9C581 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = MatchLiveTv/Resources/Info.plist; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + 9CDEE1193E594846AF90F5A3 = {isa = PBXGroup; children = (2540D3CA40CC4181B3D4AFF3, CD155C0D547D4495B9E6308A); name = MatchLiveTvTests; sourceTree = ""; }; + CECA644368B34BD1AF81B7D4 = {isa = PBXGroup; children = (A42C110EE8DB4ECC893951AD, CA19230D3F7D4C6A95406F87); name = Products; sourceTree = ""; }; + 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 */; +} diff --git a/native/ios/MatchLiveTv.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/native/ios/MatchLiveTv.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..8755d28 --- /dev/null +++ b/native/ios/MatchLiveTv.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,4 @@ + + + + diff --git a/native/ios/MatchLiveTv.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/native/ios/MatchLiveTv.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..c16f5d7 --- /dev/null +++ b/native/ios/MatchLiveTv.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 +} diff --git a/native/ios/MatchLiveTv.xcodeproj/xcshareddata/xcschemes/MatchLiveTv.xcscheme b/native/ios/MatchLiveTv.xcodeproj/xcshareddata/xcschemes/MatchLiveTv.xcscheme new file mode 100644 index 0000000..96ebad4 --- /dev/null +++ b/native/ios/MatchLiveTv.xcodeproj/xcshareddata/xcschemes/MatchLiveTv.xcscheme @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/native/ios/MatchLiveTv/App/MatchLiveTvApp.swift b/native/ios/MatchLiveTv/App/MatchLiveTvApp.swift new file mode 100644 index 0000000..78ca14d --- /dev/null +++ b/native/ios/MatchLiveTv/App/MatchLiveTvApp.swift @@ -0,0 +1,14 @@ +import SwiftUI + +@main +struct MatchLiveTvApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + var body: some Scene { + WindowGroup { + MatchLiveTheme { + AppNavHost() + } + } + } +} diff --git a/native/ios/MatchLiveTv/Core/ApiInstant.swift b/native/ios/MatchLiveTv/Core/ApiInstant.swift new file mode 100644 index 0000000..3471e16 --- /dev/null +++ b/native/ios/MatchLiveTv/Core/ApiInstant.swift @@ -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 + }() +} diff --git a/native/ios/MatchLiveTv/Core/AppConfig.swift b/native/ios/MatchLiveTv/Core/AppConfig.swift new file mode 100644 index 0000000..b433a75 --- /dev/null +++ b/native/ios/MatchLiveTv/Core/AppConfig.swift @@ -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" + } +} diff --git a/native/ios/MatchLiveTv/Core/ColorHex.swift b/native/ios/MatchLiveTv/Core/ColorHex.swift new file mode 100644 index 0000000..f1d19cc --- /dev/null +++ b/native/ios/MatchLiveTv/Core/ColorHex.swift @@ -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) + } +} diff --git a/native/ios/MatchLiveTv/Core/DeviceTelemetry.swift b/native/ios/MatchLiveTv/Core/DeviceTelemetry.swift new file mode 100644 index 0000000..7ee7529 --- /dev/null +++ b/native/ios/MatchLiveTv/Core/DeviceTelemetry.swift @@ -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 + } +} diff --git a/native/ios/MatchLiveTv/Core/MediaUrl.swift b/native/ios/MatchLiveTv/Core/MediaUrl.swift new file mode 100644 index 0000000..d1afdeb --- /dev/null +++ b/native/ios/MatchLiveTv/Core/MediaUrl.swift @@ -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)" + } +} diff --git a/native/ios/MatchLiveTv/Core/TokenStore.swift b/native/ios/MatchLiveTv/Core/TokenStore.swift new file mode 100644 index 0000000..8ba8471 --- /dev/null +++ b/native/ios/MatchLiveTv/Core/TokenStore.swift @@ -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) + } +} diff --git a/native/ios/MatchLiveTv/Core/UserFacingError.swift b/native/ios/MatchLiveTv/Core/UserFacingError.swift new file mode 100644 index 0000000..f7ebe77 --- /dev/null +++ b/native/ios/MatchLiveTv/Core/UserFacingError.swift @@ -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 + } +} diff --git a/native/ios/MatchLiveTv/Data/API/ApiDtos.swift b/native/ios/MatchLiveTv/Data/API/ApiDtos.swift new file mode 100644 index 0000000..6d49f46 --- /dev/null +++ b/native/ios/MatchLiveTv/Data/API/ApiDtos.swift @@ -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, 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, 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, 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 + } +} diff --git a/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift b/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift new file mode 100644 index 0000000..8d68578 --- /dev/null +++ b/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift @@ -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(_ path: String) async throws -> T { + try await request(path, method: "GET") + } + + private func post(_ path: String, body: B) async throws -> T { + try await request(path, method: "POST", body: body) + } + + private func postVoid(_ 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(_ 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(_ 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( + _ 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(_ 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) } + } +} diff --git a/native/ios/MatchLiveTv/Data/AppContainer.swift b/native/ios/MatchLiveTv/Data/AppContainer.swift new file mode 100644 index 0000000..d768389 --- /dev/null +++ b/native/ios/MatchLiveTv/Data/AppContainer.swift @@ -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) + } + } +} diff --git a/native/ios/MatchLiveTv/Data/Cable/ActionCableClient.swift b/native/ios/MatchLiveTv/Data/Cable/ActionCableClient.swift new file mode 100644 index 0000000..8600f1d --- /dev/null +++ b/native/ios/MatchLiveTv/Data/Cable/ActionCableClient.swift @@ -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 + } +} diff --git a/native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift b/native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift new file mode 100644 index 0000000..8014028 --- /dev/null +++ b/native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift @@ -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? + 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 + } + } + } +} diff --git a/native/ios/MatchLiveTv/Data/Repository/AuthRepository.swift b/native/ios/MatchLiveTv/Data/Repository/AuthRepository.swift new file mode 100644 index 0000000..9faff80 --- /dev/null +++ b/native/ios/MatchLiveTv/Data/Repository/AuthRepository.swift @@ -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) + } +} diff --git a/native/ios/MatchLiveTv/Data/Repository/MatchRepository.swift b/native/ios/MatchLiveTv/Data/Repository/MatchRepository.swift new file mode 100644 index 0000000..7c2373f --- /dev/null +++ b/native/ios/MatchLiveTv/Data/Repository/MatchRepository.swift @@ -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 } +} diff --git a/native/ios/MatchLiveTv/Data/Repository/MatchSessionLauncher.swift b/native/ios/MatchLiveTv/Data/Repository/MatchSessionLauncher.swift new file mode 100644 index 0000000..5e2e493 --- /dev/null +++ b/native/ios/MatchLiveTv/Data/Repository/MatchSessionLauncher.swift @@ -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 + } +} diff --git a/native/ios/MatchLiveTv/Data/Repository/ScoreRepository.swift b/native/ios/MatchLiveTv/Data/Repository/ScoreRepository.swift new file mode 100644 index 0000000..5a13156 --- /dev/null +++ b/native/ios/MatchLiveTv/Data/Repository/ScoreRepository.swift @@ -0,0 +1,49 @@ +import Foundation + +@MainActor +final class ScoreRepository { + private let api: MatchLiveAPI + private static let periodBoards: Set = ["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() + } +} diff --git a/native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift b/native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift new file mode 100644 index 0000000..3419585 --- /dev/null +++ b/native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift @@ -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 + ) + ) + } +} diff --git a/native/ios/MatchLiveTv/Data/Scoring/ScoreController.swift b/native/ios/MatchLiveTv/Data/Scoring/ScoreController.swift new file mode 100644 index 0000000..84c0b72 --- /dev/null +++ b/native/ios/MatchLiveTv/Data/Scoring/ScoreController.swift @@ -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? + 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 + } + } + } +} diff --git a/native/ios/MatchLiveTv/Data/WizardSessionHolder.swift b/native/ios/MatchLiveTv/Data/WizardSessionHolder.swift new file mode 100644 index 0000000..73d5aeb --- /dev/null +++ b/native/ios/MatchLiveTv/Data/WizardSessionHolder.swift @@ -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 + } +} diff --git a/native/ios/MatchLiveTv/Domain/MatchHubFilter.swift b/native/ios/MatchLiveTv/Domain/MatchHubFilter.swift new file mode 100644 index 0000000..0a5851b --- /dev/null +++ b/native/ios/MatchLiveTv/Domain/MatchHubFilter.swift @@ -0,0 +1,43 @@ +import Foundation + +enum MatchHubFilter { + private static let resumableStatuses: Set = ["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" + } +} diff --git a/native/ios/MatchLiveTv/Domain/MatchScoringRules.swift b/native/ios/MatchLiveTv/Domain/MatchScoringRules.swift new file mode 100644 index 0000000..6636f2d --- /dev/null +++ b/native/ios/MatchLiveTv/Domain/MatchScoringRules.swift @@ -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 +} diff --git a/native/ios/MatchLiveTv/Domain/Models.swift b/native/ios/MatchLiveTv/Domain/Models.swift new file mode 100644 index 0000000..dc5a522 --- /dev/null +++ b/native/ios/MatchLiveTv/Domain/Models.swift @@ -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 } +} diff --git a/native/ios/MatchLiveTv/Domain/ScoreState.swift b/native/ios/MatchLiveTv/Domain/ScoreState.swift new file mode 100644 index 0000000..b3afba1 --- /dev/null +++ b/native/ios/MatchLiveTv/Domain/ScoreState.swift @@ -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 = ["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 + } +} diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/native/ios/MatchLiveTv/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..e25f26f --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -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 } +} diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/native/ios/MatchLiveTv/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..c21c3cf Binary files /dev/null and b/native/ios/MatchLiveTv/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/native/ios/MatchLiveTv/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..704cd42 --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/Contents.json b/native/ios/MatchLiveTv/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..46cba7d --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{ "info" : { "author" : "xcode", "version" : 1 } } diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale.imageset/Contents.json b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale.imageset/Contents.json new file mode 100644 index 0000000..8bc6f6a --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale.imageset/Contents.json @@ -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 } +} diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale.imageset/CopertinaCanale.png b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale.imageset/CopertinaCanale.png new file mode 100644 index 0000000..3453bcb Binary files /dev/null and b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale.imageset/CopertinaCanale.png differ diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_2.imageset/Contents.json b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_2.imageset/Contents.json new file mode 100644 index 0000000..f975f72 --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_2.imageset/Contents.json @@ -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 } +} diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_2.imageset/CopertinaCanale_2.png b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_2.imageset/CopertinaCanale_2.png new file mode 100644 index 0000000..6e48671 Binary files /dev/null and b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_2.imageset/CopertinaCanale_2.png differ diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_3.imageset/Contents.json b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_3.imageset/Contents.json new file mode 100644 index 0000000..14b343e --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_3.imageset/Contents.json @@ -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 } +} diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_3.imageset/CopertinaCanale_3.png b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_3.imageset/CopertinaCanale_3.png new file mode 100644 index 0000000..e61e88d Binary files /dev/null and b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_3.imageset/CopertinaCanale_3.png differ diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_4.imageset/Contents.json b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_4.imageset/Contents.json new file mode 100644 index 0000000..0b52aaa --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_4.imageset/Contents.json @@ -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 } +} diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_4.imageset/CopertinaCanale_4.png b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_4.imageset/CopertinaCanale_4.png new file mode 100644 index 0000000..f59a048 Binary files /dev/null and b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_4.imageset/CopertinaCanale_4.png differ diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_5.imageset/Contents.json b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_5.imageset/Contents.json new file mode 100644 index 0000000..7f0122b --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_5.imageset/Contents.json @@ -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 } +} diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_5.imageset/CopertinaCanale_5.png b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_5.imageset/CopertinaCanale_5.png new file mode 100644 index 0000000..1ec8e95 Binary files /dev/null and b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_5.imageset/CopertinaCanale_5.png differ diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_6.imageset/Contents.json b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_6.imageset/Contents.json new file mode 100644 index 0000000..201e37c --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_6.imageset/Contents.json @@ -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 } +} diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_6.imageset/CopertinaCanale_6.png b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_6.imageset/CopertinaCanale_6.png new file mode 100644 index 0000000..5896113 Binary files /dev/null and b/native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_6.imageset/CopertinaCanale_6.png differ diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-dark-gradient-256.imageset/Contents.json b/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-dark-gradient-256.imageset/Contents.json new file mode 100644 index 0000000..b7ffa1a --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-dark-gradient-256.imageset/Contents.json @@ -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 } +} diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-dark-gradient-256.imageset/logo-dark-gradient-256.png b/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-dark-gradient-256.imageset/logo-dark-gradient-256.png new file mode 100644 index 0000000..e37a0b0 Binary files /dev/null and b/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-dark-gradient-256.imageset/logo-dark-gradient-256.png differ diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-dark-gradient.imageset/Contents.json b/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-dark-gradient.imageset/Contents.json new file mode 100644 index 0000000..3864543 --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-dark-gradient.imageset/Contents.json @@ -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 } +} diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-dark-gradient.imageset/logo-dark-gradient.png b/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-dark-gradient.imageset/logo-dark-gradient.png new file mode 100644 index 0000000..c21c3cf Binary files /dev/null and b/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-dark-gradient.imageset/logo-dark-gradient.png differ diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m-256.imageset/Contents.json b/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m-256.imageset/Contents.json new file mode 100644 index 0000000..af71ce5 --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m-256.imageset/Contents.json @@ -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 } +} diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m-256.imageset/logo-white-m-256.png b/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m-256.imageset/logo-white-m-256.png new file mode 100644 index 0000000..64281bc Binary files /dev/null and b/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m-256.imageset/logo-white-m-256.png differ diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m.imageset/Contents.json b/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m.imageset/Contents.json new file mode 100644 index 0000000..65ed270 --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m.imageset/Contents.json @@ -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 } +} diff --git a/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m.imageset/logo-white-m.png b/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m.imageset/logo-white-m.png new file mode 100644 index 0000000..b6a5ce5 Binary files /dev/null and b/native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m.imageset/logo-white-m.png differ diff --git a/native/ios/MatchLiveTv/Resources/Info.plist b/native/ios/MatchLiveTv/Resources/Info.plist new file mode 100644 index 0000000..ea82169 --- /dev/null +++ b/native/ios/MatchLiveTv/Resources/Info.plist @@ -0,0 +1,62 @@ + + + + + API_BASE_URL + https://www.matchlivetv.it + CFBundleDevelopmentRegion + it + CFBundleDisplayName + Match Live TV + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 2.0.0 + CFBundleURLTypes + + + CFBundleURLName + com.matchlivetv.match-live-tv + CFBundleURLSchemes + + matchlivetv + + + + CFBundleVersion + 21 + LSRequiresIPhoneOS + + NSCameraUsageDescription + Match Live TV usa la fotocamera per trasmettere le partite in diretta. + NSMicrophoneUsageDescription + Match Live TV usa il microfono per l'audio della diretta. + NSPhotoLibraryUsageDescription + Match Live TV usa le foto per i loghi delle squadre. + UIBackgroundModes + + audio + + UILaunchScreen + + UIColorName + + UIImageName + logo-dark-gradient-256 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/native/ios/MatchLiveTv/Streaming/BroadcastModels.swift b/native/ios/MatchLiveTv/Streaming/BroadcastModels.swift new file mode 100644 index 0000000..044980d --- /dev/null +++ b/native/ios/MatchLiveTv/Streaming/BroadcastModels.swift @@ -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? +} diff --git a/native/ios/MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift b/native/ios/MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift new file mode 100644 index 0000000..d74b735 --- /dev/null +++ b/native/ios/MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift @@ -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() + } +} diff --git a/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift b/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift new file mode 100644 index 0000000..5f7c841 --- /dev/null +++ b/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift @@ -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? + 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) + } +} diff --git a/native/ios/MatchLiveTv/Streaming/Overlay/CompactScoreboardElement.swift b/native/ios/MatchLiveTv/Streaming/Overlay/CompactScoreboardElement.swift new file mode 100644 index 0000000..72449cd --- /dev/null +++ b/native/ios/MatchLiveTv/Streaming/Overlay/CompactScoreboardElement.swift @@ -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 + "…" + } +} diff --git a/native/ios/MatchLiveTv/Streaming/Overlay/OverlayCanvasRenderer.swift b/native/ios/MatchLiveTv/Streaming/Overlay/OverlayCanvasRenderer.swift new file mode 100644 index 0000000..031723d --- /dev/null +++ b/native/ios/MatchLiveTv/Streaming/Overlay/OverlayCanvasRenderer.swift @@ -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 + } +} diff --git a/native/ios/MatchLiveTv/Streaming/Overlay/OverlayLogoCache.swift b/native/ios/MatchLiveTv/Streaming/Overlay/OverlayLogoCache.swift new file mode 100644 index 0000000..bb53b64 --- /dev/null +++ b/native/ios/MatchLiveTv/Streaming/Overlay/OverlayLogoCache.swift @@ -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)) } + } +} diff --git a/native/ios/MatchLiveTv/Streaming/Overlay/OverlayMappings.swift b/native/ios/MatchLiveTv/Streaming/Overlay/OverlayMappings.swift new file mode 100644 index 0000000..7e8ee46 --- /dev/null +++ b/native/ios/MatchLiveTv/Streaming/Overlay/OverlayMappings.swift @@ -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 + } + } +} diff --git a/native/ios/MatchLiveTv/Streaming/Overlay/OverlayRenderer.swift b/native/ios/MatchLiveTv/Streaming/Overlay/OverlayRenderer.swift new file mode 100644 index 0000000..54839cb --- /dev/null +++ b/native/ios/MatchLiveTv/Streaming/Overlay/OverlayRenderer.swift @@ -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? + + @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() + } + } +} diff --git a/native/ios/MatchLiveTv/Streaming/Overlay/OverlayState.swift b/native/ios/MatchLiveTv/Streaming/Overlay/OverlayState.swift new file mode 100644 index 0000000..f9b9761 --- /dev/null +++ b/native/ios/MatchLiveTv/Streaming/Overlay/OverlayState.swift @@ -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) +} diff --git a/native/ios/MatchLiveTv/Streaming/Overlay/ScoreboardElement.swift b/native/ios/MatchLiveTv/Streaming/Overlay/ScoreboardElement.swift new file mode 100644 index 0000000..32036f8 --- /dev/null +++ b/native/ios/MatchLiveTv/Streaming/Overlay/ScoreboardElement.swift @@ -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)" + } + } +} diff --git a/native/ios/MatchLiveTv/Streaming/Overlay/SponsorElement.swift b/native/ios/MatchLiveTv/Streaming/Overlay/SponsorElement.swift new file mode 100644 index 0000000..69fdfcc --- /dev/null +++ b/native/ios/MatchLiveTv/Streaming/Overlay/SponsorElement.swift @@ -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) + } +} diff --git a/native/ios/MatchLiveTv/Streaming/Overlay/WatermarkElement.swift b/native/ios/MatchLiveTv/Streaming/Overlay/WatermarkElement.swift new file mode 100644 index 0000000..b1e81e4 --- /dev/null +++ b/native/ios/MatchLiveTv/Streaming/Overlay/WatermarkElement.swift @@ -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)) + } +} diff --git a/native/ios/MatchLiveTv/Streaming/Preview/LivePreviewView.swift b/native/ios/MatchLiveTv/Streaming/Preview/LivePreviewView.swift new file mode 100644 index 0000000..2bd3c3b --- /dev/null +++ b/native/ios/MatchLiveTv/Streaming/Preview/LivePreviewView.swift @@ -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) {} +} diff --git a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift new file mode 100644 index 0000000..7dfd7fb --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift @@ -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" +} diff --git a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift new file mode 100644 index 0000000..fb2a71f --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift @@ -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() + } +} diff --git a/native/ios/MatchLiveTv/UI/Broadcast/LiveScoreActions.swift b/native/ios/MatchLiveTv/UI/Broadcast/LiveScoreActions.swift new file mode 100644 index 0000000..8c10547 --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Broadcast/LiveScoreActions.swift @@ -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? + + 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 { + 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) } + ) + } + } +} diff --git a/native/ios/MatchLiveTv/UI/Components/MatchLiveWordmark.swift b/native/ios/MatchLiveTv/UI/Components/MatchLiveWordmark.swift new file mode 100644 index 0000000..c4383a3 --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Components/MatchLiveWordmark.swift @@ -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) + } + } + } +} diff --git a/native/ios/MatchLiveTv/UI/Components/MatchPrimaryButton.swift b/native/ios/MatchLiveTv/UI/Components/MatchPrimaryButton.swift new file mode 100644 index 0000000..3a2b8f8 --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Components/MatchPrimaryButton.swift @@ -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) + } +} diff --git a/native/ios/MatchLiveTv/UI/Components/MatchScreenScaffold.swift b/native/ios/MatchLiveTv/UI/Components/MatchScreenScaffold.swift new file mode 100644 index 0000000..2b93d54 --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Components/MatchScreenScaffold.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct MatchScreenScaffold: 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) + } +} diff --git a/native/ios/MatchLiveTv/UI/Components/MatchSecondaryButton.swift b/native/ios/MatchLiveTv/UI/Components/MatchSecondaryButton.swift new file mode 100644 index 0000000..3158eb6 --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Components/MatchSecondaryButton.swift @@ -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) + } +} diff --git a/native/ios/MatchLiveTv/UI/Components/MatchStatusBadge.swift b/native/ios/MatchLiveTv/UI/Components/MatchStatusBadge.swift new file mode 100644 index 0000000..86dc5aa --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Components/MatchStatusBadge.swift @@ -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)) + } +} diff --git a/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift b/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift new file mode 100644 index 0000000..09a64d8 --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift @@ -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)) + } + } +} diff --git a/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift b/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift new file mode 100644 index 0000000..e052f03 --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift @@ -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(by keyPath: KeyPath) -> [Element] { + var seen = Set() + return filter { seen.insert($0[keyPath: keyPath]).inserted } + } +} + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/native/ios/MatchLiveTv/UI/Navigation/AppNavHost.swift b/native/ios/MatchLiveTv/UI/Navigation/AppNavHost.swift new file mode 100644 index 0000000..d6a230f --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Navigation/AppNavHost.swift @@ -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) + } +} diff --git a/native/ios/MatchLiveTv/UI/Navigation/ModalRoutes.swift b/native/ios/MatchLiveTv/UI/Navigation/ModalRoutes.swift new file mode 100644 index 0000000..7048ba5 --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Navigation/ModalRoutes.swift @@ -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 } +} diff --git a/native/ios/MatchLiveTv/UI/Navigation/Routes.swift b/native/ios/MatchLiveTv/UI/Navigation/Routes.swift new file mode 100644 index 0000000..be805a7 --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Navigation/Routes.swift @@ -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) +} diff --git a/native/ios/MatchLiveTv/UI/Permissions/BroadcastPermissions.swift b/native/ios/MatchLiveTv/UI/Permissions/BroadcastPermissions.swift new file mode 100644 index 0000000..175c705 --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Permissions/BroadcastPermissions.swift @@ -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 + } +} diff --git a/native/ios/MatchLiveTv/UI/Splash/SplashScreen.swift b/native/ios/MatchLiveTv/UI/Splash/SplashScreen.swift new file mode 100644 index 0000000..36d381f --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Splash/SplashScreen.swift @@ -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() + } + } + } +} diff --git a/native/ios/MatchLiveTv/UI/System/KeepScreenOn.swift b/native/ios/MatchLiveTv/UI/System/KeepScreenOn.swift new file mode 100644 index 0000000..fe72e4e --- /dev/null +++ b/native/ios/MatchLiveTv/UI/System/KeepScreenOn.swift @@ -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()) + } +} diff --git a/native/ios/MatchLiveTv/UI/System/ScreenOrientation.swift b/native/ios/MatchLiveTv/UI/System/ScreenOrientation.swift new file mode 100644 index 0000000..78f24bc --- /dev/null +++ b/native/ios/MatchLiveTv/UI/System/ScreenOrientation.swift @@ -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()) + } +} diff --git a/native/ios/MatchLiveTv/UI/Theme/MatchColors.swift b/native/ios/MatchLiveTv/UI/Theme/MatchColors.swift new file mode 100644 index 0000000..33066f5 --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Theme/MatchColors.swift @@ -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: View { + @ViewBuilder let content: () -> Content + + var body: some View { + content() + .preferredColorScheme(.dark) + .tint(MatchColors.primaryRed) + } +} diff --git a/native/ios/MatchLiveTv/UI/Wizard/StepMatchScreen.swift b/native/ios/MatchLiveTv/UI/Wizard/StepMatchScreen.swift new file mode 100644 index 0000000..128435d --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Wizard/StepMatchScreen.swift @@ -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 } +} diff --git a/native/ios/MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift b/native/ios/MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift new file mode 100644 index 0000000..3920643 --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift @@ -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" + } +} diff --git a/native/ios/MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift b/native/ios/MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift new file mode 100644 index 0000000..5b20777 --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift @@ -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 + } + } +} diff --git a/native/ios/MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift b/native/ios/MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift new file mode 100644 index 0000000..9a0abff --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift @@ -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) + } +} diff --git a/native/ios/MatchLiveTv/UI/Wizard/TeamColorPicker.swift b/native/ios/MatchLiveTv/UI/Wizard/TeamColorPicker.swift new file mode 100644 index 0000000..6ead8a0 --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Wizard/TeamColorPicker.swift @@ -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)) + } +} diff --git a/native/ios/MatchLiveTv/UI/Wizard/WizardComponents.swift b/native/ios/MatchLiveTv/UI/Wizard/WizardComponents.swift new file mode 100644 index 0000000..2f020e8 --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Wizard/WizardComponents.swift @@ -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) + } +} diff --git a/native/ios/MatchLiveTv/UI/Wizard/WizardShellScreen.swift b/native/ios/MatchLiveTv/UI/Wizard/WizardShellScreen.swift new file mode 100644 index 0000000..999985f --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Wizard/WizardShellScreen.swift @@ -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 + } +} diff --git a/native/ios/MatchLiveTvTests/ApiInstantTests.swift b/native/ios/MatchLiveTvTests/ApiInstantTests.swift new file mode 100644 index 0000000..19fddc7 --- /dev/null +++ b/native/ios/MatchLiveTvTests/ApiInstantTests.swift @@ -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) + } +} diff --git a/native/ios/MatchLiveTvTests/MatchScoringRulesTests.swift b/native/ios/MatchLiveTvTests/MatchScoringRulesTests.swift new file mode 100644 index 0000000..0f33ce4 --- /dev/null +++ b/native/ios/MatchLiveTvTests/MatchScoringRulesTests.swift @@ -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) + } +} diff --git a/native/ios/README.md b/native/ios/README.md new file mode 100644 index 0000000..42577d3 --- /dev/null +++ b/native/ios/README.md @@ -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 diff --git a/native/ios/generate_xcodeproj.py b/native/ios/generate_xcodeproj.py new file mode 100755 index 0000000..44b4685 --- /dev/null +++ b/native/ios/generate_xcodeproj.py @@ -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 = ""; }};') + +lines += [ + f'\t\t{assets_ref} /* Assets.xcassets */ = {{isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MatchLiveTv/Resources/Assets.xcassets; sourceTree = ""; }};', + f'\t\t{info_ref} /* Info.plist */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = MatchLiveTv/Resources/Info.plist; sourceTree = ""; }};', + 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 = ""; }};', + f'\t\t{app_group} = {{isa = PBXGroup; children = ({app_children}); name = MatchLiveTv; sourceTree = ""; }};', + f'\t\t{test_group} = {{isa = PBXGroup; children = ({test_children}); name = MatchLiveTvTests; sourceTree = ""; }};', + f'\t\t{products_group} = {{isa = PBXGroup; children = ({app_product}, {test_product}); name = Products; sourceTree = ""; }};', + f'\t\t{app_sources} = {{isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ({", ".join(bf[f] for f in app_files)}); runOnlyForDeploymentPostprocessing = 0; }};', + f'\t\t{test_sources} = {{isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ({", ".join(bf[f] for f in test_files) if test_files else ""}); runOnlyForDeploymentPostprocessing = 0; }};', + f'\t\t{app_resources} = {{isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ({assets_bf}); runOnlyForDeploymentPostprocessing = 0; }};', + f'\t\t{app_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( + '\n\n \n\n' +) +scheme_dir = OUT.parent / "xcshareddata" / "xcschemes" +scheme_dir.mkdir(parents=True, exist_ok=True) +(scheme_dir / "MatchLiveTv.xcscheme").write_text(f""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""") +print(f"Wrote {OUT} ({len(app_files)} app sources, {len(test_files)} test sources)") diff --git a/scripts/build_ios_release.sh b/scripts/build_ios_release.sh new file mode 100755 index 0000000..0ff882c --- /dev/null +++ b/scripts/build_ios_release.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +IOS_DIR="$ROOT/native/ios" +PROJECT="$IOS_DIR/MatchLiveTv.xcodeproj" +SCHEME="MatchLiveTv" +CONFIG="${CONFIG:-Release}" +API_BASE_URL="${API_BASE_URL:-https://www.matchlivetv.it}" + +cd "$IOS_DIR" + +if [[ ! -d "$PROJECT" ]]; then + echo "Generazione progetto Xcode..." + python3 "$IOS_DIR/generate_xcodeproj.py" +fi + +xcodebuild \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -sdk iphoneos \ + -configuration "$CONFIG" \ + API_BASE_URL="$API_BASE_URL" \ + CODE_SIGNING_ALLOWED=NO \ + build + +echo "Build iOS completata."