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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user