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 Sessions::Stop.new(session).call
when "pause_stream" when "pause_stream"
Sessions::Pause.new(session).call Sessions::Pause.new(session).call
when "resume_stream"
Sessions::Resume.new(session).call
end end
end end

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,9 @@ module Mediamtx
body = { body = {
source: "publisher", source: "publisher",
overridePublisher: true, overridePublisher: true,
alwaysAvailable: true,
alwaysAvailableFile: slate_file_path,
alwaysAvailableTracks: slate_tracks,
record: record, record: record,
recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S", recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S",
recordSegmentDuration: "60s" recordSegmentDuration: "60s"
@@ -43,6 +46,21 @@ module Mediamtx
@conn.post("/v3/config/paths/delete/#{CGI.escape(path_name)}") @conn.post("/v3/config/paths/delete/#{CGI.escape(path_name)}")
end 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 def list_paths
response = @conn.get("/v3/paths/list") response = @conn.get("/v3/paths/list")
return [] unless response.success? return [] unless response.success?
@@ -87,7 +105,6 @@ module Mediamtx
end end
def relay_script(session) def relay_script(session)
return "echo 'no youtube key'" if session.stream_key.blank?
return "echo 'youtube relay skipped'" unless session.platform == "youtube" return "echo 'youtube relay skipped'" unless session.platform == "youtube"
key = session.stream_key key = session.stream_key
@@ -97,5 +114,16 @@ module Mediamtx
rtmp://a.rtmp.youtube.com/live2/#{key} 2>/var/log/ffmpeg-#{path}.log rtmp://a.rtmp.youtube.com/live2/#{key} 2>/var/log/ffmpeg-#{path}.log
SCRIPT SCRIPT
end 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
end end

View File

@@ -5,14 +5,30 @@ module Sessions
end end
def call def call
cancel_timeout_job
@session.pause! if @session.may_pause? @session.pause! if @session.may_pause?
ensure_slate_on_path
log_event("paused") log_event("paused")
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" }) SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "paused" })
@session @session
end end
private 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) def log_event(type)
@session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: {}) @session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: {})
end 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 private
def handle_connect(session) def handle_connect(session)
return if session.paused?
return if session.live? && session.stream_events.where(event_type: "connected").exists? return if session.live? && session.stream_events.where(event_type: "connected").exists?
if session.reconnecting? if session.reconnecting?
@@ -38,6 +40,7 @@ module Webhooks
end end
def handle_disconnect(session) def handle_disconnect(session)
return if session.paused?
return unless session.live? || session.connecting? return unless session.live? || session.connecting?
if session.connecting? if session.connecting?

View File

@@ -76,7 +76,7 @@
</section> </section>
<% unless @stream_closed %> <% 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> <button type="button" class="regia-btn regia-btn--danger" id="btn-stop">Chiudi diretta</button>
<% end %> <% end %>
</div> </div>

View File

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

View File

