diff --git a/native/ios/MatchLiveTv/Data/Scoring/ScoreController.swift b/native/ios/MatchLiveTv/Data/Scoring/ScoreController.swift index 7152b47..576b271 100644 --- a/native/ios/MatchLiveTv/Data/Scoring/ScoreController.swift +++ b/native/ios/MatchLiveTv/Data/Scoring/ScoreController.swift @@ -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) } } } diff --git a/native/ios/MatchLiveTv/Domain/ScoreState.swift b/native/ios/MatchLiveTv/Domain/ScoreState.swift index 87037c9..cb89397 100644 --- a/native/ios/MatchLiveTv/Domain/ScoreState.swift +++ b/native/ios/MatchLiveTv/Domain/ScoreState.swift @@ -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 + } } diff --git a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift index dce241b..89db562 100644 --- a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift +++ b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift @@ -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 diff --git a/native/ios/MatchLiveTvTests/ScoreControllerTests.swift b/native/ios/MatchLiveTvTests/ScoreControllerTests.swift index 4357363..d1c08a4 100644 --- a/native/ios/MatchLiveTvTests/ScoreControllerTests.swift +++ b/native/ios/MatchLiveTvTests/ScoreControllerTests.swift @@ -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) + } }