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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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
|
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?
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
if (_pauseInFlight) return;
|
||||||
|
_pauseInFlight = true;
|
||||||
try {
|
try {
|
||||||
await StreamingChannel.stopStream();
|
final match = ref.read(sessionProvider).match;
|
||||||
} catch (_) {}
|
final session =
|
||||||
try {
|
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,10 +589,18 @@ 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(
|
||||||
label: 'Chiudi diretta',
|
label: 'Chiudi diretta',
|
||||||
@@ -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,10 +703,15 @@ 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),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,29 +26,34 @@ 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: [
|
||||||
Container(
|
if (!paused)
|
||||||
width: compact ? 6 : 8,
|
Container(
|
||||||
height: compact ? 6 : 8,
|
width: compact ? 6 : 8,
|
||||||
decoration: const BoxDecoration(
|
height: compact ? 6 : 8,
|
||||||
color: Colors.white,
|
decoration: const BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
if (!paused) SizedBox(width: compact ? 6 : 8),
|
||||||
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user