Files
MatchLiveTv/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift
Emiliano Frascaro 270bf680a4 Fix regia basket e stabilità diretta iOS.
La regia remota non va più in 500 su basket/timed; iOS gestisce chiusura set, riconnessione RTMP e avanzamento quarti in modo affidabile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 12:30:34 +02:00

539 lines
22 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 resumeInFlight = false
@State private var deviceHealth = DeviceTelemetry.snapshot()
@State private var snackbarMessage: String?
@State private var shareItem: ShareItem?
@State private var bootstrapGeneration = 0
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if permissions.allGranted {
LivePreviewView(engine: broadcastCoordinator.engine)
.ignoresSafeArea()
.allowsHitTesting(false)
.opacity(loading || session == nil || match == nil ? 0 : 1)
}
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 {
broadcastOverlay(session: session, match: match)
.id(scoreController.score.progressKey())
.zIndex(1)
}
}
.statusBarHidden(true)
.lockLandscapeOrientation()
.onAppear {
UIDevice.current.isBatteryMonitoringEnabled = true
}
.task {
await permissions.refresh()
if !permissions.allGranted {
await permissions.requestAll()
}
}
.task(id: "\(sessionId)-\(permissions.allGranted)") {
guard permissions.allGranted else {
loading = false
return
}
await bootstrap()
}
.task(id: sessionId) {
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(id: sessionId) {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 2_000_000_000)
deviceHealth = DeviceTelemetry.snapshot()
}
}
.task(id: sessionId) {
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
)
}
}
.onChange(of: scoreController.score) { _ in updateOverlay() }
.onChange(of: scoreController.lastActionError) { message in
if let message { snackbarMessage = message }
}
.onChange(of: broadcastCoordinator.metrics.phase) { phase in
updateOverlay()
if phase == .error, let message = broadcastCoordinator.metrics.lastError {
self.error = message
}
}
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("Riprova") {
Task { await retryBroadcast() }
}
Button("Esci", role: .destructive) { onFinished() }
} message: {
Text(error ?? "")
}
.sheet(item: $shareItem) { item in
ShareSheet(items: item.items, subject: item.subject) {
shareItem = nil
}
}
.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 ? {
Task {
guard await scoreController.applyBoardAction("advance_period") else { return }
updateOverlay()
}
} : nil,
onPauseOrResume: {
if isPaused {
Task { await resumeStream() }
} else {
pauseStream()
}
},
onTerminate: { Task { await stopStream() } },
onShareLive: { shareLiveLink(session: session, subject: shareSubject) },
onShareRegia: { shareRegiaLink(subject: shareSubject) },
shareLiveEnabled: true,
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() },
onScoreboardChanged: { updateOverlay() }
)
}
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 .reconnecting: return MatchColors.accentYellow
case .error: return MatchColors.primaryRed
default: return MatchColors.accentYellow
}
}
private func shareLiveLink(session: StreamSession, subject: String) {
let urlString = session.watchShareUrl() ?? "\(AppConfig.apiBaseUrl)/live/\(session.id)"
guard let url = URL(string: urlString) else {
snackbarMessage = "Link diretta non ancora disponibile"
return
}
shareItem = ShareItem(items: [url], subject: subject)
}
private func shareRegiaLink(subject: String) {
Task { @MainActor in
do {
let urlString = try await container.sessionRepository.createRegiaLink(sessionId: sessionId)
guard let url = URL(string: urlString) else {
snackbarMessage = "Link regia non valido"
return
}
shareItem = ShareItem(items: [url], subject: "Link regia — \(subject)")
} catch {
snackbarMessage = UserFacingError.message(for: error) ?? "Errore link regia"
}
}
}
private func bootstrap() async {
bootstrapGeneration += 1
let generation = bootstrapGeneration
loading = true
error = nil
container.sessionCable.disconnect()
do {
let loaded = try await container.sessionRepository.fetchSession(id: sessionId)
guard generation == bootstrapGeneration else { return }
let loadedMatch = try await container.matchRepository.fetchMatch(matchId: loaded.matchId)
guard generation == bootstrapGeneration else { return }
session = loaded
match = loadedMatch
container.scoreController.bind(sessionId: sessionId, initial: loaded.score)
wireCable()
await preloadLogos(for: loadedMatch)
updateOverlay()
loading = false
guard generation == bootstrapGeneration else { return }
guard await container.broadcastCoordinator.waitForPreviewSurface() else {
throw APIError.http(500, "Anteprima camera non pronta")
}
guard generation == bootstrapGeneration else { return }
if let url = loaded.rtmpIngestUrl, !url.isEmpty {
let config = broadcastConfig(for: loaded, rtmpUrl: url)
try await container.broadcastCoordinator.prepareBroadcast(
sessionId: sessionId,
config: config,
paused: loaded.isPaused
)
}
} catch {
guard generation == bootstrapGeneration else { return }
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
guard let container, !pauseInFlight else { return }
await applyRemotePause(container: container)
}
}
container.sessionCable.onResumeStream = { [weak container] in
Task { @MainActor in
guard let container, !resumeInFlight else { return }
await applyRemoteResume(container: container)
}
}
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 pauseStream() {
guard !pauseInFlight else { return }
pauseInFlight = true
Task {
defer { pauseInFlight = false }
do {
let updated = try await container.sessionRepository.pauseSession(id: sessionId)
session = updated
await container.broadcastCoordinator.pauseBroadcast()
snackbarMessage = "Diretta in pausa"
} catch {
snackbarMessage = UserFacingError.message(for: error) ?? "Pausa non riuscita"
}
}
}
private func resumeStream() async {
guard !resumeInFlight else { return }
resumeInFlight = true
defer { resumeInFlight = false }
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
snackbarMessage = "Diretta ripresa"
} catch {
snackbarMessage = UserFacingError.message(for: error) ?? "Ripresa non riuscita"
}
}
/// Pausa dalla regia / echo cable: solo RTMP locale, senza PATCH pause.
private func applyRemotePause(container: AppContainer) async {
guard !pauseInFlight else { return }
pauseInFlight = true
defer { pauseInFlight = false }
await container.broadcastCoordinator.pauseBroadcast()
if let fetched = try? await container.sessionRepository.fetchSession(id: sessionId) {
session = fetched
}
snackbarMessage = "Pausa dalla regia"
}
/// Ripresa dalla regia / echo cable: solo RTMP locale, senza PATCH resume.
private func applyRemoteResume(container: AppContainer) async {
guard !resumeInFlight else { return }
resumeInFlight = true
defer { resumeInFlight = false }
do {
let current = try await container.sessionRepository.fetchSession(id: sessionId)
session = current
guard let url = current.rtmpIngestUrl, !url.isEmpty else { return }
let config = broadcastConfig(for: current, rtmpUrl: url)
try await container.broadcastCoordinator.resumeBroadcast(config: config)
snackbarMessage = "Diretta ripresa"
} catch {
snackbarMessage = UserFacingError.message(for: error) ?? "Ripresa RTMP non riuscita"
}
}
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 retryBroadcast() async {
error = nil
guard let session, let url = session.rtmpIngestUrl, !url.isEmpty else { return }
let config = broadcastConfig(for: session, rtmpUrl: url)
do {
try await container.broadcastCoordinator.resumeBroadcast(config: config)
snackbarMessage = "Riconnessione avviata"
} catch {
self.error = UserFacingError.message(for: error) ?? "Riconnessione non riuscita"
}
}
private func stopStream() async {
_ = try? await container.sessionRepository.stopSession(id: sessionId)
await teardown()
onFinished()
}
private func teardown() async {
container.sessionCable.onScoreUpdate = nil
container.sessionCable.onPauseStream = nil
container.sessionCable.onResumeStream = nil
container.sessionCable.onStopStream = nil
container.sessionCable.disconnect()
await container.broadcastCoordinator.stopBroadcast(sessionId: sessionId)
}
}