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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user