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

@@ -53,6 +53,8 @@ class SessionChannel < ApplicationCable::Channel
Sessions::Stop.new(session).call
when "pause_stream"
Sessions::Pause.new(session).call
when "resume_stream"
Sessions::Resume.new(session).call
end
end

View File

@@ -29,6 +29,11 @@ module Api
render json: session_json(@session)
end
def resume
Sessions::Resume.new(@session).call
render json: session_json(@session)
end
def events
events = @session.stream_events.recent.limit(100)
render json: events.map { |e| event_json(e) }

View File

@@ -63,6 +63,7 @@ module Public
closed = @session.terminal?
{
status: @session.status,
paused: @session.paused?,
stream_closed: closed,
live: !closed && (@session.live? || mediamtx_online?(@session)),
on_air: !closed && mediamtx_online?(@session),

View File

@@ -54,7 +54,7 @@ class StreamSession < ApplicationRecord
end
event :pause do
transitions from: :live, to: :paused
transitions from: %i[live reconnecting], to: :paused
end
event :resume do

View File

@@ -18,6 +18,9 @@ module Mediamtx
body = {
source: "publisher",
overridePublisher: true,
alwaysAvailable: true,
alwaysAvailableFile: slate_file_path,
alwaysAvailableTracks: slate_tracks,
record: record,
recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S",
recordSegmentDuration: "60s"
@@ -43,6 +46,21 @@ module Mediamtx
@conn.post("/v3/config/paths/delete/#{CGI.escape(path_name)}")
end
def patch_path(session)
path = session.mediamtx_path_name
body = {
alwaysAvailable: true,
alwaysAvailableFile: slate_file_path,
alwaysAvailableTracks: slate_tracks
}
response = @conn.patch("/v3/config/paths/patch/#{CGI.escape(path)}", body)
unless response.success?
err = response.body.is_a?(Hash) ? response.body["error"] : response.body
raise Error, "MediaMTX path patch failed: #{response.status} #{err}"
end
true
end
def list_paths
response = @conn.get("/v3/paths/list")
return [] unless response.success?
@@ -87,7 +105,6 @@ module Mediamtx
end
def relay_script(session)
return "echo 'no youtube key'" if session.stream_key.blank?
return "echo 'youtube relay skipped'" unless session.platform == "youtube"
key = session.stream_key
@@ -97,5 +114,16 @@ module Mediamtx
rtmp://a.rtmp.youtube.com/live2/#{key} 2>/var/log/ffmpeg-#{path}.log
SCRIPT
end
def slate_file_path
ENV.fetch("MEDIAMTX_SLATE_FILE", "/slates/offline.mp4")
end
def slate_tracks
[
{ codec: "H264" },
{ codec: "MPEG4Audio", sampleRate: 48_000, channelCount: 2 }
]
end
end
end

View File

@@ -5,14 +5,30 @@ module Sessions
end
def call
cancel_timeout_job
@session.pause! if @session.may_pause?
ensure_slate_on_path
log_event("paused")
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "paused" })
@session
end
private
def cancel_timeout_job
return if @session.timeout_job_id.blank?
Sidekiq::ScheduledSet.new.find_job(@session.timeout_job_id)&.delete
@session.update!(timeout_job_id: nil)
end
def ensure_slate_on_path
Mediamtx::Client.new.patch_path(@session)
rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Sessions::Pause] slate path patch failed: #{e.message}")
end
def log_event(type)
@session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: {})
end

View File

@@ -0,0 +1,32 @@
module Sessions
class Resume
def initialize(session)
@session = session
end
def call
unless @session.paused?
raise Teams::EntitlementError.new("La sessione non è in pausa", code: "not_paused")
end
cancel_timeout_job
@session.begin_connect! if @session.may_begin_connect?
log_event("resumed")
SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" })
@session
end
private
def cancel_timeout_job
return if @session.timeout_job_id.blank?
Sidekiq::ScheduledSet.new.find_job(@session.timeout_job_id)&.delete
@session.update!(timeout_job_id: nil)
end
def log_event(type)
@session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: {})
end
end
end

View File

@@ -23,6 +23,8 @@ module Webhooks
private
def handle_connect(session)
return if session.paused?
return if session.live? && session.stream_events.where(event_type: "connected").exists?
if session.reconnecting?
@@ -38,6 +40,7 @@ module Webhooks
end
def handle_disconnect(session)
return if session.paused?
return unless session.live? || session.connecting?
if session.connecting?

View File

@@ -76,7 +76,7 @@
</section>
<% unless @stream_closed %>
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause">Interrompi (riprendibile)</button>
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause">Metti in pausa</button>
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop">Chiudi diretta</button>
<% end %>
</div>

View File

@@ -33,6 +33,7 @@ Rails.application.routes.draw do
patch :start
patch :stop
patch :pause
patch :resume
get :events
post :telemetry
post :pairing_token

View File

@@ -79,6 +79,11 @@
els.badge.className = "regia-badge regia-badge--ended";
return;
}
if (data.status === "paused" || data.paused) {
els.badge.textContent = "In pausa";
els.badge.className = "regia-badge regia-badge--wait";
return;
}
if (data.on_air) {
els.badge.textContent = "In onda";
els.badge.className = "regia-badge regia-badge--live";
@@ -230,7 +235,8 @@
async function pauseStream() {
await fetch(cfg.pauseUrl, { method: "POST", headers: { Accept: "application/json" } });
toast("Trasmissione in pausa");
toast("Diretta in pausa — copertina in onda");
pollStatus();
}
async function stopStream() {

View File

@@ -1,11 +1,12 @@
#!/bin/sh
# Generate offline slate MP4 for MediaMTX alwaysAvailable
# Generate offline slate MP4 for MediaMTX alwaysAvailable (pausa / attesa segnale)
set -e
OUT_DIR="$(dirname "$0")/../slates"
OUT="$OUT_DIR/offline.mp4"
mkdir -p "$OUT_DIR"
docker run --rm -v "$OUT_DIR:/out" linuxserver/ffmpeg:latest \
-f lavfi -i color=c=0x1a1a1a:s=1280x720:r=30 \
-vf "drawtext=text='Match Live TV':fontcolor=white:fontsize=48:x=(w-text_w)/2:y=(h-text_h)/2-40,drawtext=text='Trasmissione in pausa':fontcolor=0xaaaaaa:fontsize=28:x=(w-text_w)/2:y=(h-text_h)/2+20" \
-f lavfi -i sine=frequency=440:sample_rate=48000 \
-t 30 -c:v libx264 -pix_fmt yuv420p -g 30 -preset fast \
-c:a aac -b:a 128k -y /out/offline.mp4

View File

@@ -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")
}

View File

@@ -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()

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,