diff --git a/backend/app/channels/session_channel.rb b/backend/app/channels/session_channel.rb index dbf30e5..75296ca 100644 --- a/backend/app/channels/session_channel.rb +++ b/backend/app/channels/session_channel.rb @@ -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 diff --git a/backend/app/controllers/api/v1/stream_sessions_controller.rb b/backend/app/controllers/api/v1/stream_sessions_controller.rb index 70cebf0..fefb722 100644 --- a/backend/app/controllers/api/v1/stream_sessions_controller.rb +++ b/backend/app/controllers/api/v1/stream_sessions_controller.rb @@ -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) } diff --git a/backend/app/controllers/public/regia_controller.rb b/backend/app/controllers/public/regia_controller.rb index 8c91c88..7b3733e 100644 --- a/backend/app/controllers/public/regia_controller.rb +++ b/backend/app/controllers/public/regia_controller.rb @@ -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), diff --git a/backend/app/models/stream_session.rb b/backend/app/models/stream_session.rb index d4be156..398219c 100644 --- a/backend/app/models/stream_session.rb +++ b/backend/app/models/stream_session.rb @@ -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 diff --git a/backend/app/services/mediamtx/client.rb b/backend/app/services/mediamtx/client.rb index 4d56a74..4ad7451 100644 --- a/backend/app/services/mediamtx/client.rb +++ b/backend/app/services/mediamtx/client.rb @@ -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 diff --git a/backend/app/services/sessions/pause.rb b/backend/app/services/sessions/pause.rb index 3852008..c0a6d09 100644 --- a/backend/app/services/sessions/pause.rb +++ b/backend/app/services/sessions/pause.rb @@ -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 diff --git a/backend/app/services/sessions/resume.rb b/backend/app/services/sessions/resume.rb new file mode 100644 index 0000000..c9b0c27 --- /dev/null +++ b/backend/app/services/sessions/resume.rb @@ -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 diff --git a/backend/app/services/webhooks/mediamtx_handler.rb b/backend/app/services/webhooks/mediamtx_handler.rb index f40ed31..33a1e66 100644 --- a/backend/app/services/webhooks/mediamtx_handler.rb +++ b/backend/app/services/webhooks/mediamtx_handler.rb @@ -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? diff --git a/backend/app/views/public/regia/show.html.erb b/backend/app/views/public/regia/show.html.erb index 0a184d6..966add4 100644 --- a/backend/app/views/public/regia/show.html.erb +++ b/backend/app/views/public/regia/show.html.erb @@ -76,7 +76,7 @@ <% unless @stream_closed %> - + <% end %> diff --git a/backend/config/routes.rb b/backend/config/routes.rb index ac9fa45..3a438b8 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -33,6 +33,7 @@ Rails.application.routes.draw do patch :start patch :stop patch :pause + patch :resume get :events post :telemetry post :pairing_token diff --git a/backend/public/regia.js b/backend/public/regia.js index ca9b9b9..5f944be 100644 --- a/backend/public/regia.js +++ b/backend/public/regia.js @@ -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() { diff --git a/infra/scripts/generate_slate.sh b/infra/scripts/generate_slate.sh index ec873b9..4385943 100755 --- a/infra/scripts/generate_slate.sh +++ b/infra/scripts/generate_slate.sh @@ -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 diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt index 33c9d48..7d10154 100644 --- a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt +++ b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt @@ -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") } diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt index fba0655..d226295 100644 --- a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt +++ b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt @@ -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() diff --git a/mobile/lib/features/camera_mode/camera_screen.dart b/mobile/lib/features/camera_mode/camera_screen.dart index 4a3708d..7a1c768 100644 --- a/mobile/lib/features/camera_mode/camera_screen.dart +++ b/mobile/lib/features/camera_mode/camera_screen.dart @@ -54,6 +54,7 @@ class _CameraScreenState extends ConsumerState { StreamSubscription>? _metricsSub; Orientation? _lastOrientation; + bool _pauseInFlight = false; @override void initState() { @@ -125,8 +126,8 @@ class _CameraScreenState extends ConsumerState { 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 { 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.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 { if (mounted) context.go('/matches'); } - /// Pausa temporanea: si può riprendere la stessa sessione. - Future _interruptStream() async { + /// Pausa temporanea: il server manda la copertina, la camera resta aperta. + Future _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 _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 _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 { @override Widget build(BuildContext context) { + ref.listen( + 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 { 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 { 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 { 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 Function() onPause; + final Future Function() onResume; final Future 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 Function() onPause; + final Future Function() onResume; final Future 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( diff --git a/mobile/lib/providers/session_provider.dart b/mobile/lib/providers/session_provider.dart index 7e7fd47..b2ade27 100644 --- a/mobile/lib/providers/session_provider.dart +++ b/mobile/lib/providers/session_provider.dart @@ -109,6 +109,9 @@ class SessionNotifier extends StateNotifier { case 'pause_stream': newStatus = 'paused'; break; + case 'resume_stream': + newStatus = 'connecting'; + break; case 'stop_stream': newStatus = 'ended'; break; diff --git a/mobile/lib/shared/api_client.dart b/mobile/lib/shared/api_client.dart index e589b6e..a04eda7 100644 --- a/mobile/lib/shared/api_client.dart +++ b/mobile/lib/shared/api_client.dart @@ -197,6 +197,12 @@ class ApiClient { return StreamSession.fromJson(response.data!); } + Future resumeSession(String sessionId) async { + final response = + await _dio.patch>('/sessions/$sessionId/resume'); + return StreamSession.fromJson(response.data!); + } + Future createRegiaLink(String sessionId) async { final response = await _dio.post>( '/sessions/$sessionId/regia_link', diff --git a/mobile/lib/shared/widgets/end_stream_dialog.dart b/mobile/lib/shared/widgets/end_stream_dialog.dart index 1c7b18b..69610ca 100644 --- a/mobile/lib/shared/widgets/end_stream_dialog.dart +++ b/mobile/lib/shared/widgets/end_stream_dialog.dart @@ -12,7 +12,7 @@ Future 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( diff --git a/mobile/lib/shared/widgets/live_badge.dart b/mobile/lib/shared/widgets/live_badge.dart index b13f278..fd9ccd4 100644 --- a/mobile/lib/shared/widgets/live_badge.dart +++ b/mobile/lib/shared/widgets/live_badge.dart @@ -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,