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:
@@ -88,6 +88,7 @@ class StreamingEngine(
|
||||
private var lastFpsSampleAtMs: Long = 0L
|
||||
private val reconnectAttempts = AtomicInteger(0)
|
||||
private val isReleased = AtomicBoolean(false)
|
||||
private val pausingIntentionally = AtomicBoolean(false)
|
||||
|
||||
private val bitrateAdapter = BitrateAdapter { adaptedBitrate ->
|
||||
genericStream?.setVideoBitrateOnFly(adaptedBitrate)
|
||||
@@ -235,6 +236,33 @@ class StreamingEngine(
|
||||
startMetricsLoop()
|
||||
}
|
||||
|
||||
fun pauseStream() {
|
||||
ensureMainThread()
|
||||
if (state != StreamingState.STREAMING &&
|
||||
state != StreamingState.CONNECTING &&
|
||||
state != StreamingState.RECONNECTING
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
stopMetricsLoop()
|
||||
pausingIntentionally.set(true)
|
||||
genericStream?.let { stream ->
|
||||
if (stream.isStreaming) {
|
||||
stream.stopStream()
|
||||
}
|
||||
}
|
||||
pausingIntentionally.set(false)
|
||||
|
||||
streamStartedAtMs = 0L
|
||||
currentBitrateKbps = 0L
|
||||
currentFps = 0
|
||||
reconnectAttempts.set(0)
|
||||
attachPreviewToStream()
|
||||
updateState(StreamingState.PREVIEWING)
|
||||
startMetricsLoop()
|
||||
}
|
||||
|
||||
fun stopStream() {
|
||||
ensureMainThread()
|
||||
if (state == StreamingState.IDLE || state == StreamingState.STOPPING) {
|
||||
@@ -291,6 +319,9 @@ class StreamingEngine(
|
||||
|
||||
override fun onConnectionFailed(reason: String) {
|
||||
postMain {
|
||||
if (pausingIntentionally.get()) {
|
||||
return@postMain
|
||||
}
|
||||
lastError = reason
|
||||
val stream = genericStream ?: return@postMain
|
||||
val currentConfig = config ?: return@postMain
|
||||
@@ -328,6 +359,9 @@ class StreamingEngine(
|
||||
|
||||
override fun onDisconnect() {
|
||||
postMain {
|
||||
if (pausingIntentionally.get()) {
|
||||
return@postMain
|
||||
}
|
||||
if (state == StreamingState.STREAMING || state == StreamingState.RECONNECTING) {
|
||||
updateState(StreamingState.RECONNECTING, "disconnected")
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
|
||||
when (call.method) {
|
||||
"startStream" -> startStream(call, result)
|
||||
"stopStream" -> stopStream(result)
|
||||
"pauseStream" -> pauseStream(result)
|
||||
"getMetrics" -> getMetrics(result)
|
||||
"startPreview" -> startPreview(result)
|
||||
"stopPreview" -> stopPreview(result)
|
||||
@@ -203,6 +204,11 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun pauseStream(result: Result) {
|
||||
getEngine()?.pauseStream()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun getMetrics(result: Result) {
|
||||
val metrics = boundService?.getMetrics()
|
||||
?: getEngine()?.getMetrics()?.toMap()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user