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>
This commit is contained in:
co-authored by
Cursor
parent
731b43ea88
commit
270bf680a4
@@ -101,6 +101,7 @@ final class ScoreController: ObservableObject {
|
||||
syncGeneration += 1
|
||||
guard let sessionId else { return false }
|
||||
|
||||
let previous = score
|
||||
let optimistic = closedSetState(from: score)
|
||||
score = optimistic
|
||||
sessionCable.sendScoreUpdate(optimistic)
|
||||
@@ -112,9 +113,11 @@ final class ScoreController: ObservableObject {
|
||||
sessionCable.sendScoreUpdate(updated)
|
||||
return true
|
||||
}
|
||||
suppressRemoteUntil = Date().addingTimeInterval(3)
|
||||
return true
|
||||
score = previous
|
||||
lastActionError = "Chiusura set non confermata dal server"
|
||||
return false
|
||||
} catch {
|
||||
score = previous
|
||||
lastActionError = UserFacingError.message(for: error)
|
||||
return false
|
||||
}
|
||||
@@ -161,10 +164,22 @@ final class ScoreController: ObservableObject {
|
||||
case "away_point_3": state.awayPoints += 3
|
||||
case "home_undo": state.homePoints = max(0, state.homePoints - 1)
|
||||
case "away_undo": state.awayPoints = max(0, state.awayPoints - 1)
|
||||
case "advance_period": applyOptimisticAdvancePeriod(to: &state)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
private func applyOptimisticAdvancePeriod(to state: inout ScoreState) {
|
||||
guard ScoreState.periodBoards.contains(state.boardType) else { return }
|
||||
state.period += 1
|
||||
state.clockRunning = false
|
||||
if state.boardType == "basket" {
|
||||
state.periodLabel = "Q\(state.period)"
|
||||
} else if state.boardType == "timed" {
|
||||
state.periodLabel = "\(state.period)° tempo"
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleAction(_ action: String) {
|
||||
syncTask?.cancel()
|
||||
syncGeneration += 1
|
||||
@@ -174,9 +189,12 @@ final class ScoreController: ObservableObject {
|
||||
do {
|
||||
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) {
|
||||
guard generation == syncGeneration else { return }
|
||||
suppressRemoteUntil = Date().addingTimeInterval(0.5)
|
||||
suppressRemoteUntil = Date().addingTimeInterval(0.8)
|
||||
score = updated
|
||||
sessionCable.sendScoreUpdate(updated)
|
||||
} else {
|
||||
guard generation == syncGeneration else { return }
|
||||
lastActionError = "Risposta punteggio non valida"
|
||||
}
|
||||
} catch {
|
||||
guard generation == syncGeneration else { return }
|
||||
|
||||
@@ -57,6 +57,8 @@ struct ScoreState: Equatable, Sendable {
|
||||
key &+= Int64(homePoints) * 10
|
||||
key &+= Int64(awayPoints)
|
||||
key &+= Int64(clockSecs)
|
||||
key &+= Int64(period) * 100_000_000
|
||||
key &+= overtime ? 50_000_000 : 0
|
||||
key &+= Int64(setPartials.count) * 1_000_000
|
||||
for partial in setPartials {
|
||||
key &+= Int64(partial.set) * 10_000
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
/// 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 var suppressDisconnectSeconds: TimeInterval {
|
||||
UIDevice.current.userInterfaceIdiom == .pad ? 2.5 : 1.0
|
||||
}
|
||||
|
||||
static func shouldApplyMixerOrientation(
|
||||
new: AVCaptureVideoOrientation,
|
||||
|
||||
@@ -42,6 +42,8 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
private var broadcastGeneration = 0
|
||||
private var suppressDisconnectError = false
|
||||
private var orientationTransitionUntil: Date = .distantPast
|
||||
private var reconnectAttempts = 0
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
|
||||
private var orientationDebounceTask: Task<Void, Never>?
|
||||
private var rtmpSession: (any Session)?
|
||||
@@ -121,6 +123,9 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
self.suppressDisconnectError = suppressDisconnectError
|
||||
defer { self.suppressDisconnectError = false }
|
||||
broadcastGeneration += 1
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
reconnectAttempts = 0
|
||||
publishPending = false
|
||||
publishTask?.cancel()
|
||||
publishTask = nil
|
||||
@@ -130,6 +135,9 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
|
||||
func resumeBroadcast(config: BroadcastConfig) async throws {
|
||||
self.config = config
|
||||
reconnectAttempts = 0
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
publishPending = true
|
||||
if !pipelineConfigured {
|
||||
try await configurePipeline(config)
|
||||
@@ -144,6 +152,9 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
self.suppressDisconnectError = suppressDisconnectError
|
||||
defer { self.suppressDisconnectError = false }
|
||||
broadcastGeneration += 1
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
reconnectAttempts = 0
|
||||
publishTask?.cancel()
|
||||
publishTask = nil
|
||||
publishPending = false
|
||||
@@ -254,10 +265,15 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
try await publish(config: config, generation: generation)
|
||||
if generation == broadcastGeneration {
|
||||
publishPending = false
|
||||
reconnectAttempts = 0
|
||||
}
|
||||
} catch {
|
||||
guard generation == broadcastGeneration else { return }
|
||||
publishPending = false
|
||||
if phase == .reconnecting || reconnectAttempts > 0 {
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
let message = UserFacingError.message(for: error) ?? "Connessione RTMP fallita"
|
||||
setPhase(.error, error: message)
|
||||
}
|
||||
@@ -300,16 +316,16 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
try await session.connect { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self,
|
||||
!self.shouldSuppressDisconnectError,
|
||||
self.broadcastGeneration == generation,
|
||||
self.phase == .live else { return }
|
||||
self.setPhase(.error, error: "Connessione RTMP interrotta")
|
||||
self.phase == .live || self.phase == .reconnecting else { return }
|
||||
self.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
guard generation == broadcastGeneration else {
|
||||
await teardownRTMP(keepPreview: true)
|
||||
return
|
||||
}
|
||||
reconnectAttempts = 0
|
||||
setPhase(.live)
|
||||
}
|
||||
|
||||
@@ -341,9 +357,8 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
switch state {
|
||||
case .open:
|
||||
setPhase(.live)
|
||||
case .closed where phase == .live:
|
||||
guard !shouldSuppressDisconnectError else { return }
|
||||
setPhase(.error, error: "Connessione RTMP interrotta")
|
||||
case .closed where phase == .live || phase == .reconnecting:
|
||||
scheduleReconnect()
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -369,6 +384,33 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
suppressDisconnectError || Date() < orientationTransitionUntil
|
||||
}
|
||||
|
||||
/// Riconnessione automatica RTMP (come Android): evita falsi errori su iPad.
|
||||
private func scheduleReconnect() {
|
||||
guard !shouldSuppressDisconnectError else { return }
|
||||
guard phase == .live || phase == .reconnecting else { return }
|
||||
guard let config else { return }
|
||||
|
||||
reconnectAttempts += 1
|
||||
if reconnectAttempts > config.maxReconnectAttempts {
|
||||
reconnectTask = nil
|
||||
setPhase(.error, error: "Connessione RTMP interrotta")
|
||||
return
|
||||
}
|
||||
|
||||
setPhase(.reconnecting)
|
||||
reconnectTask?.cancel()
|
||||
let generation = broadcastGeneration
|
||||
let delayMs = config.reconnectDelayMs
|
||||
reconnectTask = Task {
|
||||
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
guard !Task.isCancelled, generation == broadcastGeneration else { return }
|
||||
guard phase == .reconnecting else { return }
|
||||
publishPending = true
|
||||
publishInFlight = false
|
||||
await startPendingPublish()
|
||||
}
|
||||
}
|
||||
|
||||
private func applyVideoOrientationIfNeeded(force: Bool = false) async {
|
||||
let orientation = BroadcastVideoOrientation.captureOrientation()
|
||||
let landscapeLocked = AppOrientation.mode == .landscape
|
||||
|
||||
@@ -117,7 +117,10 @@ struct BroadcastScreen: View {
|
||||
}
|
||||
}
|
||||
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||
Button("OK") { onFinished() }
|
||||
Button("Riprova") {
|
||||
Task { await retryBroadcast() }
|
||||
}
|
||||
Button("Esci", role: .destructive) { onFinished() }
|
||||
} message: {
|
||||
Text(error ?? "")
|
||||
}
|
||||
@@ -240,7 +243,12 @@ struct BroadcastScreen: View {
|
||||
onCloseSet: usesSetScoring ? {
|
||||
Task { await liveScoreActions(for: match).requestCloseSet() }
|
||||
} : nil,
|
||||
onAdvancePeriod: usesActionScoring ? { scoreController.applyAction("advance_period") } : nil,
|
||||
onAdvancePeriod: usesActionScoring ? {
|
||||
Task {
|
||||
guard await scoreController.applyBoardAction("advance_period") else { return }
|
||||
updateOverlay()
|
||||
}
|
||||
} : nil,
|
||||
onPauseOrResume: {
|
||||
if isPaused {
|
||||
Task { await resumeStream() }
|
||||
@@ -285,6 +293,7 @@ struct BroadcastScreen: View {
|
||||
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
|
||||
}
|
||||
@@ -500,6 +509,18 @@ struct BroadcastScreen: View {
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -31,9 +31,10 @@ final class LiveScoreDialogHost: ObservableObject {
|
||||
}
|
||||
|
||||
func resolve(_ value: Bool) {
|
||||
guard let continuation = waiter else { return }
|
||||
pending = nil
|
||||
waiter?.resume(returning: value)
|
||||
waiter = nil
|
||||
continuation.resume(returning: value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,10 +132,9 @@ struct ScoreDialogRouter: View {
|
||||
private var dialogBinding: Binding<ScoreDialogState?> {
|
||||
Binding(
|
||||
get: { host.pending },
|
||||
set: { newValue in
|
||||
if newValue == nil, host.pending != nil {
|
||||
host.resolve(false)
|
||||
}
|
||||
set: { _ in
|
||||
// Non risolvere qui: su iPad la dismiss dell'alert può azzerare l'item
|
||||
// dopo il tap su «Chiudi set» e sovrascrivere resolve(true) con false.
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user