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:
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
77
mobile/lib/providers/score_sync_provider.dart
Normal file
77
mobile/lib/providers/score_sync_provider.dart
Normal 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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user