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(

View File

@@ -109,6 +109,9 @@ class SessionNotifier extends StateNotifier<SessionState> {
case 'pause_stream':
newStatus = 'paused';
break;
case 'resume_stream':
newStatus = 'connecting';
break;
case 'stop_stream':
newStatus = 'ended';
break;

View File

@@ -197,6 +197,12 @@ class ApiClient {
return StreamSession.fromJson(response.data!);
}
Future<StreamSession> resumeSession(String sessionId) async {
final response =
await _dio.patch<Map<String, dynamic>>('/sessions/$sessionId/resume');
return StreamSession.fromJson(response.data!);
}
Future<RegiaLinkResponse> createRegiaLink(String sessionId) async {
final response = await _dio.post<Map<String, dynamic>>(
'/sessions/$sessionId/regia_link',

View File

@@ -12,7 +12,7 @@ Future<bool> confirmEndStream(BuildContext context) async {
content: const Text(
'La diretta verrà chiusa definitivamente. '
'Gli spettatori vedranno che lo stream è terminato e non potrai riprendere questa sessione.\n\n'
'Per una pausa temporanea usa «Interrompi».',
'Per una pausa temporanea usa «Metti in pausa».',
),
actions: [
TextButton(

View File

@@ -8,10 +8,12 @@ class LiveBadge extends StatelessWidget {
super.key,
required this.elapsed,
this.compact = false,
this.paused = false,
});
final Duration elapsed;
final bool compact;
final bool paused;
String _format(Duration d) {
final h = d.inHours;
@@ -24,29 +26,34 @@ class LiveBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bg = paused ? const Color(0xFF555555) : AppTheme.primaryRed;
final label = paused
? (compact ? 'PAUSA ${_format(elapsed)}' : 'IN PAUSA ${_format(elapsed)}')
: (compact ? 'LIVE ${_format(elapsed)}' : 'IN DIRETTA ${_format(elapsed)}');
return Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 14,
vertical: compact ? 4 : 6,
),
decoration: BoxDecoration(
color: AppTheme.primaryRed,
color: bg,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: compact ? 6 : 8,
height: compact ? 6 : 8,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
if (!paused)
Container(
width: compact ? 6 : 8,
height: compact ? 6 : 8,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
),
SizedBox(width: compact ? 6 : 8),
if (!paused) SizedBox(width: compact ? 6 : 8),
Text(
compact ? 'LIVE ${_format(elapsed)}' : 'IN DIRETTA ${_format(elapsed)}',
label,
style: theme.textTheme.labelLarge?.copyWith(
color: Colors.white,
fontSize: compact ? 11 : 13,