Aggiunge overlay server-side burn-in e stabilizza diretta live.

Tabellone, badge e brand sono ricodificati nel flusso _air via OverlayRelay;
sync punteggio HTTP, volume condiviso rails/sidekiq, qualità 720p migliorata
e badge CONNECTING in ripresa da pausa.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 23:55:17 +02:00
parent fd225fbadd
commit ab9cb02083
58 changed files with 2423 additions and 242 deletions

View File

@@ -0,0 +1,77 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../shared/api_client.dart';
import '../shared/models/score_state.dart';
import 'score_provider.dart';
import 'session_provider.dart';
/// Coda sync HTTP: evita richieste fuori ordine e allinea l'app al DB (pagina live).
class ScoreSyncService {
ScoreSyncService(this._ref);
final Ref _ref;
bool _busy = false;
bool _dirty = false;
DateTime? _suppressStaleRemoteUntil;
/// Ignora broadcast WebSocket obsoleti subito dopo un push HTTP.
bool acceptCableScore(ScoreState remote) {
final until = _suppressStaleRemoteUntil;
if (until == null || DateTime.now().isAfter(until)) return true;
final local = _ref.read(scoreProvider);
return !_isClearlyBehind(remote, local);
}
bool _isClearlyBehind(ScoreState remote, ScoreState local) {
final r = remote.homeSets * 1000 +
remote.awaySets * 1000 +
remote.homePoints +
remote.awayPoints;
final l = local.homeSets * 1000 +
local.awaySets * 1000 +
local.homePoints +
local.awayPoints;
return r < l;
}
Future<void> push() async {
_dirty = true;
if (_busy) return;
_busy = true;
while (_dirty) {
_dirty = false;
final sessionId = _ref.read(sessionProvider).session?.id;
if (sessionId == null) continue;
final payload = _ref.read(scoreProvider).toCablePayload();
try {
final remote = await _ref.read(apiClientProvider).syncScore(sessionId, payload);
_suppressStaleRemoteUntil = DateTime.now().add(const Duration(milliseconds: 500));
if (remote != null) {
_ref.read(scoreProvider.notifier).applyRemote(remote);
}
} catch (_) {
// Mantieni stato locale; il prossimo pull periodico riallinea se possibile.
}
}
_busy = false;
}
Future<void> pullFromServer() async {
if (_busy) return;
final sessionId = _ref.read(sessionProvider).session?.id;
if (sessionId == null) return;
try {
final session = await _ref.read(apiClientProvider).fetchSession(sessionId);
if (session.score != null) {
_ref.read(scoreProvider.notifier).applyRemote(session.score!);
}
} catch (_) {}
}
}
final scoreSyncProvider = Provider<ScoreSyncService>((ref) {
return ScoreSyncService(ref);
});

View File

@@ -73,11 +73,17 @@ class SessionNotifier extends StateNotifier<SessionState> {
}
if (device.status != null && state.session != null) {
final s = state.session!;
final incoming = device.status!;
// Non sovrascrivere una pausa intenzionale con stati transitori (live/connecting).
if (s.status == 'paused' && incoming != 'paused' && incoming != 'ended') {
state = state.copyWith(cameraDevice: device);
return;
}
state = state.copyWith(
session: StreamSession(
id: s.id,
matchId: s.matchId,
status: device.status!,
status: incoming,
platform: s.platform,
rtmpIngestUrl: s.rtmpIngestUrl,
hlsPlaybackUrl: s.hlsPlaybackUrl,