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
final class ScoreController: ObservableObject {
enum RemoteScoreSource {
case polling
case cable
}
@Published private(set) var score = ScoreState()
@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 sessionCable: SessionCableService
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 }
// 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
if source == .polling,
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() {
@@ -80,8 +97,8 @@ final class ScoreController: ObservableObject {
do {
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) {
suppressRemoteUntil = Date().addingTimeInterval(0.8)
score = updated
sessionCable.sendScoreUpdate(updated)
applyServerScoreIfNewer(updated)
sessionCable.sendScoreUpdate(score)
return true
}
score = previous
@@ -103,14 +120,14 @@ final class ScoreController: ObservableObject {
let previous = score
let optimistic = closedSetState(from: score)
score = optimistic
publishScore(optimistic)
sessionCable.sendScoreUpdate(optimistic)
do {
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: "close_set") {
suppressRemoteUntil = Date().addingTimeInterval(3)
score = updated
sessionCable.sendScoreUpdate(updated)
applyServerScoreIfNewer(updated)
sessionCable.sendScoreUpdate(score)
return true
}
score = previous
@@ -151,7 +168,19 @@ final class ScoreController: ObservableObject {
private func updateScore(_ transform: (inout ScoreState) -> Void) {
var next = score
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) {
@@ -190,8 +219,8 @@ final class ScoreController: ObservableObject {
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) {
guard generation == syncGeneration else { return }
suppressRemoteUntil = Date().addingTimeInterval(0.8)
score = updated
sessionCable.sendScoreUpdate(updated)
applyServerScoreIfNewer(updated)
sessionCable.sendScoreUpdate(score)
} else {
guard generation == syncGeneration else { return }
lastActionError = "Risposta punteggio non valida"
@@ -204,7 +233,7 @@ final class ScoreController: ObservableObject {
}
func closeSet() {
score = closedSetState(from: score)
publishScore(closedSetState(from: score))
schedulePush()
}
@@ -219,7 +248,7 @@ final class ScoreController: ObservableObject {
if let remote = try? await scoreRepository.syncScore(sessionId: sessionId, score: local).score {
guard generation == syncGeneration else { return }
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 partialsRaw = (data["set_partials"] as? [Any]) ?? (data["setPartials"] as? [Any]) ?? []
let periodNumber = intFrom(nested, key: "period").flatMap { $0 > 0 ? $0 : nil }
?? parsePeriodNumber(from: data["period"])
?? max(intField(data, snake: "current_set", camel: "currentSet"), 1)
return ScoreState(
@@ -95,7 +96,7 @@ struct ScoreState: Equatable, Sendable {
timeoutAway: data["timeout_away"] as? Bool ?? false,
boardType: boardType,
period: periodNumber,
periodLabel: data["period"] as? String,
periodLabel: parsePeriodLabel(from: data["period"]) ?? nested?["period"] as? String,
clockSecs: {
let direct = intField(data, snake: "clock_secs", camel: "clockSecs")
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 }
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) {
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 {
container.scoreController.applyRemote(remote)
container.scoreController.applyRemote(remote, source: .polling)
}
}
}
@@ -337,7 +337,9 @@ struct BroadcastScreen: View {
session = loaded
match = loadedMatch
container.scoreController.bind(sessionId: sessionId, initial: loaded.score)
wireCable()
let refreshOverlay = { updateOverlay() }
container.scoreController.onScoreDidChange = refreshOverlay
wireCable(refreshOverlay: refreshOverlay)
await preloadLogos(for: loadedMatch)
updateOverlay()
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 }
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
Task { @MainActor in
@@ -528,6 +534,7 @@ struct BroadcastScreen: View {
}
private func teardown() async {
container.scoreController.onScoreDidChange = nil
container.sessionCable.onScoreUpdate = nil
container.sessionCable.onPauseStream = nil
container.sessionCable.onResumeStream = nil

View File

@@ -81,4 +81,24 @@ final class ScoreControllerTests: XCTestCase {
q2.awayPoints = 8
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)
}
}