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

@@ -14,6 +14,7 @@ import '../../platform/streaming_channel.dart';
import '../../platform/streaming_preview.dart';
import '../../providers/match_rules_provider.dart';
import '../../providers/score_provider.dart';
import '../../providers/score_sync_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/regia_share.dart';
import '../../shared/watch_share.dart';
@@ -42,6 +43,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
Timer? _elapsedTimer;
Timer? _telemetryTimer;
Timer? _statsTimer;
Timer? _scorePullTimer;
int _batteryLevel = 100;
int? _lastDelta;
int _prevHomePoints = 0;
@@ -55,6 +57,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
StreamSubscription<Map<String, dynamic>>? _metricsSub;
Orientation? _lastOrientation;
bool _pauseInFlight = false;
bool _resumeInFlight = false;
String? _lastStreamErrorShown;
@override
void initState() {
@@ -73,6 +77,9 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
});
_telemetryTimer = Timer.periodic(const Duration(seconds: 10), (_) => _sendTelemetry());
_statsTimer = Timer.periodic(const Duration(seconds: 30), (_) => _fetchStats());
_scorePullTimer = Timer.periodic(const Duration(seconds: 2), (_) {
unawaited(ref.read(scoreSyncProvider).pullFromServer());
});
}
@override
@@ -121,15 +128,18 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
}
final match = await client.fetchMatch(session.matchId);
final statusAtOpen = session.status;
ref.read(sessionProvider.notifier).setSession(session, match: match);
if (session.score != null) {
ref.read(scoreProvider.notifier).reset(session.score!);
}
// Ripresa dopo crash: solo idle va in connecting; in pausa restiamo fermi.
// idle → startSession; wizard può aver già messo connecting prima della camera.
var justStartedSession = false;
if (session.status == 'idle') {
session = await client.startSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(session, match: match);
justStartedSession = true;
}
final ws = ref.read(websocketServiceProvider);
@@ -140,18 +150,41 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
final fresh = await client.fetchSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(fresh, match: match);
if (fresh.score != null) {
ref.read(scoreProvider.notifier).reset(fresh.score!);
}
if (fresh.status != 'paused' && fresh.rtmpIngestUrl != null) {
// Avvio RTMP: wizard chiama startSession prima della camera → stato "connecting".
// In pausa all'apertura: niente auto-publish (serve "RIPRENDI DIRETTA").
final wasPausedAtOpen = statusAtOpen == 'paused';
final shouldAutoPublish = fresh.rtmpIngestUrl != null &&
!wasPausedAtOpen &&
fresh.status != 'paused' &&
(justStartedSession ||
fresh.status == 'connecting' ||
fresh.status == 'live' ||
fresh.status == 'reconnecting');
if (shouldAutoPublish) {
await Future<void>.delayed(const Duration(milliseconds: 500));
await StreamingChannel.startStream(
rtmpUrl: fresh.rtmpIngestUrl!,
targetBitrate: fresh.targetBitrate,
targetFps: 30,
);
if (mounted && fresh.canResumeBroadcast && fresh.status != 'paused') {
final useFreshStart =
justStartedSession || fresh.status == 'connecting';
if (useFreshStart) {
await StreamingChannel.startStream(
rtmpUrl: fresh.rtmpIngestUrl!,
targetBitrate: fresh.targetBitrate,
targetFps: 30,
);
} else {
await StreamingChannel.resumeStream(
rtmpUrl: fresh.rtmpIngestUrl!,
targetBitrate: fresh.targetBitrate,
targetFps: 30,
);
}
if (mounted && (justStartedSession || fresh.status == 'connecting')) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Diretta ripresa — stai di nuovo in onda'),
content: Text('Diretta avviata — connessione al server…'),
duration: Duration(seconds: 3),
),
);
@@ -166,10 +199,25 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
final metrics = Map<String, dynamic>.from(
(event['metrics'] as Map?)?.map((k, v) => MapEntry(k.toString(), v)) ?? {},
);
final streamState = metrics['state'] as String?;
final lastError = metrics['lastError'] as String?;
setState(() {
_bitrateMbps = (metrics['bitrateKbps'] as num? ?? 0) / 1000;
_fps = (metrics['fps'] as num?)?.toInt() ?? 0;
});
if (lastError != null &&
lastError.isNotEmpty &&
lastError != _lastStreamErrorShown &&
(streamState == 'ERROR' || streamState == 'RECONNECTING') &&
mounted) {
_lastStreamErrorShown = lastError;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Connessione RTMP: $lastError'),
duration: const Duration(seconds: 5),
),
);
}
});
} on PlatformException catch (e) {
if (mounted) {
@@ -339,7 +387,39 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
} catch (_) {}
}
Future<void> _applyRemoteResume() async {
if (_resumeInFlight) return;
final session = ref.read(sessionProvider).session;
if (session == null || session.rtmpIngestUrl == null) return;
_resumeInFlight = true;
try {
await StreamingChannel.resumeStream(
rtmpUrl: session.rtmpIngestUrl!,
targetBitrate: session.targetBitrate,
targetFps: 30,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ripresa dalla regia — di nuovo in onda'),
duration: Duration(seconds: 3),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ripresa RTMP: $e')),
);
}
} finally {
_resumeInFlight = false;
}
}
Future<void> _resumeStream() async {
if (_resumeInFlight) return;
_resumeInFlight = true;
try {
final match = ref.read(sessionProvider).match;
var session =
@@ -347,7 +427,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
ref.read(sessionProvider.notifier).setSession(session, match: match);
if (session.rtmpIngestUrl != null) {
await StreamingChannel.startStream(
await StreamingChannel.resumeStream(
rtmpUrl: session.rtmpIngestUrl!,
targetBitrate: session.targetBitrate,
targetFps: 30,
@@ -371,6 +451,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
SnackBar(content: Text('Ripresa diretta: $e')),
);
}
} finally {
_resumeInFlight = false;
}
}
@@ -413,6 +495,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
_elapsedTimer?.cancel();
_telemetryTimer?.cancel();
_statsTimer?.cancel();
_scorePullTimer?.cancel();
_metricsSub?.cancel();
WakelockPlus.disable();
_unlockOrientation();
@@ -427,6 +510,11 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
if (next == 'paused' && previous != 'paused' && !_pauseInFlight) {
_applyRemotePause();
}
if (previous == 'paused' &&
(next == 'connecting' || next == 'live') &&
!_resumeInFlight) {
_applyRemoteResume();
}
if (next == 'ended' && previous != 'ended' && mounted) {
_leaveSession();
}
@@ -459,6 +547,26 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
platformLabel: isPaused ? 'PAUSA' : 'LIVE',
),
),
if (isPaused)
Positioned(
left: 20,
right: 20,
bottom: isLandscape ? 100 : 200,
child: Center(
child: FilledButton.icon(
onPressed: _resumeStream,
icon: const Icon(Icons.play_arrow, size: 28),
label: const Text(
'RIPRENDI DIRETTA',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
),
),
),
),
if (isLandscape)
_LandscapeOverlay(
elapsed: sessionState.elapsed,

View File

@@ -35,6 +35,19 @@ class StreamingChannel {
});
}
/// Ripresa dopo pausa: resetta stati RTMP residui (CONNECTING/RECONNECTING).
static Future<void> resumeStream({
required String rtmpUrl,
int targetBitrate = 2500000,
int targetFps = 30,
}) async {
await _channel.invokeMethod('resumeStream', {
'rtmpUrl': rtmpUrl,
'videoBitrate': targetBitrate,
'fps': targetFps,
});
}
static Future<void> stopStream() async {
await _channel.invokeMethod('stopStream');
}

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,

