Implementa pausa diretta con copertina slate e ripresa.

Metti in pausa ferma RTMP lato app mantenendo la camera aperta, MediaMTX
manda offline.mp4 agli spettatori e Chiudi termina definitivamente.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 23:31:48 +02:00
parent 1bd81d84af
commit a87cda156b
19 changed files with 305 additions and 45 deletions

View File

@@ -54,6 +54,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
StreamSubscription<Map<String, dynamic>>? _metricsSub;
Orientation? _lastOrientation;
bool _pauseInFlight = false;
@override
void initState() {
@@ -125,8 +126,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
ref.read(scoreProvider.notifier).reset(session.score!);
}
// Ripresa dopo crash: la sessione resta connecting/live, riallineiamo lo stato.
if (session.status == 'idle' || session.status == 'paused') {
// Ripresa dopo crash: solo idle va in connecting; in pausa restiamo fermi.
if (session.status == 'idle') {
session = await client.startSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(session, match: match);
}
@@ -140,14 +141,14 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
final fresh = await client.fetchSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(fresh, match: match);
if (fresh.rtmpIngestUrl != null) {
if (fresh.status != 'paused' && fresh.rtmpIngestUrl != null) {
await Future<void>.delayed(const Duration(milliseconds: 500));
await StreamingChannel.startStream(
rtmpUrl: fresh.rtmpIngestUrl!,
targetBitrate: fresh.targetBitrate,
targetFps: 30,
);
if (mounted && fresh.canResumeBroadcast) {
if (mounted && fresh.canResumeBroadcast && fresh.status != 'paused') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Diretta ripresa — stai di nuovo in onda'),
@@ -287,21 +288,90 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
if (mounted) context.go('/matches');
}
/// Pausa temporanea: si può riprendere la stessa sessione.
Future<void> _interruptStream() async {
/// Pausa temporanea: il server manda la copertina, la camera resta aperta.
Future<void> _pauseStream() async {
if (_pauseInFlight) return;
_pauseInFlight = true;
try {
await StreamingChannel.stopStream();
} catch (_) {}
try {
await ref.read(apiClientProvider).pauseSession(widget.sessionId);
final match = ref.read(sessionProvider).match;
final session =
await ref.read(apiClientProvider).pauseSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(session, match: match);
await StreamingChannel.pauseStream();
if (mounted) {
setState(() {
_bitrateMbps = 0;
_fps = 0;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Diretta in pausa — gli spettatori vedono la copertina'),
duration: Duration(seconds: 3),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Pausa sessione: $e')),
);
}
} finally {
_pauseInFlight = false;
}
}
Future<void> _applyRemotePause() async {
try {
await StreamingChannel.pauseStream();
if (mounted) {
setState(() {
_bitrateMbps = 0;
_fps = 0;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Pausa dalla regia — trasmissione fermata'),
duration: Duration(seconds: 3),
),
);
}
} catch (_) {}
}
Future<void> _resumeStream() async {
try {
final match = ref.read(sessionProvider).match;
var session =
await ref.read(apiClientProvider).resumeSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(session, match: match);
if (session.rtmpIngestUrl != null) {
await StreamingChannel.startStream(
rtmpUrl: session.rtmpIngestUrl!,
targetBitrate: session.targetBitrate,
targetFps: 30,
);
}
session = await ref.read(apiClientProvider).fetchSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(session, match: match);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Diretta ripresa — di nuovo in onda'),
duration: Duration(seconds: 3),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ripresa diretta: $e')),
);
}
}
await _leaveSession();
}
/// Chiusura definitiva: sessione ended, pagina web senza player.
@@ -351,11 +421,25 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
@override
Widget build(BuildContext context) {
ref.listen<String?>(
sessionProvider.select((s) => s.session?.status),
(previous, next) {
if (next == 'paused' && previous != 'paused' && !_pauseInFlight) {
_applyRemotePause();
}
if (next == 'ended' && previous != 'ended' && mounted) {
_leaveSession();
}
},
);
final sessionState = ref.watch(sessionProvider);
final score = ref.watch(scoreProvider);
_trackScoreDelta(score);
final match = sessionState.match;
final sessionStatus = sessionState.session?.status ?? 'live';
final isPaused = sessionStatus == 'paused';
final homeName = match?.teamName ?? 'HOME';
final awayName = match?.opponentName ?? 'AWAY';
final isLandscape =
@@ -368,11 +452,11 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
body: Stack(
fit: StackFit.expand,
children: [
const Positioned.fill(
Positioned.fill(
child: NativeStreamingPreview(
key: _previewKey,
showGrid: true,
platformLabel: 'LIVE',
platformLabel: isPaused ? 'PAUSA' : 'LIVE',
),
),
if (isLandscape)
@@ -388,7 +472,9 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
fps: _fps,
viewerCount: _viewerCount,
onShare: _showShareMenu,
onInterrupt: _interruptStream,
isPaused: isPaused,
onPause: _pauseStream,
onResume: _resumeStream,
onCloseStream: _closeStreamPermanently,
pointsTarget: pointsTarget,
)
@@ -404,7 +490,9 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
fps: _fps,
viewerCount: _viewerCount,
onShare: _showShareMenu,
onInterrupt: _interruptStream,
isPaused: isPaused,
onPause: _pauseStream,
onResume: _resumeStream,
onCloseStream: _closeStreamPermanently,
pointsTarget: pointsTarget,
),
@@ -426,7 +514,9 @@ class _PortraitOverlay extends StatelessWidget {
required this.fps,
required this.viewerCount,
required this.onShare,
required this.onInterrupt,
required this.isPaused,
required this.onPause,
required this.onResume,
required this.onCloseStream,
required this.pointsTarget,
});
@@ -441,7 +531,9 @@ class _PortraitOverlay extends StatelessWidget {
final int fps;
final int viewerCount;
final VoidCallback onShare;
final VoidCallback onInterrupt;
final bool isPaused;
final Future<void> Function() onPause;
final Future<void> Function() onResume;
final Future<void> Function() onCloseStream;
final int pointsTarget;
@@ -455,7 +547,7 @@ class _PortraitOverlay extends StatelessWidget {
padding: EdgeInsets.fromLTRB(inset.left, inset.top, inset.right, 6),
child: Row(
children: [
LiveBadge(elapsed: elapsed),
LiveBadge(elapsed: elapsed, paused: isPaused),
const Spacer(),
IconButton(
onPressed: onShare,
@@ -497,10 +589,18 @@ class _PortraitOverlay extends StatelessWidget {
label: const Text('Condividi link'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: onInterrupt,
child: const Text('Interrompi (riprendibile)'),
),
if (isPaused)
FilledButton.icon(
onPressed: onResume,
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('Riprendi'),
)
else
OutlinedButton.icon(
onPressed: onPause,
icon: const Icon(Icons.pause, size: 18),
label: const Text('Metti in pausa'),
),
const SizedBox(height: 8),
DestructiveCta(
label: 'Chiudi diretta',
@@ -527,7 +627,9 @@ class _LandscapeOverlay extends StatelessWidget {
required this.fps,
required this.viewerCount,
required this.onShare,
required this.onInterrupt,
required this.isPaused,
required this.onPause,
required this.onResume,
required this.onCloseStream,
required this.pointsTarget,
});
@@ -543,7 +645,9 @@ class _LandscapeOverlay extends StatelessWidget {
final int fps;
final int viewerCount;
final VoidCallback onShare;
final VoidCallback onInterrupt;
final bool isPaused;
final Future<void> Function() onPause;
final Future<void> Function() onResume;
final Future<void> Function() onCloseStream;
final int pointsTarget;
@@ -557,7 +661,7 @@ class _LandscapeOverlay extends StatelessWidget {
padding: EdgeInsets.fromLTRB(inset.left, inset.top, inset.right, 6),
child: Row(
children: [
LiveBadge(elapsed: elapsed, compact: true),
LiveBadge(elapsed: elapsed, compact: true, paused: isPaused),
const Spacer(),
CameraCompactMetrics(
networkType: networkType,
@@ -599,10 +703,15 @@ class _LandscapeOverlay extends StatelessWidget {
tooltip: 'Condividi link',
),
Expanded(
child: OutlinedButton(
onPressed: onInterrupt,
child: const Text('Interrompi', style: TextStyle(fontSize: 11)),
),
child: isPaused
? FilledButton(
onPressed: onResume,
child: const Text('Riprendi', style: TextStyle(fontSize: 11)),
)
: OutlinedButton(
onPressed: onPause,
child: const Text('Pausa', style: TextStyle(fontSize: 11)),
),
),
const SizedBox(width: 8),
Expanded(