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:
co-authored by
Cursor
parent
b798e0ea06
commit
585332e32e
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user