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:
Emiliano Frascaro
2026-06-20 12:30:34 +02:00
parent 731b43ea88
commit 270bf680a4
16 changed files with 665 additions and 441 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -3,28 +3,28 @@
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
<BuildActionEntries>
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="553940DB807C4DBFB63E9FFE" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="AE6E9D532CE6492D8B82687F" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</BuildActionEntry>
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="79FDBB021E5045C5A353AEE8" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="64F84AC8A424490D945D5B65" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
<Testables>
<TestableReference skipped="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="79FDBB021E5045C5A353AEE8" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="64F84AC8A424490D945D5B65" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="553940DB807C4DBFB63E9FFE" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="AE6E9D532CE6492D8B82687F" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="553940DB807C4DBFB63E9FFE" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="AE6E9D532CE6492D8B82687F" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration="Debug"/>

View File

@@ -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 }

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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()

View File

@@ -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.
}
)
}

View File

@@ -0,0 +1,25 @@
import XCTest
@testable import MatchLiveTv
@MainActor
final class LiveScoreDialogHostTests: XCTestCase {
func testResolveTrueCompletesShow() async {
let host = LiveScoreDialogHost()
let task = Task { await host.show(ScoreDialogState(kind: .setWon)) }
try? await Task.sleep(nanoseconds: 50_000_000)
host.resolve(true)
let result = await task.value
XCTAssertTrue(result)
XCTAssertNil(host.pending)
}
func testSecondResolveIsIgnored() async {
let host = LiveScoreDialogHost()
let task = Task { await host.show(ScoreDialogState(kind: .setWon)) }
try? await Task.sleep(nanoseconds: 50_000_000)
host.resolve(true)
host.resolve(false)
let result = await task.value
XCTAssertTrue(result)
}
}

View File

@@ -65,4 +65,38 @@ final class ScoreActionDecodeTests: XCTestCase {
XCTAssertEqual(score.setPartials[0].home, 25)
XCTAssertEqual(score.setPartials[0].away, 18)
}
func testDecodesBasketAdvancePeriodResponse() throws {
let json = """
{
"id": "821ddb81-9dc2-466a-9ad5-2555e78f4653",
"match_id": "e887ce1d-10ee-4680-8323-79bb8f11e0aa",
"status": "live",
"platform": "matchlivetv",
"score": {
"type": "score_update",
"board_type": "basket",
"home_points": 12,
"away_points": 10,
"current_set": 1,
"set_partials": [],
"timeout_home": false,
"timeout_away": false,
"period": "Q2",
"clock_secs": 600,
"clock_running": false,
"data": { "home_score": 12, "away_score": 10, "period": 2, "clock_secs": 600, "clock_running": false, "overtime": false }
},
"set_won": false,
"match_won": false,
"winner": null
}
""".data(using: .utf8)!
let dto = try ApiInstant.decoder.decode(StreamSessionDto.self, from: json)
let score = try XCTUnwrap(dto.score?.toDomain())
XCTAssertEqual(score.periodLabel, "Q2")
XCTAssertEqual(score.period, 2)
XCTAssertEqual(score.clockSecs, 600)
XCTAssertFalse(score.clockRunning)
}
}

View File

@@ -55,4 +55,30 @@ final class ScoreControllerTests: XCTestCase {
controller.applyAction("home_point")
XCTAssertEqual(controller.score.homePoints, 1)
}
func testAdvancePeriodOptimisticUpdatesQuarter() {
let controller = ScoreController(
scoreRepository: ScoreRepository(api: MatchLiveAPI()),
sessionCable: SessionCableService()
)
var initial = ScoreState()
initial.boardType = "basket"
initial.period = 1
initial.periodLabel = "Q1"
controller.bind(sessionId: "session-1", initial: initial)
controller.applyAction("advance_period")
XCTAssertEqual(controller.score.period, 2)
XCTAssertEqual(controller.score.periodLabel, "Q2")
XCTAssertFalse(controller.score.clockRunning)
}
func testProgressKeyChangesWhenPeriodAdvances() {
var q1 = ScoreState(boardType: "basket", period: 1, periodLabel: "Q1", clockSecs: 600)
var q2 = ScoreState(boardType: "basket", period: 2, periodLabel: "Q2", clockSecs: 600)
q1.homePoints = 10
q1.awayPoints = 8
q2.homePoints = 10
q2.awayPoints = 8
XCTAssertGreaterThan(q2.progressKey(), q1.progressKey())
}
}