Sincronizza overlay video con punteggio dalla regia remota.

L'iPad accetta gli aggiornamenti ActionCable della regia, evita race con risposte API obsolete e ridisegna subito il tabellone sullo stream RTMP.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Emiliano Frascaro
2026-06-20 12:37:46 +02:00
parent 270bf680a4
commit c92099a41b
4 changed files with 94 additions and 20 deletions

View File

@@ -2,9 +2,17 @@ import Foundation
@MainActor @MainActor
final class ScoreController: ObservableObject { final class ScoreController: ObservableObject {
enum RemoteScoreSource {
case polling
case cable
}
@Published private(set) var score = ScoreState() @Published private(set) var score = ScoreState()
@Published private(set) var lastActionError: String? @Published private(set) var lastActionError: String?
/// Chiamato dopo ogni mutazione punteggio (es. aggiornare overlay video in diretta).
var onScoreDidChange: (() -> Void)?
private let scoreRepository: ScoreRepository private let scoreRepository: ScoreRepository
private let sessionCable: SessionCableService private let sessionCable: SessionCableService
private var sessionId: String? private var sessionId: String?
@@ -31,12 +39,21 @@ final class ScoreController: ObservableObject {
} }
} }
func applyRemote(_ remote: ScoreState) { func applyRemote(_ remote: ScoreState, source: RemoteScoreSource = .polling) {
if remote == score { return } if remote == score { return }
// Non accettare mai uno stato "più vecchio" (es. polling che sovrascrive close_set).
if remote.progressKey() < score.progressKey() { return } if remote.progressKey() < score.progressKey() { return }
if Date() < suppressRemoteUntil, remote.progressKey() == score.progressKey() { return } if source == .polling,
score = remote Date() < suppressRemoteUntil,
remote.progressKey() == score.progressKey() {
return
}
if source == .cable {
// Punteggio dalla regia: annulla sync API in volo che potrebbe sovrascrivere con stato vecchio.
syncTask?.cancel()
syncGeneration += 1
suppressRemoteUntil = .distantPast
}
publishScore(remote)
} }
func incrementHome() { func incrementHome() {
@@ -80,8 +97,8 @@ final class ScoreController: ObservableObject {
do { do {
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) { if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) {
suppressRemoteUntil = Date().addingTimeInterval(0.8) suppressRemoteUntil = Date().addingTimeInterval(0.8)
score = updated applyServerScoreIfNewer(updated)
sessionCable.sendScoreUpdate(updated) sessionCable.sendScoreUpdate(score)
return true return true
} }
score = previous score = previous
@@ -103,14 +120,14 @@ final class ScoreController: ObservableObject {
let previous = score let previous = score
let optimistic = closedSetState(from: score) let optimistic = closedSetState(from: score)
score = optimistic publishScore(optimistic)
sessionCable.sendScoreUpdate(optimistic) sessionCable.sendScoreUpdate(optimistic)
do { do {
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: "close_set") { if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: "close_set") {
suppressRemoteUntil = Date().addingTimeInterval(3) suppressRemoteUntil = Date().addingTimeInterval(3)
score = updated applyServerScoreIfNewer(updated)
sessionCable.sendScoreUpdate(updated) sessionCable.sendScoreUpdate(score)
return true return true
} }
score = previous score = previous
@@ -151,7 +168,19 @@ final class ScoreController: ObservableObject {
private func updateScore(_ transform: (inout ScoreState) -> Void) { private func updateScore(_ transform: (inout ScoreState) -> Void) {
var next = score var next = score
transform(&next) transform(&next)
score = next publishScore(next)
}
private func publishScore(_ newScore: ScoreState) {
guard score != newScore else { return }
score = newScore
onScoreDidChange?()
}
/// Applica la risposta API solo se non è più vecchia dello stato locale (evita race con la regia).
private func applyServerScoreIfNewer(_ remote: ScoreState) {
guard remote.progressKey() >= score.progressKey() else { return }
publishScore(remote)
} }
private func applyOptimistic(to state: inout ScoreState, action: String) { private func applyOptimistic(to state: inout ScoreState, action: String) {
@@ -190,8 +219,8 @@ final class ScoreController: ObservableObject {
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) { if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) {
guard generation == syncGeneration else { return } guard generation == syncGeneration else { return }
suppressRemoteUntil = Date().addingTimeInterval(0.8) suppressRemoteUntil = Date().addingTimeInterval(0.8)
score = updated applyServerScoreIfNewer(updated)
sessionCable.sendScoreUpdate(updated) sessionCable.sendScoreUpdate(score)
} else { } else {
guard generation == syncGeneration else { return } guard generation == syncGeneration else { return }
lastActionError = "Risposta punteggio non valida" lastActionError = "Risposta punteggio non valida"
@@ -204,7 +233,7 @@ final class ScoreController: ObservableObject {
} }
func closeSet() { func closeSet() {
score = closedSetState(from: score) publishScore(closedSetState(from: score))
schedulePush() schedulePush()
} }
@@ -219,7 +248,7 @@ final class ScoreController: ObservableObject {
if let remote = try? await scoreRepository.syncScore(sessionId: sessionId, score: local).score { if let remote = try? await scoreRepository.syncScore(sessionId: sessionId, score: local).score {
guard generation == syncGeneration else { return } guard generation == syncGeneration else { return }
suppressRemoteUntil = Date().addingTimeInterval(0.5) suppressRemoteUntil = Date().addingTimeInterval(0.5)
score = remote applyServerScoreIfNewer(remote)
} }
} }
} }

View File

@@ -75,6 +75,7 @@ struct ScoreState: Equatable, Sendable {
let nested = data["data"] as? [String: Any] let nested = data["data"] as? [String: Any]
let partialsRaw = (data["set_partials"] as? [Any]) ?? (data["setPartials"] as? [Any]) ?? [] let partialsRaw = (data["set_partials"] as? [Any]) ?? (data["setPartials"] as? [Any]) ?? []
let periodNumber = intFrom(nested, key: "period").flatMap { $0 > 0 ? $0 : nil } let periodNumber = intFrom(nested, key: "period").flatMap { $0 > 0 ? $0 : nil }
?? parsePeriodNumber(from: data["period"])
?? max(intField(data, snake: "current_set", camel: "currentSet"), 1) ?? max(intField(data, snake: "current_set", camel: "currentSet"), 1)
return ScoreState( return ScoreState(
@@ -95,7 +96,7 @@ struct ScoreState: Equatable, Sendable {
timeoutAway: data["timeout_away"] as? Bool ?? false, timeoutAway: data["timeout_away"] as? Bool ?? false,
boardType: boardType, boardType: boardType,
period: periodNumber, period: periodNumber,
periodLabel: data["period"] as? String, periodLabel: parsePeriodLabel(from: data["period"]) ?? nested?["period"] as? String,
clockSecs: { clockSecs: {
let direct = intField(data, snake: "clock_secs", camel: "clockSecs") let direct = intField(data, snake: "clock_secs", camel: "clockSecs")
if direct > 0 || data["clock_secs"] != nil || data["clockSecs"] != nil { return direct } if direct > 0 || data["clock_secs"] != nil || data["clockSecs"] != nil { return direct }
@@ -119,4 +120,21 @@ struct ScoreState: Equatable, Sendable {
if let s = data[key] as? String, let v = Int(s) { return v } if let s = data[key] as? String, let v = Int(s) { return v }
return nil return nil
} }
private static func parsePeriodLabel(from raw: Any?) -> String? {
if let label = raw as? String, !label.isEmpty { return label }
return nil
}
private static func parsePeriodNumber(from raw: Any?) -> Int? {
if let n = raw as? NSNumber, n.intValue > 0 { return n.intValue }
if let label = raw as? String {
if let match = label.range(of: #"Q(\d+)"#, options: .regularExpression) {
let num = label[match].dropFirst()
if let value = Int(num) { return value }
}
if let value = Int(label), value > 0 { return value }
}
return nil
}
} }

View File

@@ -80,9 +80,9 @@ struct BroadcastScreen: View {
} }
.task(id: sessionId) { .task(id: sessionId) {
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 4_000_000_000) try? await Task.sleep(nanoseconds: 2_000_000_000)
if let remote = try? await container.sessionRepository.fetchSession(id: sessionId).score { if let remote = try? await container.sessionRepository.fetchSession(id: sessionId).score {
container.scoreController.applyRemote(remote) container.scoreController.applyRemote(remote, source: .polling)
} }
} }
} }
@@ -337,7 +337,9 @@ struct BroadcastScreen: View {
session = loaded session = loaded
match = loadedMatch match = loadedMatch
container.scoreController.bind(sessionId: sessionId, initial: loaded.score) container.scoreController.bind(sessionId: sessionId, initial: loaded.score)
wireCable() let refreshOverlay = { updateOverlay() }
container.scoreController.onScoreDidChange = refreshOverlay
wireCable(refreshOverlay: refreshOverlay)
await preloadLogos(for: loadedMatch) await preloadLogos(for: loadedMatch)
updateOverlay() updateOverlay()
loading = false loading = false
@@ -363,10 +365,14 @@ struct BroadcastScreen: View {
} }
} }
private func wireCable() { private func wireCable(refreshOverlay: @escaping () -> Void) {
guard let token = container.tokenStore.session?.accessToken else { return } guard let token = container.tokenStore.session?.accessToken else { return }
container.sessionCable.onScoreUpdate = { [weak container] remote in container.sessionCable.onScoreUpdate = { [weak container] remote in
container?.scoreController.applyRemote(remote) Task { @MainActor in
guard let container else { return }
container.scoreController.applyRemote(remote, source: .cable)
refreshOverlay()
}
} }
container.sessionCable.onPauseStream = { [weak container] in container.sessionCable.onPauseStream = { [weak container] in
Task { @MainActor in Task { @MainActor in
@@ -528,6 +534,7 @@ struct BroadcastScreen: View {
} }
private func teardown() async { private func teardown() async {
container.scoreController.onScoreDidChange = nil
container.sessionCable.onScoreUpdate = nil container.sessionCable.onScoreUpdate = nil
container.sessionCable.onPauseStream = nil container.sessionCable.onPauseStream = nil
container.sessionCable.onResumeStream = nil container.sessionCable.onResumeStream = nil

View File

@@ -81,4 +81,24 @@ final class ScoreControllerTests: XCTestCase {
q2.awayPoints = 8 q2.awayPoints = 8
XCTAssertGreaterThan(q2.progressKey(), q1.progressKey()) XCTAssertGreaterThan(q2.progressKey(), q1.progressKey())
} }
func testCableRemoteCancelsStaleLocalSync() {
let controller = ScoreController(
scoreRepository: ScoreRepository(api: MatchLiveAPI()),
sessionCable: SessionCableService()
)
var local = ScoreState(boardType: "basket", period: 1, periodLabel: "Q1")
local.homePoints = 5
local.awayPoints = 4
controller.bind(sessionId: "session-1", initial: local)
controller.applyAction("home_point")
var remote = ScoreState(boardType: "basket", period: 1, periodLabel: "Q1")
remote.homePoints = 6
remote.awayPoints = 7
controller.applyRemote(remote, source: .cable)
XCTAssertEqual(controller.score.homePoints, 6)
XCTAssertEqual(controller.score.awayPoints, 7)
}
} }