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>
This commit is contained in:
Emiliano Frascaro
2026-06-14 21:37:32 +02:00
parent b798e0ea06
commit 585332e32e
101 changed files with 7910 additions and 5 deletions

View 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

View File

@@ -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` |

View File

@@ -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

View File

@@ -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)
Sullapp nativa loverlay è 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
View File

@@ -0,0 +1,4 @@
build/
DerivedData/
*.xcuserstate
xcuserdata/

View 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 */;
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace version="1.0">
<FileRef location="self:"></FileRef>
</Workspace>

View File

@@ -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
}

View File

@@ -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>

View File

@@ -0,0 +1,14 @@
import SwiftUI
@main
struct MatchLiveTvApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup {
MatchLiveTheme {
AppNavHost()
}
}
}
}

View 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
}()
}

View 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"
}
}

View 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)
}
}

View 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
}
}

View 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)"
}
}

View 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)
}
}

View 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
}
}

View 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
}
}

View 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) }
}
}

View 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)
}
}
}

View 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
}
}

View 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
}
}
}
}

View 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)
}
}

View 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 }
}

View File

@@ -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
}
}

View 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()
}
}

View File

@@ -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
)
)
}
}

View 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
}
}
}
}

View 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
}
}

View 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"
}
}

View 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
}

View 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 }
}

View 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
}
}

View File

@@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

View File

@@ -0,0 +1,6 @@
{
"images" : [
{ "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

View File

@@ -0,0 +1 @@
{ "info" : { "author" : "xcode", "version" : 1 } }

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

View 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>

View 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?
}

View File

@@ -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()
}
}

View 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)
}
}

View File

@@ -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 + ""
}
}

View File

@@ -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
}
}

View File

@@ -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)) }
}
}

View File

@@ -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
}
}
}

View 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()
}
}
}

View 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)
}

View 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)"
}
}
}

View File

@@ -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)
}
}

View File

@@ -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))
}
}

View File

@@ -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) {}
}

View File

@@ -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"
}

View 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()
}
}

View 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) }
)
}
}
}

View 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)
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View 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))
}
}

View 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))
}
}
}

View 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 }
}

View 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)
}
}

View 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 }
}

View 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)
}

View File

@@ -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
}
}

View 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()
}
}
}
}

View 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())
}
}

View 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())
}
}

View 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)
}
}

View 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 }
}

View 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"
}
}

View 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
}
}
}

View 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)
}
}

View 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))
}
}

View 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)
}
}

View 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
}
}

View 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)
}
}

View 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
View 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
View 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)")

Some files were not shown because too many files have changed in this diff Show More