ScoreStateDto non leggeva home_points con convertFromSnakeCase; ScoreController assegna sempre lo stato, aggiornamento ottimistico e refresh overlay. Co-authored-by: Cursor <cursoragent@cursor.com>
457 lines
19 KiB
Swift
457 lines
19 KiB
Swift
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)
|
|
.id(scoreController.score.progressKey())
|
|
.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: scoreController.lastActionError) { message in
|
|
if let message { snackbarMessage = message }
|
|
}
|
|
.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 {
|
|
let ok = await scoreController.applyBoardAction("home_point")
|
|
if ok {
|
|
await liveScoreActions(for: match).afterPointChange(score: scoreController.score)
|
|
}
|
|
} else {
|
|
scoreController.incrementHome()
|
|
}
|
|
}
|
|
},
|
|
onPointAway: {
|
|
Task {
|
|
if usesActionScoring {
|
|
scoreController.applyAction("away_point")
|
|
} else if usesSetScoring {
|
|
let ok = await scoreController.applyBoardAction("away_point")
|
|
if ok {
|
|
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: RtmpIngestUrl.resolveForDevice(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()
|
|
}
|
|
}
|