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:
@@ -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
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
32
backend/app/services/sessions/resume.rb
Normal file
32
backend/app/services/sessions/resume.rb
Normal 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
|
||||
@@ -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?
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -33,6 +33,7 @@ Rails.application.routes.draw do
|
||||
patch :start
|
||||
patch :stop
|
||||
patch :pause
|
||||
patch :resume
|
||||
get :events
|
||||
post :telemetry
|
||||
post :pairing_token
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
try {
|
||||
await StreamingChannel.stopStream();
|
||||
} catch (_) {}
|
||||
/// Pausa temporanea: il server manda la copertina, la camera resta aperta.
|
||||
Future<void> _pauseStream() async {
|
||||
if (_pauseInFlight) return;
|
||||
_pauseInFlight = true;
|
||||
try {
|
||||
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,9 +589,17 @@ 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(
|
||||
@@ -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,9 +703,14 @@ 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),
|
||||
|
||||
@@ -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,18 +26,23 @@ 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: [
|
||||
if (!paused)
|
||||
Container(
|
||||
width: compact ? 6 : 8,
|
||||
height: compact ? 6 : 8,
|
||||
@@ -44,9 +51,9 @@ class LiveBadge extends StatelessWidget {
|
||||
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