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
@@ -201,6 +201,29 @@ struct SetPartialDto: Codable {
let set: Int?
let home: Int?
let away: Int?
init(set: Int?, home: Int?, away: Int?) {
self.set = set
self.home = home
self.away = away
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
set = Self.decodeInt(container, key: .set)
home = Self.decodeInt(container, key: .home)
away = Self.decodeInt(container, key: .away)
}
enum CodingKeys: String, CodingKey {
case set, home, away
}
private static func decodeInt(_ container: KeyedDecodingContainer<CodingKeys>, key: CodingKeys) -> Int? {
if let value = try? container.decode(Int.self, forKey: key) { return value }
if let text = try? container.decode(String.self, forKey: key), let value = Int(text) { return value }
return nil
}
}
struct ScoreStateDto: Decodable {
@@ -18,6 +18,7 @@ final class SessionCableService: ObservableObject {
private var deviceRole = "camera"
func connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
disconnect()
self.sessionId = sessionId
self.accessToken = accessToken
self.deviceRole = deviceRole
@@ -29,9 +30,12 @@ final class SessionCableService: ObservableObject {
func disconnect() {
intentionalDisconnect = true
reconnectTask?.cancel()
reconnectTask = nil
client?.disconnect()
client = nil
connected = false
sessionId = nil
accessToken = nil
}
func sendScoreUpdate(_ score: ScoreState) {
@@ -84,8 +88,8 @@ extension SessionCableService: ActionCableClient.Listener {
case "score_update":
onScoreUpdate?(ScoreState.fromCablePayload(payload))
case "command":
let command = payload["command"] as? String ?? ""
switch command {
let action = payload["action"] as? String ?? ""
switch action {
case "pause_stream": onPauseStream?()
case "resume_stream": onResumeStream?()
case "stop_stream": onStopStream?()
@@ -18,13 +18,24 @@ final class ScoreController: ObservableObject {
}
func bind(sessionId: String, initial: ScoreState?) {
let sessionChanged = self.sessionId != sessionId
self.sessionId = sessionId
if let initial { score = initial }
if sessionChanged {
syncTask?.cancel()
syncGeneration += 1
lastActionError = nil
suppressRemoteUntil = .distantPast
score = initial ?? ScoreState()
} else if let initial {
score = initial
}
}
func applyRemote(_ remote: ScoreState) {
if remote == score { return }
if Date() < suppressRemoteUntil && remote.progressKey() <= score.progressKey() { return }
// Non accettare mai uno stato "più vecchio" (es. polling che sovrascrive close_set).
if remote.progressKey() < score.progressKey() { return }
if Date() < suppressRemoteUntil, remote.progressKey() == score.progressKey() { return }
score = remote
}
@@ -85,23 +96,55 @@ final class ScoreController: ObservableObject {
/// Chiude il set corrente tramite `close_set` sul motore backend.
func closeSetAsync() async -> Bool {
lastActionError = nil
syncTask?.cancel()
syncGeneration += 1
guard let sessionId else { return false }
let optimistic = closedSetState(from: score)
score = optimistic
sessionCable.sendScoreUpdate(optimistic)
do {
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: "close_set") {
suppressRemoteUntil = Date().addingTimeInterval(1.2)
suppressRemoteUntil = Date().addingTimeInterval(3)
score = updated
sessionCable.sendScoreUpdate(updated)
return true
}
return false
suppressRemoteUntil = Date().addingTimeInterval(3)
return true
} catch {
lastActionError = UserFacingError.message(for: error)
return false
}
}
private func closedSetState(from current: ScoreState) -> ScoreState {
let homeWon = current.homePoints > current.awayPoints
let awayWon = current.awayPoints > current.homePoints
var partials = current.setPartials
if current.homePoints > 0 || current.awayPoints > 0 {
partials.append(SetPartial(set: current.currentSet, home: current.homePoints, away: current.awayPoints))
}
return ScoreState(
homeSets: homeWon ? current.homeSets + 1 : current.homeSets,
awaySets: awayWon ? current.awaySets + 1 : current.awaySets,
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
)
}
private func updateScore(_ transform: (inout ScoreState) -> Void) {
var next = score
transform(&next)
@@ -143,28 +186,7 @@ final class ScoreController: ObservableObject {
}
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
)
score = closedSetState(from: score)
schedulePush()
}
+13 -6
View File
@@ -51,12 +51,19 @@ struct ScoreState: Equatable, Sendable {
}
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)
var key = Int64(currentSet) * 10_000_000
key &+= Int64(homeSets) * 100_000
key &+= Int64(awaySets) * 1_000
key &+= Int64(homePoints) * 10
key &+= Int64(awayPoints)
key &+= Int64(clockSecs)
key &+= Int64(setPartials.count) * 1_000_000
for partial in setPartials {
key &+= Int64(partial.set) * 10_000
key &+= Int64(partial.home) * 100
key &+= Int64(partial.away)
}
return key
}
static let periodBoards: Set<String> = ["basket", "timed"]
@@ -0,0 +1,28 @@
import AVFoundation
/// Regole orientamento in diretta (allineate al debounce Android).
enum BroadcastOrientationPolicy {
/// Attesa prima di applicare rotazione al mixer (evita burst da giro iPad).
static let debounceNanoseconds: UInt64 = 350_000_000
/// Finestra in cui ignorare `closed` RTMP dopo rotazione encoder.
static let suppressDisconnectSeconds: TimeInterval = 1.0
static func shouldApplyMixerOrientation(
new: AVCaptureVideoOrientation,
previous: AVCaptureVideoOrientation?,
landscapeLocked: Bool
) -> Bool {
guard isApplicableForBroadcast(new, landscapeLocked: landscapeLocked) else { return false }
guard let previous else { return true }
return new != previous
}
/// In diretta landscape ignoriamo portrait/unknown: evita glitch quando il sensore passa da verticale.
static func isApplicableForBroadcast(
_ orientation: AVCaptureVideoOrientation,
landscapeLocked: Bool
) -> Bool {
guard landscapeLocked else { return true }
return orientation == .landscapeLeft || orientation == .landscapeRight
}
}
@@ -1,15 +1,16 @@
import AVFoundation
import HaishinKit
import UIKit
/// Allinea l'orientamento del sensore camera all'interfaccia (broadcast sempre landscape).
/// Orientamento camera allineato a HaishinKit DeviceUtil (broadcast landscape su iPad).
enum BroadcastVideoOrientation {
@MainActor
static func captureOrientation() -> AVCaptureVideoOrientation {
if let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }) {
return captureOrientation(for: scene.interfaceOrientation)
if let scene = activeWindowScene(),
let orientation = DeviceUtil.videoOrientation(by: scene.interfaceOrientation) {
return orientation
}
// BroadcastScreen forza landscapeRight.
return .landscapeRight
}
@@ -19,13 +20,10 @@ enum BroadcastVideoOrientation {
return orientation == .portrait || orientation == .portraitUpsideDown
}
private static func captureOrientation(for orientation: UIInterfaceOrientation) -> AVCaptureVideoOrientation {
switch orientation {
case .portrait: return .portrait
case .portraitUpsideDown: return .portraitUpsideDown
case .landscapeLeft: return .landscapeLeft
case .landscapeRight: return .landscapeRight
default: return .landscapeRight
}
@MainActor
private static func activeWindowScene() -> UIWindowScene? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first(where: { $0.activationState == .foregroundActive })
}
}
@@ -6,6 +6,8 @@ final class LiveBroadcastCoordinator: ObservableObject {
let engine = LiveBroadcastEngine()
@Published private(set) var metrics = BroadcastMetrics()
private(set) var activeBroadcastSessionId: String?
init() {
engine.setMetricsListener { [weak self] value in
Task { @MainActor in self?.metrics = value }
@@ -22,24 +24,56 @@ final class LiveBroadcastCoordinator: ObservableObject {
try? session.setActive(true)
}
func startBroadcast(config: BroadcastConfig) async throws {
func waitForPreviewSurface() async -> Bool {
await engine.waitForPreviewSurface()
}
func prepareBroadcast(sessionId: String, config: BroadcastConfig, paused: Bool) async throws {
configureBackgroundAudio()
try await engine.startBroadcast(config: config)
if Self.shouldStopPreviousSession(active: activeBroadcastSessionId, incoming: sessionId) {
await engine.stopBroadcast(suppressDisconnectError: true)
}
activeBroadcastSessionId = sessionId
if paused {
try await engine.preparePreview(config: config)
_ = await engine.waitForPreviewReady()
} else {
try await engine.prepareForBroadcast(config: config)
}
}
func startBroadcast(sessionId: String, config: BroadcastConfig) async throws {
try await prepareBroadcast(sessionId: sessionId, config: config, paused: false)
}
func pauseBroadcast() async {
await engine.pauseBroadcast()
await engine.pauseBroadcast(suppressDisconnectError: true)
}
func resumeBroadcast(config: BroadcastConfig) async throws {
try await engine.resumeBroadcast(config: config)
}
func stopBroadcast() async {
await engine.stopBroadcast()
/// Ferma RTMP solo se la sessione indicata è ancora quella attiva (evita race tra dirette).
func stopBroadcast(sessionId: String) async {
guard activeBroadcastSessionId == sessionId else { return }
activeBroadcastSessionId = nil
await engine.stopBroadcast(suppressDisconnectError: true)
}
/// Chiusura cover / uscita forzata: ferma sempre lo stream corrente.
func forceStopBroadcast() async {
activeBroadcastSessionId = nil
await engine.stopBroadcast(suppressDisconnectError: true)
}
func release() async {
await engine.release()
await forceStopBroadcast()
}
/// `true` solo quando si passa da una diretta già attiva a un'altra sessione (non al primo avvio).
nonisolated static func shouldStopPreviousSession(active: String?, incoming: String) -> Bool {
guard let active else { return false }
return active != incoming
}
}
@@ -39,6 +39,11 @@ final class LiveBroadcastEngine: ObservableObject {
private var previewAttached = false
private var publishPending = false
private var publishInFlight = false
private var broadcastGeneration = 0
private var suppressDisconnectError = false
private var orientationTransitionUntil: Date = .distantPast
private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
private var orientationDebounceTask: Task<Void, Never>?
private var rtmpSession: (any Session)?
private static var rtmpFactoryRegistered = false
@@ -60,14 +65,38 @@ final class LiveBroadcastEngine: ObservableObject {
/// Anteprima locale: output del mixer (come HaishinKit PublishViewModel), non dello stream RTMP.
func bindPreview(to view: MTHKView) async {
previewView = view
guard pipelineConfigured, !previewAttached else { return }
await mixer.addOutput(view)
await attachPreviewIfNeeded()
}
private func attachPreviewIfNeeded() async {
guard let previewView, pipelineConfigured, !previewAttached else { return }
await mixer.addOutput(previewView)
previewAttached = true
if publishPending {
await startPendingPublish()
}
}
/// Attende che SwiftUI monti la surface di anteprima (MTHKView).
func waitForPreviewSurface(maxAttempts: Int = 100) async -> Bool {
for _ in 0..<maxAttempts {
if previewView != nil { return true }
try? await Task.sleep(nanoseconds: 50_000_000)
}
return previewView != nil
}
/// Attende che l'anteprima sia collegata al mixer (dopo `configurePipeline`).
func waitForPreviewReady(maxAttempts: Int = 100) async -> Bool {
for _ in 0..<maxAttempts {
if Task.isCancelled { return previewAttached }
await attachPreviewIfNeeded()
if previewAttached { return true }
try? await Task.sleep(nanoseconds: 50_000_000)
}
return previewAttached
}
func preparePreview(config: BroadcastConfig) async throws {
self.config = config
publishPending = false
@@ -88,12 +117,15 @@ final class LiveBroadcastEngine: ObservableObject {
try await prepareForBroadcast(config: config)
}
func pauseBroadcast() async {
func pauseBroadcast(suppressDisconnectError: Bool = false) async {
self.suppressDisconnectError = suppressDisconnectError
defer { self.suppressDisconnectError = false }
broadcastGeneration += 1
publishPending = false
publishTask?.cancel()
publishTask = nil
await teardownRTMP(keepPreview: true)
setPhase(.paused)
await teardownRTMP(keepPreview: true)
}
func resumeBroadcast(config: BroadcastConfig) async throws {
@@ -101,27 +133,35 @@ final class LiveBroadcastEngine: ObservableObject {
publishPending = true
if !pipelineConfigured {
try await configurePipeline(config)
} else {
await attachPreviewIfNeeded()
}
setPhase(.connecting)
await startPendingPublish()
}
func stopBroadcast() async {
func stopBroadcast(suppressDisconnectError: Bool = false) async {
self.suppressDisconnectError = suppressDisconnectError
defer { self.suppressDisconnectError = false }
broadcastGeneration += 1
publishTask?.cancel()
publishTask = nil
publishPending = false
publishInFlight = false
stopOrientationMonitoring()
orientationDebounceTask?.cancel()
orientationDebounceTask = nil
lastAppliedCaptureOrientation = nil
orientationTransitionUntil = .distantPast
setPhase(.idle)
await teardownRTMP(keepPreview: false)
if let previewView, previewAttached {
await mixer.removeOutput(previewView)
}
previewAttached = false
previewView = nil
overlayRenderer.detach()
try? await mixer.stopRunning()
pipelineConfigured = false
setPhase(.idle)
}
func release() async {
@@ -133,7 +173,10 @@ final class LiveBroadcastEngine: ObservableObject {
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw APIError.http(403, "Permessi camera e microfono richiesti")
}
if pipelineConfigured { return }
if pipelineConfigured {
await attachPreviewIfNeeded()
return
}
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
@@ -149,9 +192,11 @@ final class LiveBroadcastEngine: ObservableObject {
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.attachVideo(camera, track: 0) { videoUnit in
videoUnit.isVideoMirrored = false
}
}
await applyVideoOrientation()
await applyVideoOrientationIfNeeded(force: true)
startOrientationMonitoring()
try await mixer.startRunning()
@@ -162,11 +207,7 @@ final class LiveBroadcastEngine: ObservableObject {
isPortrait: isPortrait
)
pipelineConfigured = true
if let previewView, !previewAttached {
await mixer.addOutput(previewView)
previewAttached = true
}
await attachPreviewIfNeeded()
}
private func configureScreenSize(width: Int, height: Int) async {
@@ -186,40 +227,48 @@ final class LiveBroadcastEngine: ObservableObject {
private func waitForPreviewThenPublish() async {
guard publishPending, let config else { return }
let generation = broadcastGeneration
publishInFlight = true
defer { publishInFlight = false }
for _ in 0..<80 {
for _ in 0..<120 {
if Task.isCancelled { return }
if generation != broadcastGeneration { return }
await attachPreviewIfNeeded()
if previewAttached { break }
try? await Task.sleep(nanoseconds: 50_000_000)
}
guard previewAttached else {
publishPending = false
setPhase(.error, error: "Anteprima camera non pronta")
guard previewAttached, generation == broadcastGeneration else {
if generation == broadcastGeneration {
publishPending = false
setPhase(.error, error: "Anteprima camera non pronta")
}
return
}
setPhase(.connecting)
try? await Task.sleep(nanoseconds: 350_000_000)
guard publishPending else { return }
guard publishPending, generation == broadcastGeneration else { return }
do {
try await publish(config: config)
publishPending = false
try await publish(config: config, generation: generation)
if generation == broadcastGeneration {
publishPending = false
}
} catch {
guard generation == broadcastGeneration else { return }
publishPending = false
let message = UserFacingError.message(for: error) ?? "Connessione RTMP fallita"
setPhase(.error, error: message)
}
}
private func publish(config: BroadcastConfig) async throws {
private func publish(config: BroadcastConfig, generation: Int) async throws {
guard let url = URL(string: config.rtmpUrl) else {
throw APIError.http(400, "URL RTMP non valido")
}
await Self.ensureRTMPFactoryRegistered()
await applyVideoOrientation()
await applyVideoOrientationIfNeeded(force: true)
await teardownRTMP(keepPreview: true)
let session = try await SessionBuilderFactory.shared.make(url)
@@ -246,14 +295,21 @@ final class LiveBroadcastEngine: ObservableObject {
})
await mixer.addOutput(stream)
rtmpSession = session
observeReadyState(session)
observeReadyState(session, generation: generation)
try await session.connect { [weak self] in
Task { @MainActor in
guard let self, self.phase == .live else { return }
guard let self,
!self.shouldSuppressDisconnectError,
self.broadcastGeneration == generation,
self.phase == .live else { return }
self.setPhase(.error, error: "Connessione RTMP interrotta")
}
}
guard generation == broadcastGeneration else {
await teardownRTMP(keepPreview: true)
return
}
setPhase(.live)
}
@@ -277,14 +333,16 @@ final class LiveBroadcastEngine: ObservableObject {
try await stream.setAudioSettings(audioSettings)
}
private func observeReadyState(_ session: any Session) {
private func observeReadyState(_ session: any Session, generation: Int) {
readyStateTask?.cancel()
readyStateTask = Task {
for await state in await session.readyState {
guard generation == broadcastGeneration else { return }
switch state {
case .open:
setPhase(.live)
case .closed where phase == .live:
guard !shouldSuppressDisconnectError else { return }
setPhase(.error, error: "Connessione RTMP interrotta")
default:
break
@@ -307,15 +365,49 @@ final class LiveBroadcastEngine: ObservableObject {
}
}
private func applyVideoOrientation() async {
private var shouldSuppressDisconnectError: Bool {
suppressDisconnectError || Date() < orientationTransitionUntil
}
private func applyVideoOrientationIfNeeded(force: Bool = false) async {
let orientation = BroadcastVideoOrientation.captureOrientation()
let landscapeLocked = AppOrientation.mode == .landscape
if !force,
!BroadcastOrientationPolicy.shouldApplyMixerOrientation(
new: orientation,
previous: lastAppliedCaptureOrientation,
landscapeLocked: landscapeLocked
) {
return
}
guard BroadcastOrientationPolicy.isApplicableForBroadcast(orientation, landscapeLocked: landscapeLocked) else {
return
}
if phase == .live || phase == .connecting {
orientationTransitionUntil = Date().addingTimeInterval(BroadcastOrientationPolicy.suppressDisconnectSeconds)
}
lastAppliedCaptureOrientation = orientation
await mixer.setVideoOrientation(orientation)
try? await mixer.configuration(video: 0) { videoUnit in
videoUnit.isVideoMirrored = false
}
let portrait = BroadcastVideoOrientation.isPortraitContent
guard portrait != isPortrait else { return }
isPortrait = portrait
overlayRenderer.refreshOrientation(isPortrait: portrait)
}
private func scheduleOrientationUpdate() {
orientationDebounceTask?.cancel()
orientationDebounceTask = Task {
try? await Task.sleep(nanoseconds: BroadcastOrientationPolicy.debounceNanoseconds)
guard !Task.isCancelled else { return }
await applyVideoOrientationIfNeeded()
}
}
private func startOrientationMonitoring() {
stopOrientationMonitoring()
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
@@ -325,12 +417,14 @@ final class LiveBroadcastEngine: ObservableObject {
queue: .main
) { [weak self] _ in
Task { @MainActor in
await self?.applyVideoOrientation()
self?.scheduleOrientationUpdate()
}
}
}
private func stopOrientationMonitoring() {
orientationDebounceTask?.cancel()
orientationDebounceTask = nil
if let orientationObserver {
NotificationCenter.default.removeObserver(orientationObserver)
self.orientationObserver = nil
@@ -4,12 +4,12 @@ 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 }
guard state.overlayKind != .none, state.watermarkVisible, let logo 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))
logo.draw(in: CGRect(x: left, y: top, width: targetWidth, height: targetHeight))
}
}
@@ -7,11 +7,14 @@ struct LivePreviewView: UIViewRepresentable {
func makeUIView(context: Context) -> MTHKView {
let view = MTHKView(frame: .zero)
view.videoGravity = .resizeAspectFill
// resizeAspect: mostra tutto il frame 16:9 trasmesso (resizeAspectFill taglia i lati su iPad).
view.videoGravity = .resizeAspect
view.isUserInteractionEnabled = false
Task { await engine.bindPreview(to: view) }
return view
}
func updateUIView(_ uiView: MTHKView, context: Context) {}
func updateUIView(_ uiView: MTHKView, context: Context) {
Task { await engine.bindPreview(to: uiView) }
}
}
@@ -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