@@ -79,6 +79,11 @@
els.badge.className = "regia-badge regia-badge--ended"; els.badge.className = "regia-badge regia-badge--ended";
return; 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) { if (data.on_air) {
els.badge.textContent = "In onda"; els.badge.textContent = "In onda";
els.badge.className = "regia-badge regia-badge--live"; els.badge.className = "regia-badge regia-badge--live";
@@ -230,7 +235,8 @@
async function pauseStream() { async function pauseStream() {
await fetch(cfg.pauseUrl, { method: "POST", headers: { Accept: "application/json" } }); 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() { async function stopStream() {

View File

@@ -1,11 +1,12 @@
#!/bin/sh #!/bin/sh
# Generate offline slate MP4 for MediaMTX alwaysAvailable # Generate offline slate MP4 for MediaMTX alwaysAvailable (pausa / attesa segnale)
set -e set -e
OUT_DIR="$(dirname "$0")/../slates" OUT_DIR="$(dirname "$0")/../slates"
OUT="$OUT_DIR/offline.mp4" OUT="$OUT_DIR/offline.mp4"
mkdir -p "$OUT_DIR" mkdir -p "$OUT_DIR"
docker run --rm -v "$OUT_DIR:/out" linuxserver/ffmpeg:latest \ docker run --rm -v "$OUT_DIR:/out" linuxserver/ffmpeg:latest \
-f lavfi -i color=c=0x1a1a1a:s=1280x720:r=30 \ -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 \ -f lavfi -i sine=frequency=440:sample_rate=48000 \
-t 30 -c:v libx264 -pix_fmt yuv420p -g 30 -preset fast \ -t 30 -c:v libx264 -pix_fmt yuv420p -g 30 -preset fast \
-c:a aac -b:a 128k -y /out/offline.mp4 -c:a aac -b:a 128k -y /out/offline.mp4

View File

@@ -88,6 +88,7 @@ class StreamingEngine(
private var lastFpsSampleAtMs: Long = 0L private var lastFpsSampleAtMs: Long = 0L
private val reconnectAttempts = AtomicInteger(0) private val reconnectAttempts = AtomicInteger(0)
private val isReleased = AtomicBoolean(false) private val isReleased = AtomicBoolean(false)
private val pausingIntentionally = AtomicBoolean(false)
private val bitrateAdapter = BitrateAdapter { adaptedBitrate -> private val bitrateAdapter = BitrateAdapter { adaptedBitrate ->
genericStream?.setVideoBitrateOnFly(adaptedBitrate) genericStream?.setVideoBitrateOnFly(adaptedBitrate)
@@ -235,6 +236,33 @@ class StreamingEngine(
startMetricsLoop() 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() { fun stopStream() {
ensureMainThread() ensureMainThread()
if (state == StreamingState.IDLE || state == StreamingState.STOPPING) { if (state == StreamingState.IDLE || state == StreamingState.STOPPING) {
@@ -291,6 +319,9 @@ class StreamingEngine(
override fun onConnectionFailed(reason: String) { override fun onConnectionFailed(reason: String) {
postMain { postMain {
if (pausingIntentionally.get()) {
return@postMain
}
lastError = reason lastError = reason
val stream = genericStream ?: return@postMain val stream = genericStream ?: return@postMain
val currentConfig = config ?: return@postMain val currentConfig = config ?: return@postMain
@@ -328,6 +359,9 @@ class StreamingEngine(
override fun onDisconnect() { override fun onDisconnect() {
postMain { postMain {
if (pausingIntentionally.get()) {
return@postMain
}
if (state == StreamingState.STREAMING || state == StreamingState.RECONNECTING) { if (state == StreamingState.STREAMING || state == StreamingState.RECONNECTING) {
updateState(StreamingState.RECONNECTING, "disconnected") updateState(StreamingState.RECONNECTING, "disconnected")
} }

View File

@@ -128,6 +128,7 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
when (call.method) { when (call.method) {
"startStream" -> startStream(call, result) "startStream" -> startStream(call, result)
"stopStream" -> stopStream(result) "stopStream" -> stopStream(result)
"pauseStream" -> pauseStream(result)
"getMetrics" -> getMetrics(result) "getMetrics" -> getMetrics(result)
"startPreview" -> startPreview(result) "startPreview" -> startPreview(result)
"stopPreview" -> stopPreview(result) "stopPreview" -> stopPreview(result)
@@ -203,6 +204,11 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
result.success(true) result.success(true)
} }
private fun pauseStream(result: Result) {
getEngine()?.pauseStream()
result.success(true)
}
private fun getMetrics(result: Result) { private fun getMetrics(result: Result) {
val metrics = boundService?.getMetrics() val metrics = boundService?.getMetrics()
?: getEngine()?.getMetrics()?.toMap() ?: getEngine()?.getMetrics()?.toMap()

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ Future<bool> confirmEndStream(BuildContext context) async {
content: const Text( content: const Text(
'La diretta verrà chiusa definitivamente. ' 'La diretta verrà chiusa definitivamente. '
'Gli spettatori vedranno che lo stream è terminato e non potrai riprendere questa sessione.\n\n' '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: [ actions: [
TextButton( TextButton(

View File

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