Stabilizza diretta iOS e aggiunge test simulatore pre-release.

Corregge lifecycle RTMP/preview tra partite, reset tabellone, orientamento
con debounce, condivisione su iPad e script test_ios_simulator per CI locale.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Emiliano Frascaro
2026-06-20 12:02:14 +02:00
co-authored by Cursor
parent 0f69ebcccb
commit 731b43ea88
25 changed files with 1101 additions and 562 deletions
@@ -130,6 +130,8 @@ struct BroadcastControlsOverlay: View {
)
}
}
.frame(width: sideToolbarWidth)
.allowsHitTesting(true)
}
private var rightToolbar: some View {
@@ -449,6 +451,7 @@ private struct SideIconButton: View {
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(enabled ? .white : MatchColors.textSecondary)
.frame(width: iconButtonSize, height: iconButtonSize)
.contentShape(Rectangle())
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
@@ -25,12 +25,21 @@ struct BroadcastScreen: View {
@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 {
@@ -46,9 +55,6 @@ struct BroadcastScreen: View {
}
.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)
@@ -59,22 +65,47 @@ struct BroadcastScreen: View {
.onAppear {
UIDevice.current.isBatteryMonitoringEnabled = true
}
.onDisappear {
Task { await teardown() }
}
.task {
await permissions.refresh()
if !permissions.allGranted {
await permissions.requestAll()
}
}
.task(id: permissions.allGranted) {
.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 }
@@ -90,6 +121,11 @@ struct BroadcastScreen: View {
} message: {
Text(error ?? "")
}
.sheet(item: $shareItem) { item in
ShareSheet(items: item.items, subject: item.subject) {
shareItem = nil
}
}
.background {
if let match {
ScoreDialogRouter(
@@ -215,7 +251,7 @@ struct BroadcastScreen: View {
onTerminate: { Task { await stopStream() } },
onShareLive: { shareLiveLink(session: session, subject: shareSubject) },
onShareRegia: { shareRegiaLink(subject: shareSubject) },
shareLiveEnabled: session.watchShareUrl() != nil,
shareLiveEnabled: true,
fps: metrics.fps,
targetFps: session.targetFps,
bitrateKbps: metrics.bitrateKbps,
@@ -229,7 +265,8 @@ struct BroadcastScreen: View {
rules: MatchScoringContext(match: match),
scoreController: scoreController,
dialogHost: scoreDialogHost,
onStopStream: { await stopStream() }
onStopStream: { await stopStream() },
onScoreboardChanged: { updateOverlay() }
)
}
@@ -254,58 +291,62 @@ struct BroadcastScreen: View {
}
private func shareLiveLink(session: StreamSession, subject: String) {
guard let urlString = session.watchShareUrl(), let url = URL(string: urlString) else {
let urlString = session.watchShareUrl() ?? "\(AppConfig.apiBaseUrl)/live/\(session.id)"
guard let url = URL(string: urlString) else {
snackbarMessage = "Link diretta non ancora disponibile"
return
}
presentShare(items: [url], subject: subject)
shareItem = ShareItem(items: [url], subject: subject)
}
private func shareRegiaLink(subject: String) {
Task {
Task { @MainActor in
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)")
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 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 {
bootstrapGeneration += 1
let generation = bootstrapGeneration
loading = true
error = nil
await container.broadcastCoordinator.stopBroadcast()
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)
loading = false
updateOverlay()
try? await Task.sleep(nanoseconds: 100_000_000)
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)
if loaded.isPaused {
try await container.broadcastCoordinator.engine.preparePreview(config: config)
} else {
try await container.broadcastCoordinator.engine.prepareForBroadcast(config: config)
}
try await container.broadcastCoordinator.prepareBroadcast(
sessionId: sessionId,
config: config,
paused: loaded.isPaused
)
}
startPolling()
} catch {
guard generation == bootstrapGeneration else { return }
if let message = UserFacingError.message(for: error) {
self.error = message
}
@@ -319,11 +360,15 @@ struct BroadcastScreen: View {
container?.scoreController.applyRemote(remote)
}
container.sessionCable.onPauseStream = { [weak container] in
Task { @MainActor in await container?.broadcastCoordinator.pauseBroadcast() }
Task { @MainActor in
guard let container, !pauseInFlight else { return }
await applyRemotePause(container: container)
}
}
container.sessionCable.onResumeStream = { [weak container] in
Task { @MainActor in
await resumeStream()
guard let container, !resumeInFlight else { return }
await applyRemoteResume(container: container)
}
}
container.sessionCable.onStopStream = { [weak container] in
@@ -381,48 +426,26 @@ struct BroadcastScreen: View {
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
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
@@ -431,10 +454,38 @@ struct BroadcastScreen: View {
try await container.broadcastCoordinator.resumeBroadcast(config: config)
updated = try await container.sessionRepository.fetchSession(id: sessionId)
session = updated
snackbarMessage = "Diretta ripresa"
} catch {
if let message = UserFacingError.message(for: error) {
self.error = message
}
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"
}
}
@@ -456,7 +507,11 @@ struct BroadcastScreen: View {
}
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()
await container.broadcastCoordinator.stopBroadcast(sessionId: sessionId)
}
}
@@ -43,17 +43,20 @@ final class LiveScoreActions {
private let scoreController: ScoreController
private let dialogHost: LiveScoreDialogHost
private let onStopStream: () async -> Void
private let onScoreboardChanged: () -> Void
init(
rules: MatchScoringContext,
scoreController: ScoreController,
dialogHost: LiveScoreDialogHost,
onStopStream: @escaping () async -> Void
onStopStream: @escaping () async -> Void,
onScoreboardChanged: @escaping () -> Void = {}
) {
self.rules = rules
self.scoreController = scoreController
self.dialogHost = dialogHost
self.onStopStream = onStopStream
self.onScoreboardChanged = onScoreboardChanged
}
func afterPointChange(score: ScoreState) async {
@@ -74,6 +77,7 @@ final class LiveScoreActions {
)
if close {
guard await scoreController.closeSetAsync() else { return }
onScoreboardChanged()
await afterCloseSet()
}
}
@@ -92,6 +96,7 @@ final class LiveScoreActions {
if !ok { return }
}
guard await scoreController.closeSetAsync() else { return }
onScoreboardChanged()
await afterCloseSet()
}
@@ -58,10 +58,17 @@ struct AppNavHost: View {
.lockPortraitOrientation()
}
.fullScreenCover(item: $broadcastRoute, onDismiss: {
Task { @MainActor in
container.sessionCable.disconnect()
await container.broadcastCoordinator.forceStopBroadcast()
}
AppOrientation.lockPortrait()
matchesRefreshToken += 1
}) { route in
BroadcastScreen(container: container, sessionId: route.sessionId) {
Task { @MainActor in
await container.broadcastCoordinator.stopBroadcast(sessionId: route.sessionId)
}
AppOrientation.lockPortrait()
container.wizardSession.reset()
broadcastRoute = nil
@@ -0,0 +1,28 @@
import SwiftUI
import UIKit
struct ShareItem: Identifiable {
let id = UUID()
let items: [Any]
let subject: String?
}
/// Foglio condivisione compatibile iPhone/iPad (evita present() diretto da root VC).
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
var subject: String?
var onComplete: (() -> Void)?
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
if let subject {
controller.setValue(subject, forKey: "subject")
}
controller.completionWithItemsHandler = { _, _, _, _ in
onComplete?()
}
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
@@ -19,6 +19,7 @@ struct StepNetworkTestScreen: View {
@State private var selectedQualityLabel: String?
@State private var starting = false
@State private var currentSession: StreamSession
@State private var shareItem: ShareItem?
init(
container: AppContainer,
@@ -154,6 +155,11 @@ struct StepNetworkTestScreen: View {
.task(id: session.id) {
await pollYoutubeIfNeeded()
}
.sheet(item: $shareItem) { item in
ShareSheet(items: item.items, subject: item.subject) {
shareItem = nil
}
}
}
private func runTest() {
@@ -223,8 +229,14 @@ struct StepNetworkTestScreen: View {
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])
guard let link = URL(string: url) else {
onError("Link regia non valido")
return
}
shareItem = ShareItem(
items: [link],
subject: "Link regia — \(match.teamName) vs \(match.opponentName)"
)
} catch {
if let message = UserFacingError.message(for: error) {
onError(message)
@@ -233,13 +245,6 @@ struct StepNetworkTestScreen: View {
}
}
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