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