View File

@@ -6,6 +6,7 @@ import '../providers/auth_provider.dart';
import 'models/invite_preview.dart';
import 'models/match.dart';
import 'models/recording_item.dart';
import 'models/score_state.dart';
import 'models/stream_session.dart';
import 'models/team.dart';
import 'models/user.dart';
@@ -210,6 +211,19 @@ class ApiClient {
return StreamSession.fromJson(response.data!);
}
/// Persiste il tabellone e restituisce lo stato salvato sul server.
Future<ScoreState?> syncScore(String sessionId, Map<String, dynamic> payload) async {
final response = await _dio.patch<Map<String, dynamic>>(
'/sessions/$sessionId/score',
data: payload,
);
final score = response.data?['score'];
if (score is Map<String, dynamic>) {
return ScoreState.fromJson(score);
}
return null;
}
Future<RegiaLinkResponse> createRegiaLink(String sessionId) async {
final response = await _dio.post<Map<String, dynamic>>(
'/sessions/$sessionId/regia_link',

View File

@@ -6,6 +6,7 @@ import 'package:x_action_cable_v2/x_action_cable.dart';
import '../core/config.dart';
import '../providers/auth_provider.dart';
import '../providers/score_provider.dart';
import '../providers/score_sync_provider.dart';
import '../providers/session_provider.dart';
import 'models/device_state.dart';
import 'models/score_state.dart';
@@ -88,7 +89,10 @@ class WebsocketService {
switch (type) {
case 'score_update':
_ref.read(scoreProvider.notifier).applyRemote(ScoreState.fromJson(data));
final remote = ScoreState.fromJson(data);
if (_ref.read(scoreSyncProvider).acceptCableScore(remote)) {
_ref.read(scoreProvider.notifier).applyRemote(remote);
}
break;
case 'device_state':
_ref
@@ -109,6 +113,14 @@ class WebsocketService {
_ref.read(sessionProvider.notifier).applyCommand(action);
}
break;
case 'stream_event':
final event = data['event'] as String?;
if (event == 'paused') {
_ref.read(sessionProvider.notifier).applyCommand('pause_stream');
} else if (event == 'resumed') {
_ref.read(sessionProvider.notifier).applyCommand('resume_stream');
}
break;
default:
break;
}

View File

@@ -1,10 +1,12 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/theme.dart';
import '../../features/shared/live_score_actions.dart';
import '../../providers/score_provider.dart';
import '../websocket_service.dart';
import '../../providers/score_sync_provider.dart';
import 'score_overlay_bar.dart';
/// Tabellone interattivo: aggiorna punteggio via WebSocket (senza link regia).
@@ -27,8 +29,8 @@ class LiveScoreControls extends ConsumerWidget {
final Future<void> Function()? onStopStream;
void _sync(WidgetRef ref) {
if (!ref.read(websocketServiceProvider).isConnected) return;
ref.read(websocketServiceProvider).sendScoreUpdate(ref.read(scoreProvider));
// HTTP serializzato; il server fa broadcast WebSocket. Pagina live = status.json.
unawaited(ref.read(scoreSyncProvider).push());
}
Future<void> _pointHome(BuildContext context, WidgetRef ref) async {