Aggiunge app iOS nativa con parità multi-sport e test unitari.
Porting SwiftUI da Android (auth, hub, wizard, broadcast RTMP, overlay, scoring board-aware), reload hub al ritorno da diretta, decodifica score tollerante e documentazione allineata. Co-authored-by: Cursor <cursoragent@cursor.com>
42
backend/spec/services/scoring/volley_close_set_spec.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Volley close_set" do
|
||||
let!(:user) { User.create!(email: "volley-close@test.it", name: "U", password: "password123", role: "coach") }
|
||||
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball", sets_to_win: 3) }
|
||||
let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live") }
|
||||
|
||||
before do
|
||||
session.create_score_state!(
|
||||
home_sets: 0,
|
||||
away_sets: 0,
|
||||
home_points: 26,
|
||||
away_points: 12,
|
||||
current_set: 1,
|
||||
board_type: "volley"
|
||||
)
|
||||
end
|
||||
|
||||
it "chiude il set 26-12 e apre il set 2" do
|
||||
result = Scoring::ApplyAction.new(session: session, action: "close_set").call
|
||||
score = result.score.reload
|
||||
|
||||
expect(score.home_sets).to eq(1)
|
||||
expect(score.away_sets).to eq(0)
|
||||
expect(score.home_points).to eq(0)
|
||||
expect(score.away_points).to eq(0)
|
||||
expect(score.current_set).to eq(2)
|
||||
expect(score.set_partials).to eq([{ "set" => 1, "home" => 26, "away" => 12 }])
|
||||
expect(result.set_won).to be(true)
|
||||
expect(result.match_won).to be(false)
|
||||
end
|
||||
|
||||
it "rileva vincitore set al 25-23 dopo home_point" do
|
||||
session.score_state.update!(home_points: 24, away_points: 23)
|
||||
result = Scoring::ApplyAction.new(session: session, action: "home_point").call
|
||||
expect(result.score.home_points).to eq(25)
|
||||
expect(result.set_won).to be(true)
|
||||
expect(result.winner).to eq(:home)
|
||||
end
|
||||
end
|
||||
@@ -35,7 +35,7 @@ Match Live TV è una piattaforma mobile-first per streaming di partite sportive
|
||||
| Ingest video | MediaMTX (RTMP :1935) |
|
||||
| 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` |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
4
native/ios/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
build/
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
234
native/ios/MatchLiveTv.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,234 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {};
|
||||
objectVersion = 60;
|
||||
objects = {
|
||||
5B23ED0F23434BC3A23B142F /* MatchLiveTvApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveTvApp.swift; path = MatchLiveTv/App/MatchLiveTvApp.swift; sourceTree = "<group>"; };
|
||||
D7A94757D18C46C9821833B7 /* ApiInstant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstant.swift; path = MatchLiveTv/Core/ApiInstant.swift; sourceTree = "<group>"; };
|
||||
1443F18A02BB4890A01A56F9 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppConfig.swift; path = MatchLiveTv/Core/AppConfig.swift; sourceTree = "<group>"; };
|
||||
E717B8A6A51E499490C0334B /* ColorHex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ColorHex.swift; path = MatchLiveTv/Core/ColorHex.swift; sourceTree = "<group>"; };
|
||||
6E6A356C81724C51BF41C25D /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = "<group>"; };
|
||||
DC18E1BC596E42DBB8BBBBFD /* MediaUrl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MediaUrl.swift; path = MatchLiveTv/Core/MediaUrl.swift; sourceTree = "<group>"; };
|
||||
C3DCAD32C3F24439A75920C1 /* TokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TokenStore.swift; path = MatchLiveTv/Core/TokenStore.swift; sourceTree = "<group>"; };
|
||||
B4B5CF0BD5534CF7BE51E3CB /* UserFacingError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = UserFacingError.swift; path = MatchLiveTv/Core/UserFacingError.swift; sourceTree = "<group>"; };
|
||||
E14CA2E05DE7400C852121AD /* ApiDtos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiDtos.swift; path = MatchLiveTv/Data/API/ApiDtos.swift; sourceTree = "<group>"; };
|
||||
BD1CEFBFF4D54361A4C3B4D2 /* MatchLiveAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveAPI.swift; path = MatchLiveTv/Data/API/MatchLiveAPI.swift; sourceTree = "<group>"; };
|
||||
66C7207107804520B8B87462 /* AppContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppContainer.swift; path = MatchLiveTv/Data/AppContainer.swift; sourceTree = "<group>"; };
|
||||
BE6E2D81675945268458DEE8 /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = "<group>"; };
|
||||
E902041CF4A04479B72760FE /* SessionCableService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionCableService.swift; path = MatchLiveTv/Data/Cable/SessionCableService.swift; sourceTree = "<group>"; };
|
||||
4AE94D0FE137473D8821B0E3 /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AuthRepository.swift; path = MatchLiveTv/Data/Repository/AuthRepository.swift; sourceTree = "<group>"; };
|
||||
8D20E18A8913489FABFBFFB5 /* MatchRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchRepository.swift; path = MatchLiveTv/Data/Repository/MatchRepository.swift; sourceTree = "<group>"; };
|
||||
BDDCAF6027D24CB2B273A3D6 /* MatchSessionLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSessionLauncher.swift; path = MatchLiveTv/Data/Repository/MatchSessionLauncher.swift; sourceTree = "<group>"; };
|
||||
2A5CCF3B585A4095941A6B21 /* ScoreRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreRepository.swift; path = MatchLiveTv/Data/Repository/ScoreRepository.swift; sourceTree = "<group>"; };
|
||||
4436BE62A6004577BE8356C1 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = "<group>"; };
|
||||
CF167DF2CBC14C2A97D49C1D /* ScoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreController.swift; path = MatchLiveTv/Data/Scoring/ScoreController.swift; sourceTree = "<group>"; };
|
||||
0BE371ACB29A48DF928B90DD /* WizardSessionHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardSessionHolder.swift; path = MatchLiveTv/Data/WizardSessionHolder.swift; sourceTree = "<group>"; };
|
||||
73A8C9597075432FB3DFCFFA /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = "<group>"; };
|
||||
116561D032ED4DF8A4174E59 /* MatchScoringRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRules.swift; path = MatchLiveTv/Domain/MatchScoringRules.swift; sourceTree = "<group>"; };
|
||||
AC559FEC305D475CA3F1DB21 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Models.swift; path = MatchLiveTv/Domain/Models.swift; sourceTree = "<group>"; };
|
||||
3247EDBBC6114F269C89736C /* ScoreState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreState.swift; path = MatchLiveTv/Domain/ScoreState.swift; sourceTree = "<group>"; };
|
||||
15B20863445A4EC28321DBFF /* BroadcastModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastModels.swift; path = MatchLiveTv/Streaming/BroadcastModels.swift; sourceTree = "<group>"; };
|
||||
51B7115C3000422A8DCA41ED /* LiveBroadcastCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastCoordinator.swift; path = MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift; sourceTree = "<group>"; };
|
||||
7A433A01B5BB4FE0B8E51234 /* LiveBroadcastEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastEngine.swift; path = MatchLiveTv/Streaming/LiveBroadcastEngine.swift; sourceTree = "<group>"; };
|
||||
C296D6280BCD4DC0BB467CC1 /* CompactScoreboardElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CompactScoreboardElement.swift; path = MatchLiveTv/Streaming/Overlay/CompactScoreboardElement.swift; sourceTree = "<group>"; };
|
||||
3F851B63647B4122A2753EBD /* OverlayCanvasRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayCanvasRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayCanvasRenderer.swift; sourceTree = "<group>"; };
|
||||
420794A1247149B7B931DAEA /* OverlayLogoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayLogoCache.swift; path = MatchLiveTv/Streaming/Overlay/OverlayLogoCache.swift; sourceTree = "<group>"; };
|
||||
48C5D1CE81864C2FBB3188EA /* OverlayMappings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayMappings.swift; path = MatchLiveTv/Streaming/Overlay/OverlayMappings.swift; sourceTree = "<group>"; };
|
||||
228DEA0D7B0D48D6BED86141 /* OverlayRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayRenderer.swift; sourceTree = "<group>"; };
|
||||
0F9F57E16B374D3AB379C1FF /* OverlayState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayState.swift; path = MatchLiveTv/Streaming/Overlay/OverlayState.swift; sourceTree = "<group>"; };
|
||||
0ED2AA45ECFA4193ACC31063 /* ScoreboardElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreboardElement.swift; path = MatchLiveTv/Streaming/Overlay/ScoreboardElement.swift; sourceTree = "<group>"; };
|
||||
1F2B2A3AC4C948038C03E8BB /* SponsorElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SponsorElement.swift; path = MatchLiveTv/Streaming/Overlay/SponsorElement.swift; sourceTree = "<group>"; };
|
||||
3465550280DE4F5E8D485F69 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = "<group>"; };
|
||||
42A4881EE59B42FEB7749122 /* LivePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LivePreviewView.swift; path = MatchLiveTv/Streaming/Preview/LivePreviewView.swift; sourceTree = "<group>"; };
|
||||
2B7C7A905479455F98F00DC3 /* BroadcastControlsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastControlsOverlay.swift; path = MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift; sourceTree = "<group>"; };
|
||||
8EBF7DD17E77419A83DC0496 /* BroadcastScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastScreen.swift; path = MatchLiveTv/UI/Broadcast/BroadcastScreen.swift; sourceTree = "<group>"; };
|
||||
2F0BE30BA75440D5B0A0F9A6 /* LiveScoreActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveScoreActions.swift; path = MatchLiveTv/UI/Broadcast/LiveScoreActions.swift; sourceTree = "<group>"; };
|
||||
1EDDB8ABF0ED4F24A5932D18 /* MatchLiveWordmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveWordmark.swift; path = MatchLiveTv/UI/Components/MatchLiveWordmark.swift; sourceTree = "<group>"; };
|
||||
F2D8C0FDD5EB4A3792E4D32E /* MatchPrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchPrimaryButton.swift; path = MatchLiveTv/UI/Components/MatchPrimaryButton.swift; sourceTree = "<group>"; };
|
||||
BDCE69667BE74DB9AF3E29E8 /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; };
|
||||
6E72DCB8E06D42038B6E8B69 /* MatchSecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSecondaryButton.swift; path = MatchLiveTv/UI/Components/MatchSecondaryButton.swift; sourceTree = "<group>"; };
|
||||
DC49A86D45D745E297107891 /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; };
|
||||
456228041D2F44E99258698B /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoginScreen.swift; path = MatchLiveTv/UI/Login/LoginScreen.swift; sourceTree = "<group>"; };
|
||||
6C792176D0544EED87B303C0 /* MatchesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchesScreen.swift; path = MatchLiveTv/UI/Matches/MatchesScreen.swift; sourceTree = "<group>"; };
|
||||
ED410204E5694627AEA787DD /* AppNavHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppNavHost.swift; path = MatchLiveTv/UI/Navigation/AppNavHost.swift; sourceTree = "<group>"; };
|
||||
EFF711C0619445C3944D3442 /* ModalRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ModalRoutes.swift; path = MatchLiveTv/UI/Navigation/ModalRoutes.swift; sourceTree = "<group>"; };
|
||||
98EA33F31CE34BC88EC99E64 /* Routes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Routes.swift; path = MatchLiveTv/UI/Navigation/Routes.swift; sourceTree = "<group>"; };
|
||||
097406CB65A24473B7F8A915 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = "<group>"; };
|
||||
F18D3BA8A2604783AFA7BADC /* SplashScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SplashScreen.swift; path = MatchLiveTv/UI/Splash/SplashScreen.swift; sourceTree = "<group>"; };
|
||||
8CF22D513753479E96E37546 /* KeepScreenOn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KeepScreenOn.swift; path = MatchLiveTv/UI/System/KeepScreenOn.swift; sourceTree = "<group>"; };
|
||||
3C42C3E8D4D24A6EBC8A140F /* ScreenOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScreenOrientation.swift; path = MatchLiveTv/UI/System/ScreenOrientation.swift; sourceTree = "<group>"; };
|
||||
AED45F6291AC4F7FB7B31393 /* MatchColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchColors.swift; path = MatchLiveTv/UI/Theme/MatchColors.swift; sourceTree = "<group>"; };
|
||||
E4A2CDEC380147D89E36B359 /* StepMatchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepMatchScreen.swift; path = MatchLiveTv/UI/Wizard/StepMatchScreen.swift; sourceTree = "<group>"; };
|
||||
4E0B262FA3774D72901529E8 /* StepNetworkTestScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepNetworkTestScreen.swift; path = MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift; sourceTree = "<group>"; };
|
||||
00F0EEBEE0C642FAB6298D79 /* StepTransmissionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepTransmissionScreen.swift; path = MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift; sourceTree = "<group>"; };
|
||||
64C5CD27F7354DDB9A7FCC5F /* TeamBrandingEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TeamBrandingEditor.swift; path = MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift; sourceTree = "<group>"; };
|
||||
EC0F495A7A484769ADA89E96 /* TeamColorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TeamColorPicker.swift; path = MatchLiveTv/UI/Wizard/TeamColorPicker.swift; sourceTree = "<group>"; };
|
||||
A768FA73B328428EAC69BF16 /* WizardComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardComponents.swift; path = MatchLiveTv/UI/Wizard/WizardComponents.swift; sourceTree = "<group>"; };
|
||||
5B9EE57FDB9148A4B541B8CF /* WizardShellScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardShellScreen.swift; path = MatchLiveTv/UI/Wizard/WizardShellScreen.swift; sourceTree = "<group>"; };
|
||||
2540D3CA40CC4181B3D4AFF3 /* ApiInstantTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstantTests.swift; path = MatchLiveTvTests/ApiInstantTests.swift; sourceTree = "<group>"; };
|
||||
CD155C0D547D4495B9E6308A /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = "<group>"; };
|
||||
42D318A6B43D4D4A898DBBE0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MatchLiveTv/Resources/Assets.xcassets; sourceTree = "<group>"; };
|
||||
82B310B2B49243A296C9C581 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = MatchLiveTv/Resources/Info.plist; sourceTree = "<group>"; };
|
||||
A42C110EE8DB4ECC893951AD /* MatchLiveTv.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MatchLiveTv.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CA19230D3F7D4C6A95406F87 /* MatchLiveTvTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MatchLiveTvTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
0B182AE2B64440FBAD820078 /* HaishinKit */ = {isa = XCSwiftPackageProductDependency; package = F80AB1FECA6A40798C87634A; productName = HaishinKit; };
|
||||
F0E1D34F0B3545A99C3EF141 /* RTMPHaishinKit */ = {isa = XCSwiftPackageProductDependency; package = F80AB1FECA6A40798C87634A; productName = RTMPHaishinKit; };
|
||||
C43733BB396340269131C87F /* MatchLiveTvApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B23ED0F23434BC3A23B142F /* MatchLiveTvApp.swift */; };
|
||||
3879FE4999AD42D29DD88BC9 /* ApiInstant.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A94757D18C46C9821833B7 /* ApiInstant.swift */; };
|
||||
F9B0B951CC0E45F3AD9E17AE /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1443F18A02BB4890A01A56F9 /* AppConfig.swift */; };
|
||||
FDC36E1F2673422E9F09A84D /* ColorHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = E717B8A6A51E499490C0334B /* ColorHex.swift */; };
|
||||
9BF13DD9192942E8A69CD50B /* DeviceTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6A356C81724C51BF41C25D /* DeviceTelemetry.swift */; };
|
||||
82F9B0A9BD714835A41E20CE /* MediaUrl.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC18E1BC596E42DBB8BBBBFD /* MediaUrl.swift */; };
|
||||
6A6460E2BBBA44C6964B2281 /* TokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DCAD32C3F24439A75920C1 /* TokenStore.swift */; };
|
||||
09C3F7CBE03C479FB3E4D484 /* UserFacingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B5CF0BD5534CF7BE51E3CB /* UserFacingError.swift */; };
|
||||
882F9D01445F454BBBF95AAE /* ApiDtos.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14CA2E05DE7400C852121AD /* ApiDtos.swift */; };
|
||||
A9C47B24D37C48D59221AB15 /* MatchLiveAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1CEFBFF4D54361A4C3B4D2 /* MatchLiveAPI.swift */; };
|
||||
E45FC9EC93854268B13B2814 /* AppContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C7207107804520B8B87462 /* AppContainer.swift */; };
|
||||
035C3532E9224C11A63DE8D0 /* ActionCableClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6E2D81675945268458DEE8 /* ActionCableClient.swift */; };
|
||||
BD0F196EE104494FA4959E43 /* SessionCableService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E902041CF4A04479B72760FE /* SessionCableService.swift */; };
|
||||
E16AD6EDD2E34A1BB16942CC /* AuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE94D0FE137473D8821B0E3 /* AuthRepository.swift */; };
|
||||
4D58338481074C47B177E866 /* MatchRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D20E18A8913489FABFBFFB5 /* MatchRepository.swift */; };
|
||||
5B0F3899F7B14F84AEF5CADA /* MatchSessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDDCAF6027D24CB2B273A3D6 /* MatchSessionLauncher.swift */; };
|
||||
535A5DC767124965A7E6ED4D /* ScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5CCF3B585A4095941A6B21 /* ScoreRepository.swift */; };
|
||||
1C27C8DFDE544D75B1D49507 /* SessionRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4436BE62A6004577BE8356C1 /* SessionRepository.swift */; };
|
||||
8F6BCEEF9168476FBA147FD3 /* ScoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF167DF2CBC14C2A97D49C1D /* ScoreController.swift */; };
|
||||
A2BFF50DA9294532A2F6D52B /* WizardSessionHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BE371ACB29A48DF928B90DD /* WizardSessionHolder.swift */; };
|
||||
A5EA10DEDBB34A4A95243C8A /* MatchHubFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73A8C9597075432FB3DFCFFA /* MatchHubFilter.swift */; };
|
||||
71857E5125D14E0BAC132C3B /* MatchScoringRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 116561D032ED4DF8A4174E59 /* MatchScoringRules.swift */; };
|
||||
965F2CEB66984469BD3BB2F6 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC559FEC305D475CA3F1DB21 /* Models.swift */; };
|
||||
6247A346FC674616A8833D87 /* ScoreState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247EDBBC6114F269C89736C /* ScoreState.swift */; };
|
||||
21F2A3C208DA4D9A8D8A2AD0 /* BroadcastModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15B20863445A4EC28321DBFF /* BroadcastModels.swift */; };
|
||||
C1967324B50641B1AAA7153D /* LiveBroadcastCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B7115C3000422A8DCA41ED /* LiveBroadcastCoordinator.swift */; };
|
||||
6A39E943F02842819BA0F11D /* LiveBroadcastEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A433A01B5BB4FE0B8E51234 /* LiveBroadcastEngine.swift */; };
|
||||
456274BB9DFA482EB52A77FF /* CompactScoreboardElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C296D6280BCD4DC0BB467CC1 /* CompactScoreboardElement.swift */; };
|
||||
FFF0200A28BD4C55BF288A04 /* OverlayCanvasRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F851B63647B4122A2753EBD /* OverlayCanvasRenderer.swift */; };
|
||||
EED95CFC9C044C46B8C748E8 /* OverlayLogoCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420794A1247149B7B931DAEA /* OverlayLogoCache.swift */; };
|
||||
196A4353FEBF48FB8086B6C3 /* OverlayMappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48C5D1CE81864C2FBB3188EA /* OverlayMappings.swift */; };
|
||||
73067AA8AEFA446C898CBB36 /* OverlayRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 228DEA0D7B0D48D6BED86141 /* OverlayRenderer.swift */; };
|
||||
AD747FCF7E8A40FB8FB0C09D /* OverlayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9F57E16B374D3AB379C1FF /* OverlayState.swift */; };
|
||||
4A2583194D364768910F0A2E /* ScoreboardElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ED2AA45ECFA4193ACC31063 /* ScoreboardElement.swift */; };
|
||||
63FB60603C30400A9412B97A /* SponsorElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2B2A3AC4C948038C03E8BB /* SponsorElement.swift */; };
|
||||
476CEF2BD80A4F5ABAF4BF8F /* WatermarkElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3465550280DE4F5E8D485F69 /* WatermarkElement.swift */; };
|
||||
E2B229D77E7B4B0EB3E20F3A /* LivePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A4881EE59B42FEB7749122 /* LivePreviewView.swift */; };
|
||||
455B81C72AAC40269A4B4EDE /* BroadcastControlsOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B7C7A905479455F98F00DC3 /* BroadcastControlsOverlay.swift */; };
|
||||
2E49953E828A4F1A87AF47AF /* BroadcastScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EBF7DD17E77419A83DC0496 /* BroadcastScreen.swift */; };
|
||||
87870520F2C5472DBEF4FAA0 /* LiveScoreActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0BE30BA75440D5B0A0F9A6 /* LiveScoreActions.swift */; };
|
||||
7F20ADEFF13D42EB969FA227 /* MatchLiveWordmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EDDB8ABF0ED4F24A5932D18 /* MatchLiveWordmark.swift */; };
|
||||
F05637D278B04B9E9F914685 /* MatchPrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2D8C0FDD5EB4A3792E4D32E /* MatchPrimaryButton.swift */; };
|
||||
05E7701D345F47A48EB3B1A8 /* MatchScreenScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDCE69667BE74DB9AF3E29E8 /* MatchScreenScaffold.swift */; };
|
||||
D0CA4D05E24D44B48662A467 /* MatchSecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E72DCB8E06D42038B6E8B69 /* MatchSecondaryButton.swift */; };
|
||||
093B423F42B840AC8DC4BBFE /* MatchStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC49A86D45D745E297107891 /* MatchStatusBadge.swift */; };
|
||||
6478C806699D4AF3AB895AC6 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 456228041D2F44E99258698B /* LoginScreen.swift */; };
|
||||
E981731C73724090B93301FC /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C792176D0544EED87B303C0 /* MatchesScreen.swift */; };
|
||||
D7EF5FD3160546A18B1F3268 /* AppNavHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED410204E5694627AEA787DD /* AppNavHost.swift */; };
|
||||
DE2C6A8E89FF4C019599320F /* ModalRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFF711C0619445C3944D3442 /* ModalRoutes.swift */; };
|
||||
FC4AD7E92B4248BF8D440AB5 /* Routes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98EA33F31CE34BC88EC99E64 /* Routes.swift */; };
|
||||
08FB4930AAC1443B8A6AA17A /* BroadcastPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 097406CB65A24473B7F8A915 /* BroadcastPermissions.swift */; };
|
||||
AA896C55DB9741B78A173671 /* SplashScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18D3BA8A2604783AFA7BADC /* SplashScreen.swift */; };
|
||||
F2C1D718393C4A7695FE56CD /* KeepScreenOn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CF22D513753479E96E37546 /* KeepScreenOn.swift */; };
|
||||
C06B35642D68409788DAC85F /* ScreenOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C42C3E8D4D24A6EBC8A140F /* ScreenOrientation.swift */; };
|
||||
CA27CA0592C1459AA5895B03 /* MatchColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = AED45F6291AC4F7FB7B31393 /* MatchColors.swift */; };
|
||||
0D790785EC384F4F999DF393 /* StepMatchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4A2CDEC380147D89E36B359 /* StepMatchScreen.swift */; };
|
||||
33D5A9835E1C476FA513CD7B /* StepNetworkTestScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0B262FA3774D72901529E8 /* StepNetworkTestScreen.swift */; };
|
||||
16773703D9B34B4D99E37DAB /* StepTransmissionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00F0EEBEE0C642FAB6298D79 /* StepTransmissionScreen.swift */; };
|
||||
439C3F3C9E8E4512AAE9A6F9 /* TeamBrandingEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C5CD27F7354DDB9A7FCC5F /* TeamBrandingEditor.swift */; };
|
||||
1895A7F0D8A04AB086C8B0E0 /* TeamColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC0F495A7A484769ADA89E96 /* TeamColorPicker.swift */; };
|
||||
E606392321674632B0B5A9C3 /* WizardComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A768FA73B328428EAC69BF16 /* WizardComponents.swift */; };
|
||||
437D99BC2B8446CA87766221 /* WizardShellScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B9EE57FDB9148A4B541B8CF /* WizardShellScreen.swift */; };
|
||||
76DE047F93C0417FB8D60984 /* ApiInstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2540D3CA40CC4181B3D4AFF3 /* ApiInstantTests.swift */; };
|
||||
AB9147175A564C9B848A5E43 /* MatchScoringRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD155C0D547D4495B9E6308A /* MatchScoringRulesTests.swift */; };
|
||||
9392A07F3B5343F4BE8538C9 /* Assets in Resources */ = {isa = PBXBuildFile; fileRef = 42D318A6B43D4D4A898DBBE0; };
|
||||
7AE60FABDB154D8286A2902F = {isa = PBXGroup; children = (25BA0F6BCD6248C8B1090B10, 9CDEE1193E594846AF90F5A3, CECA644368B34BD1AF81B7D4); sourceTree = "<group>"; };
|
||||
25BA0F6BCD6248C8B1090B10 = {isa = PBXGroup; children = (5B23ED0F23434BC3A23B142F, D7A94757D18C46C9821833B7, 1443F18A02BB4890A01A56F9, E717B8A6A51E499490C0334B, 6E6A356C81724C51BF41C25D, DC18E1BC596E42DBB8BBBBFD, C3DCAD32C3F24439A75920C1, B4B5CF0BD5534CF7BE51E3CB, E14CA2E05DE7400C852121AD, BD1CEFBFF4D54361A4C3B4D2, 66C7207107804520B8B87462, BE6E2D81675945268458DEE8, E902041CF4A04479B72760FE, 4AE94D0FE137473D8821B0E3, 8D20E18A8913489FABFBFFB5, BDDCAF6027D24CB2B273A3D6, 2A5CCF3B585A4095941A6B21, 4436BE62A6004577BE8356C1, CF167DF2CBC14C2A97D49C1D, 0BE371ACB29A48DF928B90DD, 73A8C9597075432FB3DFCFFA, 116561D032ED4DF8A4174E59, AC559FEC305D475CA3F1DB21, 3247EDBBC6114F269C89736C, 15B20863445A4EC28321DBFF, 51B7115C3000422A8DCA41ED, 7A433A01B5BB4FE0B8E51234, C296D6280BCD4DC0BB467CC1, 3F851B63647B4122A2753EBD, 420794A1247149B7B931DAEA, 48C5D1CE81864C2FBB3188EA, 228DEA0D7B0D48D6BED86141, 0F9F57E16B374D3AB379C1FF, 0ED2AA45ECFA4193ACC31063, 1F2B2A3AC4C948038C03E8BB, 3465550280DE4F5E8D485F69, 42A4881EE59B42FEB7749122, 2B7C7A905479455F98F00DC3, 8EBF7DD17E77419A83DC0496, 2F0BE30BA75440D5B0A0F9A6, 1EDDB8ABF0ED4F24A5932D18, F2D8C0FDD5EB4A3792E4D32E, BDCE69667BE74DB9AF3E29E8, 6E72DCB8E06D42038B6E8B69, DC49A86D45D745E297107891, 456228041D2F44E99258698B, 6C792176D0544EED87B303C0, ED410204E5694627AEA787DD, EFF711C0619445C3944D3442, 98EA33F31CE34BC88EC99E64, 097406CB65A24473B7F8A915, F18D3BA8A2604783AFA7BADC, 8CF22D513753479E96E37546, 3C42C3E8D4D24A6EBC8A140F, AED45F6291AC4F7FB7B31393, E4A2CDEC380147D89E36B359, 4E0B262FA3774D72901529E8, 00F0EEBEE0C642FAB6298D79, 64C5CD27F7354DDB9A7FCC5F, EC0F495A7A484769ADA89E96, A768FA73B328428EAC69BF16, 5B9EE57FDB9148A4B541B8CF, 42D318A6B43D4D4A898DBBE0, 82B310B2B49243A296C9C581); name = MatchLiveTv; sourceTree = "<group>"; };
|
||||
9CDEE1193E594846AF90F5A3 = {isa = PBXGroup; children = (2540D3CA40CC4181B3D4AFF3, CD155C0D547D4495B9E6308A); name = MatchLiveTvTests; sourceTree = "<group>"; };
|
||||
CECA644368B34BD1AF81B7D4 = {isa = PBXGroup; children = (A42C110EE8DB4ECC893951AD, CA19230D3F7D4C6A95406F87); name = Products; sourceTree = "<group>"; };
|
||||
75685F0D94844D25990466AB = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (C43733BB396340269131C87F, 3879FE4999AD42D29DD88BC9, F9B0B951CC0E45F3AD9E17AE, FDC36E1F2673422E9F09A84D, 9BF13DD9192942E8A69CD50B, 82F9B0A9BD714835A41E20CE, 6A6460E2BBBA44C6964B2281, 09C3F7CBE03C479FB3E4D484, 882F9D01445F454BBBF95AAE, A9C47B24D37C48D59221AB15, E45FC9EC93854268B13B2814, 035C3532E9224C11A63DE8D0, BD0F196EE104494FA4959E43, E16AD6EDD2E34A1BB16942CC, 4D58338481074C47B177E866, 5B0F3899F7B14F84AEF5CADA, 535A5DC767124965A7E6ED4D, 1C27C8DFDE544D75B1D49507, 8F6BCEEF9168476FBA147FD3, A2BFF50DA9294532A2F6D52B, A5EA10DEDBB34A4A95243C8A, 71857E5125D14E0BAC132C3B, 965F2CEB66984469BD3BB2F6, 6247A346FC674616A8833D87, 21F2A3C208DA4D9A8D8A2AD0, C1967324B50641B1AAA7153D, 6A39E943F02842819BA0F11D, 456274BB9DFA482EB52A77FF, FFF0200A28BD4C55BF288A04, EED95CFC9C044C46B8C748E8, 196A4353FEBF48FB8086B6C3, 73067AA8AEFA446C898CBB36, AD747FCF7E8A40FB8FB0C09D, 4A2583194D364768910F0A2E, 63FB60603C30400A9412B97A, 476CEF2BD80A4F5ABAF4BF8F, E2B229D77E7B4B0EB3E20F3A, 455B81C72AAC40269A4B4EDE, 2E49953E828A4F1A87AF47AF, 87870520F2C5472DBEF4FAA0, 7F20ADEFF13D42EB969FA227, F05637D278B04B9E9F914685, 05E7701D345F47A48EB3B1A8, D0CA4D05E24D44B48662A467, 093B423F42B840AC8DC4BBFE, 6478C806699D4AF3AB895AC6, E981731C73724090B93301FC, D7EF5FD3160546A18B1F3268, DE2C6A8E89FF4C019599320F, FC4AD7E92B4248BF8D440AB5, 08FB4930AAC1443B8A6AA17A, AA896C55DB9741B78A173671, F2C1D718393C4A7695FE56CD, C06B35642D68409788DAC85F, CA27CA0592C1459AA5895B03, 0D790785EC384F4F999DF393, 33D5A9835E1C476FA513CD7B, 16773703D9B34B4D99E37DAB, 439C3F3C9E8E4512AAE9A6F9, 1895A7F0D8A04AB086C8B0E0, E606392321674632B0B5A9C3, 437D99BC2B8446CA87766221); runOnlyForDeploymentPostprocessing = 0; };
|
||||
BC137E765FCA41FB85A944F8 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (76DE047F93C0417FB8D60984, AB9147175A564C9B848A5E43); runOnlyForDeploymentPostprocessing = 0; };
|
||||
E2036A7DDA8F4F7491399F71 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (9392A07F3B5343F4BE8538C9); runOnlyForDeploymentPostprocessing = 0; };
|
||||
ECE339972AA84978AE88BB46 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||
1C9A61B330DB4A6AA59CFEF4 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||
9773A4B6329B4CD2AA4CA51D = {isa = XCBuildConfiguration; buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
|
||||
ENABLE_TESTABILITY = YES;
|
||||
}; name = Debug; };
|
||||
C101F6805BCD491CBA3F987D = {isa = XCBuildConfiguration; buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
}; name = Release; };
|
||||
D8F8880914704BA9B087EE7B = {isa = XCBuildConfiguration; buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MatchLiveTv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MatchLiveTv";
|
||||
}; name = Debug; };
|
||||
2627164A4CA343B2A4C15A2E = {isa = XCBuildConfiguration; buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MatchLiveTv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MatchLiveTv";
|
||||
}; name = Release; };
|
||||
7BD2F8EE33E8434AB17CE373 = {isa = XCBuildConfiguration; buildSettings = {IPHONEOS_DEPLOYMENT_TARGET = 16.0; SWIFT_VERSION = 5.0; }; name = Debug; };
|
||||
68B5EA19B4E24E5D968BF94C = {isa = XCBuildConfiguration; buildSettings = {IPHONEOS_DEPLOYMENT_TARGET = 16.0; SWIFT_VERSION = 5.0; }; name = Release; };
|
||||
6ED2FEB21E7E44408B6A0677 = {isa = XCConfigurationList; buildConfigurations = (9773A4B6329B4CD2AA4CA51D, C101F6805BCD491CBA3F987D); defaultConfigurationName = Release; };
|
||||
EB832D1A0EA94AE69A6F2B98 = {isa = XCConfigurationList; buildConfigurations = (D8F8880914704BA9B087EE7B, 2627164A4CA343B2A4C15A2E); defaultConfigurationName = Release; };
|
||||
8683854CF3914406BD918C9D = {isa = XCConfigurationList; buildConfigurations = (7BD2F8EE33E8434AB17CE373, 68B5EA19B4E24E5D968BF94C); defaultConfigurationName = Release; };
|
||||
ACA5F32E253C473B9AD5D069 = {isa = PBXNativeTarget; buildConfigurationList = 6ED2FEB21E7E44408B6A0677; buildPhases = (75685F0D94844D25990466AB, ECE339972AA84978AE88BB46, E2036A7DDA8F4F7491399F71); buildRules = (); dependencies = (); name = MatchLiveTv; packageProductDependencies = (0B182AE2B64440FBAD820078, F0E1D34F0B3545A99C3EF141); productName = MatchLiveTv; productReference = A42C110EE8DB4ECC893951AD; productType = "com.apple.product-type.application"; };
|
||||
8BE99E481DB24808AD87CDAA = {isa = PBXContainerItemProxy; containerPortal = 715925D32BC14CF8A5A9128B /* Project object */; proxyType = 1; remoteGlobalIDString = ACA5F32E253C473B9AD5D069; remoteInfo = MatchLiveTv; };
|
||||
056ABE5A2CDA4D3E88485D37 = {isa = PBXTargetDependency; target = ACA5F32E253C473B9AD5D069 /* MatchLiveTv */; targetProxy = 8BE99E481DB24808AD87CDAA /* PBXContainerItemProxy */; };
|
||||
A5EE823E98D34E7CB20F3F9E = {isa = PBXNativeTarget; buildConfigurationList = EB832D1A0EA94AE69A6F2B98; buildPhases = (BC137E765FCA41FB85A944F8, 1C9A61B330DB4A6AA59CFEF4); buildRules = (); dependencies = (056ABE5A2CDA4D3E88485D37); name = MatchLiveTvTests; productName = MatchLiveTvTests; productReference = CA19230D3F7D4C6A95406F87; productType = "com.apple.product-type.bundle.unit-test"; };
|
||||
F80AB1FECA6A40798C87634A = {isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/shogo4405/HaishinKit.swift"; requirement = {kind = upToNextMajorVersion; minimumVersion = 2.0.0;}; };
|
||||
715925D32BC14CF8A5A9128B = {isa = PBXProject; attributes = {BuildIndependentTargetsInParallel = 0; LastSwiftUpdateCheck = 1600;}; buildConfigurationList = 8683854CF3914406BD918C9D; compatibilityVersion = "Xcode 15.0"; developmentRegion = it; mainGroup = 7AE60FABDB154D8286A2902F; packageReferences = (F80AB1FECA6A40798C87634A); productRefGroup = CECA644368B34BD1AF81B7D4; targets = (ACA5F32E253C473B9AD5D069, A5EE823E98D34E7CB20F3F9E); };
|
||||
};
|
||||
rootObject = 715925D32BC14CF8A5A9128B /* Project object */;
|
||||
}
|
||||
4
native/ios/MatchLiveTv.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace version="1.0">
|
||||
<FileRef location="self:"></FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"originHash" : "a02bd664b99b04b792f4c4ac4642c59cbac3f1660c60b2b1b9ca68761e3a17f7",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "haishinkit.swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/shogo4405/HaishinKit.swift",
|
||||
"state" : {
|
||||
"revision" : "dc880cb540b8feeb98f64e8b7dcfaaf320b6b2bd",
|
||||
"version" : "2.2.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "logboard",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/shogo4405/Logboard.git",
|
||||
"state" : {
|
||||
"revision" : "8f41c63afb903040b77049ee2efa8c257b8c0d50",
|
||||
"version" : "2.6.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme LastUpgradeVersion="1600" version="1.7">
|
||||
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="ACA5F32E253C473B9AD5D069" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5EE823E98D34E7CB20F3F9E" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
|
||||
<Testables>
|
||||
<TestableReference skipped="NO">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5EE823E98D34E7CB20F3F9E" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
|
||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="ACA5F32E253C473B9AD5D069" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
|
||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="ACA5F32E253C473B9AD5D069" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction buildConfiguration="Debug"/>
|
||||
<ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
|
||||
</Scheme>
|
||||
14
native/ios/MatchLiveTv/App/MatchLiveTvApp.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MatchLiveTvApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
MatchLiveTheme {
|
||||
AppNavHost()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
native/ios/MatchLiveTv/Core/ApiInstant.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
enum ApiInstant {
|
||||
static func parse(_ raw: String?) -> Date? {
|
||||
let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !value.isEmpty else { return nil }
|
||||
if let date = formatter.date(from: value) ?? fallbackFormatter.date(from: value) {
|
||||
return date
|
||||
}
|
||||
let normalized = value.replacingOccurrences(of: " ", with: "T")
|
||||
if normalized != value {
|
||||
return formatter.date(from: normalized) ?? fallbackFormatter.date(from: normalized)
|
||||
}
|
||||
return ISO8601DateFormatter().date(from: value)
|
||||
}
|
||||
|
||||
static func isScheduledFuture(_ raw: String?, now: Date = Date()) -> Bool {
|
||||
guard let date = parse(raw) else { return false }
|
||||
return date > now
|
||||
}
|
||||
|
||||
static func formatMatchDate(_ raw: String?) -> String? {
|
||||
guard let date = parse(raw) else { return nil }
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "it_IT")
|
||||
f.dateFormat = "EEE d MMM yyyy · HH:mm"
|
||||
return f.string(from: date)
|
||||
}
|
||||
|
||||
private static let formatter: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let fallbackFormatter: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
|
||||
static let decoder: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
d.keyDecodingStrategy = .convertFromSnakeCase
|
||||
d.dateDecodingStrategy = .custom { decoder in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let value = try container.decode(String.self)
|
||||
if let date = formatter.date(from: value) ?? fallbackFormatter.date(from: value) {
|
||||
return date
|
||||
}
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(value)")
|
||||
}
|
||||
return d
|
||||
}()
|
||||
|
||||
static let encoder: JSONEncoder = {
|
||||
let e = JSONEncoder()
|
||||
e.keyEncodingStrategy = .convertToSnakeCase
|
||||
return e
|
||||
}()
|
||||
}
|
||||
23
native/ios/MatchLiveTv/Core/AppConfig.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
enum AppConfig {
|
||||
static var apiBaseUrl: String {
|
||||
let raw = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String
|
||||
?? "https://www.matchlivetv.it"
|
||||
return raw.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
}
|
||||
|
||||
static var apiV1: String { "\(apiBaseUrl)/api/v1" }
|
||||
|
||||
static var cableUrl: String {
|
||||
guard let url = URL(string: apiBaseUrl),
|
||||
let host = url.host else {
|
||||
return "wss://www.matchlivetv.it/cable"
|
||||
}
|
||||
let wsScheme = url.scheme == "https" ? "wss" : "ws"
|
||||
if let port = url.port, port != 80 && port != 443 {
|
||||
return "\(wsScheme)://\(host):\(port)/cable"
|
||||
}
|
||||
return "\(wsScheme)://\(host)/cable"
|
||||
}
|
||||
}
|
||||
58
native/ios/MatchLiveTv/Core/ColorHex.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum ColorHex {
|
||||
static func normalizeHexColor(_ input: String?, fallback: String = "#E53935") -> String {
|
||||
guard let input, !input.isEmpty else { return fallback }
|
||||
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let pattern = #"^#?[0-9A-Fa-f]{6}$"#
|
||||
if trimmed.range(of: pattern, options: .regularExpression) != nil {
|
||||
return trimmed.hasPrefix("#") ? trimmed : "#\(trimmed)"
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
static func parseColorHex(_ hex: String?, fallback: UIColor = UIColor(red: 1, green: 0.18, blue: 0.18, alpha: 1)) -> UIColor {
|
||||
let normalized = normalizeHexColor(hex, fallback: "#FF2D2D")
|
||||
let stripped = normalized.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||||
guard stripped.count == 6, let value = Int(stripped, radix: 16) else { return fallback }
|
||||
let r = CGFloat((value >> 16) & 0xFF) / 255
|
||||
let g = CGFloat((value >> 8) & 0xFF) / 255
|
||||
let b = CGFloat(value & 0xFF) / 255
|
||||
return UIColor(red: r, green: g, blue: b, alpha: 1)
|
||||
}
|
||||
|
||||
static func swiftUIColor(_ hex: String?, fallback: Color = MatchColors.primaryRed) -> Color {
|
||||
let ui = parseColorHex(hex, fallback: UIColor(fallback))
|
||||
return Color(ui)
|
||||
}
|
||||
|
||||
static func colorToHex(_ color: UIColor) -> String {
|
||||
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
color.getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||
return String(format: "#%02X%02X%02X", Int(r * 255), Int(g * 255), Int(b * 255))
|
||||
}
|
||||
|
||||
static func placeholderTeamColor(seed: String) -> String {
|
||||
let palette = ["#FF2D2D", "#1E3A8A", "#22C55E", "#F5C518", "#8B5CF6", "#EC4899", "#06B6D4", "#F97316"]
|
||||
let hash = abs(seed.hashValue)
|
||||
return palette[hash % palette.count]
|
||||
}
|
||||
|
||||
static func hsvComponents(from hex: String) -> (hue: Double, saturation: Double, brightness: Double) {
|
||||
let ui = parseColorHex(hex)
|
||||
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
ui.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
|
||||
return (Double(h * 360), Double(s), Double(b))
|
||||
}
|
||||
|
||||
static func hsvToHex(hue: Double, saturation: Double, brightness: Double) -> String {
|
||||
let color = UIColor(
|
||||
hue: CGFloat(hue / 360),
|
||||
saturation: CGFloat(saturation),
|
||||
brightness: CGFloat(brightness),
|
||||
alpha: 1
|
||||
)
|
||||
return colorToHex(color)
|
||||
}
|
||||
}
|
||||
58
native/ios/MatchLiveTv/Core/DeviceTelemetry.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Network
|
||||
|
||||
struct DeviceHealth: Sendable {
|
||||
let batteryPercent: Int
|
||||
let batteryTempC: Double?
|
||||
let thermalLevel: Int
|
||||
let thermalLabel: String
|
||||
let networkType: String
|
||||
}
|
||||
|
||||
enum DeviceTelemetry {
|
||||
static func snapshot() -> DeviceHealth {
|
||||
UIDevice.current.isBatteryMonitoringEnabled = true
|
||||
let level = Int((UIDevice.current.batteryLevel >= 0 ? UIDevice.current.batteryLevel : 1) * 100)
|
||||
let thermal = ProcessInfo.processInfo.thermalState
|
||||
let (thermalLevel, label) = thermalInfo(thermal)
|
||||
return DeviceHealth(
|
||||
batteryPercent: level,
|
||||
batteryTempC: nil,
|
||||
thermalLevel: thermalLevel,
|
||||
thermalLabel: label,
|
||||
networkType: currentNetworkType()
|
||||
)
|
||||
}
|
||||
|
||||
private static func thermalInfo(_ state: ProcessInfo.ThermalState) -> (Int, String) {
|
||||
switch state {
|
||||
case .nominal: return (0, "OK")
|
||||
case .fair: return (1, "Caldo")
|
||||
case .serious: return (2, "Surriscaldamento")
|
||||
case .critical: return (3, "Troppo caldo")
|
||||
@unknown default: return (0, "OK")
|
||||
}
|
||||
}
|
||||
|
||||
private static func currentNetworkType() -> String {
|
||||
let monitor = NWPathMonitor()
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var result = "Sconosciuto"
|
||||
monitor.pathUpdateHandler = { path in
|
||||
if path.usesInterfaceType(.wifi) {
|
||||
result = "WiFi"
|
||||
} else if path.usesInterfaceType(.cellular) {
|
||||
result = "4G"
|
||||
} else if path.usesInterfaceType(.wiredEthernet) {
|
||||
result = "Ethernet"
|
||||
}
|
||||
semaphore.signal()
|
||||
}
|
||||
let queue = DispatchQueue(label: "network.type")
|
||||
monitor.start(queue: queue)
|
||||
_ = semaphore.wait(timeout: .now() + 0.5)
|
||||
monitor.cancel()
|
||||
return result
|
||||
}
|
||||
}
|
||||
11
native/ios/MatchLiveTv/Core/MediaUrl.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
enum MediaUrl {
|
||||
static func resolve(_ path: String?) -> String? {
|
||||
guard let path, !path.isEmpty else { return nil }
|
||||
if path.hasPrefix("http://") || path.hasPrefix("https://") { return path }
|
||||
let base = AppConfig.apiBaseUrl
|
||||
if path.hasPrefix("/") { return base + path }
|
||||
return "\(base)/\(path)"
|
||||
}
|
||||
}
|
||||
111
native/ios/MatchLiveTv/Core/TokenStore.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
struct StoredSession: Equatable, Sendable {
|
||||
let user: User
|
||||
let accessToken: String
|
||||
let refreshToken: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TokenStore: ObservableObject {
|
||||
@Published private(set) var session: StoredSession?
|
||||
@Published private(set) var activeTeamId: String?
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let service = "com.matchlivetv.match-live-tv.auth"
|
||||
|
||||
init() {
|
||||
session = loadSession()
|
||||
activeTeamId = defaults.string(forKey: Keys.activeTeamId)
|
||||
}
|
||||
|
||||
func saveSession(_ session: StoredSession) {
|
||||
self.session = session
|
||||
saveKeychain(key: Keys.accessToken, value: session.accessToken)
|
||||
saveKeychain(key: Keys.refreshToken, value: session.refreshToken)
|
||||
defaults.set(session.user.id, forKey: Keys.userId)
|
||||
defaults.set(session.user.email, forKey: Keys.userEmail)
|
||||
defaults.set(session.user.name, forKey: Keys.userName)
|
||||
defaults.set(session.user.role, forKey: Keys.userRole)
|
||||
}
|
||||
|
||||
func saveActiveTeamId(_ teamId: String) {
|
||||
activeTeamId = teamId
|
||||
defaults.set(teamId, forKey: Keys.activeTeamId)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
session = nil
|
||||
activeTeamId = nil
|
||||
deleteKeychain(key: Keys.accessToken)
|
||||
deleteKeychain(key: Keys.refreshToken)
|
||||
defaults.removeObject(forKey: Keys.userId)
|
||||
defaults.removeObject(forKey: Keys.userEmail)
|
||||
defaults.removeObject(forKey: Keys.userName)
|
||||
defaults.removeObject(forKey: Keys.userRole)
|
||||
defaults.removeObject(forKey: Keys.activeTeamId)
|
||||
}
|
||||
|
||||
private func loadSession() -> StoredSession? {
|
||||
guard let access = loadKeychain(key: Keys.accessToken),
|
||||
let refresh = loadKeychain(key: Keys.refreshToken),
|
||||
let id = defaults.string(forKey: Keys.userId) else { return nil }
|
||||
return StoredSession(
|
||||
user: User(
|
||||
id: id,
|
||||
email: defaults.string(forKey: Keys.userEmail) ?? "",
|
||||
name: defaults.string(forKey: Keys.userName) ?? "",
|
||||
role: defaults.string(forKey: Keys.userRole) ?? "coach"
|
||||
),
|
||||
accessToken: access,
|
||||
refreshToken: refresh
|
||||
)
|
||||
}
|
||||
|
||||
private enum Keys {
|
||||
static let accessToken = "access_token"
|
||||
static let refreshToken = "refresh_token"
|
||||
static let userId = "user_id"
|
||||
static let userEmail = "user_email"
|
||||
static let userName = "user_name"
|
||||
static let userRole = "user_role"
|
||||
static let activeTeamId = "active_team_id"
|
||||
}
|
||||
|
||||
private func saveKeychain(key: String, value: String) {
|
||||
let data = Data(value.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
var add = query
|
||||
add[kSecValueData as String] = data
|
||||
SecItemAdd(add as CFDictionary, nil)
|
||||
}
|
||||
|
||||
private func loadKeychain(key: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var result: AnyObject?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func deleteKeychain(key: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
19
native/ios/MatchLiveTv/Core/UserFacingError.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
enum UserFacingError {
|
||||
static func isCancellation(_ error: Error) -> Bool {
|
||||
if error is CancellationError { return true }
|
||||
if let urlError = error as? URLError, urlError.code == .cancelled { return true }
|
||||
let ns = error as NSError
|
||||
return ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled
|
||||
}
|
||||
|
||||
static func message(for error: Error, fallback: String = "Operazione non riuscita") -> String? {
|
||||
if isCancellation(error) { return nil }
|
||||
if let api = error as? LocalizedError, let description = api.errorDescription, !description.isEmpty {
|
||||
return description
|
||||
}
|
||||
let text = error.localizedDescription
|
||||
return text.isEmpty ? fallback : text
|
||||
}
|
||||
}
|
||||
475
native/ios/MatchLiveTv/Data/API/ApiDtos.swift
Normal file
@@ -0,0 +1,475 @@
|
||||
import Foundation
|
||||
|
||||
struct LoginRequest: Encodable {
|
||||
let email: String
|
||||
let password: String
|
||||
}
|
||||
|
||||
struct RefreshRequest: Encodable {
|
||||
let refreshToken: String
|
||||
}
|
||||
|
||||
struct LoginResponse: Decodable {
|
||||
let user: UserDto
|
||||
let accessToken: String
|
||||
let refreshToken: String
|
||||
|
||||
func toDomain() -> (User, AuthTokens) {
|
||||
(user.toDomain(), AuthTokens(accessToken: accessToken, refreshToken: refreshToken))
|
||||
}
|
||||
}
|
||||
|
||||
struct UserDto: Decodable {
|
||||
let id: String
|
||||
let email: String
|
||||
let name: String
|
||||
let role: String?
|
||||
|
||||
func toDomain() -> User {
|
||||
User(id: id, email: email, name: name, role: role ?? "coach")
|
||||
}
|
||||
}
|
||||
|
||||
struct SportDto: Decodable {
|
||||
let key: String
|
||||
let label: String
|
||||
let board: String
|
||||
let overlay: String
|
||||
let allowedOverlays: [String]?
|
||||
let defaultRules: [String: Int]?
|
||||
|
||||
func toDomain() -> SportOption {
|
||||
SportOption(
|
||||
key: key,
|
||||
label: label,
|
||||
board: board,
|
||||
overlay: overlay,
|
||||
allowedOverlays: allowedOverlays ?? [],
|
||||
defaultRules: defaultRules ?? [:]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct TeamDto: Decodable {
|
||||
let id: String
|
||||
let name: String
|
||||
let sport: String?
|
||||
let sportKey: String?
|
||||
let boardType: String?
|
||||
let canStream: Bool?
|
||||
let clubName: String?
|
||||
let youtubeEnabled: Bool?
|
||||
let youtubeConnected: Bool?
|
||||
let youtubeSelectable: Bool?
|
||||
let youtubeChannelTitle: String?
|
||||
let youtubeUsesPlatformChannel: Bool?
|
||||
let youtubeTeamChannelTitle: String?
|
||||
let planName: String?
|
||||
let recordingsEnabled: Bool?
|
||||
let phoneDownloadEnabled: Bool?
|
||||
let billingUrl: String?
|
||||
let staffManageUrl: String?
|
||||
let logoUrl: String?
|
||||
let primaryColor: String?
|
||||
let secondaryColor: String?
|
||||
|
||||
func toDomain() -> Team {
|
||||
Team(
|
||||
id: id,
|
||||
name: name,
|
||||
sport: sportKey ?? sport ?? "pallavolo",
|
||||
sportKey: sportKey ?? sport ?? "pallavolo",
|
||||
boardType: boardType ?? "volley",
|
||||
clubName: clubName,
|
||||
logoUrl: logoUrl,
|
||||
primaryColor: primaryColor,
|
||||
secondaryColor: secondaryColor,
|
||||
canStream: canStream ?? true,
|
||||
youtubeEnabled: youtubeEnabled ?? false,
|
||||
youtubeConnected: youtubeConnected ?? false,
|
||||
youtubeSelectable: youtubeSelectable ?? false,
|
||||
youtubeChannelTitle: youtubeChannelTitle,
|
||||
youtubeUsesPlatformChannel: youtubeUsesPlatformChannel ?? false,
|
||||
youtubeTeamChannelTitle: youtubeTeamChannelTitle,
|
||||
planName: planName,
|
||||
recordingsEnabled: recordingsEnabled ?? false,
|
||||
phoneDownloadEnabled: phoneDownloadEnabled ?? false,
|
||||
billingUrl: billingUrl,
|
||||
staffManageUrl: staffManageUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct MatchDto: Decodable {
|
||||
let id: String
|
||||
let teamId: String
|
||||
let teamName: String?
|
||||
let opponentName: String
|
||||
let location: String?
|
||||
let scheduledAt: String?
|
||||
let setsToWin: Int?
|
||||
let sportKey: String?
|
||||
let sport: String?
|
||||
let sportLabel: String?
|
||||
let boardType: String?
|
||||
let overlayKind: String?
|
||||
let effectiveOverlayKind: String?
|
||||
let category: String?
|
||||
let scoringRules: ScoringRulesBody?
|
||||
let activeSessionId: String?
|
||||
let activeSessionStatus: String?
|
||||
let streamCompleted: Bool?
|
||||
let coachHubVisible: Bool?
|
||||
let homePrimaryColor: String?
|
||||
let homeSecondaryColor: String?
|
||||
let homeLogoUrl: String?
|
||||
let opponentPrimaryColor: String?
|
||||
let opponentLogoUrl: String?
|
||||
|
||||
func toDomain() -> Match {
|
||||
Match(
|
||||
id: id,
|
||||
teamId: teamId,
|
||||
teamName: teamName ?? "",
|
||||
opponentName: opponentName,
|
||||
location: location,
|
||||
scheduledAt: scheduledAt,
|
||||
sportKey: sportKey ?? sport ?? "pallavolo",
|
||||
sportLabel: sportLabel,
|
||||
boardType: boardType ?? "volley",
|
||||
overlayKind: overlayKind,
|
||||
effectiveOverlayKind: effectiveOverlayKind ?? overlayKind ?? "volley",
|
||||
setsToWin: setsToWin ?? 3,
|
||||
category: category,
|
||||
scoringRules: scoringRules?.toDomain(),
|
||||
activeSessionId: activeSessionId,
|
||||
activeSessionStatus: activeSessionStatus,
|
||||
streamCompleted: streamCompleted ?? false,
|
||||
coachHubVisible: coachHubVisible,
|
||||
homePrimaryColor: homePrimaryColor,
|
||||
homeSecondaryColor: homeSecondaryColor,
|
||||
homeLogoUrl: homeLogoUrl,
|
||||
opponentPrimaryColor: opponentPrimaryColor,
|
||||
opponentLogoUrl: opponentLogoUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamSessionDto: Decodable {
|
||||
let id: String
|
||||
let matchId: String
|
||||
let status: String?
|
||||
let platform: String?
|
||||
let rtmpIngestUrl: String?
|
||||
let hlsPlaybackUrl: String?
|
||||
let watchPageUrl: String?
|
||||
let shareUrl: String?
|
||||
let youtubeWatchUrl: String?
|
||||
let youtubeReady: Bool?
|
||||
let privacyStatus: String?
|
||||
let targetBitrate: Int?
|
||||
let targetFps: Int?
|
||||
let startedAt: String?
|
||||
let score: ScoreStateDto?
|
||||
|
||||
func toDomain() -> StreamSession {
|
||||
StreamSession(
|
||||
id: id,
|
||||
matchId: matchId,
|
||||
status: status ?? "idle",
|
||||
platform: platform ?? "matchlivetv",
|
||||
rtmpIngestUrl: rtmpIngestUrl,
|
||||
hlsPlaybackUrl: hlsPlaybackUrl,
|
||||
watchPageUrl: watchPageUrl,
|
||||
shareUrl: shareUrl,
|
||||
youtubeWatchUrl: youtubeWatchUrl,
|
||||
youtubeReady: youtubeReady ?? false,
|
||||
privacyStatus: privacyStatus ?? "public",
|
||||
targetBitrate: targetBitrate ?? 2_500_000,
|
||||
targetFps: targetFps ?? 30,
|
||||
startedAt: startedAt,
|
||||
score: score?.toDomain()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct SetPartialDto: Codable {
|
||||
let set: Int?
|
||||
let home: Int?
|
||||
let away: Int?
|
||||
}
|
||||
|
||||
/// Decodifica tollerante per campi JSONB (`score_states.data`).
|
||||
private enum FlexibleJSON: Decodable {
|
||||
case int(Int)
|
||||
case bool(Bool)
|
||||
case string(String)
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let v = try? container.decode(Bool.self) { self = .bool(v); return }
|
||||
if let v = try? container.decode(Int.self) { self = .int(v); return }
|
||||
if let v = try? container.decode(String.self) { self = .string(v); return }
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
|
||||
}
|
||||
|
||||
var intValue: Int? {
|
||||
switch self {
|
||||
case .int(let v): return v
|
||||
case .string(let s): return Int(s)
|
||||
case .bool(let v): return v ? 1 : 0
|
||||
}
|
||||
}
|
||||
|
||||
var boolValue: Bool? {
|
||||
switch self {
|
||||
case .bool(let v): return v
|
||||
case .int(let v): return v != 0
|
||||
case .string(let s): return (s as NSString).boolValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ScoreStateDto: Decodable {
|
||||
let boardType: String?
|
||||
let homeSets: Int?
|
||||
let awaySets: Int?
|
||||
let homePoints: Int?
|
||||
let awayPoints: Int?
|
||||
let currentSet: Int?
|
||||
let setPartials: [SetPartialDto]?
|
||||
let timeoutHome: Bool?
|
||||
let timeoutAway: Bool?
|
||||
let period: String?
|
||||
let clockSecs: Int?
|
||||
let clockRunning: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case boardType = "board_type"
|
||||
case homeSets = "home_sets"
|
||||
case awaySets = "away_sets"
|
||||
case homePoints = "home_points"
|
||||
case awayPoints = "away_points"
|
||||
case currentSet = "current_set"
|
||||
case setPartials = "set_partials"
|
||||
case timeoutHome = "timeout_home"
|
||||
case timeoutAway = "timeout_away"
|
||||
case period
|
||||
case clockSecs = "clock_secs"
|
||||
case clockRunning = "clock_running"
|
||||
case data
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
boardType = try c.decodeIfPresent(String.self, forKey: .boardType)
|
||||
homeSets = Self.decodeInt(c, key: .homeSets)
|
||||
awaySets = Self.decodeInt(c, key: .awaySets)
|
||||
homePoints = Self.decodeInt(c, key: .homePoints)
|
||||
awayPoints = Self.decodeInt(c, key: .awayPoints)
|
||||
currentSet = Self.decodeInt(c, key: .currentSet)
|
||||
setPartials = try c.decodeIfPresent([SetPartialDto].self, forKey: .setPartials)
|
||||
timeoutHome = Self.decodeBool(c, key: .timeoutHome)
|
||||
timeoutAway = Self.decodeBool(c, key: .timeoutAway)
|
||||
period = Self.decodeString(c, key: .period)
|
||||
nestedData = try c.decodeIfPresent([String: FlexibleJSON].self, forKey: .data)
|
||||
clockSecs = Self.decodeInt(c, key: .clockSecs) ?? nestedData?["clock_secs"]?.intValue
|
||||
clockRunning = Self.decodeBool(c, key: .clockRunning) ?? nestedData?["clock_running"]?.boolValue
|
||||
}
|
||||
|
||||
private let nestedData: [String: FlexibleJSON]?
|
||||
|
||||
private static func decodeInt(_ c: KeyedDecodingContainer<CodingKeys>, key: CodingKeys) -> Int? {
|
||||
if let v = try? c.decodeIfPresent(Int.self, forKey: key) { return v }
|
||||
if let s = try? c.decodeIfPresent(String.self, forKey: key), let v = Int(s) { return v }
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func decodeBool(_ c: KeyedDecodingContainer<CodingKeys>, key: CodingKeys) -> Bool? {
|
||||
if let v = try? c.decodeIfPresent(Bool.self, forKey: key) { return v }
|
||||
if let i = try? c.decodeIfPresent(Int.self, forKey: key) { return i != 0 }
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func decodeString(_ c: KeyedDecodingContainer<CodingKeys>, key: CodingKeys) -> String? {
|
||||
if let s = try? c.decodeIfPresent(String.self, forKey: key) { return s }
|
||||
if let i = try? c.decodeIfPresent(Int.self, forKey: key) { return String(i) }
|
||||
return nil
|
||||
}
|
||||
|
||||
func toDomain() -> ScoreState {
|
||||
let board = boardType ?? "volley"
|
||||
let homePts = homePoints ?? nestedData?["home_score"]?.intValue ?? 0
|
||||
let awayPts = awayPoints ?? nestedData?["away_score"]?.intValue ?? 0
|
||||
let overtime = nestedData?["overtime"]?.boolValue ?? false
|
||||
return ScoreState(
|
||||
homeSets: homeSets ?? 0,
|
||||
awaySets: awaySets ?? 0,
|
||||
homePoints: homePts,
|
||||
awayPoints: awayPts,
|
||||
currentSet: max(currentSet ?? 1, 1),
|
||||
setPartials: (setPartials ?? []).map {
|
||||
SetPartial(set: $0.set ?? 1, home: $0.home ?? 0, away: $0.away ?? 0)
|
||||
},
|
||||
timeoutHome: timeoutHome ?? false,
|
||||
timeoutAway: timeoutAway ?? false,
|
||||
boardType: board,
|
||||
period: parsePeriodNumber(period, currentSet: currentSet),
|
||||
periodLabel: period,
|
||||
clockSecs: clockSecs ?? 0,
|
||||
clockRunning: clockRunning ?? false,
|
||||
overtime: overtime
|
||||
)
|
||||
}
|
||||
|
||||
private func parsePeriodNumber(_ label: String?, currentSet: Int?) -> Int {
|
||||
if let text = label {
|
||||
if let m = text.range(of: #"Q(\d+)"#, options: .regularExpression) {
|
||||
let num = text[m].dropFirst()
|
||||
if let v = Int(num) { return v }
|
||||
}
|
||||
if let v = Int(text), v > 0 { return v }
|
||||
}
|
||||
return max(currentSet ?? 1, 1)
|
||||
}
|
||||
}
|
||||
|
||||
struct ScoreActionRequest: Encodable {
|
||||
let scoreAction: String
|
||||
}
|
||||
|
||||
struct ScoreSyncRequest: Encodable {
|
||||
let homeSets: Int
|
||||
let awaySets: Int
|
||||
let homePoints: Int
|
||||
let awayPoints: Int
|
||||
let currentSet: Int
|
||||
let setPartials: [SetPartialDto]
|
||||
let clockSecs: Int?
|
||||
let clockRunning: Bool?
|
||||
let period: Int?
|
||||
let homeScore: Int?
|
||||
let awayScore: Int?
|
||||
}
|
||||
|
||||
struct CreateSessionRequest: Encodable {
|
||||
let platform: String
|
||||
let privacyStatus: String
|
||||
let qualityPreset: String
|
||||
let targetBitrate: Int
|
||||
let targetFps: Int
|
||||
let youtubeChannel: String?
|
||||
}
|
||||
|
||||
struct ScoringRulesBody: Codable {
|
||||
let setsToWin: Int?
|
||||
let pointsPerSet: Int?
|
||||
let pointsDecidingSet: Int?
|
||||
let minPointLead: Int?
|
||||
let periods: Int?
|
||||
let periodDurationSecs: Int?
|
||||
let overtimeDurationSecs: Int?
|
||||
|
||||
func toDomain() -> ScoringRules {
|
||||
ScoringRules(
|
||||
pointsPerSet: pointsPerSet ?? 25,
|
||||
pointsDecidingSet: pointsDecidingSet ?? 15,
|
||||
minPointLead: minPointLead ?? 2,
|
||||
periods: periods ?? 4,
|
||||
periodDurationSecs: periodDurationSecs ?? 600,
|
||||
overtimeDurationSecs: overtimeDurationSecs ?? 300
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdateMatchBodyFull: Encodable {
|
||||
let opponentName: String
|
||||
let location: String?
|
||||
let scheduledAt: String?
|
||||
let setsToWin: Int
|
||||
let sportKey: String?
|
||||
let overlayKind: String?
|
||||
let category: String?
|
||||
let opponentPrimaryColor: String?
|
||||
let scoringRules: [String: Int]?
|
||||
}
|
||||
|
||||
struct UpdateMatchRequestFull: Encodable {
|
||||
let match: UpdateMatchBodyFull
|
||||
}
|
||||
|
||||
struct UpdateTeamBody: Encodable {
|
||||
let primaryColor: String?
|
||||
let secondaryColor: String?
|
||||
}
|
||||
|
||||
struct UpdateTeamRequest: Encodable {
|
||||
let team: UpdateTeamBody
|
||||
}
|
||||
|
||||
struct NetworkTestResponse: Decodable {
|
||||
let ready: Bool
|
||||
let targetUploadMbps: Double?
|
||||
let qualityPreset: String?
|
||||
let targetBitrate: Int?
|
||||
let targetFps: Int?
|
||||
}
|
||||
|
||||
struct RegiaLinkResponse: Decodable {
|
||||
let regiaUrl: String
|
||||
}
|
||||
|
||||
struct CreateMatchBody: Encodable {
|
||||
let opponentName: String
|
||||
let sport: String
|
||||
let setsToWin: Int
|
||||
let scheduledAt: String?
|
||||
let location: String?
|
||||
}
|
||||
|
||||
struct CreateMatchRequest: Encodable {
|
||||
let match: CreateMatchBody
|
||||
}
|
||||
|
||||
struct NetworkTestRequest: Encodable {
|
||||
let downloadMbps: Double
|
||||
let uploadMbps: Double
|
||||
let latencyMs: Int
|
||||
let networkType: String
|
||||
}
|
||||
|
||||
struct TelemetryRequest: Encodable {
|
||||
let deviceRole: String
|
||||
let batteryLevel: Int?
|
||||
let networkType: String?
|
||||
let signalStrength: Int?
|
||||
let currentBitrate: Int?
|
||||
let targetBitrate: Int?
|
||||
let fps: Int?
|
||||
}
|
||||
|
||||
extension ScoringRules {
|
||||
func toBody(setsToWin: Int? = nil) -> ScoringRulesBody {
|
||||
ScoringRulesBody(
|
||||
setsToWin: setsToWin,
|
||||
pointsPerSet: pointsPerSet,
|
||||
pointsDecidingSet: pointsDecidingSet,
|
||||
minPointLead: minPointLead,
|
||||
periods: periods,
|
||||
periodDurationSecs: periodDurationSecs,
|
||||
overtimeDurationSecs: overtimeDurationSecs
|
||||
)
|
||||
}
|
||||
|
||||
func toMap(setsToWin: Int? = nil) -> [String: Int] {
|
||||
var map: [String: Int] = [:]
|
||||
if let setsToWin { map["sets_to_win"] = setsToWin }
|
||||
map["points_per_set"] = pointsPerSet
|
||||
map["points_deciding_set"] = pointsDecidingSet
|
||||
map["min_point_lead"] = minPointLead
|
||||
map["periods"] = periods
|
||||
map["period_duration_secs"] = periodDurationSecs
|
||||
map["overtime_duration_secs"] = overtimeDurationSecs
|
||||
return map
|
||||
}
|
||||
}
|
||||
289
native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift
Normal file
@@ -0,0 +1,289 @@
|
||||
import Foundation
|
||||
|
||||
enum APIError: LocalizedError {
|
||||
case invalidURL
|
||||
case http(Int, String?)
|
||||
case decoding(Error)
|
||||
case unauthorized
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "URL non valido"
|
||||
case .http(let code, let body): return Self.friendlyHttpMessage(code: code, body: body)
|
||||
case .decoding: return "Risposta del server non valida"
|
||||
case .unauthorized: return "Sessione scaduta. Accedi di nuovo."
|
||||
}
|
||||
}
|
||||
|
||||
private static func friendlyHttpMessage(code: Int, body: String?) -> String {
|
||||
if let body, !body.isEmpty,
|
||||
let data = body.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
if let message = json["message"] as? String, !message.isEmpty {
|
||||
return message
|
||||
}
|
||||
if let error = json["error"] as? String, !error.isEmpty, !error.hasPrefix("{") {
|
||||
if code >= 500 { return "Errore del server. Riprova tra qualche istante." }
|
||||
return error
|
||||
}
|
||||
}
|
||||
if let body, !body.isEmpty, !body.hasPrefix("{") {
|
||||
return body
|
||||
}
|
||||
switch code {
|
||||
case 500, 502, 503: return "Errore del server. Riprova tra qualche istante."
|
||||
case 404: return "Risorsa non trovata"
|
||||
case 403: return "Operazione non consentita"
|
||||
default: return "Errore HTTP \(code)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class MatchLiveAPI: @unchecked Sendable {
|
||||
private let session: URLSession
|
||||
private var accessToken: String?
|
||||
private let baseURL: URL
|
||||
|
||||
init(baseURL: URL = URL(string: AppConfig.apiV1)!, session: URLSession = .shared) {
|
||||
self.baseURL = baseURL
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func setAccessToken(_ token: String?) {
|
||||
accessToken = token
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
func login(email: String, password: String) async throws -> LoginResponse {
|
||||
try await post("auth/login", body: LoginRequest(email: email, password: password))
|
||||
}
|
||||
|
||||
func refresh(refreshToken: String) async throws -> LoginResponse {
|
||||
try await post("auth/refresh", body: RefreshRequest(refreshToken: refreshToken))
|
||||
}
|
||||
|
||||
func me() async throws -> UserDto {
|
||||
try await get("auth/me")
|
||||
}
|
||||
|
||||
func logout() async throws {
|
||||
try await postVoid("auth/logout")
|
||||
}
|
||||
|
||||
// MARK: - Sports & Teams
|
||||
|
||||
func sports() async throws -> [SportDto] {
|
||||
try await get("sports")
|
||||
}
|
||||
|
||||
func teams() async throws -> [TeamDto] {
|
||||
try await get("teams")
|
||||
}
|
||||
|
||||
func team(id: String) async throws -> TeamDto {
|
||||
try await get("teams/\(id)")
|
||||
}
|
||||
|
||||
func updateTeam(id: String, body: UpdateTeamRequest) async throws -> TeamDto {
|
||||
try await patch("teams/\(id)", body: body)
|
||||
}
|
||||
|
||||
func updateTeamMultipart(
|
||||
id: String,
|
||||
primaryColor: String?,
|
||||
secondaryColor: String?,
|
||||
logoData: Data?,
|
||||
logoFilename: String?
|
||||
) async throws -> TeamDto {
|
||||
var fields: [MultipartField] = []
|
||||
if let primaryColor { fields.append(.text("team[primary_color]", primaryColor)) }
|
||||
if let secondaryColor { fields.append(.text("team[secondary_color]", secondaryColor)) }
|
||||
if let logoData, let logoFilename {
|
||||
fields.append(.file("logo_file", logoData, filename: logoFilename, mime: "image/png"))
|
||||
}
|
||||
return try await multipartPatch("teams/\(id)", fields: fields)
|
||||
}
|
||||
|
||||
// MARK: - Matches
|
||||
|
||||
func matches(teamId: String) async throws -> [MatchDto] {
|
||||
try await get("teams/\(teamId)/matches")
|
||||
}
|
||||
|
||||
func createMatch(teamId: String, body: CreateMatchRequest) async throws -> MatchDto {
|
||||
try await post("teams/\(teamId)/matches", body: body)
|
||||
}
|
||||
|
||||
func match(id: String) async throws -> MatchDto {
|
||||
try await get("matches/\(id)")
|
||||
}
|
||||
|
||||
func updateMatch(id: String, body: UpdateMatchRequestFull) async throws -> MatchDto {
|
||||
try await patch("matches/\(id)", body: body)
|
||||
}
|
||||
|
||||
func updateMatchMultipart(id: String, fields: [MultipartField]) async throws -> MatchDto {
|
||||
try await multipartPatch("matches/\(id)", fields: fields)
|
||||
}
|
||||
|
||||
func deleteMatch(id: String) async throws {
|
||||
try await delete("matches/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Sessions
|
||||
|
||||
func createSession(matchId: String, body: CreateSessionRequest) async throws -> StreamSessionDto {
|
||||
try await post("matches/\(matchId)/sessions", body: body)
|
||||
}
|
||||
|
||||
func session(id: String) async throws -> StreamSessionDto {
|
||||
try await get("sessions/\(id)")
|
||||
}
|
||||
|
||||
func startSession(id: String) async throws -> StreamSessionDto {
|
||||
try await patch("sessions/\(id)/start", body: EmptyBody())
|
||||
}
|
||||
|
||||
func stopSession(id: String) async throws -> StreamSessionDto {
|
||||
try await patch("sessions/\(id)/stop", body: EmptyBody())
|
||||
}
|
||||
|
||||
func pauseSession(id: String) async throws -> StreamSessionDto {
|
||||
try await patch("sessions/\(id)/pause", body: EmptyBody())
|
||||
}
|
||||
|
||||
func resumeSession(id: String) async throws -> StreamSessionDto {
|
||||
try await patch("sessions/\(id)/resume", body: EmptyBody())
|
||||
}
|
||||
|
||||
func networkTest(sessionId: String, body: NetworkTestRequest) async throws -> NetworkTestResponse {
|
||||
try await post("sessions/\(sessionId)/network_test", body: body)
|
||||
}
|
||||
|
||||
func regiaLink(sessionId: String) async throws -> RegiaLinkResponse {
|
||||
try await post("sessions/\(sessionId)/regia_link", body: EmptyBody())
|
||||
}
|
||||
|
||||
func syncScore(sessionId: String, body: ScoreSyncRequest) async throws -> StreamSessionDto {
|
||||
try await patch("sessions/\(sessionId)/score", body: body)
|
||||
}
|
||||
|
||||
func applyScoreAction(sessionId: String, body: ScoreActionRequest) async throws -> StreamSessionDto {
|
||||
try await post("sessions/\(sessionId)/score_action", body: body)
|
||||
}
|
||||
|
||||
func postTelemetry(sessionId: String, body: TelemetryRequest) async throws {
|
||||
try await postVoid("sessions/\(sessionId)/telemetry", body: body)
|
||||
}
|
||||
|
||||
// MARK: - HTTP helpers
|
||||
|
||||
private struct EmptyBody: Encodable {}
|
||||
|
||||
private func get<T: Decodable>(_ path: String) async throws -> T {
|
||||
try await request(path, method: "GET")
|
||||
}
|
||||
|
||||
private func post<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||
try await request(path, method: "POST", body: body)
|
||||
}
|
||||
|
||||
private func postVoid<B: Encodable>(_ path: String, body: B = EmptyBody()) async throws {
|
||||
let _: EmptyResponse = try await request(path, method: "POST", body: body)
|
||||
}
|
||||
|
||||
private func postVoid(_ path: String) async throws {
|
||||
let _: EmptyResponse = try await request(path, method: "POST", body: EmptyBody())
|
||||
}
|
||||
|
||||
private func patch<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||
try await request(path, method: "PATCH", body: body)
|
||||
}
|
||||
|
||||
private func delete(_ path: String) async throws {
|
||||
let _: EmptyResponse = try await request(path, method: "DELETE")
|
||||
}
|
||||
|
||||
private func multipartPatch<T: Decodable>(_ path: String, fields: [MultipartField]) async throws -> T {
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(path))
|
||||
request.httpMethod = "PATCH"
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") }
|
||||
request.httpBody = MultipartBuilder.build(fields: fields, boundary: boundary)
|
||||
return try await execute(request)
|
||||
}
|
||||
|
||||
private func request<T: Decodable, B: Encodable>(
|
||||
_ path: String,
|
||||
method: String,
|
||||
body: B? = nil as EmptyBody?
|
||||
) async throws -> T {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(path))
|
||||
request.httpMethod = method
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if body != nil {
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try ApiInstant.encoder.encode(body)
|
||||
}
|
||||
if let accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") }
|
||||
return try await execute(request)
|
||||
}
|
||||
|
||||
private func execute<T: Decodable>(_ request: URLRequest) async throws -> T {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw APIError.invalidURL }
|
||||
if http.statusCode == 401 { throw APIError.unauthorized }
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let body = String(data: data, encoding: .utf8)
|
||||
throw APIError.http(http.statusCode, body)
|
||||
}
|
||||
if T.self == EmptyResponse.self {
|
||||
return EmptyResponse() as! T
|
||||
}
|
||||
do {
|
||||
return try ApiInstant.decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw APIError.decoding(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyResponse: Decodable {}
|
||||
|
||||
enum MultipartField {
|
||||
case text(String, String)
|
||||
case file(String, Data, filename: String, mime: String)
|
||||
}
|
||||
|
||||
enum MultipartBuilder {
|
||||
static func build(fields: [MultipartField], boundary: String) -> Data {
|
||||
var data = Data()
|
||||
let crlf = "\r\n"
|
||||
for field in fields {
|
||||
switch field {
|
||||
case .text(let name, let value):
|
||||
data.append("--\(boundary)\(crlf)")
|
||||
data.append("Content-Disposition: form-data; name=\"\(name)\"\(crlf)\(crlf)")
|
||||
data.append(value)
|
||||
data.append(crlf)
|
||||
case .file(let name, let fileData, let filename, let mime):
|
||||
data.append("--\(boundary)\(crlf)")
|
||||
data.append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\(crlf)")
|
||||
data.append("Content-Type: \(mime)\(crlf)\(crlf)")
|
||||
data.append(fileData)
|
||||
data.append(crlf)
|
||||
}
|
||||
}
|
||||
data.append("--\(boundary)--\(crlf)")
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
mutating func append(_ string: String) {
|
||||
if let d = string.data(using: .utf8) { append(d) }
|
||||
}
|
||||
}
|
||||
37
native/ios/MatchLiveTv/Data/AppContainer.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AppContainer: ObservableObject {
|
||||
let tokenStore: TokenStore
|
||||
let api: MatchLiveAPI
|
||||
let authRepository: AuthRepository
|
||||
let matchRepository: MatchRepository
|
||||
let sessionRepository: SessionRepository
|
||||
let scoreRepository: ScoreRepository
|
||||
let sessionCable = SessionCableService()
|
||||
let scoreController: ScoreController
|
||||
let wizardSession = WizardSessionHolder()
|
||||
let broadcastCoordinator = LiveBroadcastCoordinator()
|
||||
|
||||
init() {
|
||||
let api = MatchLiveAPI()
|
||||
let tokenStore = TokenStore()
|
||||
self.api = api
|
||||
self.tokenStore = tokenStore
|
||||
authRepository = AuthRepository(api: api, tokenStore: tokenStore) { token in
|
||||
api.setAccessToken(token)
|
||||
OverlayLogoCache.configure(token: token)
|
||||
}
|
||||
matchRepository = MatchRepository(api: api, tokenStore: tokenStore)
|
||||
sessionRepository = SessionRepository(api: api)
|
||||
scoreRepository = ScoreRepository(api: api)
|
||||
scoreController = ScoreController(scoreRepository: scoreRepository, sessionCable: sessionCable)
|
||||
}
|
||||
|
||||
func bootstrapAuth() {
|
||||
if let session = tokenStore.session {
|
||||
api.setAccessToken(session.accessToken)
|
||||
OverlayLogoCache.configure(token: session.accessToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
134
native/ios/MatchLiveTv/Data/Cable/ActionCableClient.swift
Normal file
@@ -0,0 +1,134 @@
|
||||
import Foundation
|
||||
|
||||
final class ActionCableClient: NSObject, URLSessionWebSocketDelegate, @unchecked Sendable {
|
||||
protocol Listener: AnyObject {
|
||||
func onConnected()
|
||||
func onDisconnected()
|
||||
func onChannelMessage(_ payload: [String: Any])
|
||||
}
|
||||
|
||||
private let cableUrl: URL
|
||||
private let accessToken: String
|
||||
private var webSocket: URLSessionWebSocketTask?
|
||||
private var channelIdentifier: String?
|
||||
private weak var listener: Listener?
|
||||
private let session: URLSession
|
||||
|
||||
init(cableUrl: String, accessToken: String) {
|
||||
self.cableUrl = URL(string: cableUrl)!
|
||||
self.accessToken = accessToken
|
||||
let config = URLSessionConfiguration.default
|
||||
self.session = URLSession(configuration: config)
|
||||
super.init()
|
||||
}
|
||||
|
||||
func connect(channel: String, params: [String: String], listener: Listener) {
|
||||
disconnect()
|
||||
self.listener = listener
|
||||
var identifier: [String: Any] = ["channel": channel]
|
||||
params.forEach { identifier[$0.key] = $0.value }
|
||||
channelIdentifier = jsonString(identifier)
|
||||
|
||||
var request = URLRequest(url: cableUrl)
|
||||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||
webSocket = session.webSocketTask(with: request)
|
||||
webSocket?.resume()
|
||||
receiveLoop()
|
||||
sendSubscribe()
|
||||
}
|
||||
|
||||
func performReceive(payload: [String: Any]) {
|
||||
guard let identifier = channelIdentifier else { return }
|
||||
var data = payload
|
||||
data["action"] = "receive"
|
||||
let envelope: [String: Any] = [
|
||||
"command": "message",
|
||||
"identifier": identifier,
|
||||
"data": jsonString(data) ?? "{}",
|
||||
]
|
||||
send(json: envelope)
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
webSocket?.cancel(with: .goingAway, reason: nil)
|
||||
webSocket = nil
|
||||
channelIdentifier = nil
|
||||
listener?.onDisconnected()
|
||||
}
|
||||
|
||||
private func sendSubscribe() {
|
||||
guard let identifier = channelIdentifier else { return }
|
||||
send(json: ["command": "subscribe", "identifier": identifier])
|
||||
}
|
||||
|
||||
private func receiveLoop() {
|
||||
webSocket?.receive { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .success(let message):
|
||||
switch message {
|
||||
case .string(let text):
|
||||
self.handleIncoming(text)
|
||||
case .data(let data):
|
||||
if let text = String(data: data, encoding: .utf8) { self.handleIncoming(text) }
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
self.receiveLoop()
|
||||
case .failure:
|
||||
self.listener?.onDisconnected()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleIncoming(_ text: String) {
|
||||
guard let data = text.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
||||
|
||||
if let type = json["type"] as? String, type == "ping" {
|
||||
let pong: [String: Any] = ["type": "pong", "message": json["message"] ?? Date().timeIntervalSince1970]
|
||||
send(json: pong)
|
||||
return
|
||||
}
|
||||
|
||||
if json["type"] as? String == "confirm_subscription" {
|
||||
listener?.onConnected()
|
||||
return
|
||||
}
|
||||
|
||||
if json["type"] as? String == "disconnect" {
|
||||
listener?.onDisconnected()
|
||||
return
|
||||
}
|
||||
|
||||
if let message = json["message"] {
|
||||
var payload: [String: Any]?
|
||||
if let dict = message as? [String: Any] {
|
||||
if let nested = dict["data"] as? [String: Any] {
|
||||
payload = nested
|
||||
} else if dict["method"] as? String == "receive_message",
|
||||
let nested = dict["data"] as? [String: Any] {
|
||||
payload = nested
|
||||
} else {
|
||||
payload = dict
|
||||
}
|
||||
} else if let str = message as? String,
|
||||
let d = str.data(using: .utf8),
|
||||
let dict = try? JSONSerialization.jsonObject(with: d) as? [String: Any] {
|
||||
payload = dict
|
||||
}
|
||||
if let payload { listener?.onChannelMessage(payload) }
|
||||
}
|
||||
}
|
||||
|
||||
private func send(json: [String: Any]) {
|
||||
guard let text = jsonString(json) else { return }
|
||||
webSocket?.send(.string(text)) { _ in }
|
||||
}
|
||||
|
||||
private func jsonString(_ object: Any) -> String? {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: object),
|
||||
let text = String(data: data, encoding: .utf8) else { return nil }
|
||||
return text
|
||||
}
|
||||
}
|
||||
107
native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift
Normal file
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class SessionCableService: ObservableObject {
|
||||
@Published private(set) var connected = false
|
||||
|
||||
var onScoreUpdate: ((ScoreState) -> Void)?
|
||||
var onPauseStream: (() -> Void)?
|
||||
var onResumeStream: (() -> Void)?
|
||||
var onStopStream: (() -> Void)?
|
||||
|
||||
private var client: ActionCableClient?
|
||||
private var intentionalDisconnect = false
|
||||
private var reconnectAttempt = 0
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
private var sessionId: String?
|
||||
private var accessToken: String?
|
||||
private var deviceRole = "camera"
|
||||
|
||||
func connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
|
||||
self.sessionId = sessionId
|
||||
self.accessToken = accessToken
|
||||
self.deviceRole = deviceRole
|
||||
intentionalDisconnect = false
|
||||
reconnectAttempt = 0
|
||||
openSocket()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
intentionalDisconnect = true
|
||||
reconnectTask?.cancel()
|
||||
client?.disconnect()
|
||||
client = nil
|
||||
connected = false
|
||||
}
|
||||
|
||||
func sendScoreUpdate(_ score: ScoreState) {
|
||||
client?.performReceive(payload: score.cablePayload())
|
||||
}
|
||||
|
||||
private func openSocket() {
|
||||
guard let sessionId, let accessToken else { return }
|
||||
let cable = ActionCableClient(cableUrl: AppConfig.cableUrl, accessToken: accessToken)
|
||||
cable.connect(
|
||||
channel: "SessionChannel",
|
||||
params: ["session_id": sessionId, "device_role": deviceRole],
|
||||
listener: self
|
||||
)
|
||||
client = cable
|
||||
}
|
||||
|
||||
private func scheduleReconnect() {
|
||||
guard !intentionalDisconnect else { return }
|
||||
reconnectAttempt += 1
|
||||
let delay = min(30.0, pow(2.0, Double(min(reconnectAttempt - 1, 4))))
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = Task {
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
guard !Task.isCancelled, !intentionalDisconnect else { return }
|
||||
openSocket()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SessionCableService: ActionCableClient.Listener {
|
||||
nonisolated func onConnected() {
|
||||
Task { @MainActor in
|
||||
connected = true
|
||||
reconnectAttempt = 0
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func onDisconnected() {
|
||||
Task { @MainActor in
|
||||
connected = false
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func onChannelMessage(_ payload: [String: Any]) {
|
||||
Task { @MainActor in
|
||||
let type = payload["type"] as? String ?? ""
|
||||
switch type {
|
||||
case "score_update":
|
||||
onScoreUpdate?(ScoreState.fromCablePayload(payload))
|
||||
case "command":
|
||||
let command = payload["command"] as? String ?? ""
|
||||
switch command {
|
||||
case "pause_stream": onPauseStream?()
|
||||
case "resume_stream": onResumeStream?()
|
||||
case "stop_stream": onStopStream?()
|
||||
default: break
|
||||
}
|
||||
case "stream_event":
|
||||
let event = payload["event"] as? String ?? ""
|
||||
switch event {
|
||||
case "paused": onPauseStream?()
|
||||
case "resumed": onResumeStream?()
|
||||
case "ended": onStopStream?()
|
||||
default: break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
native/ios/MatchLiveTv/Data/Repository/AuthRepository.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AuthRepository {
|
||||
private let api: MatchLiveAPI
|
||||
private let tokenStore: TokenStore
|
||||
private let onTokenChanged: (String?) -> Void
|
||||
|
||||
init(api: MatchLiveAPI, tokenStore: TokenStore, onTokenChanged: @escaping (String?) -> Void) {
|
||||
self.api = api
|
||||
self.tokenStore = tokenStore
|
||||
self.onTokenChanged = onTokenChanged
|
||||
}
|
||||
|
||||
var currentSession: StoredSession? { tokenStore.session }
|
||||
|
||||
func login(email: String, password: String) async throws -> StoredSession {
|
||||
let response = try await api.login(email: email, password: password)
|
||||
let (user, tokens) = response.toDomain()
|
||||
let session = StoredSession(user: user, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken)
|
||||
tokenStore.saveSession(session)
|
||||
onTokenChanged(tokens.accessToken)
|
||||
return session
|
||||
}
|
||||
|
||||
func validateOrRefresh() async -> StoredSession? {
|
||||
guard let stored = tokenStore.session else { return nil }
|
||||
do {
|
||||
_ = try await api.me()
|
||||
return stored
|
||||
} catch {
|
||||
return try? await refresh(stored.refreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
func refresh(_ refreshToken: String) async throws -> StoredSession {
|
||||
let response = try await api.refresh(refreshToken: refreshToken)
|
||||
let (user, tokens) = response.toDomain()
|
||||
let session = StoredSession(user: user, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken)
|
||||
tokenStore.saveSession(session)
|
||||
onTokenChanged(tokens.accessToken)
|
||||
return session
|
||||
}
|
||||
|
||||
func logout() async {
|
||||
try? await api.logout()
|
||||
tokenStore.clear()
|
||||
onTokenChanged(nil)
|
||||
}
|
||||
}
|
||||
145
native/ios/MatchLiveTv/Data/Repository/MatchRepository.swift
Normal file
@@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class MatchRepository {
|
||||
private let api: MatchLiveAPI
|
||||
private let tokenStore: TokenStore
|
||||
|
||||
init(api: MatchLiveAPI, tokenStore: TokenStore) {
|
||||
self.api = api
|
||||
self.tokenStore = tokenStore
|
||||
}
|
||||
|
||||
func fetchSports() async throws -> [SportOption] {
|
||||
try await api.sports().map { $0.toDomain() }
|
||||
}
|
||||
|
||||
func fetchTeams() async throws -> [Team] {
|
||||
try await api.teams().map { $0.toDomain() }
|
||||
}
|
||||
|
||||
func fetchTeam(teamId: String) async -> Team? {
|
||||
if let dto = try? await api.team(id: teamId) { return dto.toDomain() }
|
||||
let teams = (try? await fetchTeams()) ?? []
|
||||
return teams.first { $0.id == teamId }
|
||||
}
|
||||
|
||||
func resolveActiveTeam(teams: [Team]) -> Team? {
|
||||
guard !teams.isEmpty else { return nil }
|
||||
let selected = teams.first { $0.id == tokenStore.activeTeamId } ?? teams[0]
|
||||
tokenStore.saveActiveTeamId(selected.id)
|
||||
return selected
|
||||
}
|
||||
|
||||
func fetchMatch(matchId: String) async throws -> Match {
|
||||
try await api.match(id: matchId).toDomain()
|
||||
}
|
||||
|
||||
func fetchMatchesForTeam(teamId: String) async throws -> [Match] {
|
||||
let matches = try await api.matches(teamId: teamId).map { $0.toDomain() }
|
||||
return MatchHubFilter.sortMatches(matches.filter { MatchHubFilter.coachHubVisible($0) })
|
||||
}
|
||||
|
||||
func fetchMatches() async throws -> [Match] {
|
||||
let teams = try await fetchTeams()
|
||||
guard let team = resolveActiveTeam(teams: teams) else { return [] }
|
||||
return try await fetchMatchesForTeam(teamId: team.id)
|
||||
}
|
||||
|
||||
func selectTeam(teamId: String) {
|
||||
tokenStore.saveActiveTeamId(teamId)
|
||||
}
|
||||
|
||||
func createQuickMatch(teamId: String) async throws -> Match {
|
||||
try await api.createMatch(
|
||||
teamId: teamId,
|
||||
body: CreateMatchRequest(match: CreateMatchBody(opponentName: "Avversario", sport: "volleyball", setsToWin: 3, scheduledAt: nil, location: nil))
|
||||
).toDomain()
|
||||
}
|
||||
|
||||
func createScheduledMatch(teamId: String, opponentName: String, scheduledAt: Date, location: String?) async throws -> Match {
|
||||
let iso = ISO8601DateFormatter().string(from: scheduledAt)
|
||||
return try await api.createMatch(
|
||||
teamId: teamId,
|
||||
body: CreateMatchRequest(
|
||||
match: CreateMatchBody(
|
||||
opponentName: opponentName,
|
||||
sport: "volleyball",
|
||||
setsToWin: 3,
|
||||
scheduledAt: iso,
|
||||
location: location?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||
)
|
||||
)
|
||||
).toDomain()
|
||||
}
|
||||
|
||||
func updateTeamBranding(teamId: String, primaryColor: String, secondaryColor: String?, logoImage: UIImage?) async throws -> Team {
|
||||
if let logoImage, let data = logoImage.pngData() {
|
||||
return try await api.updateTeamMultipart(
|
||||
id: teamId,
|
||||
primaryColor: primaryColor,
|
||||
secondaryColor: secondaryColor,
|
||||
logoData: data,
|
||||
logoFilename: "logo.png"
|
||||
).toDomain()
|
||||
}
|
||||
return try await api.updateTeam(
|
||||
id: teamId,
|
||||
body: UpdateTeamRequest(team: UpdateTeamBody(primaryColor: primaryColor, secondaryColor: secondaryColor))
|
||||
).toDomain()
|
||||
}
|
||||
|
||||
func updateMatch(
|
||||
matchId: String,
|
||||
opponentName: String,
|
||||
location: String?,
|
||||
scheduledAt: String?,
|
||||
setsToWin: Int,
|
||||
category: String?,
|
||||
opponentPrimaryColor: String?,
|
||||
scoringRules: ScoringRules?,
|
||||
sportKey: String? = nil,
|
||||
overlayKind: String? = nil,
|
||||
opponentLogoImage: UIImage? = nil
|
||||
) async throws -> Match {
|
||||
let rulesMap = scoringRules?.toMap(setsToWin: setsToWin)
|
||||
let body = UpdateMatchBodyFull(
|
||||
opponentName: opponentName,
|
||||
location: location?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
|
||||
scheduledAt: scheduledAt,
|
||||
setsToWin: setsToWin,
|
||||
sportKey: sportKey,
|
||||
overlayKind: overlayKind,
|
||||
category: category?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
|
||||
opponentPrimaryColor: opponentPrimaryColor,
|
||||
scoringRules: rulesMap
|
||||
)
|
||||
if let opponentLogoImage, let data = opponentLogoImage.pngData() {
|
||||
var fields: [MultipartField] = [
|
||||
.text("match[opponent_name]", opponentName),
|
||||
.text("match[sets_to_win]", "\(setsToWin)"),
|
||||
]
|
||||
if let location = body.location { fields.append(.text("match[location]", location)) }
|
||||
if let scheduledAt { fields.append(.text("match[scheduled_at]", scheduledAt)) }
|
||||
if let category = body.category { fields.append(.text("match[category]", category)) }
|
||||
if let color = opponentPrimaryColor { fields.append(.text("match[opponent_primary_color]", color)) }
|
||||
if let rules = scoringRules {
|
||||
fields.append(.text("match[scoring_rules][points_per_set]", "\(rules.pointsPerSet)"))
|
||||
fields.append(.text("match[scoring_rules][points_deciding_set]", "\(rules.pointsDecidingSet)"))
|
||||
fields.append(.text("match[scoring_rules][min_point_lead]", "\(rules.minPointLead)"))
|
||||
}
|
||||
fields.append(.file("opponent_logo_file", data, filename: "opponent.png", mime: "image/png"))
|
||||
return try await api.updateMatchMultipart(id: matchId, fields: fields).toDomain()
|
||||
}
|
||||
return try await api.updateMatch(id: matchId, body: UpdateMatchRequestFull(match: body)).toDomain()
|
||||
}
|
||||
|
||||
func deleteMatch(matchId: String) async throws {
|
||||
try await api.deleteMatch(id: matchId)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
enum MatchSessionLauncher {
|
||||
static func resumeBroadcastSession(
|
||||
match: Match,
|
||||
sessionRepository: SessionRepository
|
||||
) async throws -> String {
|
||||
guard let sessionId = match.activeSessionId else {
|
||||
throw APIError.http(400, "Nessuna sessione attiva")
|
||||
}
|
||||
_ = try await sessionRepository.fetchSession(id: sessionId)
|
||||
return sessionId
|
||||
}
|
||||
}
|
||||
49
native/ios/MatchLiveTv/Data/Repository/ScoreRepository.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ScoreRepository {
|
||||
private let api: MatchLiveAPI
|
||||
private static let periodBoards: Set<String> = ["basket", "timed"]
|
||||
|
||||
init(api: MatchLiveAPI) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
func syncScore(sessionId: String, score: ScoreState) async throws -> StreamSession {
|
||||
let partials = score.setPartials.map { SetPartialDto(set: $0.set, home: $0.home, away: $0.away) }
|
||||
var request = ScoreSyncRequest(
|
||||
homeSets: score.homeSets,
|
||||
awaySets: score.awaySets,
|
||||
homePoints: score.homePoints,
|
||||
awayPoints: score.awayPoints,
|
||||
currentSet: score.currentSet,
|
||||
setPartials: partials,
|
||||
clockSecs: nil,
|
||||
clockRunning: nil,
|
||||
period: nil,
|
||||
homeScore: nil,
|
||||
awayScore: nil
|
||||
)
|
||||
if Self.periodBoards.contains(score.boardType) {
|
||||
request = ScoreSyncRequest(
|
||||
homeSets: score.homeSets,
|
||||
awaySets: score.awaySets,
|
||||
homePoints: score.homePoints,
|
||||
awayPoints: score.awayPoints,
|
||||
currentSet: score.currentSet,
|
||||
setPartials: partials,
|
||||
clockSecs: score.clockSecs,
|
||||
clockRunning: score.clockRunning,
|
||||
period: score.period,
|
||||
homeScore: score.homePoints,
|
||||
awayScore: score.awayPoints
|
||||
)
|
||||
}
|
||||
return try await api.syncScore(sessionId: sessionId, body: request).toDomain()
|
||||
}
|
||||
|
||||
func applyScoreAction(sessionId: String, action: String) async throws -> ScoreState? {
|
||||
let response = try await api.applyScoreAction(sessionId: sessionId, body: ScoreActionRequest(scoreAction: action))
|
||||
return response.score?.toDomain()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class SessionRepository {
|
||||
private let api: MatchLiveAPI
|
||||
|
||||
init(api: MatchLiveAPI) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
func createSession(
|
||||
matchId: String,
|
||||
platform: String = "matchlivetv",
|
||||
privacyStatus: String = "public",
|
||||
qualityPreset: String = "720p_30_2.5mbps",
|
||||
targetBitrate: Int = 2_500_000,
|
||||
targetFps: Int = 30,
|
||||
youtubeChannel: String? = nil
|
||||
) async throws -> StreamSession {
|
||||
try await api.createSession(
|
||||
matchId: matchId,
|
||||
body: CreateSessionRequest(
|
||||
platform: platform,
|
||||
privacyStatus: privacyStatus,
|
||||
qualityPreset: qualityPreset,
|
||||
targetBitrate: targetBitrate,
|
||||
targetFps: targetFps,
|
||||
youtubeChannel: youtubeChannel
|
||||
)
|
||||
).toDomain()
|
||||
}
|
||||
|
||||
func fetchSession(id: String) async throws -> StreamSession {
|
||||
try await api.session(id: id).toDomain()
|
||||
}
|
||||
|
||||
func startSession(id: String) async throws -> StreamSession {
|
||||
try await api.startSession(id: id).toDomain()
|
||||
}
|
||||
|
||||
func stopSession(id: String) async throws -> StreamSession {
|
||||
try await api.stopSession(id: id).toDomain()
|
||||
}
|
||||
|
||||
func pauseSession(id: String) async throws -> StreamSession {
|
||||
try await api.pauseSession(id: id).toDomain()
|
||||
}
|
||||
|
||||
func resumeSession(id: String) async throws -> StreamSession {
|
||||
try await api.resumeSession(id: id).toDomain()
|
||||
}
|
||||
|
||||
func submitNetworkTest(
|
||||
sessionId: String,
|
||||
downloadMbps: Double,
|
||||
uploadMbps: Double,
|
||||
latencyMs: Int,
|
||||
networkType: String
|
||||
) async throws -> NetworkTestResponse {
|
||||
try await api.networkTest(
|
||||
sessionId: sessionId,
|
||||
body: NetworkTestRequest(
|
||||
downloadMbps: downloadMbps,
|
||||
uploadMbps: uploadMbps,
|
||||
latencyMs: latencyMs,
|
||||
networkType: networkType
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func createRegiaLink(sessionId: String) async throws -> String {
|
||||
try await api.regiaLink(sessionId: sessionId).regiaUrl
|
||||
}
|
||||
|
||||
func postTelemetry(
|
||||
sessionId: String,
|
||||
health: DeviceHealth,
|
||||
currentBitrate: Int?,
|
||||
targetBitrate: Int?,
|
||||
fps: Int?
|
||||
) async throws {
|
||||
try await api.postTelemetry(
|
||||
sessionId: sessionId,
|
||||
body: TelemetryRequest(
|
||||
deviceRole: "camera",
|
||||
batteryLevel: health.batteryPercent,
|
||||
networkType: health.networkType,
|
||||
signalStrength: nil,
|
||||
currentBitrate: currentBitrate,
|
||||
targetBitrate: targetBitrate,
|
||||
fps: fps
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
159
native/ios/MatchLiveTv/Data/Scoring/ScoreController.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ScoreController: ObservableObject {
|
||||
@Published private(set) var score = ScoreState()
|
||||
|
||||
private let scoreRepository: ScoreRepository
|
||||
private let sessionCable: SessionCableService
|
||||
private var sessionId: String?
|
||||
private var suppressRemoteUntil: Date = .distantPast
|
||||
private var syncTask: Task<Void, Never>?
|
||||
private var syncGeneration = 0
|
||||
|
||||
init(scoreRepository: ScoreRepository, sessionCable: SessionCableService) {
|
||||
self.scoreRepository = scoreRepository
|
||||
self.sessionCable = sessionCable
|
||||
}
|
||||
|
||||
func bind(sessionId: String, initial: ScoreState?) {
|
||||
self.sessionId = sessionId
|
||||
if let initial { score = initial }
|
||||
}
|
||||
|
||||
func applyRemote(_ remote: ScoreState) {
|
||||
if remote == score { return }
|
||||
if Date() < suppressRemoteUntil && remote.progressKey() <= score.progressKey() { return }
|
||||
score = remote
|
||||
}
|
||||
|
||||
func incrementHome() {
|
||||
score.homePoints += 1
|
||||
schedulePush()
|
||||
}
|
||||
|
||||
func incrementAway() {
|
||||
score.awayPoints += 1
|
||||
schedulePush()
|
||||
}
|
||||
|
||||
func decrementHome() {
|
||||
if score.homePoints > 0 { score.homePoints -= 1 }
|
||||
schedulePush()
|
||||
}
|
||||
|
||||
func decrementAway() {
|
||||
if score.awayPoints > 0 { score.awayPoints -= 1 }
|
||||
schedulePush()
|
||||
}
|
||||
|
||||
func applyAction(_ action: String) {
|
||||
applyOptimistic(action)
|
||||
scheduleAction(action)
|
||||
}
|
||||
|
||||
/// Azione punteggio via API (volley/racket): il server è fonte di verità.
|
||||
func applyBoardAction(_ action: String) async {
|
||||
syncTask?.cancel()
|
||||
syncGeneration += 1
|
||||
guard let sessionId else { return }
|
||||
do {
|
||||
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) {
|
||||
suppressRemoteUntil = Date().addingTimeInterval(0.8)
|
||||
score = updated
|
||||
sessionCable.sendScoreUpdate(updated)
|
||||
}
|
||||
} catch {
|
||||
// Mantieni lo stato locale; la UI può mostrare errore a livello schermata se serve.
|
||||
}
|
||||
}
|
||||
|
||||
/// Chiude il set corrente tramite `close_set` sul motore backend.
|
||||
func closeSetAsync() async -> Bool {
|
||||
syncTask?.cancel()
|
||||
syncGeneration += 1
|
||||
guard let sessionId else { return false }
|
||||
do {
|
||||
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: "close_set") {
|
||||
suppressRemoteUntil = Date().addingTimeInterval(1.2)
|
||||
score = updated
|
||||
sessionCable.sendScoreUpdate(updated)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func applyOptimistic(_ action: String) {
|
||||
switch action {
|
||||
case "home_point": score.homePoints += 1
|
||||
case "away_point": score.awayPoints += 1
|
||||
case "home_point_2": score.homePoints += 2
|
||||
case "away_point_2": score.awayPoints += 2
|
||||
case "home_point_3": score.homePoints += 3
|
||||
case "away_point_3": score.awayPoints += 3
|
||||
case "home_undo": score.homePoints = max(0, score.homePoints - 1)
|
||||
case "away_undo": score.awayPoints = max(0, score.awayPoints - 1)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleAction(_ action: String) {
|
||||
syncTask?.cancel()
|
||||
syncGeneration += 1
|
||||
let generation = syncGeneration
|
||||
syncTask = Task {
|
||||
guard let sessionId else { return }
|
||||
if let updated = try? await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) {
|
||||
guard generation == syncGeneration else { return }
|
||||
suppressRemoteUntil = Date().addingTimeInterval(0.5)
|
||||
score = updated
|
||||
sessionCable.sendScoreUpdate(updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func closeSet() {
|
||||
let current = score
|
||||
let homeWon = current.homePoints > current.awayPoints
|
||||
var partials = current.setPartials
|
||||
if current.homePoints > 0 || current.awayPoints > 0 {
|
||||
partials.append(SetPartial(set: current.currentSet, home: current.homePoints, away: current.awayPoints))
|
||||
}
|
||||
score = ScoreState(
|
||||
homeSets: homeWon ? current.homeSets + 1 : current.homeSets,
|
||||
awaySets: homeWon ? current.awaySets : current.awaySets + 1,
|
||||
homePoints: 0,
|
||||
awayPoints: 0,
|
||||
currentSet: current.currentSet + 1,
|
||||
setPartials: partials,
|
||||
timeoutHome: false,
|
||||
timeoutAway: false,
|
||||
boardType: current.boardType,
|
||||
period: current.period,
|
||||
periodLabel: current.periodLabel,
|
||||
clockSecs: current.clockSecs,
|
||||
clockRunning: current.clockRunning,
|
||||
overtime: current.overtime
|
||||
)
|
||||
schedulePush()
|
||||
}
|
||||
|
||||
private func schedulePush() {
|
||||
syncTask?.cancel()
|
||||
syncGeneration += 1
|
||||
let generation = syncGeneration
|
||||
syncTask = Task {
|
||||
guard let sessionId else { return }
|
||||
let local = score
|
||||
sessionCable.sendScoreUpdate(local)
|
||||
if let remote = try? await scoreRepository.syncScore(sessionId: sessionId, score: local).score {
|
||||
guard generation == syncGeneration else { return }
|
||||
suppressRemoteUntil = Date().addingTimeInterval(0.5)
|
||||
score = remote
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
native/ios/MatchLiveTv/Data/WizardSessionHolder.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class WizardSessionHolder: ObservableObject {
|
||||
@Published var match: Match?
|
||||
@Published var session: StreamSession?
|
||||
@Published var team: Team?
|
||||
|
||||
func reset() {
|
||||
match = nil
|
||||
session = nil
|
||||
team = nil
|
||||
}
|
||||
}
|
||||
43
native/ios/MatchLiveTv/Domain/MatchHubFilter.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
|
||||
enum MatchHubFilter {
|
||||
private static let resumableStatuses: Set<String> = ["connecting", "live", "reconnecting", "paused"]
|
||||
|
||||
static func coachHubVisible(_ match: Match, now: Date = Date()) -> Bool {
|
||||
if let visible = match.coachHubVisible { return visible }
|
||||
|
||||
if match.hasActiveSession {
|
||||
let status = match.activeSessionStatus ?? ""
|
||||
if resumableStatuses.contains(status) || status == "idle" { return true }
|
||||
}
|
||||
if match.streamCompleted { return false }
|
||||
|
||||
return ApiInstant.isScheduledFuture(match.scheduledAt, now: now)
|
||||
}
|
||||
|
||||
static func isScheduledFuture(_ match: Match, now: Date = Date()) -> Bool {
|
||||
ApiInstant.isScheduledFuture(match.scheduledAt, now: now)
|
||||
}
|
||||
|
||||
static func isDraft(_ match: Match) -> Bool {
|
||||
!match.hasActiveSession && (match.scheduledAt == nil || match.scheduledAt?.isEmpty == true)
|
||||
}
|
||||
|
||||
static func sortMatches(_ matches: [Match]) -> [Match] {
|
||||
matches.sorted { lhs, rhs in
|
||||
let lDate = lhs.scheduledAt ?? ""
|
||||
let rDate = rhs.scheduledAt ?? ""
|
||||
if lDate != rDate { return lDate > rDate }
|
||||
return lhs.id > rhs.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum MatchPresentation {
|
||||
static func statusLabel(for match: Match) -> String {
|
||||
if match.canResumeCamera { return "RIPRENDI" }
|
||||
if match.hasActiveSession { return "IN CORSO" }
|
||||
if ApiInstant.isScheduledFuture(match.scheduledAt) { return "PROGRAMMATA" }
|
||||
return "AVVIA"
|
||||
}
|
||||
}
|
||||
68
native/ios/MatchLiveTv/Domain/MatchScoringRules.swift
Normal file
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
|
||||
enum ScoringSide: Sendable {
|
||||
case home
|
||||
case away
|
||||
}
|
||||
|
||||
struct MatchScoringContext: Sendable {
|
||||
let setsToWin: Int
|
||||
let pointsPerSet: Int
|
||||
let pointsDecidingSet: Int
|
||||
let minPointLead: Int
|
||||
let boardType: String
|
||||
|
||||
var maxSets: Int { (setsToWin * 2) - 1 }
|
||||
|
||||
var usesSetLogic: Bool {
|
||||
boardType == "volley" || boardType == "racket"
|
||||
}
|
||||
|
||||
init(match: Match) {
|
||||
let rules = match.scoringRules ?? ScoringRules()
|
||||
setsToWin = match.setsToWin
|
||||
pointsPerSet = rules.pointsPerSet
|
||||
pointsDecidingSet = rules.pointsDecidingSet
|
||||
minPointLead = rules.minPointLead
|
||||
boardType = match.boardType.isEmpty ? "volley" : match.boardType
|
||||
}
|
||||
|
||||
func pointsTarget(currentSet: Int) -> Int {
|
||||
currentSet >= maxSets ? pointsDecidingSet : pointsPerSet
|
||||
}
|
||||
|
||||
func setWinnerFromPoints(homePoints: Int, awayPoints: Int, currentSet: Int) -> ScoringSide? {
|
||||
let target = pointsTarget(currentSet: currentSet)
|
||||
if homePoints >= target, homePoints - awayPoints >= minPointLead { return .home }
|
||||
if awayPoints >= target, awayPoints - homePoints >= minPointLead { return .away }
|
||||
return nil
|
||||
}
|
||||
|
||||
func matchWinner(homeSets: Int, awaySets: Int) -> ScoringSide? {
|
||||
if homeSets >= setsToWin { return .home }
|
||||
if awaySets >= setsToWin { return .away }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
enum MatchScoringRules {
|
||||
static func rules(for match: Match) -> ScoringRules {
|
||||
match.scoringRules ?? ScoringRules()
|
||||
}
|
||||
|
||||
static func setsToWin(for match: Match) -> Int {
|
||||
match.setsToWin
|
||||
}
|
||||
|
||||
static func maxSets(for match: Match) -> Int {
|
||||
MatchScoringContext(match: match).maxSets
|
||||
}
|
||||
|
||||
static func pointsTarget(for match: Match, currentSet: Int) -> Int {
|
||||
MatchScoringContext(match: match).pointsTarget(currentSet: currentSet)
|
||||
}
|
||||
}
|
||||
|
||||
func scoringSideName(_ side: ScoringSide, homeName: String, awayName: String) -> String {
|
||||
side == .home ? homeName : awayName
|
||||
}
|
||||
137
native/ios/MatchLiveTv/Domain/Models.swift
Normal file
@@ -0,0 +1,137 @@
|
||||
import Foundation
|
||||
|
||||
struct User: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let email: String
|
||||
let name: String
|
||||
let role: String
|
||||
}
|
||||
|
||||
struct AuthTokens: Sendable {
|
||||
let accessToken: String
|
||||
let refreshToken: String
|
||||
}
|
||||
|
||||
struct SportOption: Identifiable, Equatable, Sendable {
|
||||
var id: String { key }
|
||||
let key: String
|
||||
let label: String
|
||||
let board: String
|
||||
let overlay: String
|
||||
let allowedOverlays: [String]
|
||||
let defaultRules: [String: Int]
|
||||
}
|
||||
|
||||
struct Team: Identifiable, Equatable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let sport: String
|
||||
let sportKey: String
|
||||
let boardType: String
|
||||
let clubName: String?
|
||||
let logoUrl: String?
|
||||
let primaryColor: String?
|
||||
let secondaryColor: String?
|
||||
let canStream: Bool
|
||||
let youtubeEnabled: Bool
|
||||
let youtubeConnected: Bool
|
||||
let youtubeSelectable: Bool
|
||||
let youtubeChannelTitle: String?
|
||||
let youtubeUsesPlatformChannel: Bool
|
||||
let youtubeTeamChannelTitle: String?
|
||||
let planName: String?
|
||||
let recordingsEnabled: Bool
|
||||
let phoneDownloadEnabled: Bool
|
||||
let billingUrl: String?
|
||||
let staffManageUrl: String?
|
||||
|
||||
var canUseYoutube: Bool { youtubeEnabled }
|
||||
var isYoutubeReady: Bool { youtubeSelectable }
|
||||
|
||||
var effectiveYoutubeChannel: String? {
|
||||
guard isYoutubeReady else { return nil }
|
||||
return youtubeUsesPlatformChannel ? "platform" : "team"
|
||||
}
|
||||
|
||||
var youtubeDestinationLabel: String {
|
||||
if youtubeUsesPlatformChannel { return "Canale Match Live TV" }
|
||||
let name = youtubeTeamChannelTitle?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||
?? clubName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||
?? "società"
|
||||
return "Canale società · \(name)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ScoringRules: Equatable, Sendable {
|
||||
var pointsPerSet: Int = 25
|
||||
var pointsDecidingSet: Int = 15
|
||||
var minPointLead: Int = 2
|
||||
var periods: Int = 4
|
||||
var periodDurationSecs: Int = 600
|
||||
var overtimeDurationSecs: Int = 300
|
||||
}
|
||||
|
||||
struct Match: Identifiable, Equatable, Sendable {
|
||||
let id: String
|
||||
let teamId: String
|
||||
let teamName: String
|
||||
let opponentName: String
|
||||
let location: String?
|
||||
let scheduledAt: String?
|
||||
let sportKey: String
|
||||
let sportLabel: String?
|
||||
let boardType: String
|
||||
let overlayKind: String?
|
||||
let effectiveOverlayKind: String
|
||||
let setsToWin: Int
|
||||
let category: String?
|
||||
let scoringRules: ScoringRules?
|
||||
let activeSessionId: String?
|
||||
let activeSessionStatus: String?
|
||||
let streamCompleted: Bool
|
||||
let coachHubVisible: Bool?
|
||||
let homePrimaryColor: String?
|
||||
let homeSecondaryColor: String?
|
||||
let homeLogoUrl: String?
|
||||
let opponentPrimaryColor: String?
|
||||
let opponentLogoUrl: String?
|
||||
|
||||
var hasActiveSession: Bool { activeSessionId != nil }
|
||||
|
||||
var canResumeCamera: Bool {
|
||||
guard let status = activeSessionStatus, activeSessionId != nil else { return false }
|
||||
return ["connecting", "live", "reconnecting", "paused"].contains(status)
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamSession: Identifiable, Equatable, Sendable {
|
||||
let id: String
|
||||
let matchId: String
|
||||
let status: String
|
||||
let platform: String
|
||||
let rtmpIngestUrl: String?
|
||||
let hlsPlaybackUrl: String?
|
||||
let watchPageUrl: String?
|
||||
let shareUrl: String?
|
||||
let youtubeWatchUrl: String?
|
||||
let youtubeReady: Bool
|
||||
let privacyStatus: String
|
||||
let targetBitrate: Int
|
||||
let targetFps: Int
|
||||
let startedAt: String?
|
||||
let score: ScoreState?
|
||||
|
||||
var isLive: Bool { status == "live" || status == "reconnecting" }
|
||||
var isPaused: Bool { status == "paused" }
|
||||
|
||||
func watchShareUrl() -> String? {
|
||||
if platform == "youtube", let url = youtubeWatchUrl, !url.isEmpty { return url }
|
||||
if let url = shareUrl, !url.isEmpty { return url }
|
||||
if let url = watchPageUrl, !url.isEmpty { return url }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
113
native/ios/MatchLiveTv/Domain/ScoreState.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
|
||||
struct SetPartial: Equatable, Codable, Sendable {
|
||||
let set: Int
|
||||
let home: Int
|
||||
let away: Int
|
||||
}
|
||||
|
||||
struct ScoreState: Equatable, Sendable {
|
||||
var homeSets: Int = 0
|
||||
var awaySets: Int = 0
|
||||
var homePoints: Int = 0
|
||||
var awayPoints: Int = 0
|
||||
var currentSet: Int = 1
|
||||
var setPartials: [SetPartial] = []
|
||||
var timeoutHome: Bool = false
|
||||
var timeoutAway: Bool = false
|
||||
var boardType: String = "volley"
|
||||
var period: Int = 1
|
||||
var periodLabel: String?
|
||||
var clockSecs: Int = 0
|
||||
var clockRunning: Bool = false
|
||||
var overtime: Bool = false
|
||||
|
||||
func cablePayload() -> [String: Any] {
|
||||
var payload: [String: Any] = [
|
||||
"type": "score_update",
|
||||
"board_type": boardType,
|
||||
"home_sets": homeSets,
|
||||
"away_sets": awaySets,
|
||||
"home_points": homePoints,
|
||||
"away_points": awayPoints,
|
||||
"current_set": currentSet,
|
||||
"set_partials": setPartials.map { ["set": $0.set, "home": $0.home, "away": $0.away] },
|
||||
"timeout_home": timeoutHome,
|
||||
"timeout_away": timeoutAway,
|
||||
]
|
||||
if Self.periodBoards.contains(boardType) {
|
||||
payload["period"] = periodLabel ?? "\(period)"
|
||||
payload["clock_secs"] = clockSecs
|
||||
payload["clock_running"] = clockRunning
|
||||
payload["home_score"] = homePoints
|
||||
payload["away_score"] = awayPoints
|
||||
payload["overtime"] = overtime
|
||||
}
|
||||
if boardType == "timer" {
|
||||
payload["clock_secs"] = clockSecs
|
||||
payload["clock_running"] = clockRunning
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func progressKey() -> Int64 {
|
||||
Int64(currentSet) * 10_000_000_000
|
||||
+ Int64(homeSets) * 1_000_000
|
||||
+ Int64(awaySets) * 100_000
|
||||
+ Int64(homePoints) * 1_000
|
||||
+ Int64(awayPoints)
|
||||
+ Int64(clockSecs)
|
||||
}
|
||||
|
||||
static let periodBoards: Set<String> = ["basket", "timed"]
|
||||
|
||||
static func fromCablePayload(_ data: [String: Any]) -> ScoreState {
|
||||
let boardType = data["board_type"] as? String ?? "volley"
|
||||
let nested = data["data"] as? [String: Any]
|
||||
let partialsRaw = (data["set_partials"] as? [Any]) ?? (data["setPartials"] as? [Any]) ?? []
|
||||
let periodNumber = intFrom(nested, key: "period").flatMap { $0 > 0 ? $0 : nil }
|
||||
?? max(intField(data, snake: "current_set", camel: "currentSet"), 1)
|
||||
|
||||
return ScoreState(
|
||||
homeSets: intField(data, snake: "home_sets", camel: "homeSets"),
|
||||
awaySets: intField(data, snake: "away_sets", camel: "awaySets"),
|
||||
homePoints: intField(data, snake: "home_points", camel: "homePoints"),
|
||||
awayPoints: intField(data, snake: "away_points", camel: "awayPoints"),
|
||||
currentSet: max(intField(data, snake: "current_set", camel: "currentSet"), 1),
|
||||
setPartials: partialsRaw.compactMap { item -> SetPartial? in
|
||||
guard let row = item as? [String: Any] else { return nil }
|
||||
return SetPartial(
|
||||
set: (row["set"] as? NSNumber)?.intValue ?? 1,
|
||||
home: (row["home"] as? NSNumber)?.intValue ?? 0,
|
||||
away: (row["away"] as? NSNumber)?.intValue ?? 0
|
||||
)
|
||||
},
|
||||
timeoutHome: data["timeout_home"] as? Bool ?? false,
|
||||
timeoutAway: data["timeout_away"] as? Bool ?? false,
|
||||
boardType: boardType,
|
||||
period: periodNumber,
|
||||
periodLabel: data["period"] as? String,
|
||||
clockSecs: {
|
||||
let direct = intField(data, snake: "clock_secs", camel: "clockSecs")
|
||||
if direct > 0 || data["clock_secs"] != nil || data["clockSecs"] != nil { return direct }
|
||||
return intFrom(nested, key: "clock_secs") ?? 0
|
||||
}(),
|
||||
clockRunning: data["clock_running"] as? Bool ?? nested?["clock_running"] as? Bool ?? false,
|
||||
overtime: nested?["overtime"] as? Bool ?? false
|
||||
)
|
||||
}
|
||||
|
||||
private static func intField(_ data: [String: Any], snake: String, camel: String) -> Int {
|
||||
let raw = data[snake] ?? data[camel]
|
||||
if let n = raw as? NSNumber { return n.intValue }
|
||||
if let s = raw as? String, let v = Int(s) { return v }
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func intFrom(_ data: [String: Any]?, key: String) -> Int? {
|
||||
guard let data else { return nil }
|
||||
if let n = data[key] as? NSNumber { return n.intValue }
|
||||
if let s = data[key] as? String, let v = Int(s) { return v }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"colors" : [
|
||||
{ "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0.176", "green" : "0.176", "red" : "1.000" } }, "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 257 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "info" : { "author" : "xcode", "version" : 1 } }
|
||||
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "CopertinaCanale.png", "idiom" : "universal", "scale" : "1x" },
|
||||
{ "idiom" : "universal", "scale" : "2x" },
|
||||
{ "idiom" : "universal", "scale" : "3x" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale.imageset/CopertinaCanale.png
vendored
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_2.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "CopertinaCanale_2.png", "idiom" : "universal", "scale" : "1x" },
|
||||
{ "idiom" : "universal", "scale" : "2x" },
|
||||
{ "idiom" : "universal", "scale" : "3x" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_2.imageset/CopertinaCanale_2.png
vendored
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_3.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "CopertinaCanale_3.png", "idiom" : "universal", "scale" : "1x" },
|
||||
{ "idiom" : "universal", "scale" : "2x" },
|
||||
{ "idiom" : "universal", "scale" : "3x" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_3.imageset/CopertinaCanale_3.png
vendored
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_4.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "CopertinaCanale_4.png", "idiom" : "universal", "scale" : "1x" },
|
||||
{ "idiom" : "universal", "scale" : "2x" },
|
||||
{ "idiom" : "universal", "scale" : "3x" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_4.imageset/CopertinaCanale_4.png
vendored
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_5.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "CopertinaCanale_5.png", "idiom" : "universal", "scale" : "1x" },
|
||||
{ "idiom" : "universal", "scale" : "2x" },
|
||||
{ "idiom" : "universal", "scale" : "3x" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_5.imageset/CopertinaCanale_5.png
vendored
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_6.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "CopertinaCanale_6.png", "idiom" : "universal", "scale" : "1x" },
|
||||
{ "idiom" : "universal", "scale" : "2x" },
|
||||
{ "idiom" : "universal", "scale" : "3x" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/CopertinaCanale_6.imageset/CopertinaCanale_6.png
vendored
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "logo-dark-gradient-256.png", "idiom" : "universal", "scale" : "1x" },
|
||||
{ "idiom" : "universal", "scale" : "2x" },
|
||||
{ "idiom" : "universal", "scale" : "3x" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 23 KiB |
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-dark-gradient.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "logo-dark-gradient.png", "idiom" : "universal", "scale" : "1x" },
|
||||
{ "idiom" : "universal", "scale" : "2x" },
|
||||
{ "idiom" : "universal", "scale" : "3x" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-dark-gradient.imageset/logo-dark-gradient.png
vendored
Normal file
|
After Width: | Height: | Size: 257 KiB |
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m-256.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "logo-white-m-256.png", "idiom" : "universal", "scale" : "1x" },
|
||||
{ "idiom" : "universal", "scale" : "2x" },
|
||||
{ "idiom" : "universal", "scale" : "3x" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m-256.imageset/logo-white-m-256.png
vendored
Normal file
|
After Width: | Height: | Size: 24 KiB |
8
native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "logo-white-m.png", "idiom" : "universal", "scale" : "1x" },
|
||||
{ "idiom" : "universal", "scale" : "2x" },
|
||||
{ "idiom" : "universal", "scale" : "3x" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
BIN
native/ios/MatchLiveTv/Resources/Assets.xcassets/logo-white-m.imageset/logo-white-m.png
vendored
Normal file
|
After Width: | Height: | Size: 245 KiB |
62
native/ios/MatchLiveTv/Resources/Info.plist
Normal file
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>API_BASE_URL</key>
|
||||
<string>https://www.matchlivetv.it</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>it</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Match Live TV</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.0.0</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.matchlivetv.match-live-tv</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>matchlivetv</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>21</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Match Live TV usa la fotocamera per trasmettere le partite in diretta.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Match Live TV usa il microfono per l'audio della diretta.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Match Live TV usa le foto per i loghi delle squadre.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string></string>
|
||||
<key>UIImageName</key>
|
||||
<string>logo-dark-gradient-256</string>
|
||||
</dict>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
26
native/ios/MatchLiveTv/Streaming/BroadcastModels.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
struct BroadcastConfig: Sendable {
|
||||
let rtmpUrl: String
|
||||
var width: Int = 1280
|
||||
var height: Int = 720
|
||||
var videoBitrate: Int = 2_500_000
|
||||
var audioBitrate: Int = 128_000
|
||||
var fps: Int = 30
|
||||
var rotation: Int = 0
|
||||
var portrait: Bool = false
|
||||
var maxReconnectAttempts: Int = 10
|
||||
var reconnectDelayMs: Int = 5_000
|
||||
}
|
||||
|
||||
enum BroadcastPhase: String, Sendable {
|
||||
case idle, preview, connecting, live, paused, reconnecting, error
|
||||
}
|
||||
|
||||
struct BroadcastMetrics: Sendable {
|
||||
var phase: BroadcastPhase = .idle
|
||||
var bitrateKbps: Int = 0
|
||||
var fps: Int = 0
|
||||
var connected: Bool = false
|
||||
var lastError: String?
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
@MainActor
|
||||
final class LiveBroadcastCoordinator: ObservableObject {
|
||||
let engine = LiveBroadcastEngine()
|
||||
@Published private(set) var metrics = BroadcastMetrics()
|
||||
|
||||
init() {
|
||||
engine.setMetricsListener { [weak self] value in
|
||||
Task { @MainActor in self?.metrics = value }
|
||||
}
|
||||
}
|
||||
|
||||
func updateOverlay(_ state: OverlayState) {
|
||||
engine.updateOverlay(state)
|
||||
}
|
||||
|
||||
func configureBackgroundAudio() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try? session.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
|
||||
try? session.setActive(true)
|
||||
}
|
||||
|
||||
func startBroadcast(config: BroadcastConfig) async throws {
|
||||
configureBackgroundAudio()
|
||||
try await engine.startBroadcast(config: config)
|
||||
}
|
||||
|
||||
func pauseBroadcast() async {
|
||||
await engine.pauseBroadcast()
|
||||
}
|
||||
|
||||
func resumeBroadcast(config: BroadcastConfig) async throws {
|
||||
try await engine.resumeBroadcast(config: config)
|
||||
}
|
||||
|
||||
func stopBroadcast() async {
|
||||
await engine.stopBroadcast()
|
||||
}
|
||||
|
||||
func release() async {
|
||||
await engine.release()
|
||||
}
|
||||
}
|
||||
176
native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift
Normal file
@@ -0,0 +1,176 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import HaishinKit
|
||||
import RTMPHaishinKit
|
||||
import UIKit
|
||||
|
||||
struct RTMPPublishTarget: Sendable {
|
||||
let connectURL: String
|
||||
let streamName: String
|
||||
}
|
||||
|
||||
enum RTMPUrlParser {
|
||||
static func parse(_ urlString: String) -> RTMPPublishTarget? {
|
||||
guard let url = URL(string: urlString), url.scheme?.hasPrefix("rtmp") == true else { return nil }
|
||||
let pathParts = url.path.split(separator: "/").map(String.init)
|
||||
guard pathParts.count >= 2 else { return nil }
|
||||
let app = pathParts.dropLast().joined(separator: "/")
|
||||
let streamName = pathParts.last ?? ""
|
||||
var connect = "rtmp://\(url.host ?? "")"
|
||||
if let port = url.port { connect += ":\(port)" }
|
||||
connect += "/\(app)"
|
||||
return RTMPPublishTarget(connectURL: connect, streamName: streamName)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LiveBroadcastEngine: ObservableObject {
|
||||
@Published private(set) var metrics = BroadcastMetrics()
|
||||
|
||||
let mixer = MediaMixer()
|
||||
let connection = RTMPConnection()
|
||||
let stream: RTMPStream
|
||||
private let overlayRenderer = OverlayRenderer()
|
||||
|
||||
private var config: BroadcastConfig?
|
||||
private var phase: BroadcastPhase = .idle
|
||||
private var metricsListener: ((BroadcastMetrics) -> Void)?
|
||||
private var reconnectAttempts = 0
|
||||
private var publishTask: Task<Void, Never>?
|
||||
private var pipelineConfigured = false
|
||||
private var isPortrait = false
|
||||
|
||||
init() {
|
||||
stream = RTMPStream(connection: connection)
|
||||
}
|
||||
|
||||
func setMetricsListener(_ block: ((BroadcastMetrics) -> Void)?) {
|
||||
metricsListener = block
|
||||
emitMetrics()
|
||||
}
|
||||
|
||||
func updateOverlay(_ state: OverlayState) {
|
||||
overlayRenderer.update(state: state, isPortrait: isPortrait)
|
||||
}
|
||||
|
||||
func bindPreview(to view: MTHKView) async {
|
||||
await stream.addOutput(view)
|
||||
}
|
||||
|
||||
func preparePreview(config: BroadcastConfig) async throws {
|
||||
self.config = config
|
||||
try await configurePipeline(config)
|
||||
setPhase(.preview)
|
||||
}
|
||||
|
||||
func startBroadcast(config: BroadcastConfig) async throws {
|
||||
self.config = config
|
||||
publishTask?.cancel()
|
||||
publishTask = nil
|
||||
try await configurePipeline(config)
|
||||
setPhase(.connecting)
|
||||
try await Task.sleep(nanoseconds: 350_000_000)
|
||||
try await publish(config: config)
|
||||
}
|
||||
|
||||
func pauseBroadcast() async {
|
||||
try? await stream.close()
|
||||
setPhase(.paused)
|
||||
}
|
||||
|
||||
func resumeBroadcast(config: BroadcastConfig) async throws {
|
||||
self.config = config
|
||||
if !pipelineConfigured {
|
||||
try await configurePipeline(config)
|
||||
}
|
||||
setPhase(.connecting)
|
||||
try await publish(config: config)
|
||||
}
|
||||
|
||||
func stopBroadcast() async {
|
||||
publishTask?.cancel()
|
||||
publishTask = nil
|
||||
overlayRenderer.detach()
|
||||
try? await stream.close()
|
||||
try? await connection.close()
|
||||
try? await mixer.stopRunning()
|
||||
pipelineConfigured = false
|
||||
setPhase(.idle)
|
||||
reconnectAttempts = 0
|
||||
}
|
||||
|
||||
func release() async {
|
||||
await stopBroadcast()
|
||||
}
|
||||
|
||||
private func configurePipeline(_ config: BroadcastConfig) async throws {
|
||||
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized,
|
||||
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||
throw APIError.http(403, "Permessi camera e microfono richiesti")
|
||||
}
|
||||
if pipelineConfigured { return }
|
||||
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
|
||||
try audioSession.setActive(true)
|
||||
|
||||
isPortrait = config.portrait
|
||||
var videoSettings = await mixer.videoMixerSettings
|
||||
videoSettings.mode = .offscreen
|
||||
try await mixer.setVideoMixerSettings(videoSettings)
|
||||
await configureScreenSize(width: config.width, height: config.height)
|
||||
|
||||
if let microphone = AVCaptureDevice.default(for: .audio) {
|
||||
try await mixer.attachAudio(microphone, track: 0)
|
||||
}
|
||||
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
|
||||
try await mixer.attachVideo(camera, track: 0)
|
||||
}
|
||||
try await mixer.addOutput(stream)
|
||||
try await mixer.startRunning()
|
||||
overlayRenderer.attach(mixer: mixer, width: config.width, height: config.height, isPortrait: config.portrait)
|
||||
|
||||
var streamSettings = await stream.videoSettings
|
||||
streamSettings.videoSize = .init(width: config.width, height: config.height)
|
||||
streamSettings.bitRate = config.videoBitrate
|
||||
streamSettings.maxKeyFrameIntervalDuration = 2
|
||||
try await stream.setVideoSettings(streamSettings)
|
||||
var audioSettings = await stream.audioSettings
|
||||
audioSettings.bitRate = config.audioBitrate
|
||||
try await stream.setAudioSettings(audioSettings)
|
||||
pipelineConfigured = true
|
||||
}
|
||||
|
||||
private func configureScreenSize(width: Int, height: Int) async {
|
||||
await Task { @ScreenActor in
|
||||
mixer.screen.size = CGSize(width: width, height: height)
|
||||
mixer.screen.backgroundColor = UIColor.clear.cgColor
|
||||
}.value
|
||||
}
|
||||
|
||||
private func publish(config: BroadcastConfig) async throws {
|
||||
guard let target = RTMPUrlParser.parse(config.rtmpUrl) else {
|
||||
throw APIError.http(400, "URL RTMP non valido")
|
||||
}
|
||||
try await connection.connect(target.connectURL)
|
||||
try await stream.publish(target.streamName)
|
||||
reconnectAttempts = 0
|
||||
setPhase(.live)
|
||||
}
|
||||
|
||||
private func setPhase(_ newPhase: BroadcastPhase, error: String? = nil) {
|
||||
phase = newPhase
|
||||
metrics = BroadcastMetrics(
|
||||
phase: newPhase,
|
||||
bitrateKbps: metrics.bitrateKbps,
|
||||
fps: config?.fps ?? metrics.fps,
|
||||
connected: newPhase == .live || newPhase == .reconnecting,
|
||||
lastError: error
|
||||
)
|
||||
metricsListener?(metrics)
|
||||
}
|
||||
|
||||
private func emitMetrics() {
|
||||
metricsListener?(metrics)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import UIKit
|
||||
|
||||
struct CompactScoreboardElement: OverlayElement {
|
||||
func draw(in context: CGContext, state: OverlayState, layout: OverlayLayout) {
|
||||
guard let compact = state.compactScoreboard else { return }
|
||||
let kind = state.overlayKind
|
||||
guard kind == .basket || kind == .timed || kind == .racket || kind == .volley else { return }
|
||||
|
||||
let scale = CGFloat(layout.canvasHeight) / 720
|
||||
let pad = max(8, Int((10 * scale).rounded()))
|
||||
let barW = max(3, Int((4 * scale).rounded()))
|
||||
let logoSize = max(20, Int((28 * scale).rounded()))
|
||||
let gap = max(4, Int((6 * scale).rounded()))
|
||||
let totalW = max(320, Int((CGFloat(layout.canvasWidth) * 0.62).rounded()))
|
||||
let mainH = max(56, Int((CGFloat(layout.canvasHeight) * 0.09).rounded()))
|
||||
let metaH = max(16, Int((CGFloat(layout.canvasHeight) * 0.028).rounded()))
|
||||
let totalH = mainH + metaH + pad
|
||||
let left = CGFloat(layout.marginPx)
|
||||
let top = CGFloat(layout.marginPx)
|
||||
let rect = CGRect(x: left, y: top, width: CGFloat(totalW), height: CGFloat(totalH))
|
||||
|
||||
context.setFillColor(UIColor(white: 0.047, alpha: 0.82).cgColor)
|
||||
context.addPath(UIBezierPath(roundedRect: rect, cornerRadius: 10 * scale).cgPath)
|
||||
context.fillPath()
|
||||
|
||||
let centerX = rect.midX
|
||||
let rowCenterY = top + CGFloat(pad) + CGFloat(mainH) * 0.58
|
||||
let sideMaxW = CGFloat(totalW) * 0.34
|
||||
let scoreZoneHalf = CGFloat(totalW) * 0.11
|
||||
let labelSize = max(13, CGFloat(mainH) * 0.28)
|
||||
let scoreSize = max(24, CGFloat(mainH) * 0.46)
|
||||
let labelFont = UIFont.boldSystemFont(ofSize: labelSize)
|
||||
let scoreFont = UIFont.boldSystemFont(ofSize: scoreSize)
|
||||
|
||||
drawTeamSide(context: context, compact: compact, isHome: true, rectLeft: rect.minX + CGFloat(pad), rectRight: centerX - scoreZoneHalf, centerY: rowCenterY, barW: barW, logoSize: logoSize, gap: gap, maxWidth: sideMaxW, labelFont: labelFont)
|
||||
drawTeamSide(context: context, compact: compact, isHome: false, rectLeft: centerX + scoreZoneHalf, rectRight: rect.maxX - CGFloat(pad), centerY: rowCenterY, barW: barW, logoSize: logoSize, gap: gap, maxWidth: sideMaxW, labelFont: labelFont)
|
||||
|
||||
let scoreText = "\(compact.homeScore) - \(compact.awayScore)"
|
||||
let scoreAttrs: [NSAttributedString.Key: Any] = [.font: scoreFont, .foregroundColor: UIColor.white]
|
||||
let scoreNSString = scoreText as NSString
|
||||
let scoreTextSize = scoreNSString.size(withAttributes: scoreAttrs)
|
||||
scoreNSString.draw(at: CGPoint(x: centerX - scoreTextSize.width / 2, y: rowCenterY + scoreSize * 0.32 - scoreTextSize.height), withAttributes: scoreAttrs)
|
||||
|
||||
if let period = compact.periodLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !period.isEmpty {
|
||||
let metaSize = max(11, CGFloat(metaH) * 0.72)
|
||||
let metaFont = UIFont.systemFont(ofSize: metaSize)
|
||||
let metaAttrs: [NSAttributedString.Key: Any] = [.font: metaFont, .foregroundColor: UIColor(white: 0.67, alpha: 1)]
|
||||
let metaNSString = period as NSString
|
||||
let metaSize2 = metaNSString.size(withAttributes: metaAttrs)
|
||||
metaNSString.draw(at: CGPoint(x: centerX - metaSize2.width / 2, y: rect.maxY - CGFloat(pad) * 0.45 - metaSize2.height), withAttributes: metaAttrs)
|
||||
}
|
||||
}
|
||||
|
||||
private func drawTeamSide(
|
||||
context: CGContext,
|
||||
compact: CompactScoreboardState,
|
||||
isHome: Bool,
|
||||
rectLeft: CGFloat,
|
||||
rectRight: CGFloat,
|
||||
centerY: CGFloat,
|
||||
barW: Int,
|
||||
logoSize: Int,
|
||||
gap: Int,
|
||||
maxWidth: CGFloat,
|
||||
labelFont: UIFont
|
||||
) {
|
||||
let accent = isHome ? compact.homeAccentColor : compact.awayAccentColor
|
||||
let logoUrl = isHome ? compact.homeLogoUrl : compact.awayLogoUrl
|
||||
let logo = OverlayLogoCache.get(logoUrl)
|
||||
let rawName = isHome ? compact.homeTeamName : compact.awayTeamName
|
||||
let name = ellipsize(rawName.trimmingCharacters(in: .whitespacesAndNewlines), font: labelFont, maxWidth: maxWidth * 0.55)
|
||||
let attrs: [NSAttributedString.Key: Any] = [.font: labelFont, .foregroundColor: UIColor.white]
|
||||
|
||||
if isHome {
|
||||
var x = rectLeft
|
||||
context.setFillColor(accent.cgColor)
|
||||
context.fill(CGRect(x: x, y: centerY - CGFloat(logoSize) * 0.45, width: CGFloat(barW), height: CGFloat(logoSize) * 0.9))
|
||||
x += CGFloat(barW + gap)
|
||||
if let logo, let cg = logo.cgImage {
|
||||
context.draw(cg, in: CGRect(x: x, y: centerY - CGFloat(logoSize) / 2, width: CGFloat(logoSize), height: CGFloat(logoSize)))
|
||||
x += CGFloat(logoSize + gap)
|
||||
}
|
||||
(name as NSString).draw(at: CGPoint(x: x, y: centerY + labelFont.pointSize * 0.35 - labelFont.lineHeight), withAttributes: attrs)
|
||||
} else {
|
||||
var x = rectRight
|
||||
context.setFillColor(accent.cgColor)
|
||||
context.fill(CGRect(x: x - CGFloat(barW), y: centerY - CGFloat(logoSize) * 0.45, width: CGFloat(barW), height: CGFloat(logoSize) * 0.9))
|
||||
x -= CGFloat(barW + gap)
|
||||
let nameSize = (name as NSString).size(withAttributes: attrs)
|
||||
(name as NSString).draw(at: CGPoint(x: x - nameSize.width, y: centerY + labelFont.pointSize * 0.35 - labelFont.lineHeight), withAttributes: attrs)
|
||||
x -= CGFloat(gap)
|
||||
if let logo, let cg = logo.cgImage {
|
||||
x -= CGFloat(logoSize)
|
||||
context.draw(cg, in: CGRect(x: x, y: centerY - CGFloat(logoSize) / 2, width: CGFloat(logoSize), height: CGFloat(logoSize)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ellipsize(_ text: String, font: UIFont, maxWidth: CGFloat) -> String {
|
||||
let safeMax = max(48, maxWidth)
|
||||
let attrs: [NSAttributedString.Key: Any] = [.font: font]
|
||||
if (text as NSString).size(withAttributes: attrs).width <= safeMax { return text }
|
||||
var result = text
|
||||
while result.count > 1 && (result as NSString).size(withAttributes: attrs).width > safeMax {
|
||||
result = String(result.dropLast())
|
||||
}
|
||||
return result + "…"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import UIKit
|
||||
|
||||
final class OverlayCanvasRenderer {
|
||||
private let elements: [OverlayElement] = [
|
||||
WatermarkElement(),
|
||||
ScoreboardElement(),
|
||||
CompactScoreboardElement(),
|
||||
SponsorElement(),
|
||||
]
|
||||
|
||||
private var canvasWidth = 0
|
||||
private var canvasHeight = 0
|
||||
|
||||
func setCanvasSize(width: Int, height: Int) {
|
||||
canvasWidth = width
|
||||
canvasHeight = height
|
||||
}
|
||||
|
||||
func render(state: OverlayState, isPortraitContent: Bool) -> CGImage? {
|
||||
guard canvasWidth > 0, canvasHeight > 0 else { return nil }
|
||||
let size = CGSize(width: canvasWidth, height: canvasHeight)
|
||||
let margin = max(12, Int((CGFloat(canvasWidth) * 0.012).rounded()))
|
||||
let layout = OverlayLayout(
|
||||
canvasWidth: canvasWidth,
|
||||
canvasHeight: canvasHeight,
|
||||
marginPx: margin,
|
||||
isPortraitContent: isPortraitContent
|
||||
)
|
||||
|
||||
// scale = 1.0: pixel dimensions must match HaishinKit screen (1280x720), not Retina points.
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 1.0
|
||||
let renderer = UIGraphicsImageRenderer(size: size, format: format)
|
||||
let image = renderer.image { ctx in
|
||||
let context = ctx.cgContext
|
||||
context.clear(CGRect(origin: .zero, size: size))
|
||||
elements.forEach { $0.draw(in: context, state: state, layout: layout) }
|
||||
}
|
||||
guard let cgImage = image.cgImage,
|
||||
cgImage.width == canvasWidth,
|
||||
cgImage.height == canvasHeight else { return nil }
|
||||
return cgImage
|
||||
}
|
||||
|
||||
func release() {
|
||||
canvasWidth = 0
|
||||
canvasHeight = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum OverlayLogoCache {
|
||||
private static var authToken: String?
|
||||
private static var cache: [String: UIImage] = [:]
|
||||
private static let lock = NSLock()
|
||||
|
||||
static func configure(token: String?) {
|
||||
lock.lock()
|
||||
authToken = token
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
static func get(_ url: String?) -> UIImage? {
|
||||
guard let url, !url.isEmpty else { return nil }
|
||||
lock.lock()
|
||||
if let cached = cache[url] {
|
||||
lock.unlock()
|
||||
return cached
|
||||
}
|
||||
lock.unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
static func preload(_ url: String?) async {
|
||||
guard let resolved = MediaUrl.resolve(url) else { return }
|
||||
lock.lock()
|
||||
if cache[resolved] != nil {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
lock.unlock()
|
||||
guard let image = await download(resolved) else { return }
|
||||
lock.lock()
|
||||
cache[resolved] = image
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
static func preloadAll(_ urls: [String?]) async {
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for url in urls {
|
||||
group.addTask { await preload(url) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func clear() {
|
||||
lock.lock()
|
||||
cache.removeAll()
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
private static func download(_ urlString: String) async -> UIImage? {
|
||||
guard let url = URL(string: urlString) else { return nil }
|
||||
var request = URLRequest(url: url, timeoutInterval: 20)
|
||||
if let authToken { request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") }
|
||||
guard let (data, response) = try? await URLSession.shared.data(for: request),
|
||||
let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
||||
let image = UIImage(data: data) else { return nil }
|
||||
return scale(image, maxSide: 128)
|
||||
}
|
||||
|
||||
private static func scale(_ image: UIImage, maxSide: CGFloat) -> UIImage {
|
||||
let size = image.size
|
||||
let ratio = min(maxSide / size.width, maxSide / size.height, 1)
|
||||
if ratio >= 1 { return image }
|
||||
let newSize = CGSize(width: size.width * ratio, height: size.height * ratio)
|
||||
let renderer = UIGraphicsImageRenderer(size: newSize)
|
||||
return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: newSize)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension ScoreState {
|
||||
func toScoreboardState(
|
||||
homeTeamName: String,
|
||||
awayTeamName: String,
|
||||
homeAccentColor: UIColor = ColorHex.parseColorHex("#FF2D2D"),
|
||||
awayAccentColor: UIColor = ColorHex.parseColorHex("#1E3A8A"),
|
||||
homeLogoUrl: String? = nil,
|
||||
awayLogoUrl: String? = nil
|
||||
) -> ScoreboardState {
|
||||
var columns: [ScoreboardSetColumn] = setPartials.map {
|
||||
ScoreboardSetColumn(setNumber: $0.set, homePoints: $0.home, awayPoints: $0.away, isCurrent: false)
|
||||
}
|
||||
columns.append(
|
||||
ScoreboardSetColumn(setNumber: currentSet, homePoints: homePoints, awayPoints: awayPoints, isCurrent: true)
|
||||
)
|
||||
return ScoreboardState(
|
||||
homeTeamName: homeTeamName,
|
||||
awayTeamName: awayTeamName,
|
||||
columns: columns,
|
||||
homeAccentColor: homeAccentColor,
|
||||
awayAccentColor: awayAccentColor,
|
||||
homeLogoUrl: homeLogoUrl,
|
||||
awayLogoUrl: awayLogoUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func formatOverlayClock(_ secs: Int, countUp: Bool = false) -> String {
|
||||
if secs <= 0 && !countUp { return "0:00" }
|
||||
return String(format: "%d:%02d", secs / 60, secs % 60)
|
||||
}
|
||||
|
||||
extension BroadcastPhase {
|
||||
func toOverlayStatus() -> BroadcastOverlayStatus {
|
||||
switch self {
|
||||
case .live: return .live
|
||||
case .paused: return .paused
|
||||
case .connecting, .reconnecting, .error: return .connecting
|
||||
case .preview, .idle: return .preview
|
||||
}
|
||||
}
|
||||
}
|
||||
122
native/ios/MatchLiveTv/Streaming/Overlay/OverlayRenderer.swift
Normal file
@@ -0,0 +1,122 @@
|
||||
import UIKit
|
||||
import HaishinKit
|
||||
|
||||
final class OverlayRenderer: @unchecked Sendable {
|
||||
private let canvasRenderer = OverlayCanvasRenderer()
|
||||
private var streamWidth = 0
|
||||
private var streamHeight = 0
|
||||
private var isPortraitContent = false
|
||||
private var attached = false
|
||||
private var lastFingerprint = Int.min
|
||||
private var pendingState: OverlayState?
|
||||
private weak var mixer: MediaMixer?
|
||||
private let lock = NSLock()
|
||||
private var screenTask: Task<Void, Never>?
|
||||
|
||||
@ScreenActor private var overlayObject: ImageScreenObject?
|
||||
|
||||
func attach(mixer: MediaMixer, width: Int, height: Int, isPortrait: Bool = false) {
|
||||
lock.lock()
|
||||
self.mixer = mixer
|
||||
streamWidth = width
|
||||
streamHeight = height
|
||||
isPortraitContent = isPortrait
|
||||
canvasRenderer.setCanvasSize(width: width, height: height)
|
||||
attached = true
|
||||
lastFingerprint = Int.min
|
||||
let state = pendingState
|
||||
lock.unlock()
|
||||
|
||||
enqueueScreen {
|
||||
self.overlayObject = nil
|
||||
}
|
||||
if let state { pushState(state) }
|
||||
}
|
||||
|
||||
func update(state: OverlayState, isPortrait: Bool = false) {
|
||||
lock.lock()
|
||||
isPortraitContent = isPortrait
|
||||
pendingState = state
|
||||
let isAttached = attached
|
||||
lock.unlock()
|
||||
guard isAttached else { return }
|
||||
pushState(state)
|
||||
}
|
||||
|
||||
func refreshOrientation(isPortrait: Bool) {
|
||||
lock.lock()
|
||||
isPortraitContent = isPortrait
|
||||
lastFingerprint = Int.min
|
||||
let state = pendingState
|
||||
let isAttached = attached
|
||||
lock.unlock()
|
||||
guard isAttached, let state else { return }
|
||||
pushState(state)
|
||||
}
|
||||
|
||||
func detach() {
|
||||
lock.lock()
|
||||
attached = false
|
||||
lastFingerprint = Int.min
|
||||
pendingState = nil
|
||||
let mixerRef = mixer
|
||||
mixer = nil
|
||||
lock.unlock()
|
||||
canvasRenderer.release()
|
||||
|
||||
enqueueScreen {
|
||||
if let overlay = self.overlayObject {
|
||||
mixerRef?.screen.removeChild(overlay)
|
||||
self.overlayObject = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pushState(_ state: OverlayState) {
|
||||
let fp = state.fingerprint()
|
||||
lock.lock()
|
||||
if fp == lastFingerprint {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
lastFingerprint = fp
|
||||
let portrait = isPortraitContent
|
||||
let width = streamWidth
|
||||
let height = streamHeight
|
||||
guard let mixer else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
guard let cgImage = canvasRenderer.render(state: state, isPortraitContent: portrait) else { return }
|
||||
guard cgImage.width == width, cgImage.height == height else { return }
|
||||
|
||||
enqueueScreen {
|
||||
let size = CGSize(width: cgImage.width, height: cgImage.height)
|
||||
if self.overlayObject == nil {
|
||||
let overlay = ImageScreenObject()
|
||||
overlay.horizontalAlignment = .left
|
||||
overlay.verticalAlignment = .top
|
||||
overlay.size = size
|
||||
do {
|
||||
try mixer.screen.addChild(overlay)
|
||||
self.overlayObject = overlay
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
guard let overlay = self.overlayObject else { return }
|
||||
overlay.size = size
|
||||
overlay.cgImage = cgImage
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueueScreen(_ operation: @escaping @ScreenActor () async -> Void) {
|
||||
let previous = screenTask
|
||||
screenTask = Task {
|
||||
await previous?.value
|
||||
await operation()
|
||||
}
|
||||
}
|
||||
}
|
||||
129
native/ios/MatchLiveTv/Streaming/Overlay/OverlayState.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct ScoreboardSetColumn: Equatable {
|
||||
let setNumber: Int
|
||||
let homePoints: Int
|
||||
let awayPoints: Int
|
||||
let isCurrent: Bool
|
||||
}
|
||||
|
||||
struct ScoreboardState: Equatable {
|
||||
let homeTeamName: String
|
||||
let awayTeamName: String
|
||||
let columns: [ScoreboardSetColumn]
|
||||
var homeAccentColor: UIColor = UIColor(red: 1, green: 0.176, blue: 0.176, alpha: 1)
|
||||
var awayAccentColor: UIColor = UIColor(red: 0.118, green: 0.227, blue: 0.541, alpha: 1)
|
||||
var homeLogoUrl: String?
|
||||
var awayLogoUrl: String?
|
||||
}
|
||||
|
||||
struct CompactScoreboardState: Equatable {
|
||||
let homeTeamName: String
|
||||
let awayTeamName: String
|
||||
let homeScore: Int
|
||||
let awayScore: Int
|
||||
var periodLabel: String?
|
||||
var homeAccentColor: UIColor = UIColor(red: 1, green: 0.176, blue: 0.176, alpha: 1)
|
||||
var awayAccentColor: UIColor = UIColor(red: 0.118, green: 0.227, blue: 0.541, alpha: 1)
|
||||
var homeLogoUrl: String?
|
||||
var awayLogoUrl: String?
|
||||
}
|
||||
|
||||
enum OverlayKind: String, Equatable {
|
||||
case none, volley, basket, timed, racket
|
||||
|
||||
static func fromApi(_ value: String?) -> OverlayKind {
|
||||
switch value?.lowercased() {
|
||||
case "none", "timer": return .none
|
||||
case "basket": return .basket
|
||||
case "timed": return .timed
|
||||
case "racket": return .racket
|
||||
default: return .volley
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BroadcastOverlayStatus: Equatable {
|
||||
case preview, connecting, live, paused
|
||||
}
|
||||
|
||||
struct OverlayState: Equatable {
|
||||
var overlayKind: OverlayKind = .volley
|
||||
var scoreboard: ScoreboardState?
|
||||
var compactScoreboard: CompactScoreboardState?
|
||||
var watermarkVisible: Bool = true
|
||||
var broadcastStatus: BroadcastOverlayStatus = .live
|
||||
var sponsorText: String?
|
||||
|
||||
func fingerprint() -> Int {
|
||||
var result = overlayKind.hashValue
|
||||
result = mixHash(result, watermarkVisible.hashValue)
|
||||
result = mixHash(result, broadcastStatus.hashValue)
|
||||
result = mixHash(result, sponsorText?.hashValue ?? 0)
|
||||
result = mixHash(result, scoreboard?.fingerprint() ?? 0)
|
||||
result = mixHash(result, compactScoreboard?.fingerprint() ?? 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
struct OverlayLayout {
|
||||
let canvasWidth: Int
|
||||
let canvasHeight: Int
|
||||
let marginPx: Int
|
||||
let isPortraitContent: Bool
|
||||
}
|
||||
|
||||
protocol OverlayElement {
|
||||
func draw(in context: CGContext, state: OverlayState, layout: OverlayLayout)
|
||||
}
|
||||
|
||||
private extension ScoreboardState {
|
||||
func fingerprint() -> Int {
|
||||
var result = homeTeamName.hashValue
|
||||
result = mixHash(result, awayTeamName.hashValue)
|
||||
result = mixHash(result, colorFingerprint(homeAccentColor))
|
||||
result = mixHash(result, colorFingerprint(awayAccentColor))
|
||||
result = mixHash(result, homeLogoUrl?.hashValue ?? 0)
|
||||
result = mixHash(result, awayLogoUrl?.hashValue ?? 0)
|
||||
result = mixHash(result, logoLoadedKey(homeLogoUrl))
|
||||
result = mixHash(result, logoLoadedKey(awayLogoUrl))
|
||||
for col in columns {
|
||||
result = mixHash(result, col.setNumber)
|
||||
result = mixHash(result, col.homePoints)
|
||||
result = mixHash(result, col.awayPoints)
|
||||
result = mixHash(result, col.isCurrent.hashValue)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private extension CompactScoreboardState {
|
||||
func fingerprint() -> Int {
|
||||
var result = homeTeamName.hashValue
|
||||
result = mixHash(result, awayTeamName.hashValue)
|
||||
result = mixHash(result, homeScore)
|
||||
result = mixHash(result, awayScore)
|
||||
result = mixHash(result, colorFingerprint(homeAccentColor))
|
||||
result = mixHash(result, colorFingerprint(awayAccentColor))
|
||||
result = mixHash(result, periodLabel?.hashValue ?? 0)
|
||||
result = mixHash(result, logoLoadedKey(homeLogoUrl))
|
||||
result = mixHash(result, logoLoadedKey(awayLogoUrl))
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/// Kotlin `Int` wraps on overflow; Swift debug traps unless we use wrapping ops.
|
||||
private func mixHash(_ hash: Int, _ value: Int) -> Int {
|
||||
31 &* hash &+ value
|
||||
}
|
||||
|
||||
private func logoLoadedKey(_ url: String?) -> Int {
|
||||
OverlayLogoCache.get(url) != nil ? 1 : 0
|
||||
}
|
||||
|
||||
private func colorFingerprint(_ color: UIColor) -> Int {
|
||||
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
color.getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||
return Int(r * 255) << 16 | Int(g * 255) << 8 | Int(b * 255)
|
||||
}
|
||||
185
native/ios/MatchLiveTv/Streaming/Overlay/ScoreboardElement.swift
Normal file
@@ -0,0 +1,185 @@
|
||||
import UIKit
|
||||
|
||||
struct ScoreboardElement: OverlayElement {
|
||||
private static let sizeBoost: CGFloat = 1.1
|
||||
|
||||
func draw(in context: CGContext, state: OverlayState, layout: OverlayLayout) {
|
||||
guard state.overlayKind == .volley || state.overlayKind == .racket,
|
||||
let board = state.scoreboard, !board.columns.isEmpty else { return }
|
||||
|
||||
let scale = CGFloat(layout.canvasHeight) / 720 * Self.sizeBoost
|
||||
let pad = max(6, Int((8 * scale).rounded()))
|
||||
let colorBarW = max(4, Int((5 * scale).rounded()))
|
||||
let logoSize = max(16, Int((24 * scale).rounded()))
|
||||
let logoGap = max(3, Int((4 * scale).rounded()))
|
||||
let teamW = Int((158 * scale).rounded())
|
||||
let colW = max(22, Int((28 * scale).rounded()))
|
||||
let brandH = max(14, Int((18 * scale).rounded()))
|
||||
let headH = max(12, Int((16 * scale).rounded()))
|
||||
let rowH = max(18, Int((22 * scale).rounded()))
|
||||
let footerH = max(14, Int((18 * scale).rounded()))
|
||||
let n = board.columns.count
|
||||
let logoSlotW = logoSlotWidth(board, logoSize: logoSize, logoGap: logoGap)
|
||||
let boxW = pad * 2 + colorBarW + logoSlotW + teamW + n * colW
|
||||
let boxH = pad * 2 + brandH + headH + rowH * 2 + footerH
|
||||
let left = CGFloat(layout.marginPx)
|
||||
let top = CGFloat(layout.marginPx)
|
||||
|
||||
let bgPath = UIBezierPath(roundedRect: CGRect(x: left, y: top, width: CGFloat(boxW), height: CGFloat(boxH)), cornerRadius: 6 * scale)
|
||||
context.saveGState()
|
||||
context.setFillColor(UIColor(white: 0.047, alpha: 0.82).cgColor)
|
||||
context.addPath(bgPath.cgPath)
|
||||
context.fillPath()
|
||||
context.restoreGState()
|
||||
|
||||
drawBrandHeader(context: context, left: left, top: top, pad: pad, scale: scale, brandH: brandH)
|
||||
|
||||
let headerSize = max(8, 10 * scale)
|
||||
let headerFont = UIFont.boldSystemFont(ofSize: headerSize)
|
||||
let headerAttrs: [NSAttributedString.Key: Any] = [
|
||||
.font: headerFont,
|
||||
.foregroundColor: UIColor(white: 0.53, alpha: 1),
|
||||
]
|
||||
let scoresLeft = left + CGFloat(pad + colorBarW + logoSlotW + teamW)
|
||||
let headerBaseline = top + CGFloat(pad + brandH + headH) - 2 * scale
|
||||
for (index, column) in board.columns.enumerated() {
|
||||
let cx = scoresLeft + CGFloat(index * colW + colW / 2)
|
||||
let text = "\(column.setNumber)" as NSString
|
||||
let size = text.size(withAttributes: headerAttrs)
|
||||
text.draw(at: CGPoint(x: cx - size.width / 2, y: headerBaseline - size.height), withAttributes: headerAttrs)
|
||||
}
|
||||
|
||||
let homeRowTop = top + CGFloat(pad + brandH + headH)
|
||||
let awayRowTop = homeRowTop + CGFloat(rowH)
|
||||
drawTeamRow(context: context, board: board, isHome: true, rowTop: homeRowTop, left: left, pad: pad, colorBarW: colorBarW, logoSize: logoSize, logoGap: logoGap, logoSlotW: logoSlotW, teamW: teamW, colW: colW, rowH: rowH, scoresLeft: scoresLeft, scale: scale)
|
||||
drawTeamRow(context: context, board: board, isHome: false, rowTop: awayRowTop, left: left, pad: pad, colorBarW: colorBarW, logoSize: logoSize, logoGap: logoGap, logoSlotW: logoSlotW, teamW: teamW, colW: colW, rowH: rowH, scoresLeft: scoresLeft, scale: scale)
|
||||
|
||||
let currentSet = board.columns.last(where: { $0.isCurrent })?.setNumber ?? board.columns.count
|
||||
let footerText = ordinalSetLabel(currentSet)
|
||||
let footerSize = max(8, 10 * scale)
|
||||
let footerAttrs: [NSAttributedString.Key: Any] = [
|
||||
.font: UIFont.systemFont(ofSize: footerSize),
|
||||
.foregroundColor: UIColor(white: 0.8, alpha: 1),
|
||||
]
|
||||
let footerBaseline: CGFloat = top + CGFloat(boxH - pad) - 2 * scale
|
||||
let footerX: CGFloat = left + CGFloat(pad + colorBarW) + 4 * scale
|
||||
let footerY: CGFloat = footerBaseline - footerSize
|
||||
let footerPoint = CGPoint(x: footerX, y: footerY)
|
||||
(footerText as NSString).draw(at: footerPoint, withAttributes: footerAttrs)
|
||||
}
|
||||
|
||||
private func logoSlotWidth(_ board: ScoreboardState, logoSize: Int, logoGap: Int) -> Int {
|
||||
let needs = !(board.homeLogoUrl?.isEmpty ?? true) || !(board.awayLogoUrl?.isEmpty ?? true)
|
||||
return needs ? logoSize + logoGap : 0
|
||||
}
|
||||
|
||||
private func drawBrandHeader(context: CGContext, left: CGFloat, top: CGFloat, pad: Int, scale: CGFloat, brandH: Int) {
|
||||
let textSize = max(7, 9 * scale)
|
||||
let matchFont = UIFont.boldSystemFont(ofSize: textSize)
|
||||
let tvFont = UIFont.boldSystemFont(ofSize: textSize)
|
||||
let liveFont = UIFont.boldSystemFont(ofSize: textSize * 0.88)
|
||||
var x = left + CGFloat(pad)
|
||||
let baseline = top + CGFloat(pad) + CGFloat(brandH) * 0.72
|
||||
("MATCH" as NSString).draw(at: CGPoint(x: x, y: baseline - textSize), withAttributes: [.font: matchFont, .foregroundColor: UIColor.white])
|
||||
x += ("MATCH" as NSString).size(withAttributes: [.font: matchFont]).width + 4 * scale
|
||||
let liveText = "LIVE"
|
||||
let livePadH = 4 * scale
|
||||
let liveW = (liveText as NSString).size(withAttributes: [.font: liveFont]).width + livePadH * 2
|
||||
let liveH = textSize + 4 * scale
|
||||
let liveTop = baseline - textSize - 2 * scale
|
||||
let liveRect = CGRect(x: x, y: liveTop, width: liveW, height: liveH)
|
||||
context.setFillColor(UIColor(red: 1, green: 0.176, blue: 0.176, alpha: 1).cgColor)
|
||||
context.addPath(UIBezierPath(roundedRect: liveRect, cornerRadius: 3 * scale).cgPath)
|
||||
context.fillPath()
|
||||
(liveText as NSString).draw(at: CGPoint(x: x + livePadH, y: baseline - textSize), withAttributes: [.font: liveFont, .foregroundColor: UIColor.white])
|
||||
x += liveW + 4 * scale
|
||||
("TV" as NSString).draw(at: CGPoint(x: x, y: baseline - textSize), withAttributes: [.font: tvFont, .foregroundColor: UIColor(red: 0.612, green: 0.639, blue: 0.686, alpha: 1)])
|
||||
}
|
||||
|
||||
private func drawTeamRow(
|
||||
context: CGContext,
|
||||
board: ScoreboardState,
|
||||
isHome: Bool,
|
||||
rowTop: CGFloat,
|
||||
left: CGFloat,
|
||||
pad: Int,
|
||||
colorBarW: Int,
|
||||
logoSize: Int,
|
||||
logoGap: Int,
|
||||
logoSlotW: Int,
|
||||
teamW: Int,
|
||||
colW: Int,
|
||||
rowH: Int,
|
||||
scoresLeft: CGFloat,
|
||||
scale: CGFloat
|
||||
) {
|
||||
let accent = isHome ? board.homeAccentColor : board.awayAccentColor
|
||||
let barLeft = left + CGFloat(pad)
|
||||
let barTop = rowTop + 2 * scale
|
||||
let barBottom = rowTop + CGFloat(rowH) - 2 * scale
|
||||
context.setFillColor(accent.cgColor)
|
||||
let barRect = CGRect(x: barLeft, y: barTop, width: CGFloat(colorBarW), height: barBottom - barTop)
|
||||
context.addRect(barRect)
|
||||
context.fillPath()
|
||||
|
||||
let logoUrl = isHome ? board.homeLogoUrl : board.awayLogoUrl
|
||||
let logo = OverlayLogoCache.get(logoUrl)
|
||||
var nameLeft = barLeft + CGFloat(colorBarW + logoGap)
|
||||
if logoSlotW > 0 {
|
||||
if let logo, let cg = logo.cgImage {
|
||||
let logoLeft = nameLeft
|
||||
let logoTop = rowTop + (CGFloat(rowH) - CGFloat(logoSize)) / 2
|
||||
context.draw(cg, in: CGRect(x: logoLeft, y: logoTop, width: CGFloat(logoSize), height: CGFloat(logoSize)))
|
||||
}
|
||||
nameLeft += CGFloat(logoSize + logoGap)
|
||||
}
|
||||
|
||||
let rawName = (isHome ? board.homeTeamName : board.awayTeamName).trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
let teamSize = max(9, 11 * scale)
|
||||
let teamFont = UIFont.boldSystemFont(ofSize: teamSize)
|
||||
let name = ellipsize(rawName, font: teamFont, maxWidth: CGFloat(teamW) - 6 * scale)
|
||||
let nameBaseline = rowTop + CGFloat(rowH) * 0.72
|
||||
(name as NSString).draw(at: CGPoint(x: nameLeft, y: nameBaseline - teamSize), withAttributes: [.font: teamFont, .foregroundColor: UIColor.white])
|
||||
|
||||
let scoreSize = max(10, 13 * scale)
|
||||
let scoreFont = UIFont.boldSystemFont(ofSize: scoreSize)
|
||||
let winColor = UIColor(red: 1, green: 0.176, blue: 0.176, alpha: 1)
|
||||
for (index, column) in board.columns.enumerated() {
|
||||
let cx = scoresLeft + CGFloat(index * colW + colW / 2)
|
||||
let score = isHome ? column.homePoints : column.awayPoints
|
||||
let color: UIColor
|
||||
if column.isCurrent {
|
||||
color = .white
|
||||
} else {
|
||||
let homeWon = column.homePoints > column.awayPoints
|
||||
let awayWon = column.awayPoints > column.homePoints
|
||||
color = (isHome && homeWon) || (!isHome && awayWon) ? winColor : .white
|
||||
}
|
||||
let text = "\(score)" as NSString
|
||||
let size = text.size(withAttributes: [.font: scoreFont])
|
||||
text.draw(at: CGPoint(x: cx - size.width / 2, y: nameBaseline - scoreSize), withAttributes: [.font: scoreFont, .foregroundColor: color])
|
||||
}
|
||||
}
|
||||
|
||||
private func ellipsize(_ text: String, font: UIFont, maxWidth: CGFloat) -> String {
|
||||
let safeMax = max(48, maxWidth)
|
||||
let attrs: [NSAttributedString.Key: Any] = [.font: font]
|
||||
if (text as NSString).size(withAttributes: attrs).width <= safeMax { return text }
|
||||
var result = text
|
||||
while result.count > 1 && (result as NSString).size(withAttributes: attrs).width > safeMax {
|
||||
result = String(result.dropLast())
|
||||
}
|
||||
return result + "…"
|
||||
}
|
||||
|
||||
private func ordinalSetLabel(_ setNumber: Int) -> String {
|
||||
switch setNumber {
|
||||
case 1: return "1° set"
|
||||
case 2: return "2° set"
|
||||
case 3: return "3° set"
|
||||
case 4: return "4° set"
|
||||
case 5: return "5° set"
|
||||
default: return "Set \(setNumber)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import UIKit
|
||||
|
||||
struct SponsorElement: OverlayElement {
|
||||
func draw(in context: CGContext, state: OverlayState, layout: OverlayLayout) {
|
||||
guard let text = state.sponsorText?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return }
|
||||
let fontSize = max(12, CGFloat(layout.canvasHeight) * 0.045)
|
||||
let font = UIFont.boldSystemFont(ofSize: fontSize)
|
||||
let attrs: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.foregroundColor: UIColor(red: 0.961, green: 0.773, blue: 0.094, alpha: 0.86),
|
||||
]
|
||||
let size = (text as NSString).size(withAttributes: attrs)
|
||||
let x = (CGFloat(layout.canvasWidth) - size.width) / 2
|
||||
let y = CGFloat(layout.marginPx) * 2.2
|
||||
(text as NSString).draw(at: CGPoint(x: x, y: y), withAttributes: attrs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import UIKit
|
||||
|
||||
struct WatermarkElement: OverlayElement {
|
||||
private let logo = UIImage(named: "logo-white-m-256")
|
||||
|
||||
func draw(in context: CGContext, state: OverlayState, layout: OverlayLayout) {
|
||||
guard state.overlayKind != .none, state.watermarkVisible, let logo, let cg = logo.cgImage else { return }
|
||||
let targetWidth = min(72, max(28, Int((CGFloat(layout.canvasWidth) * 0.055).rounded())))
|
||||
let scale = CGFloat(targetWidth) / logo.size.width
|
||||
let targetHeight = Int(logo.size.height * scale)
|
||||
let left = layout.canvasWidth - layout.marginPx - targetWidth
|
||||
let top = layout.canvasHeight - layout.marginPx - targetHeight
|
||||
context.draw(cg, in: CGRect(x: left, y: top, width: targetWidth, height: targetHeight))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import HaishinKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct LivePreviewView: UIViewRepresentable {
|
||||
let engine: LiveBroadcastEngine
|
||||
|
||||
func makeUIView(context: Context) -> MTHKView {
|
||||
let view = MTHKView(frame: .zero)
|
||||
view.videoGravity = .resizeAspectFill
|
||||
Task { await engine.bindPreview(to: view) }
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: MTHKView, context: Context) {}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import SwiftUI
|
||||
|
||||
private let sideToolbarWidth: CGFloat = 44
|
||||
private let iconButtonSize: CGFloat = 36
|
||||
private let scoreButtonHeight: CGFloat = 32
|
||||
|
||||
struct BroadcastControlsOverlay: View {
|
||||
let controlsVisible: Bool
|
||||
let onToggleControls: () -> Void
|
||||
let statusText: String
|
||||
let statusColor: Color
|
||||
let cableConnected: Bool
|
||||
let isPaused: Bool
|
||||
let homeName: String
|
||||
let awayName: String
|
||||
let homeAccentColor: Color
|
||||
let awayAccentColor: Color
|
||||
let homeLogoUrl: String?
|
||||
let awayLogoUrl: String?
|
||||
let score: ScoreState
|
||||
let boardType: String
|
||||
let pointsTarget: Int
|
||||
let onPointHome: () -> Void
|
||||
let onPointAway: () -> Void
|
||||
let onMinusHome: () -> Void
|
||||
let onMinusAway: () -> Void
|
||||
var onPoint2Home: (() -> Void)?
|
||||
var onPoint3Home: (() -> Void)?
|
||||
var onPoint2Away: (() -> Void)?
|
||||
var onPoint3Away: (() -> Void)?
|
||||
var onCloseSet: (() -> Void)?
|
||||
var onAdvancePeriod: (() -> Void)?
|
||||
let onPauseOrResume: () -> Void
|
||||
let onTerminate: () -> Void
|
||||
let onShareLive: () -> Void
|
||||
let onShareRegia: () -> Void
|
||||
let shareLiveEnabled: Bool
|
||||
let fps: Int
|
||||
let targetFps: Int
|
||||
let bitrateKbps: Int
|
||||
let networkType: String
|
||||
let deviceHealth: DeviceHealth
|
||||
|
||||
@State private var showTerminateConfirm = false
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.overlay(alignment: .topLeading) {
|
||||
MatchStatusBadge(
|
||||
text: statusText,
|
||||
backgroundColor: MatchColors.background.opacity(0.78),
|
||||
textColor: statusColor
|
||||
)
|
||||
.padding(.leading, 8)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
SideIconButton(
|
||||
systemName: controlsVisible ? "eye.slash.fill" : "eye.fill",
|
||||
accessibilityLabel: controlsVisible ? "Nascondi controlli" : "Mostra controlli",
|
||||
action: onToggleControls
|
||||
)
|
||||
BroadcastTelemetryPanel(
|
||||
cableConnected: cableConnected,
|
||||
fps: fps,
|
||||
targetFps: targetFps,
|
||||
bitrateKbps: bitrateKbps,
|
||||
networkType: networkType,
|
||||
deviceHealth: deviceHealth
|
||||
)
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.overlay(alignment: .leading) {
|
||||
if controlsVisible {
|
||||
leftToolbar
|
||||
.padding(.leading, 6)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .trailing) {
|
||||
if controlsVisible {
|
||||
rightToolbar
|
||||
.padding(.trailing, 6)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
if controlsVisible {
|
||||
scoreControlsRow
|
||||
.padding(.horizontal, sideToolbarWidth)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
.alert("Terminare la diretta?", isPresented: $showTerminateConfirm) {
|
||||
Button("Annulla", role: .cancel) {}
|
||||
Button("TERMINA", role: .destructive, action: onTerminate)
|
||||
} message: {
|
||||
Text("Lo streaming verrà chiuso per tutti gli spettatori.")
|
||||
}
|
||||
}
|
||||
|
||||
private var leftToolbar: some View {
|
||||
VStack(spacing: 6) {
|
||||
SideIconButton(
|
||||
systemName: "square.and.arrow.up",
|
||||
accessibilityLabel: "Condividi diretta",
|
||||
action: onShareLive,
|
||||
enabled: shareLiveEnabled
|
||||
)
|
||||
SideIconButton(
|
||||
systemName: "video.fill",
|
||||
accessibilityLabel: "Condividi link regia",
|
||||
action: onShareRegia
|
||||
)
|
||||
if let onCloseSet {
|
||||
SideIconButton(
|
||||
systemName: "checkmark",
|
||||
accessibilityLabel: "Chiudi set",
|
||||
action: onCloseSet
|
||||
)
|
||||
}
|
||||
if let onAdvancePeriod {
|
||||
SideIconButton(
|
||||
systemName: "forward.end.fill",
|
||||
accessibilityLabel: "Periodo successivo",
|
||||
action: onAdvancePeriod
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var rightToolbar: some View {
|
||||
VStack(spacing: 6) {
|
||||
SideIconButton(
|
||||
systemName: isPaused ? "play.fill" : "pause.fill",
|
||||
accessibilityLabel: isPaused ? "Riprendi diretta" : "Pausa diretta",
|
||||
action: onPauseOrResume,
|
||||
highlighted: isPaused
|
||||
)
|
||||
SideIconButton(
|
||||
systemName: "stop.fill",
|
||||
accessibilityLabel: "Termina diretta",
|
||||
action: { showTerminateConfirm = true },
|
||||
danger: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var scoreControlsRow: some View {
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
TeamScoreColumn(
|
||||
teamLabel: "CASA",
|
||||
teamName: homeName,
|
||||
accentColor: homeAccentColor,
|
||||
logoUrl: homeLogoUrl,
|
||||
points: score.homePoints,
|
||||
onPlus: onPointHome,
|
||||
onMinus: onMinusHome,
|
||||
onPlus2: onPoint2Home,
|
||||
onPlus3: onPoint3Home,
|
||||
showBasketButtons: boardType == "basket",
|
||||
alignEnd: false
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
ScoreCenterPanel(score: score, boardType: boardType, pointsTarget: pointsTarget)
|
||||
|
||||
TeamScoreColumn(
|
||||
teamLabel: "OSPITE",
|
||||
teamName: awayName,
|
||||
accentColor: awayAccentColor,
|
||||
logoUrl: awayLogoUrl,
|
||||
points: score.awayPoints,
|
||||
onPlus: onPointAway,
|
||||
onMinus: onMinusAway,
|
||||
onPlus2: onPoint2Away,
|
||||
onPlus3: onPoint3Away,
|
||||
showBasketButtons: boardType == "basket",
|
||||
alignEnd: true
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BroadcastTelemetryPanel: View {
|
||||
let cableConnected: Bool
|
||||
let fps: Int
|
||||
let targetFps: Int
|
||||
let bitrateKbps: Int
|
||||
let networkType: String
|
||||
let deviceHealth: DeviceHealth
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(cableConnected ? "Tabellone OK" : "Tabellone offline")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(cableConnected ? MatchColors.successGreen : MatchColors.textSecondary)
|
||||
let fpsLabel = fps > 0 ? "\(fps) fps" : "— fps"
|
||||
let targetSuffix = targetFps > 0 ? " / \(targetFps)" : ""
|
||||
Text(fpsLabel + targetSuffix)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
if bitrateKbps > 0 {
|
||||
Text(formatBitrateKbps(bitrateKbps))
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
ThermalIndicator(
|
||||
tempC: deviceHealth.batteryTempC,
|
||||
level: deviceHealth.thermalLevel,
|
||||
label: deviceHealth.thermalLabel
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(MatchColors.background.opacity(0.78), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ThermalIndicator: View {
|
||||
let tempC: Double?
|
||||
let level: Int
|
||||
let label: String
|
||||
|
||||
private var color: Color {
|
||||
switch level {
|
||||
case 0: return MatchColors.successGreen
|
||||
case 1: return MatchColors.accentYellow
|
||||
case 2: return Color.orange
|
||||
default: return MatchColors.primaryRed
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let tempText = tempC.map { "\(Int($0))°C" } ?? "—°C"
|
||||
let warningBg = level >= 1 ? color.opacity(0.18) : Color.clear
|
||||
HStack(spacing: 4) {
|
||||
Text(tempText)
|
||||
.font(.system(size: 11, weight: level >= 1 ? .bold : .regular))
|
||||
.foregroundStyle(color)
|
||||
if level >= 1 {
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 1)
|
||||
.background(warningBg, in: RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ScoreCenterPanel: View {
|
||||
let score: ScoreState
|
||||
let boardType: String
|
||||
let pointsTarget: Int
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 2) {
|
||||
Text("\(score.homePoints) - \(score.awayPoints)")
|
||||
.font(MatchTypography.titleMedium)
|
||||
.foregroundStyle(.white)
|
||||
.fontWeight(.bold)
|
||||
switch boardType {
|
||||
case "basket", "timed":
|
||||
Text(score.periodLabel ?? (boardType == "basket" ? "Q\(score.period)" : "\(score.period)° tempo"))
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
case "generic":
|
||||
EmptyView()
|
||||
default:
|
||||
Text("Set \(score.currentSet) · \(pointsTarget) pt")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
if score.homeSets > 0 || score.awaySets > 0 {
|
||||
Text("Set vinti \(score.homeSets)-\(score.awaySets)")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TeamScoreColumn: View {
|
||||
let teamLabel: String
|
||||
let teamName: String
|
||||
let accentColor: Color
|
||||
let logoUrl: String?
|
||||
let points: Int
|
||||
let onPlus: () -> Void
|
||||
let onMinus: () -> Void
|
||||
var onPlus2: (() -> Void)?
|
||||
var onPlus3: (() -> Void)?
|
||||
let showBasketButtons: Bool
|
||||
let alignEnd: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: alignEnd ? .trailing : .leading, spacing: 0) {
|
||||
TeamIdentityRow(
|
||||
teamLabel: teamLabel,
|
||||
teamName: teamName,
|
||||
accentColor: accentColor,
|
||||
logoUrl: logoUrl,
|
||||
alignEnd: alignEnd
|
||||
)
|
||||
Spacer().frame(height: 6)
|
||||
Text("\(points)")
|
||||
.font(MatchTypography.headlineMedium)
|
||||
.foregroundStyle(accentColor)
|
||||
.fontWeight(.bold)
|
||||
.padding(.vertical, 4)
|
||||
Spacer().frame(height: 4)
|
||||
let teamSide = alignEnd ? "ospite" : "casa"
|
||||
HStack(spacing: 6) {
|
||||
if alignEnd {
|
||||
if showBasketButtons {
|
||||
if let onPlus3 {
|
||||
ScoreIconButton(label: "+3", tooltip: "+3 \(teamSide)", action: onPlus3, primary: true)
|
||||
}
|
||||
if let onPlus2 {
|
||||
ScoreIconButton(label: "+2", tooltip: "+2 \(teamSide)", action: onPlus2, primary: true)
|
||||
}
|
||||
}
|
||||
ScoreIconButton(label: "+1", tooltip: "Aggiungi punto \(teamSide)", action: onPlus, primary: !showBasketButtons)
|
||||
ScoreIconButton(label: "−", tooltip: "Togli punto \(teamSide)", action: onMinus)
|
||||
} else {
|
||||
ScoreIconButton(label: "−", tooltip: "Togli punto \(teamSide)", action: onMinus)
|
||||
ScoreIconButton(label: "+1", tooltip: "Aggiungi punto \(teamSide)", action: onPlus, primary: !showBasketButtons)
|
||||
if showBasketButtons {
|
||||
if let onPlus2 {
|
||||
ScoreIconButton(label: "+2", tooltip: "+2 \(teamSide)", action: onPlus2, primary: true)
|
||||
}
|
||||
if let onPlus3 {
|
||||
ScoreIconButton(label: "+3", tooltip: "+3 \(teamSide)", action: onPlus3, primary: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: alignEnd ? .trailing : .leading)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(MatchColors.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
|
||||
private struct TeamIdentityRow: View {
|
||||
let teamLabel: String
|
||||
let teamName: String
|
||||
let accentColor: Color
|
||||
let logoUrl: String?
|
||||
let alignEnd: Bool
|
||||
|
||||
var body: some View {
|
||||
let resolvedLogoUrl = MediaUrl.resolve(logoUrl)
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
if alignEnd {
|
||||
VStack(alignment: .trailing, spacing: 0) {
|
||||
Text(teamLabel)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.kerning(1)
|
||||
Text(teamName)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
if let resolvedLogoUrl, let url = URL(string: resolvedLogoUrl) {
|
||||
Spacer().frame(width: 8)
|
||||
TeamLogoThumbnail(url: url)
|
||||
}
|
||||
Spacer().frame(width: 6)
|
||||
TeamColorBar(color: accentColor)
|
||||
} else {
|
||||
TeamColorBar(color: accentColor)
|
||||
if let resolvedLogoUrl, let url = URL(string: resolvedLogoUrl) {
|
||||
Spacer().frame(width: 6)
|
||||
TeamLogoThumbnail(url: url)
|
||||
}
|
||||
Spacer().frame(width: 8)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(teamLabel)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.kerning(1)
|
||||
Text(teamName)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TeamColorBar: View {
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(color)
|
||||
.frame(width: 4, height: 36)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TeamLogoThumbnail: View {
|
||||
let url: URL
|
||||
|
||||
var body: some View {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFill()
|
||||
default:
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
.frame(width: 32, height: 32)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(MatchColors.outline, lineWidth: 1))
|
||||
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
|
||||
private struct SideIconButton: View {
|
||||
let systemName: String
|
||||
let accessibilityLabel: String
|
||||
let action: () -> Void
|
||||
var enabled: Bool = true
|
||||
var highlighted: Bool = false
|
||||
var danger: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(enabled ? .white : MatchColors.textSecondary)
|
||||
.frame(width: iconButtonSize, height: iconButtonSize)
|
||||
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
if danger { return MatchColors.primaryRed.opacity(0.88) }
|
||||
if highlighted { return MatchColors.successGreen.opacity(0.55) }
|
||||
return MatchColors.background.opacity(0.78)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ScoreIconButton: View {
|
||||
let label: String
|
||||
let tooltip: String
|
||||
let action: () -> Void
|
||||
var primary: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: label == "+1" ? 40 : scoreButtonHeight, height: scoreButtonHeight)
|
||||
.background(primary ? MatchColors.primaryRed : MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(tooltip)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatBitrateKbps(_ kbps: Int) -> String {
|
||||
if kbps >= 1000 {
|
||||
return String(format: "%.1f Mbps", Double(kbps) / 1000.0)
|
||||
}
|
||||
return "\(kbps) kbps"
|
||||
}
|
||||
448
native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift
Normal file
@@ -0,0 +1,448 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct BroadcastScreen: View {
|
||||
@ObservedObject var container: AppContainer
|
||||
@ObservedObject private var scoreController: ScoreController
|
||||
@ObservedObject private var broadcastCoordinator: LiveBroadcastCoordinator
|
||||
let sessionId: String
|
||||
let onFinished: () -> Void
|
||||
|
||||
@StateObject private var permissions = BroadcastPermissions()
|
||||
@StateObject private var scoreDialogHost = LiveScoreDialogHost()
|
||||
|
||||
init(container: AppContainer, sessionId: String, onFinished: @escaping () -> Void) {
|
||||
self.container = container
|
||||
self.sessionId = sessionId
|
||||
self.onFinished = onFinished
|
||||
_scoreController = ObservedObject(wrappedValue: container.scoreController)
|
||||
_broadcastCoordinator = ObservedObject(wrappedValue: container.broadcastCoordinator)
|
||||
}
|
||||
@State private var session: StreamSession?
|
||||
@State private var match: Match?
|
||||
@State private var error: String?
|
||||
@State private var loading = true
|
||||
@State private var controlsVisible = true
|
||||
@State private var logoReady = 0
|
||||
@State private var pauseInFlight = false
|
||||
@State private var deviceHealth = DeviceTelemetry.snapshot()
|
||||
@State private var snackbarMessage: String?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
if loading {
|
||||
ProgressView().tint(MatchColors.primaryRed)
|
||||
} else if !permissions.allGranted {
|
||||
VStack(spacing: 12) {
|
||||
Text("Consenti camera e microfono per andare in diretta")
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.accentYellow)
|
||||
.multilineTextAlignment(.center)
|
||||
MatchPrimaryButton(label: "CONCEDI PERMESSI") {
|
||||
Task { await permissions.requestAll() }
|
||||
}
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.padding(24)
|
||||
} else if let session, let match {
|
||||
LivePreviewView(engine: broadcastCoordinator.engine)
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(false)
|
||||
broadcastOverlay(session: session, match: match)
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
.statusBarHidden(true)
|
||||
.lockLandscapeOrientation()
|
||||
.onAppear {
|
||||
UIDevice.current.isBatteryMonitoringEnabled = true
|
||||
}
|
||||
.onDisappear {
|
||||
Task { await teardown() }
|
||||
}
|
||||
.task {
|
||||
await permissions.refresh()
|
||||
if !permissions.allGranted {
|
||||
await permissions.requestAll()
|
||||
}
|
||||
}
|
||||
.task(id: permissions.allGranted) {
|
||||
guard permissions.allGranted else {
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
await bootstrap()
|
||||
}
|
||||
.onChange(of: scoreController.score) { _ in updateOverlay() }
|
||||
.onChange(of: broadcastCoordinator.metrics.phase) { _ in updateOverlay() }
|
||||
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||
Button("OK") { onFinished() }
|
||||
} message: {
|
||||
Text(error ?? "")
|
||||
}
|
||||
.background {
|
||||
if let match {
|
||||
ScoreDialogRouter(
|
||||
host: scoreDialogHost,
|
||||
homeName: match.teamName,
|
||||
awayName: match.opponentName
|
||||
)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if let snackbarMessage {
|
||||
Text(snackbarMessage)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 8))
|
||||
.padding(.top, 48)
|
||||
.onAppear {
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
self.snackbarMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func broadcastOverlay(session: StreamSession, match: Match) -> some View {
|
||||
let metrics = broadcastCoordinator.metrics
|
||||
let score = scoreController.score
|
||||
let isPaused = session.isPaused || metrics.phase == .paused
|
||||
let statusText = broadcastStatusText(isPaused: isPaused, metrics: metrics)
|
||||
let statusColor = broadcastStatusColor(isPaused: isPaused, metrics: metrics)
|
||||
let boardType = match.boardType.isEmpty ? score.boardType : match.boardType
|
||||
let usesActionScoring = boardType == "basket" || boardType == "timed"
|
||||
let usesSetScoring = boardType == "volley" || boardType == "racket"
|
||||
let pointsTarget = MatchScoringRules.pointsTarget(for: match, currentSet: score.currentSet)
|
||||
let shareSubject = "\(match.teamName) vs \(match.opponentName)"
|
||||
|
||||
BroadcastControlsOverlay(
|
||||
controlsVisible: controlsVisible,
|
||||
onToggleControls: { controlsVisible.toggle() },
|
||||
statusText: statusText,
|
||||
statusColor: statusColor,
|
||||
cableConnected: container.sessionCable.connected,
|
||||
isPaused: isPaused,
|
||||
homeName: match.teamName,
|
||||
awayName: match.opponentName,
|
||||
homeAccentColor: ColorHex.swiftUIColor(match.homePrimaryColor, fallback: Color(red: 1, green: 0.176, blue: 0.176)),
|
||||
awayAccentColor: ColorHex.swiftUIColor(match.opponentPrimaryColor, fallback: Color(red: 0.118, green: 0.227, blue: 0.541)),
|
||||
homeLogoUrl: match.homeLogoUrl,
|
||||
awayLogoUrl: match.opponentLogoUrl,
|
||||
score: score,
|
||||
boardType: boardType,
|
||||
pointsTarget: pointsTarget,
|
||||
onPointHome: {
|
||||
Task {
|
||||
if usesActionScoring {
|
||||
scoreController.applyAction("home_point")
|
||||
} else if usesSetScoring {
|
||||
await scoreController.applyBoardAction("home_point")
|
||||
await liveScoreActions(for: match).afterPointChange(score: scoreController.score)
|
||||
} else {
|
||||
scoreController.incrementHome()
|
||||
}
|
||||
}
|
||||
},
|
||||
onPointAway: {
|
||||
Task {
|
||||
if usesActionScoring {
|
||||
scoreController.applyAction("away_point")
|
||||
} else if usesSetScoring {
|
||||
await scoreController.applyBoardAction("away_point")
|
||||
await liveScoreActions(for: match).afterPointChange(score: scoreController.score)
|
||||
} else {
|
||||
scoreController.incrementAway()
|
||||
}
|
||||
}
|
||||
},
|
||||
onMinusHome: {
|
||||
Task {
|
||||
if usesActionScoring {
|
||||
scoreController.applyAction("home_undo")
|
||||
} else if usesSetScoring {
|
||||
await scoreController.applyBoardAction("home_undo")
|
||||
} else {
|
||||
scoreController.decrementHome()
|
||||
}
|
||||
}
|
||||
},
|
||||
onMinusAway: {
|
||||
Task {
|
||||
if usesActionScoring {
|
||||
scoreController.applyAction("away_undo")
|
||||
} else if usesSetScoring {
|
||||
await scoreController.applyBoardAction("away_undo")
|
||||
} else {
|
||||
scoreController.decrementAway()
|
||||
}
|
||||
}
|
||||
},
|
||||
onPoint2Home: boardType == "basket" ? { scoreController.applyAction("home_point_2") } : nil,
|
||||
onPoint3Home: boardType == "basket" ? { scoreController.applyAction("home_point_3") } : nil,
|
||||
onPoint2Away: boardType == "basket" ? { scoreController.applyAction("away_point_2") } : nil,
|
||||
onPoint3Away: boardType == "basket" ? { scoreController.applyAction("away_point_3") } : nil,
|
||||
onCloseSet: usesSetScoring ? {
|
||||
Task { await liveScoreActions(for: match).requestCloseSet() }
|
||||
} : nil,
|
||||
onAdvancePeriod: usesActionScoring ? { scoreController.applyAction("advance_period") } : nil,
|
||||
onPauseOrResume: {
|
||||
if isPaused {
|
||||
Task { await resumeStream() }
|
||||
} else {
|
||||
pauseStream()
|
||||
}
|
||||
},
|
||||
onTerminate: { Task { await stopStream() } },
|
||||
onShareLive: { shareLiveLink(session: session, subject: shareSubject) },
|
||||
onShareRegia: { shareRegiaLink(subject: shareSubject) },
|
||||
shareLiveEnabled: session.watchShareUrl() != nil,
|
||||
fps: metrics.fps,
|
||||
targetFps: session.targetFps,
|
||||
bitrateKbps: metrics.bitrateKbps,
|
||||
networkType: deviceHealth.networkType,
|
||||
deviceHealth: deviceHealth
|
||||
)
|
||||
}
|
||||
|
||||
private func liveScoreActions(for match: Match) -> LiveScoreActions {
|
||||
LiveScoreActions(
|
||||
rules: MatchScoringContext(match: match),
|
||||
scoreController: scoreController,
|
||||
dialogHost: scoreDialogHost,
|
||||
onStopStream: { await stopStream() }
|
||||
)
|
||||
}
|
||||
|
||||
private func broadcastStatusText(isPaused: Bool, metrics: BroadcastMetrics) -> String {
|
||||
if isPaused { return "PAUSA" }
|
||||
switch metrics.phase {
|
||||
case .live: return "IN DIRETTA"
|
||||
case .connecting: return "CONNESSIONE…"
|
||||
case .reconnecting: return "RICONNESSIONE…"
|
||||
case .error: return metrics.lastError ?? "ERRORE"
|
||||
default: return "PREVIEW"
|
||||
}
|
||||
}
|
||||
|
||||
private func broadcastStatusColor(isPaused: Bool, metrics: BroadcastMetrics) -> Color {
|
||||
if isPaused { return MatchColors.accentYellow }
|
||||
switch metrics.phase {
|
||||
case .live: return MatchColors.successGreen
|
||||
case .error: return MatchColors.primaryRed
|
||||
default: return MatchColors.accentYellow
|
||||
}
|
||||
}
|
||||
|
||||
private func shareLiveLink(session: StreamSession, subject: String) {
|
||||
guard let urlString = session.watchShareUrl(), let url = URL(string: urlString) else {
|
||||
snackbarMessage = "Link diretta non ancora disponibile"
|
||||
return
|
||||
}
|
||||
presentShare(items: [url], subject: subject)
|
||||
}
|
||||
|
||||
private func shareRegiaLink(subject: String) {
|
||||
Task {
|
||||
do {
|
||||
let urlString = try await container.sessionRepository.createRegiaLink(sessionId: sessionId)
|
||||
guard let url = URL(string: urlString) else { return }
|
||||
presentShare(items: [url], subject: "Link regia — \(subject)")
|
||||
} catch {
|
||||
snackbarMessage = UserFacingError.message(for: error) ?? "Errore link regia"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presentShare(items: [Any], subject: String) {
|
||||
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let root = scene.windows.first?.rootViewController else { return }
|
||||
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
controller.setValue(subject, forKey: "subject")
|
||||
root.present(controller, animated: true)
|
||||
}
|
||||
|
||||
private func bootstrap() async {
|
||||
loading = true
|
||||
error = nil
|
||||
await container.broadcastCoordinator.stopBroadcast()
|
||||
do {
|
||||
let loaded = try await container.sessionRepository.fetchSession(id: sessionId)
|
||||
let loadedMatch = try await container.matchRepository.fetchMatch(matchId: loaded.matchId)
|
||||
session = loaded
|
||||
match = loadedMatch
|
||||
container.scoreController.bind(sessionId: sessionId, initial: loaded.score)
|
||||
wireCable()
|
||||
if let url = loaded.rtmpIngestUrl, !url.isEmpty {
|
||||
let config = broadcastConfig(for: loaded, rtmpUrl: url)
|
||||
if loaded.isPaused {
|
||||
try await container.broadcastCoordinator.engine.preparePreview(config: config)
|
||||
} else {
|
||||
try await container.broadcastCoordinator.startBroadcast(config: config)
|
||||
}
|
||||
}
|
||||
await preloadLogos(for: loadedMatch)
|
||||
startPolling()
|
||||
loading = false
|
||||
updateOverlay()
|
||||
} catch {
|
||||
if let message = UserFacingError.message(for: error) {
|
||||
self.error = message
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
private func wireCable() {
|
||||
guard let token = container.tokenStore.session?.accessToken else { return }
|
||||
container.sessionCable.onScoreUpdate = { [weak container] remote in
|
||||
container?.scoreController.applyRemote(remote)
|
||||
}
|
||||
container.sessionCable.onPauseStream = { [weak container] in
|
||||
Task { @MainActor in await container?.broadcastCoordinator.pauseBroadcast() }
|
||||
}
|
||||
container.sessionCable.onResumeStream = { [weak container] in
|
||||
Task { @MainActor in
|
||||
await resumeStream()
|
||||
}
|
||||
}
|
||||
container.sessionCable.onStopStream = { [weak container] in
|
||||
Task { @MainActor in await stopStream() }
|
||||
}
|
||||
container.sessionCable.connect(sessionId: sessionId, accessToken: token)
|
||||
}
|
||||
|
||||
private func preloadLogos(for match: Match) async {
|
||||
await OverlayLogoCache.preloadAll([match.homeLogoUrl, match.opponentLogoUrl])
|
||||
logoReady += 1
|
||||
}
|
||||
|
||||
private func updateOverlay() {
|
||||
guard let match, let session else { return }
|
||||
let overlayKind = OverlayKind.fromApi(match.effectiveOverlayKind)
|
||||
let homeColor = ColorHex.parseColorHex(match.homePrimaryColor ?? "#FF2D2D")
|
||||
let awayColor = ColorHex.parseColorHex(match.opponentPrimaryColor ?? "#1E3A8A")
|
||||
let score = container.scoreController.score
|
||||
let state: OverlayState
|
||||
switch overlayKind {
|
||||
case .none:
|
||||
state = OverlayState(overlayKind: .none, watermarkVisible: false, broadcastStatus: container.broadcastCoordinator.metrics.phase.toOverlayStatus())
|
||||
case .basket, .timed:
|
||||
let period = score.periodLabel ?? (overlayKind == .basket ? "Q\(score.period)" : "\(score.period)° tempo")
|
||||
state = OverlayState(
|
||||
overlayKind: overlayKind,
|
||||
compactScoreboard: CompactScoreboardState(
|
||||
homeTeamName: match.teamName,
|
||||
awayTeamName: match.opponentName,
|
||||
homeScore: score.homePoints,
|
||||
awayScore: score.awayPoints,
|
||||
periodLabel: period,
|
||||
homeAccentColor: homeColor,
|
||||
awayAccentColor: awayColor,
|
||||
homeLogoUrl: match.homeLogoUrl,
|
||||
awayLogoUrl: match.opponentLogoUrl
|
||||
),
|
||||
broadcastStatus: container.broadcastCoordinator.metrics.phase.toOverlayStatus()
|
||||
)
|
||||
default:
|
||||
state = OverlayState(
|
||||
overlayKind: overlayKind,
|
||||
scoreboard: score.toScoreboardState(
|
||||
homeTeamName: match.teamName,
|
||||
awayTeamName: match.opponentName,
|
||||
homeAccentColor: homeColor,
|
||||
awayAccentColor: awayColor,
|
||||
homeLogoUrl: match.homeLogoUrl,
|
||||
awayLogoUrl: match.opponentLogoUrl
|
||||
),
|
||||
broadcastStatus: container.broadcastCoordinator.metrics.phase.toOverlayStatus()
|
||||
)
|
||||
}
|
||||
container.broadcastCoordinator.updateOverlay(state)
|
||||
}
|
||||
|
||||
private func startPolling() {
|
||||
Task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
if let remote = try? await container.sessionRepository.fetchSession(id: sessionId).score {
|
||||
container.scoreController.applyRemote(remote)
|
||||
}
|
||||
}
|
||||
}
|
||||
Task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
deviceHealth = DeviceTelemetry.snapshot()
|
||||
}
|
||||
}
|
||||
Task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 10_000_000_000)
|
||||
let health = DeviceTelemetry.snapshot()
|
||||
let metrics = container.broadcastCoordinator.metrics
|
||||
try? await container.sessionRepository.postTelemetry(
|
||||
sessionId: sessionId,
|
||||
health: health,
|
||||
currentBitrate: metrics.bitrateKbps * 1000,
|
||||
targetBitrate: session?.targetBitrate,
|
||||
fps: metrics.fps
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pauseStream() {
|
||||
guard !pauseInFlight else { return }
|
||||
pauseInFlight = true
|
||||
Task {
|
||||
_ = try? await container.sessionRepository.pauseSession(id: sessionId)
|
||||
await container.broadcastCoordinator.pauseBroadcast()
|
||||
pauseInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
private func resumeStream() async {
|
||||
do {
|
||||
var updated = try await container.sessionRepository.resumeSession(id: sessionId)
|
||||
session = updated
|
||||
guard let url = updated.rtmpIngestUrl, !url.isEmpty else { return }
|
||||
let config = broadcastConfig(for: updated, rtmpUrl: url)
|
||||
try await container.broadcastCoordinator.resumeBroadcast(config: config)
|
||||
updated = try await container.sessionRepository.fetchSession(id: sessionId)
|
||||
session = updated
|
||||
} catch {
|
||||
if let message = UserFacingError.message(for: error) {
|
||||
self.error = message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func broadcastConfig(for loaded: StreamSession, rtmpUrl: String) -> BroadcastConfig {
|
||||
BroadcastConfig(
|
||||
rtmpUrl: rtmpUrl,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
videoBitrate: loaded.targetBitrate,
|
||||
audioBitrate: 128_000,
|
||||
fps: loaded.targetFps
|
||||
)
|
||||
}
|
||||
|
||||
private func stopStream() async {
|
||||
_ = try? await container.sessionRepository.stopSession(id: sessionId)
|
||||
await teardown()
|
||||
onFinished()
|
||||
}
|
||||
|
||||
private func teardown() async {
|
||||
container.sessionCable.disconnect()
|
||||
await container.broadcastCoordinator.stopBroadcast()
|
||||
}
|
||||
}
|
||||
164
native/ios/MatchLiveTv/UI/Broadcast/LiveScoreActions.swift
Normal file
@@ -0,0 +1,164 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum ScoreDialogKind {
|
||||
case setWon
|
||||
case closeSetAnyway
|
||||
case matchWon
|
||||
}
|
||||
|
||||
struct ScoreDialogState: Identifiable {
|
||||
let id = UUID()
|
||||
let kind: ScoreDialogKind
|
||||
var winnerSide: ScoringSide?
|
||||
var homePoints: Int = 0
|
||||
var awayPoints: Int = 0
|
||||
var homeSets: Int = 0
|
||||
var awaySets: Int = 0
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LiveScoreDialogHost: ObservableObject {
|
||||
@Published private(set) var pending: ScoreDialogState?
|
||||
private var waiter: CheckedContinuation<Bool, Never>?
|
||||
|
||||
func show(_ state: ScoreDialogState) async -> Bool {
|
||||
guard pending == nil else { return false }
|
||||
return await withCheckedContinuation { continuation in
|
||||
pending = state
|
||||
waiter = continuation
|
||||
}
|
||||
}
|
||||
|
||||
func resolve(_ value: Bool) {
|
||||
pending = nil
|
||||
waiter?.resume(returning: value)
|
||||
waiter = nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LiveScoreActions {
|
||||
private let rules: MatchScoringContext
|
||||
private let scoreController: ScoreController
|
||||
private let dialogHost: LiveScoreDialogHost
|
||||
private let onStopStream: () async -> Void
|
||||
|
||||
init(
|
||||
rules: MatchScoringContext,
|
||||
scoreController: ScoreController,
|
||||
dialogHost: LiveScoreDialogHost,
|
||||
onStopStream: @escaping () async -> Void
|
||||
) {
|
||||
self.rules = rules
|
||||
self.scoreController = scoreController
|
||||
self.dialogHost = dialogHost
|
||||
self.onStopStream = onStopStream
|
||||
}
|
||||
|
||||
func afterPointChange(score: ScoreState) async {
|
||||
guard rules.usesSetLogic else { return }
|
||||
guard let winner = rules.setWinnerFromPoints(
|
||||
homePoints: score.homePoints,
|
||||
awayPoints: score.awayPoints,
|
||||
currentSet: score.currentSet
|
||||
) else { return }
|
||||
|
||||
let close = await dialogHost.show(
|
||||
ScoreDialogState(
|
||||
kind: .setWon,
|
||||
winnerSide: winner,
|
||||
homePoints: score.homePoints,
|
||||
awayPoints: score.awayPoints
|
||||
)
|
||||
)
|
||||
if close {
|
||||
guard await scoreController.closeSetAsync() else { return }
|
||||
await afterCloseSet()
|
||||
}
|
||||
}
|
||||
|
||||
func requestCloseSet() async {
|
||||
guard rules.usesSetLogic else { return }
|
||||
|
||||
let score = scoreController.score
|
||||
let winner = rules.setWinnerFromPoints(
|
||||
homePoints: score.homePoints,
|
||||
awayPoints: score.awayPoints,
|
||||
currentSet: score.currentSet
|
||||
)
|
||||
if winner == nil {
|
||||
let ok = await dialogHost.show(ScoreDialogState(kind: .closeSetAnyway))
|
||||
if !ok { return }
|
||||
}
|
||||
guard await scoreController.closeSetAsync() else { return }
|
||||
await afterCloseSet()
|
||||
}
|
||||
|
||||
private func afterCloseSet() async {
|
||||
let score = scoreController.score
|
||||
guard let winner = rules.matchWinner(homeSets: score.homeSets, awaySets: score.awaySets) else { return }
|
||||
let stop = await dialogHost.show(
|
||||
ScoreDialogState(
|
||||
kind: .matchWon,
|
||||
winnerSide: winner,
|
||||
homeSets: score.homeSets,
|
||||
awaySets: score.awaySets
|
||||
)
|
||||
)
|
||||
if stop { await onStopStream() }
|
||||
}
|
||||
}
|
||||
|
||||
struct ScoreDialogRouter: View {
|
||||
@ObservedObject var host: LiveScoreDialogHost
|
||||
let homeName: String
|
||||
let awayName: String
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.frame(width: 0, height: 0)
|
||||
.alert(item: dialogBinding) { dialog in
|
||||
alert(for: dialog)
|
||||
}
|
||||
}
|
||||
|
||||
private var dialogBinding: Binding<ScoreDialogState?> {
|
||||
Binding(
|
||||
get: { host.pending },
|
||||
set: { newValue in
|
||||
if newValue == nil, host.pending != nil {
|
||||
host.resolve(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func alert(for dialog: ScoreDialogState) -> Alert {
|
||||
switch dialog.kind {
|
||||
case .setWon:
|
||||
let winner = dialog.winnerSide.map { scoringSideName($0, homeName: homeName, awayName: awayName) } ?? ""
|
||||
return Alert(
|
||||
title: Text("Set concluso"),
|
||||
message: Text("\(winner) vince il set \(dialog.homePoints)-\(dialog.awayPoints).\n\nChiudere il set e passare al successivo?"),
|
||||
primaryButton: .default(Text("Chiudi set")) { host.resolve(true) },
|
||||
secondaryButton: .cancel(Text("Continua a segnare")) { host.resolve(false) }
|
||||
)
|
||||
case .closeSetAnyway:
|
||||
return Alert(
|
||||
title: Text("Chiudi set"),
|
||||
message: Text("Il punteggio non soddisfa ancora le regole del torneo. Chiudere il set comunque?"),
|
||||
primaryButton: .default(Text("Chiudi comunque")) { host.resolve(true) },
|
||||
secondaryButton: .cancel(Text("Annulla")) { host.resolve(false) }
|
||||
)
|
||||
case .matchWon:
|
||||
let winner = dialog.winnerSide.map { scoringSideName($0, homeName: homeName, awayName: awayName) } ?? ""
|
||||
return Alert(
|
||||
title: Text("Partita terminata"),
|
||||
message: Text("\(winner) vince la partita (\(dialog.homeSets)-\(dialog.awaySets) set).\n\nChiudere definitivamente la diretta?"),
|
||||
primaryButton: .default(Text("Chiudi diretta")) { host.resolve(true) },
|
||||
secondaryButton: .cancel(Text("Continua in onda")) { host.resolve(false) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
40
native/ios/MatchLiveTv/UI/Components/MatchLiveWordmark.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MatchLiveWordmark: View {
|
||||
var compact: Bool = false
|
||||
var showSlogan: Bool = false
|
||||
var showLogo: Bool { !compact }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if showLogo {
|
||||
Image("logo-white-m")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: compact ? 40 : 80, height: compact ? 40 : 80)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Text("MATCH")
|
||||
.font(MatchTypography.displaySmall)
|
||||
.foregroundStyle(.white)
|
||||
Text("LIVE")
|
||||
.font(MatchTypography.labelLarge)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, compact ? 8 : 12)
|
||||
.padding(.vertical, compact ? 2 : 4)
|
||||
.background(MatchColors.primaryRed, in: RoundedRectangle(cornerRadius: 6))
|
||||
Text("TV")
|
||||
.font(MatchTypography.displaySmall)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
if showSlogan {
|
||||
Text("OGNI PARTITA, OGNI EVENTO, PER I TUOI TIFOSI.")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MatchPrimaryButton: View {
|
||||
let label: String
|
||||
let action: () -> Void
|
||||
var enabled: Bool = true
|
||||
var loading: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Group {
|
||||
if loading {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text(label)
|
||||
.font(MatchTypography.labelLarge)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(enabled && !loading ? MatchColors.primaryRed : MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 8))
|
||||
.foregroundStyle(enabled && !loading ? .white : MatchColors.textSecondary)
|
||||
.disabled(!enabled || loading)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MatchScreenScaffold<Content: View, TopBar: View>: View {
|
||||
@ViewBuilder var topBar: () -> TopBar
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
init(
|
||||
@ViewBuilder topBar: @escaping () -> TopBar = { EmptyView() },
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.topBar = topBar
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
topBar()
|
||||
content()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.background(MatchColors.background)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MatchSecondaryButton: View {
|
||||
let label: String
|
||||
let action: () -> Void
|
||||
var enabled: Bool = true
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(MatchTypography.labelLarge)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(enabled ? MatchColors.outline : MatchColors.surfaceElevated, lineWidth: 1)
|
||||
)
|
||||
.foregroundStyle(enabled ? .white : MatchColors.textSecondary)
|
||||
.disabled(!enabled)
|
||||
}
|
||||
}
|
||||
16
native/ios/MatchLiveTv/UI/Components/MatchStatusBadge.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MatchStatusBadge: View {
|
||||
let text: String
|
||||
var backgroundColor: Color = MatchColors.surfaceElevated
|
||||
var textColor: Color = MatchColors.accentYellow
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(MatchTypography.labelLarge)
|
||||
.foregroundStyle(textColor)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
125
native/ios/MatchLiveTv/UI/Login/LoginScreen.swift
Normal file
@@ -0,0 +1,125 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginScreen: View {
|
||||
@ObservedObject var container: AppContainer
|
||||
let onLoggedIn: () -> Void
|
||||
|
||||
@State private var email = ""
|
||||
@State private var password = ""
|
||||
@State private var error: String?
|
||||
@State private var loading = false
|
||||
@State private var passwordVisible = false
|
||||
|
||||
var body: some View {
|
||||
MatchScreenScaffold {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
MatchLiveWordmark(showSlogan: true)
|
||||
.padding(.top, 32)
|
||||
Text("ACCEDI")
|
||||
.font(MatchTypography.headlineMedium)
|
||||
.padding(.top, 48)
|
||||
Text("Gestisci le dirette della tua squadra")
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
VStack(spacing: 16) {
|
||||
MatchTextField(title: "Email", text: $email, placeholder: "coach@squadra.it", keyboard: .emailAddress)
|
||||
MatchSecureField(title: "Password", text: $password, visible: $passwordVisible)
|
||||
}
|
||||
.padding(.top, 32)
|
||||
if let error {
|
||||
Text(error)
|
||||
.foregroundStyle(MatchColors.primaryRed)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
MatchPrimaryButton(
|
||||
label: "ACCEDI",
|
||||
action: submitLogin,
|
||||
enabled: !email.isEmpty && !password.isEmpty,
|
||||
loading: loading
|
||||
)
|
||||
.padding(.top, 32)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func submitLogin() {
|
||||
guard !loading, !email.isEmpty, !password.isEmpty else { return }
|
||||
loading = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
_ = try await container.authRepository.login(email: email.trimmingCharacters(in: .whitespaces), password: password)
|
||||
onLoggedIn()
|
||||
} catch {
|
||||
let message = error.localizedDescription
|
||||
if message.contains("401") {
|
||||
self.error = "Email o password non corretti"
|
||||
} else if message.localizedCaseInsensitiveContains("timeout") {
|
||||
self.error = "Server non raggiungibile. Verifica la connessione."
|
||||
} else {
|
||||
self.error = message
|
||||
}
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MatchTextField: View {
|
||||
let title: String
|
||||
@Binding var text: String
|
||||
var placeholder: String = ""
|
||||
var keyboard: UIKeyboardType = .default
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
TextField(placeholder, text: $text)
|
||||
.textInputAutocapitalization(.never)
|
||||
.keyboardType(keyboard)
|
||||
.autocorrectionDisabled()
|
||||
.padding(12)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MatchSecureField: View {
|
||||
let title: String
|
||||
@Binding var text: String
|
||||
@Binding var visible: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
HStack {
|
||||
Group {
|
||||
if visible {
|
||||
TextField("", text: $text)
|
||||
} else {
|
||||
SecureField("", text: $text)
|
||||
}
|
||||
}
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
Button(action: { visible.toggle() }) {
|
||||
Image(systemName: visible ? "eye.slash" : "eye")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||
}
|
||||
}
|
||||
}
|
||||
533
native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift
Normal file
@@ -0,0 +1,533 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MatchesScreen: View {
|
||||
@ObservedObject var container: AppContainer
|
||||
var refreshToken: Int = 0
|
||||
let onOpenSetup: (String) -> Void
|
||||
let onOpenBroadcast: (String) -> Void
|
||||
let onLogout: () -> Void
|
||||
|
||||
@State private var loading = true
|
||||
@State private var refreshing = false
|
||||
@State private var actionLoading = false
|
||||
@State private var error: String?
|
||||
@State private var teams: [Team] = []
|
||||
@State private var activeTeam: Team?
|
||||
@State private var matches: [Match] = []
|
||||
@State private var showNewMatch = false
|
||||
@State private var showSchedule = false
|
||||
@State private var showTeamPicker = false
|
||||
@State private var resumeMatch: Match?
|
||||
@State private var deleteMatch: Match?
|
||||
@State private var snackbar: String?
|
||||
|
||||
var body: some View {
|
||||
MatchScreenScaffold(
|
||||
topBar: {
|
||||
HStack {
|
||||
MatchLiveWordmark(compact: true)
|
||||
Spacer()
|
||||
Button("Esci") {
|
||||
Task {
|
||||
await container.authRepository.logout()
|
||||
onLogout()
|
||||
}
|
||||
}
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
},
|
||||
content: {
|
||||
Group {
|
||||
if loading {
|
||||
ProgressView().tint(MatchColors.primaryRed)
|
||||
} else if teams.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Text("Nessuna squadra disponibile")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
MatchPrimaryButton(label: "RIPROVA", action: { reload(showSpinner: false) })
|
||||
}
|
||||
.padding(24)
|
||||
} else if let error {
|
||||
VStack(spacing: 16) {
|
||||
Text(error).foregroundStyle(MatchColors.primaryRed).multilineTextAlignment(.center)
|
||||
MatchPrimaryButton(label: "RIPROVA", action: { reload(showSpinner: false) })
|
||||
}
|
||||
.padding(24)
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Ciao, \(container.tokenStore.session?.user.name ?? "")")
|
||||
.font(MatchTypography.headlineMedium)
|
||||
Text("Riprendi una diretta in corso o avvia una partita programmata.")
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
HStack(spacing: 12) {
|
||||
MatchSecondaryButton(label: "PARTITA PROGRAMMATA", action: { showSchedule = true })
|
||||
MatchPrimaryButton(label: "NUOVA PARTITA", action: { showNewMatch = true })
|
||||
}
|
||||
if let activeTeam {
|
||||
TeamPickerBar(
|
||||
team: activeTeam,
|
||||
showPicker: teams.count > 1,
|
||||
onTap: { if teams.count > 1 { showTeamPicker = true } }
|
||||
)
|
||||
}
|
||||
if let active = activeMatch {
|
||||
ActiveSessionBanner(match: active) {
|
||||
resumeMatch = active
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Text(calendarSectionTitle)
|
||||
.font(MatchTypography.labelLarge)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
if calendarMatches.isEmpty && activeMatch == nil {
|
||||
Text(emptyCalendarMessage)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
} else if calendarMatches.isEmpty {
|
||||
Text("Nessuna altra partita in calendario.")
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
} else {
|
||||
ForEach(calendarMatches) { match in
|
||||
MatchListCard(
|
||||
match: match,
|
||||
onTap: { openMatch(match) },
|
||||
onDelete: (!match.hasActiveSession && !match.streamCompleted)
|
||||
? { deleteMatch = match }
|
||||
: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.refreshable { reload(showSpinner: false) }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
)
|
||||
.task(id: refreshToken) { reload(showSpinner: refreshToken == 0) }
|
||||
.alert("Riprendi diretta?", isPresented: Binding(get: { resumeMatch != nil }, set: { if !$0 { resumeMatch = nil } })) {
|
||||
Button("Riprendi") {
|
||||
if let match = resumeMatch { resumeBroadcast(match) }
|
||||
resumeMatch = nil
|
||||
}
|
||||
Button("Configura", role: .cancel) {
|
||||
if let match = resumeMatch { onOpenSetup(match.id) }
|
||||
resumeMatch = nil
|
||||
}
|
||||
}
|
||||
.alert("Elimina partita?", isPresented: Binding(get: { deleteMatch != nil }, set: { if !$0 { deleteMatch = nil } })) {
|
||||
Button("Elimina", role: .destructive) {
|
||||
if let match = deleteMatch {
|
||||
Task {
|
||||
try? await container.matchRepository.deleteMatch(matchId: match.id)
|
||||
reload(showSpinner: false)
|
||||
}
|
||||
}
|
||||
deleteMatch = nil
|
||||
}
|
||||
Button("Annulla", role: .cancel) { deleteMatch = nil }
|
||||
}
|
||||
.sheet(isPresented: $showNewMatch) {
|
||||
NewMatchSheet(
|
||||
onSchedule: {
|
||||
showNewMatch = false
|
||||
showSchedule = true
|
||||
},
|
||||
onQuickStart: {
|
||||
showNewMatch = false
|
||||
createQuickMatch()
|
||||
}
|
||||
)
|
||||
.presentationDetents([.medium])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(isPresented: $showSchedule) {
|
||||
ScheduleMatchSheet(container: container, teamId: activeTeam?.id) { match in
|
||||
showSchedule = false
|
||||
onOpenSetup(match.id)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showTeamPicker) {
|
||||
TeamPickerSheet(teams: teams, activeTeamId: activeTeam?.id) { team in
|
||||
container.matchRepository.selectTeam(teamId: team.id)
|
||||
activeTeam = team
|
||||
reload(showSpinner: false)
|
||||
showTeamPicker = false
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if actionLoading {
|
||||
Color.black.opacity(0.35)
|
||||
.ignoresSafeArea()
|
||||
ProgressView().tint(MatchColors.primaryRed)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
if let snackbar {
|
||||
Text(snackbar)
|
||||
.padding()
|
||||
.background(MatchColors.surfaceElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.padding()
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.snackbar = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activeMatch: Match? {
|
||||
matches.first { $0.canResumeCamera || ($0.hasActiveSession && $0.activeSessionStatus == "idle") }
|
||||
}
|
||||
|
||||
private var scheduledMatches: [Match] {
|
||||
matches.filter { !$0.hasActiveSession && MatchHubFilter.isScheduledFuture($0) }
|
||||
}
|
||||
|
||||
private var calendarMatches: [Match] {
|
||||
let drafts = matches.filter { MatchHubFilter.isDraft($0) }
|
||||
return (scheduledMatches + drafts).uniqued(by: \.id)
|
||||
}
|
||||
|
||||
private var calendarSectionTitle: String {
|
||||
if calendarMatches.isEmpty && activeMatch == nil {
|
||||
return "Nessuna partita in calendario"
|
||||
}
|
||||
if !scheduledMatches.isEmpty {
|
||||
return "Partite programmate"
|
||||
}
|
||||
return "Pronte da avviare"
|
||||
}
|
||||
|
||||
private var emptyCalendarMessage: String {
|
||||
var message = "Programma una partita o avviane una nuova con «Nuova partita»."
|
||||
if let teamName = activeTeam?.name {
|
||||
message += "\n\nSquadra attiva: \(teamName)."
|
||||
}
|
||||
if teams.count > 1 {
|
||||
message += "\nHai più squadre: verifica quella selezionata sopra."
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
private func reload(showSpinner: Bool) {
|
||||
if showSpinner { loading = true } else { refreshing = true }
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
let loadedTeams = try await container.matchRepository.fetchTeams()
|
||||
teams = loadedTeams
|
||||
activeTeam = container.matchRepository.resolveActiveTeam(teams: loadedTeams)
|
||||
if let team = activeTeam {
|
||||
matches = try await container.matchRepository.fetchMatchesForTeam(teamId: team.id)
|
||||
}
|
||||
} catch {
|
||||
self.error = UserFacingError.message(for: error)
|
||||
}
|
||||
loading = false
|
||||
refreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
private func openMatch(_ match: Match) {
|
||||
if match.hasActiveSession { resumeMatch = match } else { onOpenSetup(match.id) }
|
||||
}
|
||||
|
||||
private func resumeBroadcast(_ match: Match) {
|
||||
guard !actionLoading else { return }
|
||||
actionLoading = true
|
||||
Task {
|
||||
do {
|
||||
let sessionId = try await MatchSessionLauncher.resumeBroadcastSession(match: match, sessionRepository: container.sessionRepository)
|
||||
onOpenBroadcast(sessionId)
|
||||
} catch {
|
||||
snackbar = UserFacingError.message(for: error)
|
||||
}
|
||||
actionLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private func createQuickMatch() {
|
||||
guard let teamId = activeTeam?.id, !actionLoading else { return }
|
||||
actionLoading = true
|
||||
Task {
|
||||
do {
|
||||
let match = try await container.matchRepository.createQuickMatch(teamId: teamId)
|
||||
reload(showSpinner: false)
|
||||
onOpenSetup(match.id)
|
||||
} catch {
|
||||
snackbar = UserFacingError.message(for: error)
|
||||
}
|
||||
actionLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TeamPickerBar: View {
|
||||
let team: Team
|
||||
var showPicker: Bool = true
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack {
|
||||
Text(team.name).font(MatchTypography.titleMedium)
|
||||
Spacer()
|
||||
MatchStatusBadge(text: team.sportKey.uppercased())
|
||||
if showPicker {
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!showPicker)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActiveSessionBanner: View {
|
||||
let match: Match
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "video.fill")
|
||||
.foregroundStyle(MatchColors.primaryRed)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Riprendi diretta in corso")
|
||||
.font(MatchTypography.titleMedium)
|
||||
.foregroundStyle(MatchColors.primaryRed)
|
||||
Text("\(match.teamName) vs \(match.opponentName)")
|
||||
.font(MatchTypography.bodyMedium)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(MatchColors.primaryRed)
|
||||
}
|
||||
.padding(14)
|
||||
.background(MatchColors.primaryRed.opacity(0.15), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MatchListCard: View {
|
||||
let match: Match
|
||||
let onTap: () -> Void
|
||||
let onDelete: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("\(match.teamName) vs \(match.opponentName)")
|
||||
.font(MatchTypography.titleMedium)
|
||||
if let date = ApiInstant.formatMatchDate(match.scheduledAt) {
|
||||
Text(date)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
if let location = match.location?.trimmingCharacters(in: .whitespacesAndNewlines), !location.isEmpty {
|
||||
Text(location)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(MatchPresentation.statusLabel(for: match))
|
||||
.font(MatchTypography.labelLarge)
|
||||
.foregroundStyle(match.hasActiveSession ? MatchColors.primaryRed : MatchColors.textSecondary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
match.hasActiveSession
|
||||
? MatchColors.primaryRed.opacity(0.2)
|
||||
: MatchColors.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: 8)
|
||||
)
|
||||
if let onDelete {
|
||||
Button(action: onDelete) {
|
||||
Image(systemName: "trash")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewMatchSheet: View {
|
||||
let onSchedule: () -> Void
|
||||
let onQuickStart: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Nuova partita")
|
||||
.font(MatchTypography.headlineMedium)
|
||||
Text("Programma in anticipo o avvia la configurazione diretta subito.")
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.padding(.top, 6)
|
||||
VStack(spacing: 10) {
|
||||
SheetOptionTile(
|
||||
systemImage: "calendar.badge.clock",
|
||||
title: "Programma partita",
|
||||
subtitle: "Data, ora e avversario — visibile anche sul sito",
|
||||
action: onSchedule
|
||||
)
|
||||
SheetOptionTile(
|
||||
systemImage: "play.circle",
|
||||
title: "Avvia subito",
|
||||
subtitle: "Crea la partita e passa al wizard senza orario",
|
||||
action: onQuickStart
|
||||
)
|
||||
}
|
||||
.padding(.top, 20)
|
||||
Spacer(minLength: 24)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(MatchColors.surface)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SheetOptionTile: View {
|
||||
let systemImage: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.title2)
|
||||
.foregroundStyle(MatchColors.primaryRed)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title).font(MatchTypography.titleMedium)
|
||||
Text(subtitle)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
.padding(16)
|
||||
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ScheduleMatchSheet: View {
|
||||
@ObservedObject var container: AppContainer
|
||||
let teamId: String?
|
||||
let onCreated: (Match) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var opponent = ""
|
||||
@State private var location = ""
|
||||
@State private var date = Date().addingTimeInterval(86400)
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
TextField("Avversario", text: $opponent)
|
||||
TextField("Luogo", text: $location)
|
||||
DatePicker("Data", selection: $date)
|
||||
}
|
||||
.navigationTitle("Partita programmata")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("Chiudi") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Crea") { create() }.disabled(opponent.isEmpty || teamId == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func create() {
|
||||
guard let teamId else { return }
|
||||
Task {
|
||||
if let match = try? await container.matchRepository.createScheduledMatch(
|
||||
teamId: teamId,
|
||||
opponentName: opponent,
|
||||
scheduledAt: date,
|
||||
location: location.nilIfEmpty
|
||||
) {
|
||||
onCreated(match)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TeamPickerSheet: View {
|
||||
let teams: [Team]
|
||||
let activeTeamId: String?
|
||||
let onSelect: (Team) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(teams) { team in
|
||||
Button {
|
||||
onSelect(team)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Text(team.name)
|
||||
Spacer()
|
||||
if team.id == activeTeamId {
|
||||
Image(systemName: "checkmark").foregroundStyle(MatchColors.primaryRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Squadra")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
func uniqued<ID: Hashable>(by keyPath: KeyPath<Element, ID>) -> [Element] {
|
||||
var seen = Set<ID>()
|
||||
return filter { seen.insert($0[keyPath: keyPath]).inserted }
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
79
native/ios/MatchLiveTv/UI/Navigation/AppNavHost.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppNavHost: View {
|
||||
@StateObject private var container = AppContainer()
|
||||
@State private var path: [Routes] = []
|
||||
@State private var wizardRoute: WizardRoute?
|
||||
@State private var broadcastRoute: BroadcastRoute?
|
||||
/// Incrementato al ritorno da wizard/broadcast per ricaricare l'hub partite.
|
||||
@State private var matchesRefreshToken = 0
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
SplashScreen(
|
||||
container: container,
|
||||
onAuthenticated: { path = [.matches] },
|
||||
onUnauthenticated: { path = [.login] }
|
||||
)
|
||||
.navigationDestination(for: Routes.self) { route in
|
||||
switch route {
|
||||
case .splash:
|
||||
EmptyView()
|
||||
case .login:
|
||||
LoginScreen(container: container) { path = [.matches] }
|
||||
case .matches:
|
||||
MatchesScreen(
|
||||
container: container,
|
||||
refreshToken: matchesRefreshToken,
|
||||
onOpenSetup: { matchId in
|
||||
wizardRoute = WizardRoute(matchId: matchId, step: 1)
|
||||
},
|
||||
onOpenBroadcast: { sessionId in
|
||||
broadcastRoute = BroadcastRoute(sessionId: sessionId)
|
||||
},
|
||||
onLogout: { path = [.login] }
|
||||
)
|
||||
.lockPortraitOrientation()
|
||||
case .setup, .broadcast:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.lockPortraitOrientation()
|
||||
.fullScreenCover(item: $wizardRoute) { route in
|
||||
WizardShellScreen(
|
||||
container: container,
|
||||
matchId: route.matchId,
|
||||
step: route.step,
|
||||
onClose: {
|
||||
container.wizardSession.reset()
|
||||
wizardRoute = nil
|
||||
matchesRefreshToken += 1
|
||||
},
|
||||
onStartLive: { sessionId in
|
||||
wizardRoute = nil
|
||||
broadcastRoute = BroadcastRoute(sessionId: sessionId)
|
||||
}
|
||||
)
|
||||
.lockPortraitOrientation()
|
||||
}
|
||||
.fullScreenCover(item: $broadcastRoute, onDismiss: {
|
||||
AppOrientation.lockPortrait()
|
||||
matchesRefreshToken += 1
|
||||
}) { route in
|
||||
BroadcastScreen(container: container, sessionId: route.sessionId) {
|
||||
AppOrientation.lockPortrait()
|
||||
container.wizardSession.reset()
|
||||
broadcastRoute = nil
|
||||
path = [.matches]
|
||||
}
|
||||
.keepScreenOn()
|
||||
}
|
||||
.onChange(of: broadcastRoute?.sessionId) { sessionId in
|
||||
if sessionId == nil {
|
||||
AppOrientation.lockPortrait()
|
||||
}
|
||||
}
|
||||
.environmentObject(container)
|
||||
}
|
||||
}
|
||||
14
native/ios/MatchLiveTv/UI/Navigation/ModalRoutes.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
struct WizardRoute: Identifiable, Equatable {
|
||||
let matchId: String
|
||||
let step: Int
|
||||
|
||||
var id: String { "\(matchId)-\(step)" }
|
||||
}
|
||||
|
||||
struct BroadcastRoute: Identifiable, Equatable {
|
||||
let sessionId: String
|
||||
|
||||
var id: String { sessionId }
|
||||
}
|
||||
9
native/ios/MatchLiveTv/UI/Navigation/Routes.swift
Normal file
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
enum Routes: Hashable {
|
||||
case splash
|
||||
case login
|
||||
case matches
|
||||
case setup(matchId: String, step: Int)
|
||||
case broadcast(sessionId: String)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class BroadcastPermissions: ObservableObject {
|
||||
@Published var cameraGranted = false
|
||||
@Published var microphoneGranted = false
|
||||
|
||||
var allGranted: Bool { cameraGranted && microphoneGranted }
|
||||
|
||||
func refresh() async {
|
||||
cameraGranted = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||
microphoneGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
}
|
||||
|
||||
func requestAll() async {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try? session.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
|
||||
let video = await AVCaptureDevice.requestAccess(for: .video)
|
||||
cameraGranted = video
|
||||
let audio = await AVCaptureDevice.requestAccess(for: .audio)
|
||||
microphoneGranted = audio
|
||||
}
|
||||
}
|
||||
27
native/ios/MatchLiveTv/UI/Splash/SplashScreen.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SplashScreen: View {
|
||||
@ObservedObject var container: AppContainer
|
||||
let onAuthenticated: () -> Void
|
||||
let onUnauthenticated: () -> Void
|
||||
|
||||
var body: some View {
|
||||
MatchScreenScaffold {
|
||||
ZStack {
|
||||
MatchLiveWordmark(showSlogan: true)
|
||||
ProgressView()
|
||||
.tint(MatchColors.primaryRed)
|
||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
container.bootstrapAuth()
|
||||
if await container.authRepository.validateOrRefresh() != nil {
|
||||
onAuthenticated()
|
||||
} else {
|
||||
onUnauthenticated()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
native/ios/MatchLiveTv/UI/System/KeepScreenOn.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct KeepScreenOnModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content.onAppear { UIApplication.shared.isIdleTimerDisabled = true }
|
||||
.onDisappear { UIApplication.shared.isIdleTimerDisabled = false }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func keepScreenOn() -> some View {
|
||||
modifier(KeepScreenOnModifier())
|
||||
}
|
||||
}
|
||||
114
native/ios/MatchLiveTv/UI/System/ScreenOrientation.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum AppOrientation {
|
||||
enum Mode {
|
||||
case portrait
|
||||
case landscape
|
||||
}
|
||||
|
||||
private(set) static var mode: Mode = .portrait
|
||||
|
||||
static func lockPortrait() {
|
||||
apply(.portrait)
|
||||
}
|
||||
|
||||
static func lockLandscape() {
|
||||
apply(.landscape)
|
||||
}
|
||||
|
||||
/// Torna al portrait (alias di `lockPortrait` per compatibilità).
|
||||
static func unlock() {
|
||||
lockPortrait()
|
||||
}
|
||||
|
||||
static func apply(_ newMode: Mode) {
|
||||
mode = newMode
|
||||
refreshSupportedOrientations()
|
||||
requestOrientationUpdate(for: newMode)
|
||||
}
|
||||
|
||||
static var interfaceOrientationMask: UIInterfaceOrientationMask {
|
||||
mode == .landscape ? .landscape : .portrait
|
||||
}
|
||||
|
||||
private static func activeWindowScene() -> UIWindowScene? {
|
||||
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||||
return scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first
|
||||
}
|
||||
|
||||
private static func refreshSupportedOrientations() {
|
||||
guard let scene = activeWindowScene() else { return }
|
||||
for window in scene.windows {
|
||||
window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
topViewController(from: window.rootViewController)?.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
}
|
||||
}
|
||||
|
||||
private static func requestOrientationUpdate(for mode: Mode) {
|
||||
guard let scene = activeWindowScene() else { return }
|
||||
let mask: UIInterfaceOrientationMask = mode == .landscape ? .landscape : .portrait
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { _ in }
|
||||
|
||||
if mode == .portrait, scene.interfaceOrientation.isLandscape {
|
||||
UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")
|
||||
} else if mode == .landscape, scene.interfaceOrientation.isPortrait {
|
||||
UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation")
|
||||
}
|
||||
}
|
||||
|
||||
private static func topViewController(from root: UIViewController?) -> UIViewController? {
|
||||
if let presented = root?.presentedViewController {
|
||||
return topViewController(from: presented)
|
||||
}
|
||||
if let nav = root as? UINavigationController {
|
||||
return topViewController(from: nav.visibleViewController)
|
||||
}
|
||||
if let tab = root as? UITabBarController {
|
||||
return topViewController(from: tab.selectedViewController)
|
||||
}
|
||||
return root
|
||||
}
|
||||
}
|
||||
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
AppOrientation.lockPortrait()
|
||||
return true
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
supportedInterfaceOrientationsFor window: UIWindow?
|
||||
) -> UIInterfaceOrientationMask {
|
||||
AppOrientation.interfaceOrientationMask
|
||||
}
|
||||
}
|
||||
|
||||
private struct PortraitOrientationModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onAppear { AppOrientation.lockPortrait() }
|
||||
}
|
||||
}
|
||||
|
||||
private struct LandscapeOrientationModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onAppear { AppOrientation.lockLandscape() }
|
||||
.onDisappear { AppOrientation.lockPortrait() }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func lockPortraitOrientation() -> some View {
|
||||
modifier(PortraitOrientationModifier())
|
||||
}
|
||||
|
||||
func lockLandscapeOrientation() -> some View {
|
||||
modifier(LandscapeOrientationModifier())
|
||||
}
|
||||
}
|
||||
30
native/ios/MatchLiveTv/UI/Theme/MatchColors.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import SwiftUI
|
||||
|
||||
enum MatchColors {
|
||||
static let primaryRed = Color(red: 1, green: 0.176, blue: 0.176)
|
||||
static let background = Color(red: 0.039, green: 0.039, blue: 0.039)
|
||||
static let surface = Color(red: 0.118, green: 0.118, blue: 0.118)
|
||||
static let surfaceElevated = Color(red: 0.165, green: 0.165, blue: 0.165)
|
||||
static let accentYellow = Color(red: 0.961, green: 0.773, blue: 0.094)
|
||||
static let successGreen = Color(red: 0.133, green: 0.773, blue: 0.369)
|
||||
static let textSecondary = Color(red: 0.612, green: 0.639, blue: 0.686)
|
||||
static let outline = Color(red: 0.251, green: 0.251, blue: 0.251)
|
||||
}
|
||||
|
||||
struct MatchTypography {
|
||||
static let displaySmall = Font.system(size: 28, weight: .black).leading(.loose)
|
||||
static let headlineMedium = Font.system(size: 24, weight: .bold)
|
||||
static let titleMedium = Font.system(size: 18, weight: .semibold)
|
||||
static let bodyMedium = Font.system(size: 14, weight: .regular)
|
||||
static let labelLarge = Font.system(size: 14, weight: .heavy).leading(.loose)
|
||||
}
|
||||
|
||||
struct MatchLiveTheme<Content: View>: View {
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.preferredColorScheme(.dark)
|
||||
.tint(MatchColors.primaryRed)
|
||||
}
|
||||
}
|
||||
438
native/ios/MatchLiveTv/UI/Wizard/StepMatchScreen.swift
Normal file
@@ -0,0 +1,438 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
private enum WizardDefaults {
|
||||
static let setsToWin = 3
|
||||
static let pointsPerSet = 25
|
||||
static let pointsDecidingSet = 15
|
||||
static let minPointLead = 2
|
||||
static let periods = 4
|
||||
static let periodDurationSecs = 600
|
||||
static let overtimeDurationSecs = 300
|
||||
static let opponentColor = "#1E3A8A"
|
||||
}
|
||||
|
||||
private func overlayLabel(_ key: String) -> String {
|
||||
let name = OverlayKind.fromApi(key).rawValue
|
||||
return name.prefix(1).uppercased() + name.dropFirst()
|
||||
}
|
||||
|
||||
private func matchHasCustomOverlay(_ match: Match, sportDefaultOverlay: String?) -> Bool {
|
||||
guard let defaultOverlay = sportDefaultOverlay, let current = match.overlayKind else { return false }
|
||||
return current != defaultOverlay
|
||||
}
|
||||
|
||||
private func matchHasCustomScoring(_ match: Match, boardType: String) -> Bool {
|
||||
guard let rules = match.scoringRules else { return false }
|
||||
switch boardType {
|
||||
case "basket", "timed":
|
||||
return rules.periods != WizardDefaults.periods
|
||||
|| rules.periodDurationSecs != WizardDefaults.periodDurationSecs
|
||||
|| rules.overtimeDurationSecs != WizardDefaults.overtimeDurationSecs
|
||||
default:
|
||||
if match.setsToWin != WizardDefaults.setsToWin { return true }
|
||||
return rules.pointsPerSet != WizardDefaults.pointsPerSet
|
||||
|| rules.pointsDecidingSet != WizardDefaults.pointsDecidingSet
|
||||
|| rules.minPointLead != WizardDefaults.minPointLead
|
||||
}
|
||||
}
|
||||
|
||||
struct StepMatchScreen: View {
|
||||
@ObservedObject var container: AppContainer
|
||||
let match: Match
|
||||
let onNext: () -> Void
|
||||
let onError: (String) -> Void
|
||||
|
||||
@State private var opponent: String
|
||||
@State private var location: String
|
||||
@State private var campionato: String
|
||||
@State private var homeTeam: Team?
|
||||
@State private var homeLogoUrl: String?
|
||||
@State private var opponentLogoUrl: String?
|
||||
@State private var homePrimaryColor: String
|
||||
@State private var opponentPrimaryColor: String
|
||||
@State private var homeLogoImage: UIImage?
|
||||
@State private var opponentLogoImage: UIImage?
|
||||
@State private var saving = false
|
||||
@State private var sports: [SportOption] = []
|
||||
@State private var customRules = false
|
||||
@State private var setsToWin: Int
|
||||
@State private var pointsPerSet: Int
|
||||
@State private var pointsDecidingSet: Int
|
||||
@State private var pointsPerSetText: String
|
||||
@State private var pointsDecidingSetText: String
|
||||
@State private var periods: Int
|
||||
@State private var periodDurationMins: Int
|
||||
@State private var overtimeDurationMins: Int
|
||||
@State private var periodDurationText: String
|
||||
@State private var overtimeDurationText: String
|
||||
@State private var customOverlay = false
|
||||
@State private var selectedOverlay: String
|
||||
|
||||
private var isScheduledMatch: Bool { match.scheduledAt != nil }
|
||||
|
||||
init(container: AppContainer, match: Match, onNext: @escaping () -> Void, onError: @escaping (String) -> Void) {
|
||||
self.container = container
|
||||
self.match = match
|
||||
self.onNext = onNext
|
||||
self.onError = onError
|
||||
let rules = match.scoringRules
|
||||
_opponent = State(initialValue: match.opponentName)
|
||||
_location = State(initialValue: match.location ?? "")
|
||||
_campionato = State(initialValue: match.category ?? "")
|
||||
_homeLogoUrl = State(initialValue: match.homeLogoUrl)
|
||||
_opponentLogoUrl = State(initialValue: match.opponentLogoUrl)
|
||||
_homePrimaryColor = State(initialValue: ColorHex.normalizeHexColor(match.homePrimaryColor))
|
||||
_opponentPrimaryColor = State(initialValue: ColorHex.normalizeHexColor(match.opponentPrimaryColor, fallback: WizardDefaults.opponentColor))
|
||||
_setsToWin = State(initialValue: min(max(match.setsToWin, 2), 3))
|
||||
_pointsPerSet = State(initialValue: rules?.pointsPerSet ?? WizardDefaults.pointsPerSet)
|
||||
_pointsDecidingSet = State(initialValue: rules?.pointsDecidingSet ?? WizardDefaults.pointsDecidingSet)
|
||||
_pointsPerSetText = State(initialValue: "\(rules?.pointsPerSet ?? WizardDefaults.pointsPerSet)")
|
||||
_pointsDecidingSetText = State(initialValue: "\(rules?.pointsDecidingSet ?? WizardDefaults.pointsDecidingSet)")
|
||||
_periods = State(initialValue: rules?.periods ?? WizardDefaults.periods)
|
||||
_periodDurationMins = State(initialValue: (rules?.periodDurationSecs ?? WizardDefaults.periodDurationSecs) / 60)
|
||||
_overtimeDurationMins = State(initialValue: (rules?.overtimeDurationSecs ?? WizardDefaults.overtimeDurationSecs) / 60)
|
||||
_periodDurationText = State(initialValue: "\((rules?.periodDurationSecs ?? WizardDefaults.periodDurationSecs) / 60)")
|
||||
_overtimeDurationText = State(initialValue: "\((rules?.overtimeDurationSecs ?? WizardDefaults.overtimeDurationSecs) / 60)")
|
||||
_selectedOverlay = State(initialValue: match.effectiveOverlayKind)
|
||||
}
|
||||
|
||||
private var teamSportKey: String? {
|
||||
homeTeam?.sportKey.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||
}
|
||||
|
||||
private var effectiveSportKey: String {
|
||||
teamSportKey ?? match.sportKey
|
||||
}
|
||||
|
||||
private var currentSport: SportOption? {
|
||||
sports.first { $0.key == effectiveSportKey }
|
||||
}
|
||||
|
||||
private var allowedOverlays: [String] {
|
||||
currentSport?.allowedOverlays ?? []
|
||||
}
|
||||
|
||||
private var defaultOverlay: String {
|
||||
currentSport?.overlay ?? match.effectiveOverlayKind
|
||||
}
|
||||
|
||||
private var boardType: String {
|
||||
currentSport?.board ?? homeTeam?.boardType ?? match.boardType
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Dettagli partita")
|
||||
.font(MatchTypography.headlineMedium)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
TeamBrandingRow(
|
||||
sectionLabel: "Squadra di casa",
|
||||
teamName: .constant(match.teamName),
|
||||
nameEditable: false,
|
||||
remoteLogoUrl: homeLogoUrl,
|
||||
localLogoImage: $homeLogoImage,
|
||||
primaryColorHex: $homePrimaryColor,
|
||||
placeholderColorSeed: "home-\(match.teamId)"
|
||||
)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
TeamBrandingRow(
|
||||
sectionLabel: "Squadra avversaria",
|
||||
teamName: $opponent,
|
||||
nameEditable: true,
|
||||
remoteLogoUrl: opponentLogoUrl,
|
||||
localLogoImage: $opponentLogoImage,
|
||||
primaryColorHex: $opponentPrimaryColor,
|
||||
placeholderColorSeed: "away-\(match.id)"
|
||||
)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
WizardOutlinedField(label: "Luogo", text: $location)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
WizardOutlinedField(label: "Campionato (facoltativo)", text: $campionato)
|
||||
Text("Es. Serie C, torneo estivo — lo useremo in descrizione e overlay.")
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.padding(.top, 4)
|
||||
|
||||
if isScheduledMatch {
|
||||
WizardReadOnlyField(
|
||||
label: "Programmata per",
|
||||
value: ApiInstant.formatMatchDate(match.scheduledAt) ?? "—"
|
||||
)
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
if !allowedOverlays.isEmpty {
|
||||
Toggle("Overlay video personalizzato", isOn: $customOverlay)
|
||||
.padding(.top, 20)
|
||||
if customOverlay {
|
||||
VStack(spacing: 6) {
|
||||
ForEach(allowedOverlays, id: \.self) { overlayKey in
|
||||
WizardChoiceButton(
|
||||
label: overlayLabel(overlayKey),
|
||||
selected: selectedOverlay == overlayKey,
|
||||
action: { selectedOverlay = overlayKey }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
|
||||
Toggle(isOn: $customRules) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Regole punteggio personalizzate")
|
||||
Text(customRulesDescription)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
}
|
||||
.padding(.top, 20)
|
||||
|
||||
if customRules && ["basket", "timed"].contains(boardType) {
|
||||
periodRulesSection
|
||||
}
|
||||
|
||||
if customRules && ["volley", "racket"].contains(boardType) {
|
||||
volleyRulesSection
|
||||
}
|
||||
|
||||
MatchPrimaryButton(label: "AVANTI >", action: saveAndContinue, enabled: !saving, loading: saving)
|
||||
.padding(.top, 32)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.task(id: match.id) {
|
||||
if let cached = container.wizardSession.team {
|
||||
applyHomeTeam(cached)
|
||||
} else if let team = await container.matchRepository.fetchTeam(teamId: match.teamId) {
|
||||
container.wizardSession.team = team
|
||||
applyHomeTeam(team)
|
||||
}
|
||||
if let loaded = try? await container.matchRepository.fetchSports() {
|
||||
sports = loaded
|
||||
}
|
||||
customRules = matchHasCustomScoring(match, boardType: boardType)
|
||||
customOverlay = matchHasCustomOverlay(match, sportDefaultOverlay: defaultOverlay)
|
||||
if customOverlay {
|
||||
selectedOverlay = match.overlayKind ?? match.effectiveOverlayKind
|
||||
} else {
|
||||
selectedOverlay = defaultOverlay
|
||||
}
|
||||
}
|
||||
.onChange(of: customOverlay) { enabled in
|
||||
if !enabled {
|
||||
selectedOverlay = defaultOverlay
|
||||
} else if !allowedOverlays.contains(selectedOverlay) {
|
||||
selectedOverlay = defaultOverlay
|
||||
}
|
||||
}
|
||||
.onChange(of: defaultOverlay) { overlay in
|
||||
if !customOverlay {
|
||||
selectedOverlay = overlay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var customRulesDescription: String {
|
||||
if !customRules && ["basket", "timed"].contains(boardType) {
|
||||
return "Regole standard dello sport selezionato."
|
||||
}
|
||||
if customRules && ["basket", "timed"].contains(boardType) {
|
||||
return "Torneo non standard: tempi e periodi personalizzati."
|
||||
}
|
||||
if customRules {
|
||||
return "Torneo non standard: imposta set e punteggi."
|
||||
}
|
||||
return "Standard FIPAV: 3 set per vincere, set a 25, tie-break a 15."
|
||||
}
|
||||
|
||||
private var periodRulesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(boardType == "basket" ? "Quarti" : "Tempi")
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.padding(.top, 16)
|
||||
HStack(spacing: 8) {
|
||||
ForEach([2, 4], id: \.self) { value in
|
||||
WizardChoiceButton(
|
||||
label: "\(value)",
|
||||
selected: periods == value,
|
||||
action: { periods = value }
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
WizardOutlinedField(
|
||||
label: boardType == "basket" ? "Minuti per quarto" : "Minuti per tempo",
|
||||
text: $periodDurationText
|
||||
)
|
||||
.onChange(of: periodDurationText) { text in
|
||||
let filtered = String(text.filter(\.isNumber).prefix(3))
|
||||
if filtered != text { periodDurationText = filtered }
|
||||
if let value = Int(filtered) { periodDurationMins = min(max(value, 1), 120) }
|
||||
}
|
||||
WizardOutlinedField(label: "Minuti supplementari", text: $overtimeDurationText)
|
||||
.onChange(of: overtimeDurationText) { text in
|
||||
let filtered = String(text.filter(\.isNumber).prefix(3))
|
||||
if filtered != text { overtimeDurationText = filtered }
|
||||
if let value = Int(filtered) { overtimeDurationMins = min(max(value, 1), 60) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var volleyRulesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Set da vincere la partita")
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.padding(.top, 16)
|
||||
HStack(spacing: 8) {
|
||||
ForEach([2, 3], id: \.self) { value in
|
||||
WizardChoiceButton(
|
||||
label: "\(value)",
|
||||
selected: setsToWin == value,
|
||||
action: { setsToWin = value }
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
WizardOutlinedField(label: "Punti per vincere un set", text: $pointsPerSetText)
|
||||
.onChange(of: pointsPerSetText) { text in
|
||||
let filtered = String(text.filter(\.isNumber).prefix(2))
|
||||
if filtered != text { pointsPerSetText = filtered }
|
||||
if let value = Int(filtered) { pointsPerSet = min(max(value, 1), 99) }
|
||||
}
|
||||
WizardOutlinedField(label: "Punti tie-break (ultimo set)", text: $pointsDecidingSetText)
|
||||
.onChange(of: pointsDecidingSetText) { text in
|
||||
let filtered = String(text.filter(\.isNumber).prefix(2))
|
||||
if filtered != text { pointsDecidingSetText = filtered }
|
||||
if let value = Int(filtered) { pointsDecidingSet = min(max(value, 1), 99) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAndContinue() {
|
||||
let trimmedOpponent = opponent.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedOpponent.isEmpty else {
|
||||
onError("Inserisci il nome avversario")
|
||||
return
|
||||
}
|
||||
|
||||
if customRules && ["volley", "racket"].contains(boardType) {
|
||||
guard let perSet = Int(pointsPerSetText), perSet >= 1 else {
|
||||
onError("Inserisci i punti per vincere un set")
|
||||
return
|
||||
}
|
||||
guard let deciding = Int(pointsDecidingSetText), deciding >= 1 else {
|
||||
onError("Inserisci i punti del tie-break")
|
||||
return
|
||||
}
|
||||
pointsPerSet = perSet
|
||||
pointsDecidingSet = deciding
|
||||
}
|
||||
|
||||
if customRules && ["basket", "timed"].contains(boardType) {
|
||||
guard let periodMins = Int(periodDurationText), periodMins >= 1 else {
|
||||
onError("Inserisci la durata del periodo in minuti")
|
||||
return
|
||||
}
|
||||
guard let overtimeMins = Int(overtimeDurationText), overtimeMins >= 1 else {
|
||||
onError("Inserisci la durata dei supplementari in minuti")
|
||||
return
|
||||
}
|
||||
periodDurationMins = periodMins
|
||||
overtimeDurationMins = overtimeMins
|
||||
}
|
||||
|
||||
let resolvedSets: Int
|
||||
if customRules && ["volley", "racket"].contains(boardType) {
|
||||
resolvedSets = setsToWin
|
||||
} else {
|
||||
resolvedSets = WizardDefaults.setsToWin
|
||||
}
|
||||
|
||||
let resolvedRules: ScoringRules?
|
||||
if customRules && ["volley", "racket"].contains(boardType) {
|
||||
resolvedRules = ScoringRules(
|
||||
pointsPerSet: pointsPerSet,
|
||||
pointsDecidingSet: pointsDecidingSet,
|
||||
minPointLead: match.scoringRules?.minPointLead ?? WizardDefaults.minPointLead,
|
||||
periods: WizardDefaults.periods,
|
||||
periodDurationSecs: WizardDefaults.periodDurationSecs,
|
||||
overtimeDurationSecs: WizardDefaults.overtimeDurationSecs
|
||||
)
|
||||
} else if customRules && ["basket", "timed"].contains(boardType) {
|
||||
resolvedRules = ScoringRules(
|
||||
pointsPerSet: WizardDefaults.pointsPerSet,
|
||||
pointsDecidingSet: WizardDefaults.pointsDecidingSet,
|
||||
minPointLead: WizardDefaults.minPointLead,
|
||||
periods: periods,
|
||||
periodDurationSecs: periodDurationMins * 60,
|
||||
overtimeDurationSecs: overtimeDurationMins * 60
|
||||
)
|
||||
} else {
|
||||
resolvedRules = nil
|
||||
}
|
||||
|
||||
let initialHomeColor = ColorHex.normalizeHexColor(homeTeam?.primaryColor ?? match.homePrimaryColor)
|
||||
let homeBrandingChanged =
|
||||
ColorHex.normalizeHexColor(homePrimaryColor) != initialHomeColor || homeLogoImage != nil
|
||||
|
||||
let overlayKind: String? = {
|
||||
if customOverlay && selectedOverlay != defaultOverlay { return selectedOverlay }
|
||||
return ""
|
||||
}()
|
||||
|
||||
guard !saving else { return }
|
||||
saving = true
|
||||
Task { @MainActor in
|
||||
defer { saving = false }
|
||||
do {
|
||||
if homeBrandingChanged, let team = homeTeam {
|
||||
_ = try await container.matchRepository.updateTeamBranding(
|
||||
teamId: team.id,
|
||||
primaryColor: ColorHex.normalizeHexColor(homePrimaryColor),
|
||||
secondaryColor: team.secondaryColor,
|
||||
logoImage: homeLogoImage
|
||||
)
|
||||
}
|
||||
let updated = try await container.matchRepository.updateMatch(
|
||||
matchId: match.id,
|
||||
opponentName: trimmedOpponent,
|
||||
location: location.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
|
||||
scheduledAt: match.scheduledAt,
|
||||
setsToWin: resolvedSets,
|
||||
category: campionato.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
|
||||
opponentPrimaryColor: opponentPrimaryColor,
|
||||
scoringRules: resolvedRules,
|
||||
sportKey: effectiveSportKey,
|
||||
overlayKind: overlayKind,
|
||||
opponentLogoImage: opponentLogoImage
|
||||
)
|
||||
container.wizardSession.match = updated
|
||||
onNext()
|
||||
} catch {
|
||||
if let message = UserFacingError.message(for: error) {
|
||||
onError(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyHomeTeam(_ team: Team) {
|
||||
homeTeam = team
|
||||
homePrimaryColor = ColorHex.normalizeHexColor(team.primaryColor ?? match.homePrimaryColor)
|
||||
homeLogoUrl = team.logoUrl ?? match.homeLogoUrl
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
249
native/ios/MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift
Normal file
@@ -0,0 +1,249 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct StepNetworkTestScreen: View {
|
||||
@ObservedObject var container: AppContainer
|
||||
let match: Match
|
||||
let session: StreamSession
|
||||
let onBack: () -> Void
|
||||
let onStartLive: (String) -> Void
|
||||
let onError: (String) -> Void
|
||||
|
||||
@State private var testing = false
|
||||
@State private var testCompleted = false
|
||||
@State private var ready = false
|
||||
@State private var downloadMbps = 0.0
|
||||
@State private var uploadMbps = 0.0
|
||||
@State private var latencyMs = 0
|
||||
@State private var networkType = "—"
|
||||
@State private var selectedQualityLabel: String?
|
||||
@State private var starting = false
|
||||
@State private var currentSession: StreamSession
|
||||
|
||||
init(
|
||||
container: AppContainer,
|
||||
match: Match,
|
||||
session: StreamSession,
|
||||
onBack: @escaping () -> Void,
|
||||
onStartLive: @escaping (String) -> Void,
|
||||
onError: @escaping (String) -> Void
|
||||
) {
|
||||
self.container = container
|
||||
self.match = match
|
||||
self.session = session
|
||||
self.onBack = onBack
|
||||
self.onStartLive = onStartLive
|
||||
self.onError = onError
|
||||
_currentSession = State(initialValue: session)
|
||||
}
|
||||
|
||||
private var shareUrl: String? {
|
||||
currentSession.watchShareUrl()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Test rete")
|
||||
.font(MatchTypography.headlineMedium)
|
||||
Text("Verifica che la connessione regga l'upload della diretta.")
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.padding(.top, 8)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
MetricCard(
|
||||
label: "Download",
|
||||
value: testing ? "..." : String(format: "%.1f Mbps", downloadMbps)
|
||||
)
|
||||
MetricCard(
|
||||
label: "Upload",
|
||||
value: testing ? "..." : String(format: "%.1f Mbps", uploadMbps),
|
||||
highlight: ready
|
||||
)
|
||||
MetricCard(
|
||||
label: "Latenza",
|
||||
value: testing ? "..." : "\(latencyMs) ms"
|
||||
)
|
||||
}
|
||||
.padding(.top, 24)
|
||||
|
||||
MetricCard(label: "Tipo rete", value: networkType)
|
||||
.padding(.top, 12)
|
||||
|
||||
if testCompleted && ready {
|
||||
Text("PRONTO PER ANDARE IN DIRETTA")
|
||||
.font(MatchTypography.titleMedium)
|
||||
.foregroundStyle(MatchColors.successGreen)
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 20)
|
||||
|
||||
if let quality = selectedQualityLabel {
|
||||
WizardReadOnlyField(
|
||||
label: "Qualità streaming (automatica)",
|
||||
value: quality
|
||||
)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
|
||||
if testCompleted, let shareUrl {
|
||||
WizardReadOnlyField(
|
||||
label: currentSession.platform == "youtube" ? "Link YouTube" : "Link diretta",
|
||||
value: shareUrl
|
||||
)
|
||||
.padding(.top, 16)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
MatchSecondaryButton(label: "COPIA", action: { copyToClipboard(shareUrl) })
|
||||
if let url = URL(string: shareUrl) {
|
||||
ShareLink(
|
||||
item: url,
|
||||
subject: Text("Diretta — \(match.teamName) vs \(match.opponentName)"),
|
||||
message: Text(shareUrl)
|
||||
) {
|
||||
Text("CONDIVIDI")
|
||||
.font(MatchTypography.labelLarge)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
MatchSecondaryButton(label: "CONDIVIDI LINK REGIA", action: shareRegiaLink)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
if !testCompleted {
|
||||
MatchSecondaryButton(
|
||||
label: testing ? "TEST IN CORSO..." : "AVVIA TEST RETE",
|
||||
action: runTest,
|
||||
enabled: !testing
|
||||
)
|
||||
.padding(.top, 24)
|
||||
}
|
||||
|
||||
GeometryReader { proxy in
|
||||
let spacing: CGFloat = 12
|
||||
let backWidth = (proxy.size.width - spacing) / 3
|
||||
let forwardWidth = (proxy.size.width - spacing) * 2 / 3
|
||||
HStack(spacing: spacing) {
|
||||
MatchSecondaryButton(label: "Indietro", action: onBack, enabled: !starting)
|
||||
.frame(width: backWidth)
|
||||
MatchPrimaryButton(
|
||||
label: "INIZIA >",
|
||||
action: startLive,
|
||||
enabled: ready,
|
||||
loading: starting
|
||||
)
|
||||
.frame(width: forwardWidth)
|
||||
}
|
||||
}
|
||||
.frame(height: 52)
|
||||
.padding(.top, 32)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.task(id: session.id) {
|
||||
await pollYoutubeIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func runTest() {
|
||||
testing = true
|
||||
ready = false
|
||||
Task { @MainActor in
|
||||
networkType = DeviceTelemetry.snapshot().networkType
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
downloadMbps = Double.random(in: 8...20)
|
||||
uploadMbps = Double.random(in: 2...6)
|
||||
latencyMs = Int.random(in: 20...99)
|
||||
|
||||
let result = try? await container.sessionRepository.submitNetworkTest(
|
||||
sessionId: currentSession.id,
|
||||
downloadMbps: downloadMbps,
|
||||
uploadMbps: uploadMbps,
|
||||
latencyMs: latencyMs,
|
||||
networkType: networkType
|
||||
)
|
||||
if let result {
|
||||
selectedQualityLabel = formatQualityLabel(result)
|
||||
if let updated = try? await container.sessionRepository.fetchSession(id: currentSession.id) {
|
||||
currentSession = updated
|
||||
container.wizardSession.session = updated
|
||||
}
|
||||
}
|
||||
ready = result?.ready ?? (uploadMbps >= 2.0)
|
||||
testCompleted = true
|
||||
testing = false
|
||||
}
|
||||
}
|
||||
|
||||
private func startLive() {
|
||||
guard ready, !starting else { return }
|
||||
starting = true
|
||||
Task { @MainActor in
|
||||
defer { starting = false }
|
||||
do {
|
||||
let started = try await container.sessionRepository.startSession(id: currentSession.id)
|
||||
currentSession = started
|
||||
container.wizardSession.session = started
|
||||
onStartLive(started.id)
|
||||
} catch {
|
||||
if let message = UserFacingError.message(for: error) {
|
||||
onError(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pollYoutubeIfNeeded() async {
|
||||
guard currentSession.platform == "youtube", !currentSession.youtubeReady else { return }
|
||||
for _ in 0..<20 {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
guard let updated = try? await container.sessionRepository.fetchSession(id: currentSession.id) else { continue }
|
||||
currentSession = updated
|
||||
container.wizardSession.session = updated
|
||||
if updated.youtubeReady || !(updated.youtubeWatchUrl?.isEmpty ?? true) { return }
|
||||
}
|
||||
}
|
||||
|
||||
private func copyToClipboard(_ text: String) {
|
||||
UIPasteboard.general.string = text
|
||||
}
|
||||
|
||||
private func shareRegiaLink() {
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let url = try await container.sessionRepository.createRegiaLink(sessionId: currentSession.id)
|
||||
guard let link = URL(string: url) else { return }
|
||||
presentShare(items: [link])
|
||||
} catch {
|
||||
if let message = UserFacingError.message(for: error) {
|
||||
onError(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presentShare(items: [Any]) {
|
||||
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let root = scene.windows.first?.rootViewController else { return }
|
||||
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
root.present(controller, animated: true)
|
||||
}
|
||||
|
||||
private func formatQualityLabel(_ result: NetworkTestResponse) -> String {
|
||||
let bitrateMbps = Double(result.targetBitrate ?? 2_500_000) / 1_000_000.0
|
||||
let fps = result.targetFps ?? 30
|
||||
let resolution = result.qualityPreset?.hasPrefix("1080p") == true ? "1080p" : "720p"
|
||||
return "\(resolution) · \(fps)fps · \(String(format: "%.1f", bitrateMbps)) Mbps"
|
||||
}
|
||||
}
|
||||
147
native/ios/MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift
Normal file
@@ -0,0 +1,147 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StepTransmissionScreen: View {
|
||||
@ObservedObject var container: AppContainer
|
||||
let match: Match
|
||||
let onBack: () -> Void
|
||||
let onNext: () -> Void
|
||||
let onError: (String) -> Void
|
||||
|
||||
@State private var team: Team?
|
||||
@State private var platform = "matchlivetv"
|
||||
@State private var privacy = "public"
|
||||
@State private var creating = false
|
||||
|
||||
private var youtubeReady: Bool {
|
||||
team?.isYoutubeReady == true
|
||||
}
|
||||
|
||||
private var youtubeSubtitle: String {
|
||||
guard let team else { return "Canale in attivazione" }
|
||||
if !team.canUseYoutube { return "Premium Light o Full" }
|
||||
if !team.isYoutubeReady { return "Canale in attivazione" }
|
||||
return team.youtubeDestinationLabel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if let plan = team?.planName {
|
||||
Text("Piano \(plan)")
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
|
||||
Text("Piattaforma")
|
||||
.font(MatchTypography.headlineMedium)
|
||||
|
||||
WizardPlatformCard(
|
||||
title: "Match Live TV",
|
||||
subtitle: "Diretta sul nostro sito (incluso)",
|
||||
selected: platform == "matchlivetv",
|
||||
onClick: { platform = "matchlivetv" }
|
||||
)
|
||||
.padding(.top, 12)
|
||||
|
||||
WizardPlatformCard(
|
||||
title: "YouTube Live",
|
||||
subtitle: youtubeSubtitle,
|
||||
selected: platform == "youtube",
|
||||
enabled: youtubeReady,
|
||||
badge: team?.canUseYoutube == false ? "Premium" : nil,
|
||||
onClick: selectYoutube
|
||||
)
|
||||
.padding(.top, 8)
|
||||
|
||||
Text("Visibilità")
|
||||
.font(MatchTypography.headlineMedium)
|
||||
.padding(.top, 24)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
WizardChoiceButton(
|
||||
label: "PUBBLICO",
|
||||
selected: privacy == "public",
|
||||
action: { privacy = "public" }
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
WizardChoiceButton(
|
||||
label: "NON IN ELENCO",
|
||||
selected: privacy == "unlisted",
|
||||
action: { privacy = "unlisted" }
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
Text(visibilityDescription)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 8)
|
||||
|
||||
Text("La partita resta sempre visibile nel backend della squadra per tutta la durata dell'abbonamento.")
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 6)
|
||||
|
||||
WizardFooterButtons(
|
||||
onBack: onBack,
|
||||
forwardLabel: "AVANTI >",
|
||||
forwardLoading: creating,
|
||||
onForward: createSessionAndContinue
|
||||
)
|
||||
.padding(.top, 32)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.task {
|
||||
if let cached = container.wizardSession.team {
|
||||
team = cached
|
||||
} else {
|
||||
team = await container.matchRepository.fetchTeam(teamId: match.teamId)
|
||||
container.wizardSession.team = team
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var visibilityDescription: String {
|
||||
if privacy == "public" {
|
||||
return "Compare nell'elenco dirette su MatchLiveTV.it, nelle ricerche e sul canale YouTube (se selezionato)."
|
||||
}
|
||||
return "Non compare negli elenchi pubblici né nelle ricerche. Solo chi ha il link può guardare."
|
||||
}
|
||||
|
||||
private func selectYoutube() {
|
||||
if youtubeReady {
|
||||
platform = "youtube"
|
||||
} else {
|
||||
onError("YouTube non disponibile per questa squadra")
|
||||
}
|
||||
}
|
||||
|
||||
private func createSessionAndContinue() {
|
||||
creating = true
|
||||
Task {
|
||||
do {
|
||||
let session = try await container.sessionRepository.createSession(
|
||||
matchId: match.id,
|
||||
platform: platform,
|
||||
privacyStatus: privacy,
|
||||
youtubeChannel: platform == "youtube" ? team?.effectiveYoutubeChannel : nil
|
||||
)
|
||||
container.wizardSession.session = session
|
||||
onNext()
|
||||
} catch {
|
||||
if let message = UserFacingError.message(for: error) {
|
||||
onError(message)
|
||||
}
|
||||
}
|
||||
creating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
224
native/ios/MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift
Normal file
@@ -0,0 +1,224 @@
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
struct TeamBrandingRow: View {
|
||||
let sectionLabel: String
|
||||
@Binding var teamName: String
|
||||
let nameEditable: Bool
|
||||
let remoteLogoUrl: String?
|
||||
@Binding var localLogoImage: UIImage?
|
||||
@Binding var primaryColorHex: String
|
||||
let placeholderColorSeed: String
|
||||
|
||||
@State private var showCustomize = false
|
||||
|
||||
private var hasLogo: Bool {
|
||||
localLogoImage != nil || !(remoteLogoUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
|
||||
}
|
||||
|
||||
private var isConfigured: Bool {
|
||||
hasLogo && !primaryColorHex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private var displayColorHex: String {
|
||||
let trimmed = primaryColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? ColorHex.placeholderTeamColor(seed: placeholderColorSeed) : trimmed
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(sectionLabel)
|
||||
.font(MatchTypography.labelLarge)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
HStack(spacing: 10) {
|
||||
if isConfigured {
|
||||
TeamLogoImage(remoteLogoUrl: remoteLogoUrl, localLogoImage: localLogoImage, size: 44)
|
||||
ColorAccentBar(color: ColorHex.swiftUIColor(displayColorHex), height: 36)
|
||||
Text(teamName.isEmpty ? "Squadra" : teamName)
|
||||
.font(MatchTypography.titleMedium)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
ColorAccentBar(color: ColorHex.swiftUIColor(displayColorHex), height: 32)
|
||||
if nameEditable {
|
||||
TextField("Nome avversario", text: $teamName)
|
||||
.font(MatchTypography.titleMedium)
|
||||
.padding(8)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
|
||||
} else {
|
||||
Text(teamName.isEmpty ? "Squadra" : teamName)
|
||||
.font(MatchTypography.titleMedium)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Button { showCustomize = true } label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.sheet(isPresented: $showCustomize) {
|
||||
TeamBrandingCustomizeSheet(
|
||||
title: sectionLabel,
|
||||
teamName: $teamName,
|
||||
nameEditable: nameEditable,
|
||||
remoteLogoUrl: remoteLogoUrl,
|
||||
localLogoImage: $localLogoImage,
|
||||
primaryColorHex: $primaryColorHex,
|
||||
fallbackColorHex: ColorHex.placeholderTeamColor(seed: placeholderColorSeed),
|
||||
onDismiss: { showCustomize = false }
|
||||
)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TeamBrandingCustomizeSheet: View {
|
||||
let title: String
|
||||
@Binding var teamName: String
|
||||
let nameEditable: Bool
|
||||
let remoteLogoUrl: String?
|
||||
@Binding var localLogoImage: UIImage?
|
||||
@Binding var primaryColorHex: String
|
||||
let fallbackColorHex: String
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@State private var draftName: String = ""
|
||||
@State private var draftColor: String = ""
|
||||
@State private var logoPickerItem: PhotosPickerItem?
|
||||
|
||||
private var hasLogo: Bool {
|
||||
localLogoImage != nil || !(remoteLogoUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Personalizza").font(MatchTypography.headlineMedium)
|
||||
Text(title)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.padding(.top, 4)
|
||||
|
||||
if nameEditable {
|
||||
Text("Nome squadra").font(MatchTypography.labelLarge).padding(.top, 20)
|
||||
TextField("Nome avversario", text: $draftName)
|
||||
.padding(12)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
|
||||
.padding(.top, 8)
|
||||
} else {
|
||||
Text("Nome squadra").font(MatchTypography.labelLarge).padding(.top, 20)
|
||||
Text(teamName).font(MatchTypography.titleMedium).padding(.top, 4)
|
||||
}
|
||||
|
||||
Text("Logo").font(MatchTypography.labelLarge).padding(.top, 20)
|
||||
HStack(spacing: 14) {
|
||||
TeamLogoImage(remoteLogoUrl: remoteLogoUrl, localLogoImage: localLogoImage, size: 72)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
PhotosPicker(selection: $logoPickerItem, matching: .images) {
|
||||
Text("CARICA LOGO")
|
||||
.font(MatchTypography.labelLarge)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if hasLogo {
|
||||
MatchSecondaryButton(label: "RIMUOVI", action: {
|
||||
localLogoImage = nil
|
||||
logoPickerItem = nil
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 10)
|
||||
|
||||
Text("Colore squadra").font(MatchTypography.labelLarge).padding(.top, 20)
|
||||
TeamColorPickerPanel(initialColorHex: draftColor.isEmpty ? fallbackColorHex : draftColor) { draftColor = $0 }
|
||||
.padding(.top, 12)
|
||||
|
||||
MatchPrimaryButton(label: "SALVA", action: save)
|
||||
.padding(.top, 24)
|
||||
MatchSecondaryButton(label: "ANNULLA", action: onDismiss)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.background(MatchColors.surfaceElevated)
|
||||
.onAppear {
|
||||
draftName = teamName
|
||||
draftColor = primaryColorHex.isEmpty ? fallbackColorHex : primaryColorHex
|
||||
}
|
||||
.onChange(of: logoPickerItem) { item in
|
||||
guard let item else { return }
|
||||
Task {
|
||||
if let data = try? await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) {
|
||||
localLogoImage = image
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
if nameEditable { teamName = draftName.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
primaryColorHex = ColorHex.normalizeHexColor(draftColor, fallback: fallbackColorHex)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private struct TeamLogoImage: View {
|
||||
let remoteLogoUrl: String?
|
||||
let localLogoImage: UIImage?
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let localLogoImage {
|
||||
Image(uiImage: localLogoImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else if let urlString = MediaUrl.resolve(remoteLogoUrl), let url = URL(string: urlString) {
|
||||
AsyncImage(url: url) { phase in
|
||||
if let image = phase.image {
|
||||
image.resizable().scaledToFill()
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(RoundedRectangle(cornerRadius: size > 50 ? 12 : 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: size > 50 ? 12 : 8).stroke(MatchColors.outline, lineWidth: 1))
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
ZStack {
|
||||
MatchColors.surface
|
||||
Text("Nessun logo")
|
||||
.font(.caption)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ColorAccentBar: View {
|
||||
let color: Color
|
||||
let height: CGFloat
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(color)
|
||||
.frame(width: 5, height: height)
|
||||
}
|
||||
}
|
||||
91
native/ios/MatchLiveTv/UI/Wizard/TeamColorPicker.swift
Normal file
@@ -0,0 +1,91 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TeamColorPickerPanel: View {
|
||||
let initialColorHex: String
|
||||
let onColorChange: (String) -> Void
|
||||
|
||||
@State private var hue: Double = 0
|
||||
@State private var saturation: Double = 1
|
||||
@State private var brightness: Double = 1
|
||||
|
||||
var body: some View {
|
||||
let selected = ColorHex.swiftUIColor(
|
||||
ColorHex.hsvToHex(hue: hue, saturation: saturation, brightness: brightness)
|
||||
)
|
||||
VStack(spacing: 14) {
|
||||
Circle()
|
||||
.fill(selected)
|
||||
.frame(width: 56, height: 56)
|
||||
.overlay(Circle().stroke(MatchColors.outline, lineWidth: 2))
|
||||
saturationBrightnessPicker
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Tonalità")
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
hueGradient
|
||||
Slider(value: $hue, in: 0...360) { _ in emit() }
|
||||
.tint(.white)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
let hsv = ColorHex.hsvComponents(from: initialColorHex)
|
||||
hue = hsv.hue
|
||||
saturation = hsv.saturation
|
||||
brightness = hsv.brightness
|
||||
}
|
||||
.onChange(of: hue) { _ in emit() }
|
||||
.onChange(of: saturation) { _ in emit() }
|
||||
.onChange(of: brightness) { _ in emit() }
|
||||
}
|
||||
|
||||
private var hueGradient: some View {
|
||||
LinearGradient(
|
||||
colors: [.red, .yellow, .green, .cyan, .blue, .purple, .red],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.frame(height: 16)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var saturationBrightnessPicker: some View {
|
||||
let baseHue = Color(
|
||||
uiColor: UIColor(
|
||||
hue: CGFloat(hue / 360),
|
||||
saturation: 1,
|
||||
brightness: 1,
|
||||
alpha: 1
|
||||
)
|
||||
)
|
||||
return GeometryReader { geo in
|
||||
ZStack {
|
||||
Rectangle().fill(.white)
|
||||
Rectangle().fill(LinearGradient(colors: [.white, baseHue], startPoint: .leading, endPoint: .trailing))
|
||||
Rectangle().fill(LinearGradient(colors: [.clear, .black], startPoint: .top, endPoint: .bottom))
|
||||
Circle()
|
||||
.strokeBorder(.white, lineWidth: 3)
|
||||
.background(Circle().stroke(Color.black.opacity(0.35), lineWidth: 1.5))
|
||||
.frame(width: 20, height: 20)
|
||||
.position(
|
||||
x: saturation * geo.size.width,
|
||||
y: (1 - brightness) * geo.size.height
|
||||
)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(MatchColors.outline, lineWidth: 1))
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
saturation = min(max(value.location.x / geo.size.width, 0), 1)
|
||||
brightness = 1 - min(max(value.location.y / geo.size.height, 0), 1)
|
||||
emit()
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(height: 140)
|
||||
}
|
||||
|
||||
private func emit() {
|
||||
onColorChange(ColorHex.hsvToHex(hue: hue, saturation: saturation, brightness: brightness))
|
||||
}
|
||||
}
|
||||
166
native/ios/MatchLiveTv/UI/Wizard/WizardComponents.swift
Normal file
@@ -0,0 +1,166 @@
|
||||
import SwiftUI
|
||||
|
||||
private let wizardStepTitles = ["01 · Partita", "02 · Trasmissione", "03 · Test rete"]
|
||||
|
||||
func wizardStepTitle(_ step: Int) -> String {
|
||||
wizardStepTitles[min(max(step, 1), 3) - 1]
|
||||
}
|
||||
|
||||
struct WizardStepIndicator: View {
|
||||
let currentStep: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(1...3, id: \.self) { step in
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(step <= currentStep ? MatchColors.primaryRed : MatchColors.surfaceElevated)
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
struct WizardFooterButtons: View {
|
||||
var showBack: Bool = true
|
||||
let onBack: () -> Void
|
||||
let forwardLabel: String
|
||||
var forwardLoading: Bool = false
|
||||
let onForward: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
if showBack {
|
||||
MatchSecondaryButton(label: "Indietro", action: onBack)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
MatchPrimaryButton(label: forwardLabel, action: onForward, loading: forwardLoading)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WizardOutlinedField: View {
|
||||
let label: String
|
||||
@Binding var text: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(label)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
TextField("", text: $text)
|
||||
.padding(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(MatchColors.outline, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WizardReadOnlyField: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label).font(MatchTypography.bodyMedium)
|
||||
Text(value).font(MatchTypography.titleMedium)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
|
||||
struct WizardChoiceButton: View {
|
||||
let label: String
|
||||
let selected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if selected {
|
||||
MatchPrimaryButton(label: label, action: action)
|
||||
} else {
|
||||
MatchSecondaryButton(label: label, action: action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MetricCard: View {
|
||||
let label: String
|
||||
let value: String
|
||||
var highlight: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(label)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
Text(value)
|
||||
.font(MatchTypography.titleMedium)
|
||||
.foregroundStyle(highlight ? MatchColors.successGreen : .white)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(12)
|
||||
.background(
|
||||
highlight ? MatchColors.successGreen.opacity(0.12) : MatchColors.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: 10)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct WizardPlatformCard: View {
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let selected: Bool
|
||||
var enabled: Bool = true
|
||||
var badge: String?
|
||||
let onClick: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onClick) {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Text(selected ? "●" : "○")
|
||||
.foregroundStyle(selected ? MatchColors.primaryRed : MatchColors.textSecondary)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(MatchTypography.titleMedium)
|
||||
.foregroundStyle(.white.opacity(enabled ? 1 : 0.55))
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary.opacity(enabled ? 1 : 0.55))
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if let badge {
|
||||
Text(badge)
|
||||
.font(MatchTypography.labelLarge)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
selected ? MatchColors.primaryRed.opacity(0.15) : MatchColors.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: 10)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(selected ? MatchColors.primaryRed : .clear, lineWidth: 1)
|
||||
)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
.opacity(enabled ? 1 : 0.55)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
113
native/ios/MatchLiveTv/UI/Wizard/WizardShellScreen.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WizardShellScreen: View {
|
||||
@ObservedObject var container: AppContainer
|
||||
let matchId: String
|
||||
let initialStep: Int
|
||||
let onClose: () -> Void
|
||||
let onStartLive: (String) -> Void
|
||||
|
||||
@State private var match: Match?
|
||||
@State private var team: Team?
|
||||
@State private var currentStep: Int
|
||||
@State private var error: String?
|
||||
|
||||
init(container: AppContainer, matchId: String, step: Int, onClose: @escaping () -> Void, onStartLive: @escaping (String) -> Void) {
|
||||
self.container = container
|
||||
self.matchId = matchId
|
||||
self.initialStep = step
|
||||
self.onClose = onClose
|
||||
self.onStartLive = onStartLive
|
||||
_currentStep = State(initialValue: min(max(step, 1), 3))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
MatchScreenScaffold(
|
||||
topBar: {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark").foregroundStyle(.white)
|
||||
}
|
||||
Text(wizardStepTitle(currentStep))
|
||||
.font(MatchTypography.titleMedium)
|
||||
Spacer(minLength: 0)
|
||||
if currentStep == 1, let team {
|
||||
MatchStatusBadge(text: team.sportKey.uppercased())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
},
|
||||
content: {
|
||||
if let match {
|
||||
VStack(spacing: 0) {
|
||||
WizardStepIndicator(currentStep: currentStep)
|
||||
Group {
|
||||
switch currentStep {
|
||||
case 1:
|
||||
StepMatchScreen(
|
||||
container: container,
|
||||
match: match,
|
||||
onNext: { currentStep = 2 },
|
||||
onError: { presentError($0) }
|
||||
)
|
||||
case 2:
|
||||
StepTransmissionScreen(
|
||||
container: container,
|
||||
match: match,
|
||||
onBack: { currentStep = 1 },
|
||||
onNext: { currentStep = 3 },
|
||||
onError: { presentError($0) }
|
||||
)
|
||||
default:
|
||||
if let session = container.wizardSession.session {
|
||||
StepNetworkTestScreen(
|
||||
container: container,
|
||||
match: match,
|
||||
session: session,
|
||||
onBack: { currentStep = 2 },
|
||||
onStartLive: onStartLive,
|
||||
onError: { presentError($0) }
|
||||
)
|
||||
} else {
|
||||
Text("Completa lo step Trasmissione")
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
} else {
|
||||
ProgressView().tint(MatchColors.primaryRed)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
)
|
||||
.task {
|
||||
do {
|
||||
let loaded = try await container.matchRepository.fetchMatch(matchId: matchId)
|
||||
match = loaded
|
||||
container.wizardSession.match = loaded
|
||||
team = await container.matchRepository.fetchTeam(teamId: loaded.teamId)
|
||||
container.wizardSession.team = team
|
||||
} catch {
|
||||
if let message = UserFacingError.message(for: error) {
|
||||
presentError(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(error ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private func presentError(_ message: String) {
|
||||
guard !message.isEmpty else { return }
|
||||
error = message
|
||||
}
|
||||
}
|
||||
47
native/ios/MatchLiveTvTests/ApiInstantTests.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import XCTest
|
||||
|
||||
/// Smoke test eseguibile senza @testable import (validazione logica pura).
|
||||
final class ApiInstantTests: XCTestCase {
|
||||
func testOverlayKindMapping() {
|
||||
XCTAssertEqual(OverlayKindMapping.fromApi("basket"), "basket")
|
||||
XCTAssertEqual(OverlayKindMapping.fromApi("none"), "none")
|
||||
XCTAssertEqual(OverlayKindMapping.fromApi(nil), "volley")
|
||||
}
|
||||
|
||||
func testRTMPUrlParser() {
|
||||
let target = RTMPUrlParserLogic.parse("rtmp://stream.example.com/live/match_abc-123")
|
||||
XCTAssertEqual(target?.connectURL, "rtmp://stream.example.com/live")
|
||||
XCTAssertEqual(target?.streamName, "match_abc-123")
|
||||
}
|
||||
}
|
||||
|
||||
enum OverlayKindMapping {
|
||||
static func fromApi(_ value: String?) -> String {
|
||||
switch value?.lowercased() {
|
||||
case "none", "timer": return "none"
|
||||
case "basket": return "basket"
|
||||
case "timed": return "timed"
|
||||
case "racket": return "racket"
|
||||
default: return "volley"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RTMPUrlParserLogic {
|
||||
struct Target {
|
||||
let connectURL: String
|
||||
let streamName: String
|
||||
}
|
||||
|
||||
static func parse(_ urlString: String) -> Target? {
|
||||
guard let url = URL(string: urlString), url.scheme?.hasPrefix("rtmp") == true else { return nil }
|
||||
let pathParts = url.path.split(separator: "/").map(String.init)
|
||||
guard pathParts.count >= 2 else { return nil }
|
||||
let app = pathParts.dropLast().joined(separator: "/")
|
||||
let streamName = pathParts.last ?? ""
|
||||
var connect = "rtmp://\(url.host ?? "")"
|
||||
if let port = url.port { connect += ":\(port)" }
|
||||
connect += "/\(app)"
|
||||
return Target(connectURL: connect, streamName: streamName)
|
||||
}
|
||||
}
|
||||
140
native/ios/MatchLiveTvTests/MatchScoringRulesTests.swift
Normal file
@@ -0,0 +1,140 @@
|
||||
import XCTest
|
||||
@testable import MatchLiveTv
|
||||
|
||||
final class MatchScoringRulesTests: XCTestCase {
|
||||
private func volleyMatch(setsToWin: Int = 3, rules: ScoringRules? = nil) -> Match {
|
||||
Match(
|
||||
id: "match-1",
|
||||
teamId: "team-1",
|
||||
teamName: "Tigers Volley",
|
||||
opponentName: "Avversario",
|
||||
location: nil,
|
||||
scheduledAt: nil,
|
||||
sportKey: "pallavolo",
|
||||
sportLabel: "Pallavolo",
|
||||
boardType: "volley",
|
||||
overlayKind: "volley",
|
||||
effectiveOverlayKind: "volley",
|
||||
setsToWin: setsToWin,
|
||||
category: nil,
|
||||
scoringRules: rules,
|
||||
activeSessionId: nil,
|
||||
activeSessionStatus: nil,
|
||||
streamCompleted: false,
|
||||
coachHubVisible: nil,
|
||||
homePrimaryColor: "#FF2D2D",
|
||||
homeSecondaryColor: nil,
|
||||
homeLogoUrl: nil,
|
||||
opponentPrimaryColor: "#1E3A8A",
|
||||
opponentLogoUrl: nil
|
||||
)
|
||||
}
|
||||
|
||||
func testSetWinnerAt26_12() {
|
||||
let rules = MatchScoringContext(match: volleyMatch())
|
||||
XCTAssertEqual(rules.setWinnerFromPoints(homePoints: 26, awayPoints: 12, currentSet: 1), .home)
|
||||
}
|
||||
|
||||
func testSetWinnerRequiresMinLead() {
|
||||
let rules = MatchScoringContext(match: volleyMatch())
|
||||
XCTAssertNil(rules.setWinnerFromPoints(homePoints: 25, awayPoints: 24, currentSet: 1))
|
||||
XCTAssertEqual(rules.setWinnerFromPoints(homePoints: 25, awayPoints: 23, currentSet: 1), .home)
|
||||
}
|
||||
|
||||
func testDecidingSetUses15Points() {
|
||||
let rules = MatchScoringContext(match: volleyMatch(setsToWin: 3))
|
||||
XCTAssertEqual(rules.maxSets, 5)
|
||||
XCTAssertEqual(rules.pointsTarget(currentSet: 5), 15)
|
||||
XCTAssertEqual(rules.setWinnerFromPoints(homePoints: 15, awayPoints: 13, currentSet: 5), .home)
|
||||
}
|
||||
|
||||
func testMatchWinnerAfterThreeSets() {
|
||||
let rules = MatchScoringContext(match: volleyMatch(setsToWin: 3))
|
||||
XCTAssertEqual(rules.matchWinner(homeSets: 3, awaySets: 1), .home)
|
||||
XCTAssertNil(rules.matchWinner(homeSets: 2, awaySets: 1))
|
||||
}
|
||||
|
||||
func testCustomScoringRulesFromMatch() {
|
||||
let custom = ScoringRules(pointsPerSet: 21, pointsDecidingSet: 15, minPointLead: 2)
|
||||
let rules = MatchScoringContext(match: volleyMatch(setsToWin: 2, rules: custom))
|
||||
XCTAssertEqual(rules.pointsTarget(currentSet: 1), 21)
|
||||
XCTAssertEqual(rules.setWinnerFromPoints(homePoints: 21, awayPoints: 19, currentSet: 1), .home)
|
||||
}
|
||||
|
||||
func testUsesSetLogicOnlyForVolleyAndRacket() {
|
||||
XCTAssertTrue(MatchScoringContext(match: volleyMatch()).usesSetLogic)
|
||||
var basket = volleyMatch()
|
||||
basket = Match(
|
||||
id: basket.id,
|
||||
teamId: basket.teamId,
|
||||
teamName: basket.teamName,
|
||||
opponentName: basket.opponentName,
|
||||
location: basket.location,
|
||||
scheduledAt: basket.scheduledAt,
|
||||
sportKey: "basket",
|
||||
sportLabel: "Basket",
|
||||
boardType: "basket",
|
||||
overlayKind: "basket",
|
||||
effectiveOverlayKind: "basket",
|
||||
setsToWin: 0,
|
||||
category: nil,
|
||||
scoringRules: nil,
|
||||
activeSessionId: nil,
|
||||
activeSessionStatus: nil,
|
||||
streamCompleted: false,
|
||||
coachHubVisible: nil,
|
||||
homePrimaryColor: nil,
|
||||
homeSecondaryColor: nil,
|
||||
homeLogoUrl: nil,
|
||||
opponentPrimaryColor: nil,
|
||||
opponentLogoUrl: nil
|
||||
)
|
||||
XCTAssertFalse(MatchScoringContext(match: basket).usesSetLogic)
|
||||
}
|
||||
|
||||
func testDecodesFlexibleScorePayload() throws {
|
||||
let json = """
|
||||
{
|
||||
"board_type": "basket",
|
||||
"home_points": 42,
|
||||
"away_points": 38,
|
||||
"current_set": 1,
|
||||
"period": "Q3",
|
||||
"clock_secs": 312,
|
||||
"clock_running": 1,
|
||||
"data": { "home_score": 42, "away_score": 38, "period": 3, "overtime": false }
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let dto = try JSONDecoder().decode(ScoreStateDto.self, from: json)
|
||||
let score = dto.toDomain()
|
||||
XCTAssertEqual(score.boardType, "basket")
|
||||
XCTAssertEqual(score.homePoints, 42)
|
||||
XCTAssertEqual(score.periodLabel, "Q3")
|
||||
XCTAssertEqual(score.clockSecs, 312)
|
||||
XCTAssertTrue(score.clockRunning)
|
||||
}
|
||||
}
|
||||
|
||||
final class ScoreboardOverlayTests: XCTestCase {
|
||||
func testScoreboardColumnsAfterSetClosed() {
|
||||
let score = ScoreState(
|
||||
homeSets: 1,
|
||||
awaySets: 0,
|
||||
homePoints: 0,
|
||||
awayPoints: 0,
|
||||
currentSet: 2,
|
||||
setPartials: [SetPartial(set: 1, home: 26, away: 12)],
|
||||
boardType: "volley"
|
||||
)
|
||||
let board = score.toScoreboardState(homeTeamName: "Casa", awayTeamName: "Ospite")
|
||||
XCTAssertEqual(board.columns.count, 2)
|
||||
XCTAssertEqual(board.columns[0].setNumber, 1)
|
||||
XCTAssertEqual(board.columns[0].homePoints, 26)
|
||||
XCTAssertEqual(board.columns[0].awayPoints, 12)
|
||||
XCTAssertFalse(board.columns[0].isCurrent)
|
||||
XCTAssertEqual(board.columns[1].setNumber, 2)
|
||||
XCTAssertTrue(board.columns[1].isCurrent)
|
||||
XCTAssertEqual(board.columns[1].homePoints, 0)
|
||||
XCTAssertEqual(board.columns[1].awayPoints, 0)
|
||||
}
|
||||
}
|
||||
84
native/ios/README.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Match Live TV — app iOS nativa
|
||||
|
||||
Porting SwiftUI dell'app Android con stessa UI, brand e contratti API backend.
|
||||
|
||||
## Stack
|
||||
|
||||
- UI: SwiftUI, NavigationStack
|
||||
- Rete: URLSession + Codable
|
||||
- Auth: Keychain
|
||||
- Streaming: HaishinKit (RTMP) + AVFoundation
|
||||
- Overlay: Core Graphics (bitmap composito su pipeline video)
|
||||
|
||||
## Struttura
|
||||
|
||||
```
|
||||
MatchLiveTv/
|
||||
App/ — entry point
|
||||
Core/ — config, token store, utility
|
||||
Data/ — API, repository, ActionCable
|
||||
Domain/ — modelli
|
||||
Streaming/ — motore RTMP, overlay nativi
|
||||
UI/ — schermate SwiftUI
|
||||
Resources/ — Assets.xcassets (brand), Info.plist
|
||||
```
|
||||
|
||||
## Parità multi-sport (riferimento: `docs/TABELLONI_E_OVERLAY.md`)
|
||||
|
||||
| Board | Controlli broadcast | Overlay video | Stato iOS |
|
||||
|-------|---------------------|---------------|-----------|
|
||||
| volley / racket | +1, undo, chiudi set (API `close_set`) | Tabellone a colonne | Completo |
|
||||
| basket | +1/+2/+3, undo, `advance_period` | Compact scoreboard | Completo |
|
||||
| timed | +1, undo, `advance_period` | Compact scoreboard | Completo |
|
||||
| generic | +1 / undo locale | Watermark | Completo |
|
||||
| timer | — | Solo watermark | Completo |
|
||||
|
||||
Il cronometro basket/timed è gestito in **regia web**, non sull'overlay video (come Android).
|
||||
|
||||
## Asset brand
|
||||
|
||||
Gli asset in `brand/` sono importati in `Resources/Assets.xcassets`:
|
||||
- `logo-white-m`, `logo-white-m-256` — wordmark e watermark
|
||||
- `logo-dark-gradient`, `logo-dark-gradient-256` — app icon
|
||||
- `CopertinaCanale*.png` — riferimenti visivi brand
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
python3 generate_xcodeproj.py # rigenera MatchLiveTv.xcodeproj
|
||||
./scripts/build_ios_release.sh
|
||||
```
|
||||
|
||||
Override API: `API_BASE_URL=https://staging.example.com ./scripts/build_ios_release.sh`
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd native/ios
|
||||
python3 generate_xcodeproj.py
|
||||
xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-derivedDataPath build/DerivedData \
|
||||
ONLY_ACTIVE_ARCH=YES ARCHS=arm64 EXCLUDED_ARCHS=x86_64 \
|
||||
test -only-testing:MatchLiveTvTests
|
||||
```
|
||||
|
||||
## Requisiti
|
||||
|
||||
- Xcode 16+
|
||||
- iOS 16+
|
||||
- Mac con Apple Silicon o Intel
|
||||
|
||||
## Funzionalità
|
||||
|
||||
- Login JWT, hub partite (reload al ritorno da diretta), wizard 3 step
|
||||
- Diretta RTMP 1280×720 con overlay tabellone/watermark
|
||||
- ActionCable SessionChannel per punteggio e comandi regia
|
||||
- Telemetria dispositivo periodica
|
||||
- Orientamento: portrait ovunque, landscape solo in broadcast
|
||||
|
||||
## Documentazione correlata
|
||||
|
||||
- [`docs/TABELLONI_E_OVERLAY.md`](../../docs/TABELLONI_E_OVERLAY.md) — regolamenti, board, overlay
|
||||
- [`docs/MULTI_SPORT.md`](../../docs/MULTI_SPORT.md) — catalogo sport e engine scoring
|
||||
- [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) — infrastruttura e flussi live/HLS
|
||||
183
native/ios/generate_xcodeproj.py
Executable file
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
APP_DIR = ROOT / "MatchLiveTv"
|
||||
TEST_DIR = ROOT / "MatchLiveTvTests"
|
||||
OUT = ROOT / "MatchLiveTv.xcodeproj" / "project.pbxproj"
|
||||
|
||||
def uid():
|
||||
return uuid.uuid4().hex[:24].upper()
|
||||
|
||||
app_files = sorted(APP_DIR.rglob("*.swift"))
|
||||
test_files = sorted(TEST_DIR.rglob("*.swift"))
|
||||
|
||||
ids = {f: uid() for f in app_files + test_files}
|
||||
bf = {f: uid() for f in app_files + test_files}
|
||||
assets_ref, assets_bf = uid(), uid()
|
||||
info_ref = uid()
|
||||
app_product, test_product = uid(), uid()
|
||||
app_target, test_target = uid(), uid()
|
||||
app_sources, app_frameworks, app_resources = uid(), uid(), uid()
|
||||
test_sources, test_frameworks = uid(), uid()
|
||||
main_group, app_group, test_group, products_group = uid(), uid(), uid(), uid()
|
||||
proj = uid()
|
||||
pkg = uid()
|
||||
hk, rtmp = uid(), uid()
|
||||
hk_bf, rtmp_bf = uid(), uid()
|
||||
bcl_proj_dbg, bcl_proj_rel = uid(), uid()
|
||||
bcl_app_dbg, bcl_app_rel = uid(), uid()
|
||||
bcl_test_dbg, bcl_test_rel = uid(), uid()
|
||||
cfg_app_dbg, cfg_app_rel = uid(), uid()
|
||||
cfg_test_dbg, cfg_test_rel = uid(), uid()
|
||||
cfg_proj_dbg, cfg_proj_rel = uid(), uid()
|
||||
bcl_app, bcl_test, bcl_proj = uid(), uid(), uid()
|
||||
test_dep, test_dep_proxy = uid(), uid()
|
||||
scheme_id = uid()
|
||||
|
||||
lines = [
|
||||
"// !$*UTF8*$!",
|
||||
"{",
|
||||
"\tarchiveVersion = 1;",
|
||||
"\tclasses = {};",
|
||||
"\tobjectVersion = 60;",
|
||||
"\tobjects = {",
|
||||
]
|
||||
|
||||
for f, fid in ids.items():
|
||||
lines.append(f'\t\t{fid} /* {f.name} */ = {{isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = {f.name}; path = {f.relative_to(ROOT)}; sourceTree = "<group>"; }};')
|
||||
|
||||
lines += [
|
||||
f'\t\t{assets_ref} /* Assets.xcassets */ = {{isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MatchLiveTv/Resources/Assets.xcassets; sourceTree = "<group>"; }};',
|
||||
f'\t\t{info_ref} /* Info.plist */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = MatchLiveTv/Resources/Info.plist; sourceTree = "<group>"; }};',
|
||||
f'\t\t{app_product} /* MatchLiveTv.app */ = {{isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MatchLiveTv.app; sourceTree = BUILT_PRODUCTS_DIR; }};',
|
||||
f'\t\t{test_product} /* MatchLiveTvTests.xctest */ = {{isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MatchLiveTvTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }};',
|
||||
f'\t\t{hk} /* HaishinKit */ = {{isa = XCSwiftPackageProductDependency; package = {pkg}; productName = HaishinKit; }};',
|
||||
f'\t\t{rtmp} /* RTMPHaishinKit */ = {{isa = XCSwiftPackageProductDependency; package = {pkg}; productName = RTMPHaishinKit; }};',
|
||||
]
|
||||
|
||||
for f, bid in bf.items():
|
||||
lines.append(f'\t\t{bid} /* {f.name} in Sources */ = {{isa = PBXBuildFile; fileRef = {ids[f]} /* {f.name} */; }};')
|
||||
lines += [
|
||||
f'\t\t{assets_bf} /* Assets in Resources */ = {{isa = PBXBuildFile; fileRef = {assets_ref}; }};',
|
||||
]
|
||||
|
||||
app_children = ", ".join(ids[f] for f in app_files) + f", {assets_ref}, {info_ref}"
|
||||
test_children = ", ".join(ids[f] for f in test_files)
|
||||
|
||||
lines += [
|
||||
f'\t\t{main_group} = {{isa = PBXGroup; children = ({app_group}, {test_group}, {products_group}); sourceTree = "<group>"; }};',
|
||||
f'\t\t{app_group} = {{isa = PBXGroup; children = ({app_children}); name = MatchLiveTv; sourceTree = "<group>"; }};',
|
||||
f'\t\t{test_group} = {{isa = PBXGroup; children = ({test_children}); name = MatchLiveTvTests; sourceTree = "<group>"; }};',
|
||||
f'\t\t{products_group} = {{isa = PBXGroup; children = ({app_product}, {test_product}); name = Products; sourceTree = "<group>"; }};',
|
||||
f'\t\t{app_sources} = {{isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ({", ".join(bf[f] for f in app_files)}); runOnlyForDeploymentPostprocessing = 0; }};',
|
||||
f'\t\t{test_sources} = {{isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ({", ".join(bf[f] for f in test_files) if test_files else ""}); runOnlyForDeploymentPostprocessing = 0; }};',
|
||||
f'\t\t{app_resources} = {{isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ({assets_bf}); runOnlyForDeploymentPostprocessing = 0; }};',
|
||||
f'\t\t{app_frameworks} = {{isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }};',
|
||||
f'\t\t{test_frameworks} = {{isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }};',
|
||||
]
|
||||
|
||||
app_settings = """
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
"""
|
||||
|
||||
app_debug_settings = app_settings + """
|
||||
ENABLE_TESTABILITY = YES;
|
||||
"""
|
||||
|
||||
test_settings = """
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MatchLiveTv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MatchLiveTv";
|
||||
"""
|
||||
|
||||
lines += [
|
||||
f'\t\t{cfg_app_dbg} = {{isa = XCBuildConfiguration; buildSettings = {{{app_debug_settings}\t\t\t}}; name = Debug; }};',
|
||||
f'\t\t{cfg_app_rel} = {{isa = XCBuildConfiguration; buildSettings = {{{app_settings}\t\t\t}}; name = Release; }};',
|
||||
f'\t\t{cfg_test_dbg} = {{isa = XCBuildConfiguration; buildSettings = {{{test_settings}\t\t\t}}; name = Debug; }};',
|
||||
f'\t\t{cfg_test_rel} = {{isa = XCBuildConfiguration; buildSettings = {{{test_settings}\t\t\t}}; name = Release; }};',
|
||||
f'\t\t{cfg_proj_dbg} = {{isa = XCBuildConfiguration; buildSettings = {{IPHONEOS_DEPLOYMENT_TARGET = 16.0; SWIFT_VERSION = 5.0; }}; name = Debug; }};',
|
||||
f'\t\t{cfg_proj_rel} = {{isa = XCBuildConfiguration; buildSettings = {{IPHONEOS_DEPLOYMENT_TARGET = 16.0; SWIFT_VERSION = 5.0; }}; name = Release; }};',
|
||||
f'\t\t{bcl_app} = {{isa = XCConfigurationList; buildConfigurations = ({cfg_app_dbg}, {cfg_app_rel}); defaultConfigurationName = Release; }};',
|
||||
f'\t\t{bcl_test} = {{isa = XCConfigurationList; buildConfigurations = ({cfg_test_dbg}, {cfg_test_rel}); defaultConfigurationName = Release; }};',
|
||||
f'\t\t{bcl_proj} = {{isa = XCConfigurationList; buildConfigurations = ({cfg_proj_dbg}, {cfg_proj_rel}); defaultConfigurationName = Release; }};',
|
||||
f'\t\t{app_target} = {{isa = PBXNativeTarget; buildConfigurationList = {bcl_app}; buildPhases = ({app_sources}, {app_frameworks}, {app_resources}); buildRules = (); dependencies = (); name = MatchLiveTv; packageProductDependencies = ({hk}, {rtmp}); productName = MatchLiveTv; productReference = {app_product}; productType = "com.apple.product-type.application"; }};',
|
||||
f'\t\t{test_dep_proxy} = {{isa = PBXContainerItemProxy; containerPortal = {proj} /* Project object */; proxyType = 1; remoteGlobalIDString = {app_target}; remoteInfo = MatchLiveTv; }};',
|
||||
f'\t\t{test_dep} = {{isa = PBXTargetDependency; target = {app_target} /* MatchLiveTv */; targetProxy = {test_dep_proxy} /* PBXContainerItemProxy */; }};',
|
||||
f'\t\t{test_target} = {{isa = PBXNativeTarget; buildConfigurationList = {bcl_test}; buildPhases = ({test_sources}, {test_frameworks}); buildRules = (); dependencies = ({test_dep}); name = MatchLiveTvTests; productName = MatchLiveTvTests; productReference = {test_product}; productType = "com.apple.product-type.bundle.unit-test"; }};',
|
||||
f'\t\t{pkg} = {{isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/shogo4405/HaishinKit.swift"; requirement = {{kind = upToNextMajorVersion; minimumVersion = 2.0.0;}}; }};',
|
||||
f'\t\t{proj} = {{isa = PBXProject; attributes = {{BuildIndependentTargetsInParallel = 0; LastSwiftUpdateCheck = 1600;}}; buildConfigurationList = {bcl_proj}; compatibilityVersion = "Xcode 15.0"; developmentRegion = it; mainGroup = {main_group}; packageReferences = ({pkg}); productRefGroup = {products_group}; targets = ({app_target}, {test_target}); }};',
|
||||
"\t};",
|
||||
f"\trootObject = {proj} /* Project object */;",
|
||||
"}",
|
||||
]
|
||||
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text("\n".join(lines) + "\n")
|
||||
(OUT.parent / "project.xcworkspace").mkdir(exist_ok=True)
|
||||
(OUT.parent / "project.xcworkspace" / "contents.xcworkspacedata").write_text(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n<Workspace version="1.0">\n <FileRef location="self:"></FileRef>\n</Workspace>\n'
|
||||
)
|
||||
scheme_dir = OUT.parent / "xcshareddata" / "xcschemes"
|
||||
scheme_dir.mkdir(parents=True, exist_ok=True)
|
||||
(scheme_dir / "MatchLiveTv.xcscheme").write_text(f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme LastUpgradeVersion="1600" version="1.7">
|
||||
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{app_target}" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{test_target}" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
|
||||
<Testables>
|
||||
<TestableReference skipped="NO">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{test_target}" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
|
||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{app_target}" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
|
||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{app_target}" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction buildConfiguration="Debug"/>
|
||||
<ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
|
||||
</Scheme>
|
||||
""")
|
||||
print(f"Wrote {OUT} ({len(app_files)} app sources, {len(test_files)} test sources)")
|
||||