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