Aggiunge overlay server-side burn-in e stabilizza diretta live.
Tabellone, badge e brand sono ricodificati nel flusso _air via OverlayRelay; sync punteggio HTTP, volume condiviso rails/sidekiq, qualità 720p migliorata e badge CONNECTING in ripresa da pausa. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
FROM ruby:3.3-bookworm
|
||||
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install -y --no-install-recommends build-essential libpq-dev curl ffmpeg && \
|
||||
apt-get install -y --no-install-recommends build-essential libpq-dev curl ffmpeg librsvg2-bin fonts-dejavu-core && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -59,16 +59,7 @@ class SessionChannel < ApplicationCable::Channel
|
||||
end
|
||||
|
||||
def update_score(session, data)
|
||||
score = session.score_state || session.create_score_state!
|
||||
score.update!(
|
||||
home_sets: data["home_sets"] || score.home_sets,
|
||||
away_sets: data["away_sets"] || score.away_sets,
|
||||
home_points: data["home_points"] || score.home_points,
|
||||
away_points: data["away_points"] || score.away_points,
|
||||
current_set: data["current_set"] || score.current_set,
|
||||
set_partials: data.key?("set_partials") ? data["set_partials"] : score.set_partials
|
||||
)
|
||||
SessionChannel.broadcast_message(session, score.as_cable_payload)
|
||||
Scoring::SyncState.new(session: session, payload: data).call
|
||||
end
|
||||
|
||||
def update_timeout(session, data)
|
||||
|
||||
@@ -34,6 +34,11 @@ module Api
|
||||
render json: session_json(@session)
|
||||
end
|
||||
|
||||
def score
|
||||
Scoring::SyncState.new(session: @session, payload: score_sync_params).call
|
||||
render json: session_json(@session, detail: true)
|
||||
end
|
||||
|
||||
def events
|
||||
events = @session.stream_events.recent.limit(100)
|
||||
render json: events.map { |e| event_json(e) }
|
||||
@@ -126,6 +131,13 @@ module Api
|
||||
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
|
||||
end
|
||||
|
||||
def score_sync_params
|
||||
params.permit(
|
||||
:home_sets, :away_sets, :home_points, :away_points, :current_set,
|
||||
set_partials: %i[set home away]
|
||||
)
|
||||
end
|
||||
|
||||
def session_json(session, detail: false)
|
||||
data = {
|
||||
id: session.id,
|
||||
|
||||
34
backend/app/controllers/concerns/mediamtx_playback.rb
Normal file
34
backend/app/controllers/concerns/mediamtx_playback.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
module MediamtxPlayback
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def mediamtx_paths_index
|
||||
@mediamtx_paths_index ||= Mediamtx::Client.new.list_paths.index_by { |i| i["name"] }
|
||||
end
|
||||
|
||||
def mediamtx_path_info(session, path_name: mediamtx_playback_path_name(session))
|
||||
mediamtx_paths_index[path_name]
|
||||
end
|
||||
|
||||
def mediamtx_playback_path_name(session)
|
||||
session.effective_hls_path_name
|
||||
end
|
||||
|
||||
# Player HLS: path _air con overlay quando il relay è attivo.
|
||||
def mediamtx_stream_playable?(session)
|
||||
return false if session.terminal?
|
||||
|
||||
mediamtx_path_has_output?(session)
|
||||
end
|
||||
|
||||
def mediamtx_path_has_output?(session)
|
||||
info = mediamtx_path_info(session)
|
||||
info && (info["ready"] || info["available"] || info["online"])
|
||||
end
|
||||
|
||||
def mediamtx_publisher_online?(session)
|
||||
info = mediamtx_paths_index[session.mediamtx_path_name]
|
||||
info && info["online"]
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,7 @@
|
||||
module Public
|
||||
class LiveController < SiteBaseController
|
||||
include MediamtxPlayback
|
||||
|
||||
layout "marketing_live"
|
||||
|
||||
ACTIVE_STATUSES = %w[live connecting reconnecting].freeze
|
||||
@@ -40,31 +42,41 @@ module Public
|
||||
|
||||
def show
|
||||
@session = StreamSession.includes(match: { team: :club }).find(params[:id])
|
||||
@session = Mediamtx::PublisherSync.new(@session).call unless @session.terminal?
|
||||
@match = @session.match
|
||||
@stream_closed = @session.terminal?
|
||||
@on_air = !@stream_closed && mediamtx_online?(@session)
|
||||
@on_air = !@stream_closed && mediamtx_stream_playable?(@session)
|
||||
@publisher_online = !@stream_closed && mediamtx_publisher_online?(@session)
|
||||
end
|
||||
|
||||
def status
|
||||
# Mai cache: il tabellone deve restare allineato con l'app (poll ogni ~1,5s).
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
|
||||
session = StreamSession.includes(match: { team: :club }).find(params[:id])
|
||||
session = Mediamtx::PublisherSync.new(session).call unless session.terminal?
|
||||
closed = session.terminal?
|
||||
publisher_online = !closed && mediamtx_publisher_online?(session)
|
||||
playable = !closed && mediamtx_stream_playable?(session)
|
||||
render json: {
|
||||
status: session.status,
|
||||
paused: session.paused?,
|
||||
stream_closed: closed,
|
||||
live: !closed && (session.live? || mediamtx_online?(session)),
|
||||
on_air: !closed && mediamtx_online?(session),
|
||||
live: !closed && (
|
||||
session.live? || session.paused? || session.connecting? || session.reconnecting? ||
|
||||
mediamtx_publisher_online?(session)
|
||||
),
|
||||
on_air: playable,
|
||||
publisher_online: publisher_online,
|
||||
awaiting_signal: !closed && !mediamtx_path_has_output?(session),
|
||||
showing_cover: !closed && mediamtx_path_has_output?(session) &&
|
||||
(session.paused? || !mediamtx_publisher_online?(session)),
|
||||
ended_at: session.ended_at,
|
||||
home_name: session.match.team.name,
|
||||
away_name: session.match.opponent_name,
|
||||
score: session.score_state&.as_cable_payload
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mediamtx_online?(session)
|
||||
@online_paths_cache ||= Mediamtx::Client.new.online_path_names
|
||||
@online_paths_cache.include?(session.mediamtx_path_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
module Public
|
||||
class RegiaController < SiteBaseController
|
||||
include Public::LiveHelper
|
||||
include MediamtxPlayback
|
||||
|
||||
layout "regia"
|
||||
|
||||
@@ -17,7 +18,7 @@ module Public
|
||||
)
|
||||
@rules = Scoring::Rules.from_match(@match)
|
||||
@stream_closed = @session.terminal?
|
||||
@on_air = !@stream_closed && mediamtx_online?(@session)
|
||||
@on_air = !@stream_closed && mediamtx_stream_playable?(@session)
|
||||
@share_url = request.original_url
|
||||
@share_title = "Regia — #{@team.name} vs #{@match.opponent_name}"
|
||||
end
|
||||
@@ -61,12 +62,14 @@ module Public
|
||||
def status_payload
|
||||
score = @session.score_state
|
||||
closed = @session.terminal?
|
||||
playable = !closed && mediamtx_stream_playable?(@session)
|
||||
{
|
||||
status: @session.status,
|
||||
paused: @session.paused?,
|
||||
stream_closed: closed,
|
||||
live: !closed && (@session.live? || mediamtx_online?(@session)),
|
||||
on_air: !closed && mediamtx_online?(@session),
|
||||
live: !closed && (@session.live? || @session.paused? || mediamtx_publisher_online?(@session)),
|
||||
on_air: playable,
|
||||
publisher_online: !closed && mediamtx_publisher_online?(@session),
|
||||
ended_at: @session.ended_at,
|
||||
home_name: @session.match.team.name,
|
||||
away_name: @session.match.opponent_name,
|
||||
@@ -74,10 +77,5 @@ module Public
|
||||
score: score&.as_cable_payload
|
||||
}
|
||||
end
|
||||
|
||||
def mediamtx_online?(session)
|
||||
@online_paths_cache ||= Mediamtx::Client.new.online_path_names
|
||||
@online_paths_cache.include?(session.mediamtx_path_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -61,8 +61,27 @@ module Public
|
||||
"#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}"
|
||||
end
|
||||
|
||||
# Colore ospite sul tabellone: secondario società se leggibile su bianco, altrimenti blu visitatore.
|
||||
def live_scorebug_away_color(team)
|
||||
club = team.club
|
||||
candidate = club&.effective_secondary_color.presence || "#1565c0"
|
||||
return candidate unless light_hex_color?(candidate)
|
||||
|
||||
"#1565c0"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def light_hex_color?(hex)
|
||||
h = hex.to_s.delete_prefix("#")
|
||||
return false unless h.match?(/\A\h{6}\z/i)
|
||||
|
||||
r = h[0..1].to_i(16)
|
||||
g = h[2..3].to_i(16)
|
||||
b = h[4..5].to_i(16)
|
||||
(0.299 * r + 0.587 * g + 0.114 * b) > 200
|
||||
end
|
||||
|
||||
def format_set_partial_entry(partial)
|
||||
data = partial.respond_to?(:with_indifferent_access) ? partial.with_indifferent_access : partial
|
||||
set_no = data[:set] || data["set"]
|
||||
|
||||
31
backend/app/jobs/overlay_refresh_job.rb
Normal file
31
backend/app/jobs/overlay_refresh_job.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
class OverlayRefreshJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
INTERVAL = 2.seconds
|
||||
REDIS_CHAIN_KEY = "overlay_refresh:chain:%s"
|
||||
|
||||
def self.schedule_chain(session_id)
|
||||
redis.set(format(REDIS_CHAIN_KEY, session_id), "1", ex: 48.hours.to_i)
|
||||
set(wait: INTERVAL).perform_later(session_id)
|
||||
end
|
||||
|
||||
def self.cancel_chain(session_id)
|
||||
redis.del(format(REDIS_CHAIN_KEY, session_id))
|
||||
end
|
||||
|
||||
def self.redis
|
||||
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||
end
|
||||
|
||||
def perform(session_id)
|
||||
return unless self.class.redis.get(format(REDIS_CHAIN_KEY, session_id))
|
||||
|
||||
session = StreamSession.find_by(id: session_id)
|
||||
return self.class.cancel_chain(session_id) unless session
|
||||
return self.class.cancel_chain(session_id) if session.terminal?
|
||||
return self.class.cancel_chain(session_id) unless Streams::OverlayRelay.running?(session_id)
|
||||
|
||||
Streams::Overlay::Refresh.call(session)
|
||||
self.class.set(wait: INTERVAL).perform_later(session_id)
|
||||
end
|
||||
end
|
||||
@@ -54,11 +54,11 @@ class StreamSession < ApplicationRecord
|
||||
end
|
||||
|
||||
event :pause do
|
||||
transitions from: %i[live reconnecting], to: :paused
|
||||
transitions from: %i[live reconnecting connecting], to: :paused
|
||||
end
|
||||
|
||||
event :resume do
|
||||
transitions from: :paused, to: :live
|
||||
transitions from: :paused, to: :connecting
|
||||
end
|
||||
|
||||
event :finish do
|
||||
@@ -81,13 +81,30 @@ class StreamSession < ApplicationRecord
|
||||
"live/match_#{id}"
|
||||
end
|
||||
|
||||
def mediamtx_overlay_path_name
|
||||
"#{mediamtx_path_name}_air"
|
||||
end
|
||||
|
||||
def matchlivetv_platform?
|
||||
platform == "matchlivetv"
|
||||
end
|
||||
|
||||
def hls_playback_url
|
||||
base = MatchLiveTv.hls_public_url.chomp("/")
|
||||
"#{base}/#{mediamtx_path_name}/index.m3u8"
|
||||
"#{base}/#{effective_hls_path_name}/index.m3u8"
|
||||
end
|
||||
|
||||
def effective_hls_path_name
|
||||
return mediamtx_path_name if terminal?
|
||||
return mediamtx_overlay_path_name unless Streams::OverlayRelay.running?(id)
|
||||
return mediamtx_overlay_path_name if overlay_air_has_output?
|
||||
|
||||
mediamtx_path_name
|
||||
end
|
||||
|
||||
def overlay_air_has_output?
|
||||
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == mediamtx_overlay_path_name }
|
||||
info && (info["online"] || info["ready"] || info["available"])
|
||||
end
|
||||
|
||||
def watch_page_url
|
||||
|
||||
@@ -12,28 +12,32 @@ module Mediamtx
|
||||
end
|
||||
end
|
||||
|
||||
def create_path(session)
|
||||
path = session.mediamtx_path_name
|
||||
record = session.match.team.entitlements.recording_enabled_for_mediamtx?
|
||||
def create_overlay_path(session)
|
||||
path = session.mediamtx_overlay_path_name
|
||||
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"
|
||||
record: false
|
||||
}
|
||||
if record
|
||||
retention_hours = session.match.team.entitlements.recording_retention_days * 24 + 48
|
||||
body[:recordDeleteAfter] = "#{retention_hours}h"
|
||||
end
|
||||
unless session.matchlivetv_platform?
|
||||
body[:runOnReady] = run_on_ready_script(session)
|
||||
body[:runOnReadyRestart] = true
|
||||
body[:runOnNotReady] = webhook_curl(session, "disconnect")
|
||||
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
|
||||
unless response.success?
|
||||
err = response.body.is_a?(Hash) ? response.body["error"] : response.body
|
||||
raise Error, "MediaMTX overlay path create failed: #{response.status} #{err}"
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def create_path(session)
|
||||
path = session.mediamtx_path_name
|
||||
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
|
||||
# solo la slate (schermo nero) in pausa/attesa.
|
||||
body = recording_body(session, enabled: false).merge(
|
||||
source: "publisher",
|
||||
overridePublisher: true,
|
||||
alwaysAvailable: true,
|
||||
alwaysAvailableFile: slate_file_path
|
||||
)
|
||||
# YouTube: relay continuo gestito da Streams::YoutubeRelay (ffmpeg in sidekiq/rails).
|
||||
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
|
||||
unless response.success?
|
||||
err = response.body.is_a?(Hash) ? response.body["error"] : response.body
|
||||
@@ -44,18 +48,29 @@ module Mediamtx
|
||||
|
||||
def delete_path(session)
|
||||
delete_path_name(session.mediamtx_path_name)
|
||||
delete_path_name(session.mediamtx_overlay_path_name)
|
||||
end
|
||||
|
||||
def delete_path_name(path_name)
|
||||
@conn.post("/v3/config/paths/delete/#{CGI.escape(path_name)}")
|
||||
end
|
||||
|
||||
def set_path_recording(session, enabled:)
|
||||
path = session.mediamtx_path_name
|
||||
body = recording_body(session, enabled: enabled)
|
||||
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 recording patch failed: #{response.status} #{err}"
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def patch_path(session)
|
||||
path = session.mediamtx_path_name
|
||||
body = {
|
||||
alwaysAvailable: true,
|
||||
alwaysAvailableFile: slate_file_path,
|
||||
alwaysAvailableTracks: slate_tracks
|
||||
alwaysAvailableFile: slate_file_path
|
||||
}
|
||||
response = @conn.patch("/v3/config/paths/patch/#{CGI.escape(path)}", body)
|
||||
unless response.success?
|
||||
@@ -81,6 +96,21 @@ module Mediamtx
|
||||
|
||||
private
|
||||
|
||||
def recording_body(session, enabled:)
|
||||
ent = session.match.team.entitlements
|
||||
can_record = ent.recording_enabled_for_mediamtx?
|
||||
body = {
|
||||
record: enabled && can_record,
|
||||
recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S",
|
||||
recordSegmentDuration: "60s"
|
||||
}
|
||||
if enabled && can_record
|
||||
retention_hours = ent.recording_retention_days * 24 + 48
|
||||
body[:recordDeleteAfter] = "#{retention_hours}h"
|
||||
end
|
||||
body
|
||||
end
|
||||
|
||||
def publish_webhook(session)
|
||||
webhook_curl(session, "connect")
|
||||
end
|
||||
@@ -122,12 +152,5 @@ module Mediamtx
|
||||
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
|
||||
|
||||
51
backend/app/services/mediamtx/publisher_sync.rb
Normal file
51
backend/app/services/mediamtx/publisher_sync.rb
Normal file
@@ -0,0 +1,51 @@
|
||||
module Mediamtx
|
||||
# MediaMTX in produzione è distroless: runOnReady con wget non funziona.
|
||||
# Sincronizziamo lo stato Rails leggendo l'API paths (poll da /live/:id/status.json).
|
||||
class PublisherSync
|
||||
def initialize(session)
|
||||
@session = session
|
||||
end
|
||||
|
||||
def call
|
||||
return @session if @session.terminal?
|
||||
|
||||
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == @session.mediamtx_path_name }
|
||||
publisher_online = info && info["online"]
|
||||
|
||||
if publisher_online
|
||||
if @session.paused?
|
||||
# RTMP ancora connesso in pausa: non forzare live/reconnect.
|
||||
elsif @session.may_go_live?
|
||||
@session.go_live!
|
||||
Streams::Overlay::Refresh.call(@session.reload)
|
||||
elsif @session.reconnecting? && @session.may_reconnect?
|
||||
@session.reconnect!
|
||||
cancel_timeout
|
||||
Streams::Overlay::Refresh.call(@session.reload)
|
||||
end
|
||||
elsif !@session.paused? && @session.live? && @session.may_lose_connection?
|
||||
@session.lose_connection!
|
||||
schedule_timeout unless @session.paused?
|
||||
end
|
||||
|
||||
@session.reload
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cancel_timeout
|
||||
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 schedule_timeout
|
||||
job = DisconnectionTimeoutJob.perform_in(
|
||||
MatchLiveTv.reconnect_timeout_seconds.seconds,
|
||||
@session.id
|
||||
)
|
||||
@session.update!(timeout_job_id: job)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -64,8 +64,11 @@ module Recordings
|
||||
@merged_temp_path = File.join(Dir.tmpdir, "replay-#{@session.id}-#{SecureRandom.hex(4)}.mp4")
|
||||
list_path = "#{@merged_temp_path}.txt"
|
||||
File.write(list_path, source_files.map { |f| "file '#{f.gsub("'", "'\\''")}'" }.join("\n"))
|
||||
success = system("ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", @merged_temp_path,
|
||||
out: File::NULL, err: File::NULL)
|
||||
success = system(
|
||||
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_path,
|
||||
"-c", "copy", "-movflags", "+faststart", @merged_temp_path,
|
||||
out: File::NULL, err: File::NULL
|
||||
)
|
||||
FileUtils.rm_f(list_path)
|
||||
raise Error, "Impossibile unire i segmenti video" unless success && File.exist?(@merged_temp_path)
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ module Scoring
|
||||
|
||||
def broadcast(score)
|
||||
SessionChannel.broadcast_message(@session, score.as_cable_payload)
|
||||
Streams::Overlay::Refresh.call(@session)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
28
backend/app/services/scoring/sync_state.rb
Normal file
28
backend/app/services/scoring/sync_state.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
module Scoring
|
||||
# Sincronizza lo stato completo del tabellone (app mobile / Action Cable).
|
||||
class SyncState
|
||||
SCORE_KEYS = %w[home_sets away_sets home_points away_points current_set].freeze
|
||||
|
||||
def initialize(session:, payload:)
|
||||
@session = session
|
||||
@payload = payload.stringify_keys
|
||||
end
|
||||
|
||||
def call
|
||||
score = @session.score_state || @session.create_score_state!(
|
||||
home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: []
|
||||
)
|
||||
|
||||
attrs = {}
|
||||
SCORE_KEYS.each do |key|
|
||||
attrs[key] = @payload[key] if @payload.key?(key)
|
||||
end
|
||||
attrs[:set_partials] = @payload["set_partials"] if @payload.key?("set_partials")
|
||||
|
||||
score.update!(attrs)
|
||||
SessionChannel.broadcast_message(@session, score.as_cable_payload)
|
||||
Streams::Overlay::Refresh.call(@session)
|
||||
score
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -36,7 +36,11 @@ module Sessions
|
||||
StreamSession.transaction do
|
||||
session.save!
|
||||
session.create_score_state!
|
||||
Mediamtx::Client.new.create_path(session)
|
||||
mtx = Mediamtx::Client.new
|
||||
mtx.create_path(session)
|
||||
mtx.create_overlay_path(session)
|
||||
start_overlay_relay(session)
|
||||
start_youtube_relay(session)
|
||||
log_event(session, "pairing", { created: true, platform: session.platform })
|
||||
end
|
||||
|
||||
@@ -70,6 +74,20 @@ module Sessions
|
||||
session.rtmp_url = broadcast[:rtmp_url]
|
||||
end
|
||||
|
||||
def start_overlay_relay(session)
|
||||
Streams::OverlayRelay.start(session)
|
||||
rescue Streams::OverlayRelay::Error => e
|
||||
Rails.logger.warn("[Sessions::Create] Overlay relay: #{e.message}")
|
||||
end
|
||||
|
||||
def start_youtube_relay(session)
|
||||
return unless session.platform == "youtube"
|
||||
|
||||
Streams::YoutubeRelay.start(session)
|
||||
rescue Streams::YoutubeRelay::Error => e
|
||||
Rails.logger.warn("[Sessions::Create] YouTube relay: #{e.message}")
|
||||
end
|
||||
|
||||
def log_event(session, type, metadata)
|
||||
session.stream_events.create!(event_type: type, metadata: metadata, occurred_at: Time.current)
|
||||
end
|
||||
|
||||
@@ -7,10 +7,11 @@ module Sessions
|
||||
def call
|
||||
cancel_timeout_job
|
||||
@session.pause! if @session.may_pause?
|
||||
ensure_slate_on_path
|
||||
# Slate già su path (create_path + alwaysAvailable): stop RTMP basta, senza patch API.
|
||||
log_event("paused")
|
||||
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
||||
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "paused" })
|
||||
Streams::Overlay::Refresh.call(@session)
|
||||
@session
|
||||
end
|
||||
|
||||
@@ -23,12 +24,6 @@ module Sessions
|
||||
@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
|
||||
|
||||
@@ -10,9 +10,13 @@ module Sessions
|
||||
end
|
||||
|
||||
cancel_timeout_job
|
||||
# connecting finché RTMP non è online (evita lose_connection da PublisherSync)
|
||||
@session.begin_connect! if @session.may_begin_connect?
|
||||
# Recording riabilitato in PublisherSync quando RTMP è online (evita patch path prima del publisher).
|
||||
log_event("resumed")
|
||||
SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" })
|
||||
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "resumed" })
|
||||
Streams::Overlay::Refresh.call(@session)
|
||||
@session
|
||||
end
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ module Sessions
|
||||
|
||||
def call
|
||||
cancel_timeout_job
|
||||
Streams::OverlayRelay.stop(@session)
|
||||
Streams::YoutubeRelay.stop(@session)
|
||||
Sessions::RegiaAccess.revoke!(@session)
|
||||
@session.end_stream!
|
||||
complete_youtube_broadcast! if @session.youtube_broadcast_id.present?
|
||||
|
||||
35
backend/app/services/streams/overlay.rb
Normal file
35
backend/app/services/streams/overlay.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
module Streams
|
||||
module Overlay
|
||||
module_function
|
||||
|
||||
def overlay_dir(session_id)
|
||||
Rails.root.join("tmp/stream_overlays", session_id.to_s)
|
||||
end
|
||||
|
||||
def overlay_png_path(session)
|
||||
overlay_dir(session.id).join("overlay.png")
|
||||
end
|
||||
|
||||
def overlay_pipe_path(session)
|
||||
overlay_dir(session.id).join("overlay.pipe")
|
||||
end
|
||||
|
||||
def badge_away_color(team)
|
||||
club = team.club
|
||||
candidate = club&.effective_secondary_color.presence || "#1565c0"
|
||||
return candidate unless light_hex_color?(candidate)
|
||||
|
||||
"#1565c0"
|
||||
end
|
||||
|
||||
def light_hex_color?(hex)
|
||||
h = hex.to_s.delete_prefix("#")
|
||||
return false unless h.match?(/\A\h{6}\z/i)
|
||||
|
||||
r = h[0..1].to_i(16)
|
||||
g = h[2..3].to_i(16)
|
||||
b = h[4..5].to_i(16)
|
||||
(0.299 * r + 0.587 * g + 0.114 * b) > 200
|
||||
end
|
||||
end
|
||||
end
|
||||
53
backend/app/services/streams/overlay/badge_label.rb
Normal file
53
backend/app/services/streams/overlay/badge_label.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
module Streams
|
||||
module Overlay
|
||||
# Etichetta stato broadcast (stessa logica della pagina live / badge HTML).
|
||||
class BadgeLabel
|
||||
Result = Struct.new(:text, :background, :foreground, keyword_init: true)
|
||||
|
||||
def self.for(session)
|
||||
new(session).call
|
||||
end
|
||||
|
||||
def initialize(session)
|
||||
@session = session
|
||||
end
|
||||
|
||||
def call
|
||||
return Result.new(text: "DIRETTA CHIUSA", background: "#374151", foreground: "#dddddd") if @session.terminal?
|
||||
return Result.new(text: "IN PAUSA", background: "#455a64", foreground: "#ffffff") if @session.paused?
|
||||
|
||||
if publisher_online? || (@session.live? && !@session.paused?)
|
||||
return Result.new(text: "IN ONDA", background: "#2e7d32", foreground: "#ffffff")
|
||||
end
|
||||
|
||||
if path_has_output?
|
||||
if @session.reconnecting?
|
||||
return Result.new(text: "RICONNESSIONE", background: "#f57c00", foreground: "#ffffff")
|
||||
end
|
||||
if @session.connecting?
|
||||
return Result.new(text: "CONNECTING", background: "#f57c00", foreground: "#ffffff")
|
||||
end
|
||||
return Result.new(text: "COPERTINA", background: "#455a64", foreground: "#ffffff")
|
||||
end
|
||||
|
||||
Result.new(text: "IN ATTESA", background: "#455a64", foreground: "#ffffff")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def path_info
|
||||
@path_info ||= Mediamtx::Client.new.list_paths.find { |i| i["name"] == @session.mediamtx_path_name }
|
||||
end
|
||||
|
||||
def publisher_online?
|
||||
info = path_info
|
||||
info && info["online"]
|
||||
end
|
||||
|
||||
def path_has_output?
|
||||
info = path_info
|
||||
info && (info["ready"] || info["available"] || info["online"])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
48
backend/app/services/streams/overlay/refresh.rb
Normal file
48
backend/app/services/streams/overlay/refresh.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
module Streams
|
||||
module Overlay
|
||||
class Refresh
|
||||
class Error < StandardError; end
|
||||
|
||||
def self.call(session)
|
||||
new(session).call
|
||||
end
|
||||
|
||||
def initialize(session)
|
||||
@session = session.reload
|
||||
end
|
||||
|
||||
def call
|
||||
return false if @session.terminal?
|
||||
|
||||
dir = Streams::Overlay.overlay_dir(@session.id)
|
||||
FileUtils.mkdir_p(dir)
|
||||
badge = BadgeLabel.for(@session)
|
||||
svg = SvgBuilder.new(@session, badge: badge).to_svg
|
||||
svg_path = dir.join("overlay.svg")
|
||||
png_path = Streams::Overlay.overlay_png_path(@session)
|
||||
atomic_write(svg_path, svg)
|
||||
render_png!(svg_path, png_path)
|
||||
true
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[Overlay::Refresh] session=#{@session.id} #{e.class}: #{e.message}")
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def atomic_write(path, content)
|
||||
tmp = "#{path}.tmp"
|
||||
File.write(tmp, content)
|
||||
File.rename(tmp, path)
|
||||
end
|
||||
|
||||
def render_png!(svg_path, png_path)
|
||||
unless system("rsvg-convert", "-w", "1280", "-h", "720", "-o", png_path.to_s, svg_path.to_s, out: File::NULL, err: File::NULL)
|
||||
raise Error, "rsvg-convert fallito per session #{@session.id}"
|
||||
end
|
||||
|
||||
FileUtils.touch(png_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
145
backend/app/services/streams/overlay/svg_builder.rb
Normal file
145
backend/app/services/streams/overlay/svg_builder.rb
Normal file
@@ -0,0 +1,145 @@
|
||||
module Streams
|
||||
module Overlay
|
||||
# PNG 1280×720 trasparente: tabellone alto-sinistra, badge alto-destra, brand sotto tabellone.
|
||||
class SvgBuilder
|
||||
CANVAS_W = 1280
|
||||
CANVAS_H = 720
|
||||
|
||||
def initialize(session, badge:)
|
||||
@session = session
|
||||
@match = session.match
|
||||
@team = @match.team
|
||||
@score = session.score_state
|
||||
@badge = badge
|
||||
end
|
||||
|
||||
def to_svg
|
||||
cols = score_columns
|
||||
<<~SVG
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="#{CANVAS_W}" height="#{CANVAS_H}" viewBox="0 0 #{CANVAS_W} #{CANVAS_H}">
|
||||
#{scorebug_svg(cols)}
|
||||
#{badge_svg}
|
||||
</svg>
|
||||
SVG
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def score_columns
|
||||
score = @score
|
||||
unless score
|
||||
return {
|
||||
labels: ["1"], home: ["0"], away: ["0"], home_name: abbrev(@match.team.name),
|
||||
away_name: abbrev(@match.opponent_name), sets_won: "0-0", current_set: 1
|
||||
}
|
||||
end
|
||||
|
||||
partials = Array(score.set_partials)
|
||||
labels = partials.map { |p| p["set"].to_s }
|
||||
home = partials.map { |p| p["home"].to_s }
|
||||
away = partials.map { |p| p["away"].to_s }
|
||||
labels << score.current_set.to_s
|
||||
home << score.home_points.to_s
|
||||
away << score.away_points.to_s
|
||||
{
|
||||
labels: labels,
|
||||
home: home,
|
||||
away: away,
|
||||
home_name: abbrev(@match.team.name),
|
||||
away_name: abbrev(@match.opponent_name),
|
||||
sets_won: "#{score.home_sets}-#{score.away_sets}",
|
||||
current_set: score.current_set
|
||||
}
|
||||
end
|
||||
|
||||
def scorebug_svg(cols)
|
||||
x0 = 12
|
||||
y0 = 12
|
||||
col_w = 26
|
||||
team_w = 118
|
||||
row_h = 18
|
||||
head_h = 16
|
||||
pad = 8
|
||||
n = cols[:labels].length
|
||||
box_w = team_w + n * col_w + pad * 2
|
||||
box_h = head_h + row_h * 2 + 22 + pad * 2
|
||||
live_i = n - 1
|
||||
home_primary = @team.effective_primary_color
|
||||
away_primary = Overlay.badge_away_color(@team)
|
||||
|
||||
header_cells = cols[:labels].map.with_index do |label, i|
|
||||
cx = x0 + pad + team_w + i * col_w + col_w / 2
|
||||
"<text x=\"#{cx}\" y=\"#{y0 + pad + 12}\" text-anchor=\"middle\" font-family=\"DejaVu Sans, sans-serif\" font-size=\"10\" font-weight=\"700\" fill=\"#666666\">#{escape(label)}</text>"
|
||||
end.join
|
||||
|
||||
home_pts = cols[:home].map.with_index do |val, i|
|
||||
cx = x0 + pad + team_w + i * col_w + col_w / 2
|
||||
y = y0 + pad + head_h + 14
|
||||
fill = i == live_i ? home_primary : "#111111"
|
||||
weight = i == live_i ? "900" : "800"
|
||||
bg = i == live_i ? "<rect x=\"#{cx - 12}\" y=\"#{y - 13}\" width=\"24\" height=\"16\" rx=\"3\" fill=\"#{home_primary}\" fill-opacity=\"0.14\"/>" : ""
|
||||
"#{bg}<text x=\"#{cx}\" y=\"#{y}\" text-anchor=\"middle\" font-family=\"DejaVu Sans, sans-serif\" font-size=\"13\" font-weight=\"#{weight}\" fill=\"#{fill}\">#{escape(val)}</text>"
|
||||
end.join
|
||||
|
||||
away_pts = cols[:away].map.with_index do |val, i|
|
||||
cx = x0 + pad + team_w + i * col_w + col_w / 2
|
||||
y = y0 + pad + head_h + row_h + 14
|
||||
fill = i == live_i ? away_primary : "#111111"
|
||||
weight = i == live_i ? "900" : "800"
|
||||
bg = i == live_i ? "<rect x=\"#{cx - 12}\" y=\"#{y - 13}\" width=\"24\" height=\"16\" rx=\"3\" fill=\"#{away_primary}\" fill-opacity=\"0.14\"/>" : ""
|
||||
"#{bg}<text x=\"#{cx}\" y=\"#{y}\" text-anchor=\"middle\" font-family=\"DejaVu Sans, sans-serif\" font-size=\"13\" font-weight=\"#{weight}\" fill=\"#{fill}\">#{escape(val)}</text>"
|
||||
end.join
|
||||
|
||||
<<~SVG
|
||||
<g id="scorebug">
|
||||
<rect x="#{x0}" y="#{y0}" width="#{box_w}" height="#{box_h}" rx="6" fill="#ffffff" fill-opacity="0.94"/>
|
||||
<line x1="#{x0 + pad}" y1="#{y0 + pad + head_h}" x2="#{x0 + box_w - pad}" y2="#{y0 + pad + head_h}" stroke="#000000" stroke-opacity="0.12"/>
|
||||
#{header_cells}
|
||||
<text x="#{x0 + pad}" y="#{y0 + pad + head_h + 14}" font-family="DejaVu Sans, sans-serif" font-size="12" font-weight="700" fill="#{home_primary}">#{escape(cols[:home_name])}</text>
|
||||
#{home_pts}
|
||||
<text x="#{x0 + pad}" y="#{y0 + pad + head_h + row_h + 14}" font-family="DejaVu Sans, sans-serif" font-size="12" font-weight="700" fill="#{away_primary}">#{escape(cols[:away_name])}</text>
|
||||
#{away_pts}
|
||||
<rect x="#{x0 + pad}" y="#{y0 + box_h - pad - 18}" width="#{box_w - pad * 2}" height="16" rx="4" fill="url(#brandGrad)"/>
|
||||
<text x="#{x0 + box_w / 2}" y="#{y0 + box_h - pad - 6}" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="9" font-weight="800" fill="#ffffff" letter-spacing="1.2">MATCH <tspan fill="#e53935">LIVE TV</tspan></text>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="brandGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#14141c"/>
|
||||
<stop offset="55%" stop-color="#2a1214"/>
|
||||
<stop offset="100%" stop-color="#1a1a24"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
SVG
|
||||
end
|
||||
|
||||
def badge_svg
|
||||
text = @badge.text
|
||||
bg = @badge.background
|
||||
fg = @badge.foreground
|
||||
pad_x = 12
|
||||
pad_y = 6
|
||||
fs = 11
|
||||
tw = text.length * 6.5 + pad_x * 2
|
||||
th = fs + pad_y * 2
|
||||
x = CANVAS_W - 12 - tw
|
||||
y = 12
|
||||
<<~SVG
|
||||
<g id="badge">
|
||||
<rect x="#{x}" y="#{y}" width="#{tw}" height="#{th}" rx="#{th / 2}" fill="#{bg}"/>
|
||||
<text x="#{x + tw / 2}" y="#{y + th - pad_y - 1}" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="#{fs}" font-weight="700" fill="#{fg}" letter-spacing="0.5">#{escape(text)}</text>
|
||||
</g>
|
||||
SVG
|
||||
end
|
||||
|
||||
def abbrev(name, max = 16)
|
||||
n = name.to_s
|
||||
n.length <= max ? n : "#{n[0, max - 1]}…"
|
||||
end
|
||||
|
||||
def escape(str)
|
||||
str.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
205
backend/app/services/streams/overlay_relay.rb
Normal file
205
backend/app/services/streams/overlay_relay.rb
Normal file
@@ -0,0 +1,205 @@
|
||||
module Streams
|
||||
# Transcodifica path grezzo → path _air con overlay PNG (tabellone + badge) bruciato nel video.
|
||||
class OverlayRelay
|
||||
class Error < StandardError; end
|
||||
|
||||
REDIS_KEY = "overlay_relay:pid:%s"
|
||||
|
||||
class << self
|
||||
def start(session)
|
||||
return if session.terminal?
|
||||
|
||||
stop(session) if pid_for(session.id).present?
|
||||
|
||||
Streams::Overlay::Refresh.call(session)
|
||||
|
||||
dir = Streams::Overlay.overlay_dir(session.id)
|
||||
FileUtils.mkdir_p(dir)
|
||||
png = Streams::Overlay.overlay_png_path(session)
|
||||
pipe = Streams::Overlay.overlay_pipe_path(session)
|
||||
ensure_pipe!(pipe)
|
||||
intake = "#{mediamtx_rtmp_url}/#{session.mediamtx_path_name}"
|
||||
output = "#{mediamtx_rtmp_url}/#{session.mediamtx_overlay_path_name}"
|
||||
log_path = log_file(session)
|
||||
FileUtils.mkdir_p(File.dirname(log_path))
|
||||
|
||||
fps = output_fps
|
||||
overlay_fps = overlay_input_fps
|
||||
filter = "[1:v]format=rgba[ol];[0:v]fps=#{fps},format=yuv420p[vid];[vid][ol]overlay=0:0:format=auto[vout]"
|
||||
|
||||
ffmpeg_pid = Process.spawn(
|
||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||
"-fflags", "+genpts+discardcorrupt",
|
||||
"-rw_timeout", "15000000",
|
||||
"-i", intake,
|
||||
"-f", "image2pipe", "-framerate", overlay_fps.to_s, "-i", pipe.to_s,
|
||||
"-filter_complex", filter,
|
||||
"-map", "[vout]", "-map", "0:a?",
|
||||
"-fps_mode", "cfr", "-r", fps.to_s,
|
||||
"-c:v", "libx264", "-preset", x264_preset, "-bf", "0",
|
||||
"-profile:v", "high", "-pix_fmt", "yuv420p",
|
||||
"-b:v", bitrate_for(session), "-maxrate", maxrate_for(session), "-bufsize", bufsize_for(session),
|
||||
"-g", fps.to_s, "-keyint_min", fps.to_s,
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
||||
"-f", "flv", output,
|
||||
%i[out err] => log_path,
|
||||
pgroup: true
|
||||
)
|
||||
spawn_png_feeder!(ffmpeg_pid, pipe, png, log_path)
|
||||
Process.detach(ffmpeg_pid)
|
||||
store_pid(session.id, ffmpeg_pid)
|
||||
OverlayRefreshJob.schedule_chain(session.id)
|
||||
Rails.logger.info("[OverlayRelay] started pid=#{ffmpeg_pid} session=#{session.id}")
|
||||
ffmpeg_pid
|
||||
rescue Errno::ENOENT => e
|
||||
raise Error, "ffmpeg non disponibile: #{e.message}"
|
||||
end
|
||||
|
||||
def stop(session)
|
||||
pid = pid_for(session.id)
|
||||
return false if pid.blank?
|
||||
|
||||
terminate_pid(pid)
|
||||
clear_pid(session.id)
|
||||
pipe = Streams::Overlay.overlay_pipe_path(session)
|
||||
FileUtils.rm_f(pipe) if pipe.exist?
|
||||
OverlayRefreshJob.cancel_chain(session.id)
|
||||
Rails.logger.info("[OverlayRelay] stopped pid=#{pid} session=#{session.id}")
|
||||
true
|
||||
end
|
||||
|
||||
def running?(session_id)
|
||||
pid = pid_for(session_id)
|
||||
return false if pid.blank?
|
||||
|
||||
return true unless overlay_relay_process_check_local?
|
||||
|
||||
process_alive?(pid)
|
||||
end
|
||||
|
||||
# Riavvia il relay se il processo è morto o _air non pubblica (es. feeder bloccato).
|
||||
def ensure_publishing!(session)
|
||||
return if session.terminal?
|
||||
|
||||
if running?(session.id) && overlay_path_publishing?(session)
|
||||
return
|
||||
end
|
||||
|
||||
if running?(session.id)
|
||||
Rails.logger.warn("[OverlayRelay] _air non attivo, restart session=#{session.id}")
|
||||
stop(session)
|
||||
end
|
||||
|
||||
start(session)
|
||||
rescue Error => e
|
||||
Rails.logger.warn("[OverlayRelay] ensure_publishing session=#{session.id}: #{e.message}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mediamtx_rtmp_url
|
||||
ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935").chomp("/")
|
||||
end
|
||||
|
||||
# Ricodifica con overlay: serve più budget della sorgente (generational loss).
|
||||
def output_video_kbps(session)
|
||||
override = ENV["OVERLAY_RELAY_VIDEO_KBPS"].presence&.to_i
|
||||
return override if override&.positive?
|
||||
|
||||
source_kbps = [(session.target_bitrate.to_i / 1000), 2500].max
|
||||
[(source_kbps * 1.8).to_i, 4500].max
|
||||
end
|
||||
|
||||
def bitrate_for(session)
|
||||
"#{output_video_kbps(session)}k"
|
||||
end
|
||||
|
||||
def maxrate_for(session)
|
||||
kbps = (output_video_kbps(session) * 1.15).to_i
|
||||
"#{kbps}k"
|
||||
end
|
||||
|
||||
def bufsize_for(session)
|
||||
kbps = output_video_kbps(session) * 2
|
||||
"#{kbps}k"
|
||||
end
|
||||
|
||||
def output_fps
|
||||
ENV.fetch("OVERLAY_RELAY_FPS", "30").to_i
|
||||
end
|
||||
|
||||
def x264_preset
|
||||
ENV.fetch("OVERLAY_RELAY_X264_PRESET", "fast")
|
||||
end
|
||||
|
||||
def overlay_input_fps
|
||||
ENV.fetch("OVERLAY_INPUT_FPS", "2").to_i
|
||||
end
|
||||
|
||||
def ensure_pipe!(path)
|
||||
FileUtils.rm_f(path) if path.exist? && !path.pipe?
|
||||
system("mkfifo", path.to_s) unless path.exist?
|
||||
raise Error, "impossibile creare fifo overlay #{path}" unless path.pipe?
|
||||
end
|
||||
|
||||
def spawn_png_feeder!(ffmpeg_pid, pipe, png, log_path)
|
||||
feeder = Rails.root.join("bin/overlay_png_feeder.rb")
|
||||
poll = (1.0 / overlay_input_fps).round(2)
|
||||
Process.spawn(
|
||||
RbConfig.ruby, feeder.to_s, pipe.to_s, png.to_s, poll.to_s,
|
||||
%i[out err] => log_path,
|
||||
pgroup: ffmpeg_pid
|
||||
)
|
||||
end
|
||||
|
||||
def overlay_relay_process_check_local?
|
||||
ENV["OVERLAY_RELAY_PROCESS_CHECK"] != "false"
|
||||
end
|
||||
|
||||
def overlay_path_publishing?(session)
|
||||
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == session.mediamtx_overlay_path_name }
|
||||
info && (info["online"] || info["ready"] || info["available"])
|
||||
end
|
||||
|
||||
def log_file(session)
|
||||
Rails.root.join("log", "overlay_relay_#{session.id}.log").to_s
|
||||
end
|
||||
|
||||
def redis
|
||||
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||
end
|
||||
|
||||
def store_pid(session_id, pid)
|
||||
redis.set(format(REDIS_KEY, session_id), pid, ex: 48.hours.to_i)
|
||||
end
|
||||
|
||||
def clear_pid(session_id)
|
||||
redis.del(format(REDIS_KEY, session_id))
|
||||
end
|
||||
|
||||
def pid_for(session_id)
|
||||
redis.get(format(REDIS_KEY, session_id))
|
||||
end
|
||||
|
||||
def process_alive?(pid)
|
||||
Process.kill(0, pid.to_i)
|
||||
true
|
||||
rescue Errno::ESRCH, Errno::EPERM
|
||||
false
|
||||
end
|
||||
|
||||
def terminate_pid(pid)
|
||||
Process.kill("TERM", -pid.to_i)
|
||||
sleep 0.5
|
||||
rescue Errno::ESRCH
|
||||
nil
|
||||
else
|
||||
begin
|
||||
Process.kill("KILL", -pid.to_i)
|
||||
rescue Errno::ESRCH
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
103
backend/app/services/streams/youtube_relay.rb
Normal file
103
backend/app/services/streams/youtube_relay.rb
Normal file
@@ -0,0 +1,103 @@
|
||||
module Streams
|
||||
# Relay continuo verso YouTube: legge l'uscita MediaMTX del path (camera o slate alwaysAvailable)
|
||||
# senza interrompere FLV quando il telefono si mette in pausa o perde rete.
|
||||
class YoutubeRelay
|
||||
class Error < StandardError; end
|
||||
|
||||
REDIS_KEY = "youtube_relay:pid:%s"
|
||||
|
||||
class << self
|
||||
def start(session)
|
||||
return unless session.platform == "youtube"
|
||||
return if session.stream_key.blank?
|
||||
return if session.terminal?
|
||||
|
||||
stop(session) if pid_for(session.id).present?
|
||||
|
||||
log_path = log_file(session)
|
||||
FileUtils.mkdir_p(File.dirname(log_path))
|
||||
intake = mediamtx_intake_url(session)
|
||||
output = "rtmp://a.rtmp.youtube.com/live2/#{session.stream_key}"
|
||||
|
||||
pid = Process.spawn(
|
||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||
"-rw_timeout", "15000000",
|
||||
"-i", intake,
|
||||
"-c", "copy",
|
||||
"-f", "flv",
|
||||
output,
|
||||
%i[out err] => log_path,
|
||||
pgroup: true
|
||||
)
|
||||
Process.detach(pid)
|
||||
store_pid(session.id, pid)
|
||||
Rails.logger.info("[YoutubeRelay] started pid=#{pid} session=#{session.id}")
|
||||
pid
|
||||
rescue Errno::ENOENT => e
|
||||
raise Error, "ffmpeg non disponibile: #{e.message}"
|
||||
end
|
||||
|
||||
def stop(session)
|
||||
pid = pid_for(session.id)
|
||||
return false if pid.blank?
|
||||
|
||||
terminate_pid(pid)
|
||||
clear_pid(session.id)
|
||||
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id}")
|
||||
true
|
||||
end
|
||||
|
||||
def running?(session_id)
|
||||
pid = pid_for(session_id)
|
||||
pid.present? && process_alive?(pid)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mediamtx_intake_url(session)
|
||||
base = ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
|
||||
"#{base.chomp('/')}/#{session.mediamtx_overlay_path_name}"
|
||||
end
|
||||
|
||||
def log_file(session)
|
||||
Rails.root.join("log", "youtube_relay_#{session.id}.log").to_s
|
||||
end
|
||||
|
||||
def redis
|
||||
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||
end
|
||||
|
||||
def store_pid(session_id, pid)
|
||||
redis.set(format(REDIS_KEY, session_id), pid, ex: 48.hours.to_i)
|
||||
end
|
||||
|
||||
def clear_pid(session_id)
|
||||
redis.del(format(REDIS_KEY, session_id))
|
||||
end
|
||||
|
||||
def pid_for(session_id)
|
||||
redis.get(format(REDIS_KEY, session_id))
|
||||
end
|
||||
|
||||
def process_alive?(pid)
|
||||
Process.kill(0, pid.to_i)
|
||||
true
|
||||
rescue Errno::ESRCH, Errno::EPERM
|
||||
false
|
||||
end
|
||||
|
||||
def terminate_pid(pid)
|
||||
Process.kill("TERM", -pid.to_i)
|
||||
sleep 0.5
|
||||
rescue Errno::ESRCH
|
||||
nil
|
||||
else
|
||||
begin
|
||||
Process.kill("KILL", -pid.to_i)
|
||||
rescue Errno::ESRCH
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -25,21 +25,26 @@ module Webhooks
|
||||
def handle_connect(session)
|
||||
return if session.paused?
|
||||
|
||||
Streams::OverlayRelay.ensure_publishing!(session)
|
||||
|
||||
return if session.live? && session.stream_events.where(event_type: "connected").exists?
|
||||
|
||||
if session.reconnecting?
|
||||
session.reconnect! if session.may_reconnect?
|
||||
enable_recording(session)
|
||||
cancel_timeout(session)
|
||||
log(session, "reconnected")
|
||||
broadcast(session, "reconnected")
|
||||
elsif session.connecting? || session.idle?
|
||||
session.go_live! if session.may_go_live?
|
||||
enable_recording(session)
|
||||
log(session, "connected")
|
||||
broadcast(session, "connected")
|
||||
end
|
||||
end
|
||||
|
||||
def handle_disconnect(session)
|
||||
disable_recording(session)
|
||||
return if session.paused?
|
||||
return unless session.live? || session.connecting?
|
||||
|
||||
@@ -57,6 +62,20 @@ module Webhooks
|
||||
log(session, "connected", { ready: true })
|
||||
end
|
||||
|
||||
def enable_recording(session)
|
||||
return unless session.match.team.entitlements.recording_enabled_for_mediamtx?
|
||||
|
||||
Mediamtx::Client.new.set_path_recording(session, enabled: true)
|
||||
rescue Mediamtx::Client::Error => e
|
||||
Rails.logger.warn("[MediamtxHandler] enable recording: #{e.message}")
|
||||
end
|
||||
|
||||
def disable_recording(session)
|
||||
Mediamtx::Client.new.set_path_recording(session, enabled: false)
|
||||
rescue Mediamtx::Client::Error => e
|
||||
Rails.logger.warn("[MediamtxHandler] disable recording: #{e.message}")
|
||||
end
|
||||
|
||||
def schedule_timeout(session)
|
||||
job = DisconnectionTimeoutJob.perform_in(
|
||||
MatchLiveTv.reconnect_timeout_seconds.seconds,
|
||||
@@ -92,6 +111,7 @@ module Webhooks
|
||||
|
||||
def broadcast(session, event)
|
||||
SessionChannel.broadcast_message(session, { type: "stream_event", event: event })
|
||||
Streams::Overlay::Refresh.call(session)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=36">
|
||||
<link rel="stylesheet" href="/live.css?v=4">
|
||||
<link rel="stylesheet" href="/live.css?v=22">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
<body<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||
|
||||
@@ -13,23 +13,6 @@
|
||||
<%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %>
|
||||
|
||||
<%= live_match_page_heading(@match) %>
|
||||
<p>
|
||||
<% if @stream_closed %>
|
||||
<span id="status-badge" class="badge badge-ended">Diretta chiusa</span>
|
||||
<% elsif @on_air %>
|
||||
<span id="status-badge" class="badge badge-on-air">In onda</span>
|
||||
<% else %>
|
||||
<span id="status-badge" class="badge badge-wait">In attesa</span>
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
<div id="scoreboard" class="scoreboard">
|
||||
<p id="sets-line" class="sets-line"><%= live_score_sets_label(@session.score_state) || "—" %></p>
|
||||
<p id="partials-line" class="partials-line"<%= " hidden" unless live_score_partials_label(@session.score_state).present? %>>
|
||||
Parziali: <%= live_score_partials_label(@session.score_state) %>
|
||||
</p>
|
||||
<p id="score-line" class="score"><%= live_score_points_label(@match, @session.score_state) %></p>
|
||||
</div>
|
||||
|
||||
<% if @stream_closed %>
|
||||
<div id="stream-ended" class="stream-ended" role="status" aria-live="polite">
|
||||
@@ -43,76 +26,94 @@
|
||||
<%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<video id="player" controls playsinline muted autoplay></video>
|
||||
<p id="offline-msg" style="display:none;color:#aaa;">La diretta non è ancora disponibile. Si aggiorna automaticamente quando torna in onda.</p>
|
||||
<div class="live-player-wrap">
|
||||
<video id="player" controls playsinline muted autoplay></video>
|
||||
<%# Tabellone, badge stato e brand sono bruciati nel video (Streams::OverlayRelay). %>
|
||||
<button type="button" id="play-hint" class="live-play-hint" hidden>
|
||||
▶ Avvia la diretta
|
||||
</button>
|
||||
</div>
|
||||
<p id="offline-msg" class="live-status-msg" hidden>La diretta non è ancora disponibile. Si aggiorna automaticamente quando torna in onda.</p>
|
||||
<p id="awaiting-msg" class="live-status-msg" hidden>In attesa del segnale dal telefono — apri la camera nell'app e verifica che la diretta sia avviata (non in pausa).</p>
|
||||
<p id="paused-msg" class="live-status-msg" hidden>Trasmissione in pausa — il flusso continua (copertina server).</p>
|
||||
|
||||
<script>
|
||||
const sessionId = "<%= @session.id %>";
|
||||
const hlsUrl = "<%= j @session.hls_playback_url %>";
|
||||
const video = document.getElementById("player");
|
||||
const badge = document.getElementById("status-badge");
|
||||
const setsLine = document.getElementById("sets-line");
|
||||
const partialsLine = document.getElementById("partials-line");
|
||||
const scoreLine = document.getElementById("score-line");
|
||||
const offlineMsg = document.getElementById("offline-msg");
|
||||
const pausedMsg = document.getElementById("paused-msg");
|
||||
const awaitingMsg = document.getElementById("awaiting-msg");
|
||||
const playHint = document.getElementById("play-hint");
|
||||
|
||||
let hls = null;
|
||||
let playerStarted = false;
|
||||
let wasOnAir = <%= @on_air ? "true" : "false" %>;
|
||||
let wasPublisherOnline = <%= @publisher_online ? "true" : "false" %>;
|
||||
let wasPaused = <%= @session.paused? ? "true" : "false" %>;
|
||||
let reloadTimer = null;
|
||||
let stallPolls = 0;
|
||||
let liveEdgeTimer = null;
|
||||
let publisherStuckPolls = 0;
|
||||
let didHardReloadForLive = false;
|
||||
let pendingLiveRecovery = false;
|
||||
let liveRecoveryAttempts = 0;
|
||||
const MAX_LIVE_RECOVERY_ATTEMPTS = 10;
|
||||
let streamClosed = false;
|
||||
const hlsUrlStable = hlsUrl;
|
||||
|
||||
function setBadge(status, live, onAir, closed) {
|
||||
if (closed) {
|
||||
badge.textContent = "Diretta chiusa";
|
||||
badge.className = "badge badge-ended";
|
||||
function hlsConfig() {
|
||||
return {
|
||||
enableWorker: false,
|
||||
lowLatencyMode: false,
|
||||
liveDurationInfinity: true,
|
||||
liveSyncDurationCount: 3,
|
||||
liveMaxLatencyDurationCount: 8,
|
||||
liveSyncMode: "edge",
|
||||
maxLiveSyncPlaybackRate: 1.08,
|
||||
maxBufferHole: 1.0,
|
||||
backBufferLength: 20,
|
||||
nudgeMaxRetry: 8,
|
||||
};
|
||||
}
|
||||
|
||||
let lastLiveSeekAt = 0;
|
||||
let firstLevelSynced = false;
|
||||
|
||||
function jumpToLiveEdge(force = false) {
|
||||
if (!hls || !playerStarted) return;
|
||||
const pos = hls.liveSyncPosition;
|
||||
if (pos == null || !Number.isFinite(pos)) {
|
||||
ensurePlaying();
|
||||
return;
|
||||
}
|
||||
if (onAir) {
|
||||
badge.textContent = "In onda";
|
||||
badge.className = "badge badge-on-air";
|
||||
} else if (live) {
|
||||
badge.textContent = "LIVE";
|
||||
badge.className = "badge badge-live";
|
||||
} else if (status === "reconnecting") {
|
||||
badge.textContent = "Riconnessione";
|
||||
badge.className = "badge badge-wait";
|
||||
} else {
|
||||
badge.textContent = status;
|
||||
badge.className = "badge badge-wait";
|
||||
const lag = Math.max(0, pos - video.currentTime);
|
||||
const now = Date.now();
|
||||
// Evita seek continui (causano video a scatti): solo se molto indietro o recovery forzato.
|
||||
if (!force && lag < 4 && now - lastLiveSeekAt < 8000) {
|
||||
ensurePlaying();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSets(score) {
|
||||
if (!score) return "—";
|
||||
return `Set ${score.current_set} · Set vinti ${score.home_sets}-${score.away_sets}`;
|
||||
}
|
||||
|
||||
function formatPartials(score) {
|
||||
const partials = score?.set_partials;
|
||||
if (!partials?.length) return "";
|
||||
return partials
|
||||
.map((p) => `Set ${p.set} ${p.home}-${p.away}`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
function formatPoints(data) {
|
||||
if (!data.score || !data.home_name || !data.away_name) return "—";
|
||||
const s = data.score;
|
||||
return `${data.home_name} ${s.home_points} - ${s.away_points} ${data.away_name}`;
|
||||
}
|
||||
|
||||
function updateScoreboard(data) {
|
||||
const score = data.score;
|
||||
setsLine.textContent = formatSets(score);
|
||||
const partials = formatPartials(score);
|
||||
if (partials) {
|
||||
partialsLine.textContent = `Parziali: ${partials}`;
|
||||
partialsLine.hidden = false;
|
||||
} else {
|
||||
partialsLine.hidden = true;
|
||||
if (force || lag > 2) {
|
||||
video.currentTime = Math.max(0, pos - 1);
|
||||
lastLiveSeekAt = now;
|
||||
}
|
||||
scoreLine.textContent = formatPoints(data);
|
||||
ensurePlaying();
|
||||
}
|
||||
|
||||
function ensurePlaying() {
|
||||
if (streamClosed || !playerStarted) return;
|
||||
video.play().then(() => {
|
||||
if (playHint) playHint.hidden = true;
|
||||
}).catch(() => {
|
||||
if (playHint) playHint.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
function liveEdgeLagSec() {
|
||||
if (!hls || !playerStarted) return 0;
|
||||
const pos = hls.liveSyncPosition;
|
||||
if (pos == null || !Number.isFinite(pos)) return 0;
|
||||
return Math.max(0, pos - video.currentTime);
|
||||
}
|
||||
|
||||
function showStreamEnded() {
|
||||
@@ -120,7 +121,7 @@
|
||||
streamClosed = true;
|
||||
destroyPlayer();
|
||||
video.style.display = "none";
|
||||
offlineMsg.style.display = "none";
|
||||
offlineMsg.hidden = true;
|
||||
const panel = document.createElement("div");
|
||||
panel.id = "stream-ended";
|
||||
panel.className = "stream-ended";
|
||||
@@ -129,18 +130,14 @@
|
||||
<div class="stream-ended-icon" aria-hidden="true">📡</div>
|
||||
<h2>Diretta terminata</h2>
|
||||
<p>Lo streaming di questa partita è stato chiuso.</p>
|
||||
<p class="stream-ended-hint">Il punteggio finale resta visibile sopra.</p>
|
||||
<p class="stream-ended-hint">Ricarica la pagina per l’ultimo stato registrato.</p>
|
||||
<a href="<%= public_live_index_path %>" class="btn btn-secondary stream-ended-btn">Tutte le dirette</a>
|
||||
`;
|
||||
video.parentNode.insertBefore(panel, video.nextSibling);
|
||||
}
|
||||
|
||||
function hlsUrlFresh() {
|
||||
const sep = hlsUrl.includes("?") ? "&" : "?";
|
||||
return `${hlsUrl}${sep}_ts=${Date.now()}`;
|
||||
}
|
||||
|
||||
function destroyPlayer() {
|
||||
playerStarted = false;
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
hls = null;
|
||||
@@ -149,24 +146,119 @@
|
||||
video.load();
|
||||
}
|
||||
|
||||
/** Segue il live dopo switch copertina→camera (stesso player, stesso URL). */
|
||||
function recoverLiveVideo() {
|
||||
if (!hls || !playerStarted) return;
|
||||
try {
|
||||
if (hls.levels?.length) {
|
||||
hls.startLoad(-1);
|
||||
window.setTimeout(() => jumpToLiveEdge(true), 300);
|
||||
}
|
||||
} catch (_) {
|
||||
ensurePlaying();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleLiveEdgeSync() {
|
||||
if (liveEdgeTimer) return;
|
||||
liveEdgeTimer = setTimeout(() => {
|
||||
liveEdgeTimer = null;
|
||||
recoverLiveVideo();
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function bindHlsEvents(instance) {
|
||||
instance.on(Hls.Events.MANIFEST_PARSED, () => ensurePlaying());
|
||||
instance.on(Hls.Events.LEVEL_LOADED, () => {
|
||||
if (!firstLevelSynced) {
|
||||
firstLevelSynced = true;
|
||||
jumpToLiveEdge(true);
|
||||
}
|
||||
});
|
||||
instance.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (!data.fatal) return;
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
instance.startLoad(-1);
|
||||
ensurePlaying();
|
||||
return;
|
||||
}
|
||||
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
instance.recoverMediaError();
|
||||
return;
|
||||
}
|
||||
scheduleReload(5000);
|
||||
});
|
||||
}
|
||||
|
||||
function isCaughtUpWithLive() {
|
||||
if (!hls || !playerStarted) return false;
|
||||
const lag = liveEdgeLagSec();
|
||||
return video.readyState >= 2 && lag < 1.2 && (video.videoWidth || 0) > 0;
|
||||
}
|
||||
|
||||
function tryEscapeCoverBuffer() {
|
||||
if (!playerStarted || streamClosed || liveRecoveryAttempts >= MAX_LIVE_RECOVERY_ATTEMPTS) return;
|
||||
liveRecoveryAttempts += 1;
|
||||
didHardReloadForLive = false;
|
||||
recoverLiveVideo();
|
||||
if (liveRecoveryAttempts >= 2) {
|
||||
hardReloadLiveOnce();
|
||||
}
|
||||
}
|
||||
|
||||
function onPublisherBack() {
|
||||
pendingLiveRecovery = true;
|
||||
didHardReloadForLive = false;
|
||||
publisherStuckPolls = 0;
|
||||
liveRecoveryAttempts = 0;
|
||||
recoverLiveVideo();
|
||||
window.setTimeout(() => tryEscapeCoverBuffer(), 500);
|
||||
window.setTimeout(() => {
|
||||
if (!isCaughtUpWithLive()) tryEscapeCoverBuffer();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/** Dopo pausa il buffer HLS resta sulla copertina: forza reload finché il live è allineato. */
|
||||
function onResumeFromPause() {
|
||||
pendingLiveRecovery = true;
|
||||
didHardReloadForLive = false;
|
||||
publisherStuckPolls = 0;
|
||||
liveRecoveryAttempts = 0;
|
||||
tryEscapeCoverBuffer();
|
||||
}
|
||||
|
||||
/** Ultima risorsa: nuova playlist quando il buffer resta sulla copertina con RTMP attivo. */
|
||||
function hardReloadLiveOnce() {
|
||||
if (didHardReloadForLive || !Hls.isSupported()) return;
|
||||
didHardReloadForLive = true;
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
hls = null;
|
||||
}
|
||||
const url = `${hlsUrlStable}${hlsUrlStable.includes("?") ? "&" : "?"}_live=${Date.now()}`;
|
||||
hls = new Hls(hlsConfig());
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
bindHlsEvents(hls);
|
||||
hls.startLoad(-1);
|
||||
video.play().catch(() => {});
|
||||
playerStarted = true;
|
||||
}
|
||||
|
||||
/** Un solo attach HLS per sessione: MediaMTX concatena slate/camera sullo stesso path. */
|
||||
function startPlayer() {
|
||||
destroyPlayer();
|
||||
const url = hlsUrlFresh();
|
||||
if (playerStarted) return;
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({ lowLatencyMode: true, enableWorker: true });
|
||||
hls.loadSource(url);
|
||||
hls = new Hls(hlsConfig());
|
||||
hls.loadSource(hlsUrlStable);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (data.fatal) {
|
||||
scheduleReload(2500);
|
||||
}
|
||||
});
|
||||
bindHlsEvents(hls);
|
||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = url;
|
||||
video.src = hlsUrlStable;
|
||||
video.addEventListener("loadedmetadata", () => video.play().catch(() => {}), { once: true });
|
||||
}
|
||||
playerStarted = true;
|
||||
}
|
||||
|
||||
function scheduleReload(delayMs) {
|
||||
@@ -179,11 +271,12 @@
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const res = await fetch(`/live/${sessionId}/status.json`);
|
||||
const res = await fetch(`/live/${sessionId}/status.json?t=${Date.now()}`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
setBadge(data.status, data.live, data.on_air, data.stream_closed);
|
||||
updateScoreboard(data);
|
||||
|
||||
const paused = !!data.paused || data.status === "paused";
|
||||
const publisherOnline = !!data.publisher_online;
|
||||
const awaitingSignal = !!data.awaiting_signal;
|
||||
const showingCover = !!data.showing_cover;
|
||||
if (data.stream_closed) {
|
||||
showStreamEnded();
|
||||
return;
|
||||
@@ -192,39 +285,106 @@
|
||||
const onAir = !!data.on_air;
|
||||
|
||||
if (onAir) {
|
||||
offlineMsg.style.display = "none";
|
||||
if (!wasOnAir) {
|
||||
startPlayer();
|
||||
stallPolls = 0;
|
||||
} else if (video.readyState < 2) {
|
||||
stallPolls += 1;
|
||||
if (stallPolls >= 2) {
|
||||
startPlayer();
|
||||
stallPolls = 0;
|
||||
}
|
||||
offlineMsg.hidden = true;
|
||||
if (!publisherOnline && showingCover && !paused) {
|
||||
awaitingMsg.hidden = false;
|
||||
awaitingMsg.textContent = "In attesa del segnale dal telefono — riprendi la diretta dall'app.";
|
||||
} else {
|
||||
stallPolls = 0;
|
||||
awaitingMsg.hidden = !awaitingSignal;
|
||||
}
|
||||
pausedMsg.hidden = !paused;
|
||||
if (!playerStarted) startPlayer();
|
||||
|
||||
if (paused) {
|
||||
pendingLiveRecovery = false;
|
||||
liveRecoveryAttempts = 0;
|
||||
} else if (wasPaused) {
|
||||
pendingLiveRecovery = true;
|
||||
}
|
||||
|
||||
if (publisherOnline && !wasPublisherOnline) {
|
||||
onPublisherBack();
|
||||
} else if (!paused && wasPaused && playerStarted) {
|
||||
onResumeFromPause();
|
||||
} else if (publisherOnline && !paused && playerStarted) {
|
||||
if (pendingLiveRecovery && !isCaughtUpWithLive()) {
|
||||
tryEscapeCoverBuffer();
|
||||
} else if (pendingLiveRecovery && isCaughtUpWithLive()) {
|
||||
pendingLiveRecovery = false;
|
||||
liveRecoveryAttempts = 0;
|
||||
publisherStuckPolls = 0;
|
||||
} else {
|
||||
const lag = liveEdgeLagSec();
|
||||
if (lag > 4) {
|
||||
publisherStuckPolls += 1;
|
||||
pendingLiveRecovery = true;
|
||||
if (publisherStuckPolls >= 2) tryEscapeCoverBuffer();
|
||||
else if (publisherStuckPolls >= 1) recoverLiveVideo();
|
||||
} else {
|
||||
publisherStuckPolls = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!publisherOnline) {
|
||||
didHardReloadForLive = false;
|
||||
publisherStuckPolls = 0;
|
||||
}
|
||||
if (video.paused) video.play().catch(() => {});
|
||||
} else {
|
||||
offlineMsg.style.display = "block";
|
||||
destroyPlayer();
|
||||
stallPolls = 0;
|
||||
offlineMsg.hidden = false;
|
||||
awaitingMsg.hidden = true;
|
||||
pausedMsg.hidden = true;
|
||||
}
|
||||
|
||||
wasOnAir = onAir;
|
||||
wasPublisherOnline = publisherOnline;
|
||||
wasPaused = paused;
|
||||
} catch (_) {
|
||||
scheduleReload(5000);
|
||||
}
|
||||
}
|
||||
|
||||
if (wasOnAir) {
|
||||
if (playHint) {
|
||||
playHint.addEventListener("click", () => {
|
||||
jumpToLiveEdge();
|
||||
ensurePlaying();
|
||||
});
|
||||
}
|
||||
video.addEventListener("pause", () => {
|
||||
if (!streamClosed && playerStarted && !video.ended) {
|
||||
if (playHint) playHint.hidden = false;
|
||||
}
|
||||
});
|
||||
video.addEventListener("playing", () => {
|
||||
if (playHint) playHint.hidden = true;
|
||||
});
|
||||
|
||||
if (!streamClosed) {
|
||||
startPlayer();
|
||||
if (!wasOnAir) offlineMsg.hidden = false;
|
||||
window.setTimeout(() => {
|
||||
if (playerStarted && video.readyState < 2 && !didHardReloadForLive) {
|
||||
hardReloadLiveOnce();
|
||||
}
|
||||
}, 4500);
|
||||
} else {
|
||||
offlineMsg.style.display = "block";
|
||||
offlineMsg.hidden = true;
|
||||
}
|
||||
|
||||
pollStatus();
|
||||
setInterval(pollStatus, 3000);
|
||||
setInterval(pollStatus, 1500);
|
||||
setInterval(() => {
|
||||
if (!playerStarted || streamClosed || !pendingLiveRecovery) return;
|
||||
if (!isCaughtUpWithLive()) tryEscapeCoverBuffer();
|
||||
}, 6000);
|
||||
|
||||
if (!streamClosed && wasPublisherOnline && !wasPaused) {
|
||||
pendingLiveRecovery = true;
|
||||
window.setTimeout(() => {
|
||||
if (playerStarted && !isCaughtUpWithLive()) tryEscapeCoverBuffer();
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
</script>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="card replay-show__status">
|
||||
<p>Registrazione in elaborazione. Riceverai un’email quando sarà pronta.</p>
|
||||
</div>
|
||||
<% elsif @recording.ready? %>
|
||||
<% elsif @recording.ready? && @recording.storage_key.present? %>
|
||||
<div class="replay-show__player card">
|
||||
<video id="replay-player" class="replay-video" controls playsinline preload="metadata"
|
||||
<% if @recording.thumbnail_url %> poster="<%= @recording.thumbnail_url %>"<% end %>>
|
||||
@@ -46,6 +46,11 @@
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% elsif @recording.ready? %>
|
||||
<div class="card replay-show__status">
|
||||
<p>Il file video non è più disponibile (archivio rimosso o in migrazione).</p>
|
||||
<p class="replay-show__details-line">Durata registrata: <%= @recording.duration_label %> · <%= @recording.byte_size_label %></p>
|
||||
</div>
|
||||
<% elsif @recording.status == "failed" %>
|
||||
<div class="card replay-show__status">
|
||||
<p>Replay non disponibile: <%= @recording.error_message.presence || "errore di elaborazione" %>.</p>
|
||||
|
||||
32
backend/bin/overlay_png_feeder.rb
Normal file
32
backend/bin/overlay_png_feeder.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env ruby
|
||||
# Invia overlay.png aggiornata a ffmpeg via image2pipe (FIFO).
|
||||
# ffmpeg con -loop 1 legge il file una sola volta: questo processo rilegge il PNG
|
||||
# quando cambia mtime (punteggio, badge stato, …).
|
||||
require "fileutils"
|
||||
|
||||
pipe_path = ARGV.fetch(0)
|
||||
png_path = ARGV.fetch(1)
|
||||
poll_sec = (ARGV[2] || ENV.fetch("OVERLAY_PIPE_POLL_SEC", "0.4")).to_f
|
||||
|
||||
abort "pipe mancante: #{pipe_path}" unless File.exist?(pipe_path)
|
||||
abort "png mancante: #{png_path}" unless File.exist?(png_path)
|
||||
|
||||
stop = false
|
||||
trap("TERM") { stop = true }
|
||||
trap("INT") { stop = true }
|
||||
|
||||
# image2pipe richiede frame continui: inviamo la PNG a ritmo fisso ( anche se invariata ).
|
||||
File.open(pipe_path, "wb") do |pipe|
|
||||
loop do
|
||||
break if stop
|
||||
|
||||
if File.exist?(png_path)
|
||||
pipe.write(File.binread(png_path))
|
||||
pipe.flush
|
||||
end
|
||||
|
||||
sleep poll_sec
|
||||
end
|
||||
rescue Errno::EPIPE
|
||||
# ffmpeg chiuso
|
||||
end
|
||||
@@ -41,6 +41,7 @@ Rails.application.routes.draw do
|
||||
patch :stop
|
||||
patch :pause
|
||||
patch :resume
|
||||
patch :score
|
||||
get :events
|
||||
post :telemetry
|
||||
post :pairing_token
|
||||
|
||||
18
backend/lib/tasks/stream_overlay.rake
Normal file
18
backend/lib/tasks/stream_overlay.rake
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace :stream_overlay do
|
||||
desc "Avvia path _air e OverlayRelay per sessioni attive (dopo deploy overlay server)"
|
||||
task start_active: :environment do
|
||||
sessions = StreamSession.where(status: %w[live connecting reconnecting paused])
|
||||
mtx = Mediamtx::Client.new
|
||||
sessions.find_each do |session|
|
||||
begin
|
||||
mtx.create_overlay_path(session)
|
||||
rescue Mediamtx::Client::Error => e
|
||||
Rails.logger.info("[stream_overlay:start_active] path #{session.id}: #{e.message}")
|
||||
end
|
||||
Streams::OverlayRelay.start(session)
|
||||
puts "Overlay avviato per sessione #{session.id}"
|
||||
rescue StandardError => e
|
||||
warn "Sessione #{session.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
BIN
backend/public/images/copertina-canale.png
Normal file
BIN
backend/public/images/copertina-canale.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -5,11 +5,285 @@
|
||||
padding: 24px 20px 48px;
|
||||
}
|
||||
|
||||
.live-main video {
|
||||
.live-main .live-player-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
background: #000;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: min(70vh, calc(100vw * 9 / 16));
|
||||
min-height: 200px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.live-main video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
border-radius: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Sovraimpressioni chiare sul video (copertina scura). */
|
||||
.live-main .live-player-overlays {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.live-main .live-ovl-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.live-main .live-ovl-badge--right {
|
||||
right: 12px;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
/* Tabellone broadcast: tabella per colonne allineate (set / punteggi). */
|
||||
.live-main .live-scorebug {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
width: max-content;
|
||||
max-width: min(92%, 320px);
|
||||
padding: 6px 8px 5px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
color: #111;
|
||||
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.45);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug__table {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug__col-team {
|
||||
width: 7.25rem;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug__col-set {
|
||||
width: 1.85rem;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug__corner {
|
||||
width: 7.25rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug thead th {
|
||||
padding: 0 0 4px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug__col-h {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug tbody th,
|
||||
.live-main .live-scorebug tbody td {
|
||||
padding: 3px 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug tbody tr + tr th,
|
||||
.live-main .live-scorebug tbody tr + tr td {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug__team {
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 7.25rem;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug__row--home .live-scorebug__team {
|
||||
color: var(--sb-home-primary, #111);
|
||||
}
|
||||
|
||||
.live-main .live-scorebug__row--away .live-scorebug__team {
|
||||
color: var(--sb-away-primary, #1565c0);
|
||||
}
|
||||
|
||||
.live-main .live-scorebug__pts {
|
||||
text-align: center;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #111;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug__pts--live-home {
|
||||
background: color-mix(in srgb, var(--sb-home-primary, #e53935) 16%, #fff);
|
||||
border-radius: 3px;
|
||||
color: var(--sb-home-primary, #111);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug__pts--live-away {
|
||||
background: color-mix(in srgb, var(--sb-away-primary, #1565c0) 16%, #fff);
|
||||
border-radius: 3px;
|
||||
color: var(--sb-away-primary, #111);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug__brand {
|
||||
margin-top: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(135deg, #14141c 0%, #2a1214 55%, #1a1a24 100%);
|
||||
border: 1px solid rgba(229, 57, 53, 0.35);
|
||||
text-align: center;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug__brand-text {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug__brand-accent {
|
||||
color: #e53935;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug .visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.live-main .live-scorebug {
|
||||
font-size: 0.72rem;
|
||||
max-width: 88%;
|
||||
padding: 5px 6px 4px;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug__col-team,
|
||||
.live-main .live-scorebug__corner {
|
||||
width: 5.75rem;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug__team {
|
||||
max-width: 5.75rem;
|
||||
}
|
||||
|
||||
.live-main .live-scorebug__col-set {
|
||||
width: 1.55rem;
|
||||
}
|
||||
}
|
||||
|
||||
.live-main .live-status-msg {
|
||||
margin: 12px 0 0;
|
||||
color: #f5f5f5;
|
||||
font-size: 0.95rem;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.live-main .live-status-msg[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.live-main .live-play-hint {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
z-index: 5;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 12px 22px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(229, 57, 53, 0.92);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.live-main .live-play-hint[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.live-main .live-cover-veil {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.live-main .live-cover-veil__hint {
|
||||
color: #ffffff;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* Messaggio pausa sopra il video: la copertina brand è nello stream HLS (MediaMTX). */
|
||||
.live-main .live-cover-veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 0 16px 14%;
|
||||
background: linear-gradient(180deg, transparent 55%, rgba(0, 0, 0, 0.55) 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.live-main .live-cover-veil[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.live-main .live-cover-veil.is-active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.live-main .live-cover-veil__hint {
|
||||
margin: 0;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #fff;
|
||||
font-size: clamp(0.9rem, 2.5vw, 1.1rem);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.live-main .badge {
|
||||
@@ -24,17 +298,24 @@
|
||||
|
||||
.live-main .badge-live { background: #e53935; color: #fff; }
|
||||
.live-main .badge-on-air { background: #2e7d32; color: #fff; }
|
||||
.live-main .badge-wait { background: #444; color: #ddd; }
|
||||
.live-main .badge-wait { background: rgba(0, 0, 0, 0.65); color: #fff; border: 1px solid rgba(255, 255, 255, 0.35); }
|
||||
.live-main .badge-connecting { background: #f57c00; color: #fff; }
|
||||
.live-main .badge-ended { background: #374151; color: #ddd; }
|
||||
|
||||
.live-main .stream-ended {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
margin: 16px 0;
|
||||
padding: 24px;
|
||||
margin: 0;
|
||||
background: linear-gradient(160deg, #14141c 0%, #0a0a0e 100%);
|
||||
border: 1px dashed #2a2a36;
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.live-main .stream-ended-icon {
|
||||
|
||||
33
backend/spec/services/scoring/sync_state_spec.rb
Normal file
33
backend/spec/services/scoring/sync_state_spec.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Scoring::SyncState do
|
||||
let!(:user) { User.create!(email: "sync@test.it", name: "U", password: "password123", role: "coach") }
|
||||
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball", sets_to_win: 3) }
|
||||
let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live") }
|
||||
|
||||
it "persiste punti zero dopo chiusura set" do
|
||||
session.create_score_state!(
|
||||
home_sets: 1, away_sets: 0, home_points: 25, away_points: 20, current_set: 2,
|
||||
set_partials: [{ "set" => 1, "home" => 25, "away" => 20 }]
|
||||
)
|
||||
|
||||
described_class.new(
|
||||
session: session,
|
||||
payload: {
|
||||
"home_sets" => 1,
|
||||
"away_sets" => 0,
|
||||
"home_points" => 0,
|
||||
"away_points" => 0,
|
||||
"current_set" => 2,
|
||||
"set_partials" => [{ "set" => 1, "home" => 25, "away" => 20 }]
|
||||
}
|
||||
).call
|
||||
|
||||
score = session.score_state.reload
|
||||
expect(score.home_points).to eq(0)
|
||||
expect(score.away_points).to eq(0)
|
||||
expect(score.current_set).to eq(2)
|
||||
end
|
||||
end
|
||||
102
docs/LIVE_STREAMING.md
Normal file
102
docs/LIVE_STREAMING.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Diretta live — architettura MediaMTX
|
||||
|
||||
## Idea (non è folle)
|
||||
|
||||
MediaMTX espone **un unico flusso di uscita** per path (`live/match_{uuid}`):
|
||||
|
||||
1. **Telefono in onda** → publisher RTMP (camera).
|
||||
2. **Pausa / rete assente** → `alwaysAvailable` con file slate (`/slates/offline.mp4`) **senza interrompere** l’HLS verso il sito.
|
||||
3. **YouTube** → `ffmpeg` in container Rails/Sidekiq legge **sempre** quell’uscita (`-c copy`) e inoltra a `rtmp://a.rtmp.youtube.com/live2/...` per tutta la sessione.
|
||||
|
||||
Il telefono **non** invia la copertina: risparmia banda; lo switch è lato server.
|
||||
|
||||
## Componenti
|
||||
|
||||
| Pezzo | Ruolo |
|
||||
|--------|--------|
|
||||
| App Android | RTMP 48 kHz mono, 720p — solo quando in onda |
|
||||
| MediaMTX | Path dinamico, `alwaysAvailable` + slate |
|
||||
| `Streams::YoutubeRelay` | Relay continuo verso YouTube (no hook wget su distroless) |
|
||||
| `Mediamtx::PublisherSync` | Stato Rails da API paths (publisher online) |
|
||||
| `/hls/...` (Rails proxy) | Player web |
|
||||
|
||||
## Pausa e continuità
|
||||
|
||||
1. API `PATCH /sessions/:id/pause` → stato `paused`, registrazione off.
|
||||
2. App → `pauseStream()` (stop RTMP).
|
||||
3. MediaMTX → passa alla slate sul **medesimo path** (senza interrompere l’uscita HLS).
|
||||
4. **Ripresa** → API `resume` → `connecting`, recording on, app `resumeStream()`; MediaMTX concatena di nuovo camera sulla stessa uscita HLS.
|
||||
5. **Sito** → **un solo** player HLS per tutta la sessione (mai reload in pausa/ripresa). Copertina brand (`brand/CopertinaCanale_6.png` → `infra/slates/offline.mp4`) muxata da MediaMTX; in pausa solo un messaggio leggero sopra il video.
|
||||
6. **YouTube** → relay ffmpeg continuo; vede la stessa uscita MediaMTX.
|
||||
|
||||
Non fare `patch` del path MediaMTX in pausa: ricarica il path e interrompe gli HLS reader.
|
||||
|
||||
**Recording**: disabilitato sul path live (`record: false`) — il recorder MediaMTX al switch slate→camera distruggeva il muxer HLS (`too many reordered frames`). I replay vanno gestiti con job dedicato (vedi `REPLAY_MODULE.md`).
|
||||
|
||||
Il player web resta attivo finché `ready|available|online` sul path (non solo quando il telefono è `online`).
|
||||
|
||||
## Infrastruttura vs browser
|
||||
|
||||
```
|
||||
Telefono RTMP ──► MediaMTX path live/match_{uuid}
|
||||
│
|
||||
publisher ON ├─► camera (H.264/AAC)
|
||||
publisher OFF└─► alwaysAvailable → /slates/offline.mp4
|
||||
│
|
||||
├─► HLS (segmenti ~1s) ──► proxy Rails /hls/... ──► player web (HLS.js)
|
||||
└─► RTMP lettura ──► Streams::YoutubeRelay (ffmpeg -c copy) ──► YouTube
|
||||
```
|
||||
|
||||
| Responsabilità | Dove |
|
||||
|----------------|------|
|
||||
| Switch copertina ↔ live | **MediaMTX** (stesso path, stesso URL HLS) |
|
||||
| Continuità YouTube | **Relay ffmpeg** sulla stessa uscita |
|
||||
| Badge «In onda» / stato pausa | **Rails** (`status.json`, `PublisherSync`) |
|
||||
| Uscire dal buffer copertina dopo ripresa | **Browser** (`pendingLiveRecovery`, reload HLS) |
|
||||
| Fluidità playback | **Browser** (evitare seek continui su `liveSyncPosition`) |
|
||||
|
||||
Lo switch slate→camera **non** richiede un nuovo URL lato player: MediaMTX concatena sulla playlist. Il sito può però restare «indietro» nel buffer HLS (ancora segmenti della slate) anche con `publisher_online: true` — da qui i recovery controllati in `show.html.erb`, **senza** chiamare `jumpToLiveEdge()` a ogni frammento bufferizzato.
|
||||
|
||||
### Player web — anti-scatti (HLS.js)
|
||||
|
||||
Configurazione in `backend/app/views/public/live/show.html.erb`:
|
||||
|
||||
- `maxLiveSyncPlaybackRate: 1.08` (evita accelerazioni visibili; prima 1.5 causava micro-scatti).
|
||||
- `jumpToLiveEdge(force)` con throttle: seek solo se lag > 2s o `force`, e non più di una volta ogni ~8s se lag < 4s.
|
||||
- **Niente** seek su `FRAG_BUFFERED` né nel poll ogni 1.5s.
|
||||
- Sync iniziale una volta su `LEVEL_LOADED`; recovery solo se `liveEdgeLagSec() > 4` o `pendingLiveRecovery`.
|
||||
- Interval 6s solo durante recovery attivo (`tryEscapeCoverBuffer`).
|
||||
|
||||
### Grafica nel video (overlay server)
|
||||
|
||||
Tabellone, badge stato («In onda», «In pausa», «Copertina», …) e banner **Match Live TV** sono **bruciati nel flusso** da `Streams::OverlayRelay`:
|
||||
|
||||
```
|
||||
Telefono → live/match_{uuid} (grezzo)
|
||||
└─► ffmpeg + PNG 1280×720 (Streams::Overlay::Refresh ogni ~2s)
|
||||
└─► live/match_{uuid}_air
|
||||
├─► HLS (sito, anche fullscreen)
|
||||
└─► YouTube relay (-c copy)
|
||||
```
|
||||
|
||||
- `Streams::Overlay::SvgBuilder` + `rsvg-convert` generano `tmp/stream_overlays/{session_id}/overlay.png`
|
||||
- `Streams::Overlay::Refresh` + `bin/overlay_png_feeder.rb` aggiornano la PNG ogni ~2s (e su punteggio/stato); il feeder la invia a ffmpeg via FIFO perché `-loop 1` non rilegge il file su disco
|
||||
- Volume Docker condiviso `stream_overlays` tra **rails** (ffmpeg) e **sidekiq** (OverlayRefreshJob); altrimenti il job scrive PNG su un filesystem diverso e badge/punteggio restano congelati
|
||||
- La pagina live **non** sovrappone più HTML sul player (evita incongruenze con fullscreen / YouTube)
|
||||
- **Qualità video**: il relay normalizza a **30 fps CFR** (l’app RTMP può inviare timestamp errati) e ricodifica a **≥4.5 Mbps** (`fast`, no B-frame). Override: `OVERLAY_RELAY_VIDEO_KBPS`, `OVERLAY_RELAY_X264_PRESET`, `OVERLAY_RELAY_FPS`.
|
||||
|
||||
### Punteggio (persistenza)
|
||||
|
||||
L’app mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (`Scoring::SyncState`), poi refresh overlay.
|
||||
|
||||
`GET /live/:id/status.json` resta per messaggi sotto il player e recovery HLS; `Cache-Control: no-store`.
|
||||
|
||||
## Rigenerare la slate
|
||||
|
||||
```bash
|
||||
cd infra
|
||||
bash scripts/generate_slate.sh
|
||||
# Copia su server: infra/slates/offline.mp4 montato in /slates nel container mediamtx
|
||||
```
|
||||
|
||||
Formato obbligatorio: **H.264 1280×720 30fps, AAC 48 kHz mono** (allineato all’app).
|
||||
@@ -17,6 +17,8 @@ Al termine di ogni diretta Premium, MediaMTX registra i segmenti. Sidekiq unisce
|
||||
### 1. Registrazione automatica
|
||||
Pipeline: `Sessions::Stop` → `FinalizeSession` → `UploadJob` → storage.
|
||||
|
||||
MediaMTX registra **solo mentre il telefono pubblica** (RTMP connesso). In pausa o con sola slate `alwaysAvailable` la registrazione è disattivata, così il replay non contiene minuti di schermo «Trasmissione in pausa».
|
||||
|
||||
### 2. Retention e purge
|
||||
`Recordings::PurgeExpiredJob` / `rails recordings:purge_expired`
|
||||
|
||||
@@ -86,11 +88,31 @@ bash scripts/setup_garage_production.sh # garage.prod.toml, bucket, chiavi →
|
||||
- `REPLAY_STORAGE_ACCESS_KEY_ID` = Key ID Garage (es. `GK...`)
|
||||
- `REPLAY_STORAGE_SECRET_ACCESS_KEY` = Secret mostrato una sola volta alla creazione chiave
|
||||
|
||||
## Cron consigliati (produzione)
|
||||
## Cron (produzione)
|
||||
|
||||
```cron
|
||||
0 3 * * * cd /opt/matchlivetv/infra && docker compose exec -T rails bundle exec rails recordings:purge_expired
|
||||
0 8 * * * cd /opt/matchlivetv/infra && docker compose exec -T rails bundle exec rails recordings:expiry_warnings
|
||||
Installazione sul server:
|
||||
|
||||
```bash
|
||||
cd /opt/matchlivetv/infra
|
||||
bash scripts/install_production_cron.sh
|
||||
```
|
||||
|
||||
Job attivi (utente `eminux`):
|
||||
|
||||
| Orario | Task |
|
||||
|--------|------|
|
||||
| 03:00 | `recordings:purge_expired` — elimina replay scaduti (DB + Garage) |
|
||||
| 08:00 | `recordings:expiry_warnings` — email avviso scadenza (7 gg) |
|
||||
|
||||
Log: `/opt/matchlivetv/log/cron-replay.log`
|
||||
|
||||
### Capacità Garage
|
||||
|
||||
All’installazione, `setup_garage_production.sh` assegna ~**(disco libero − 20 GB)** al nodo.
|
||||
Per ridimensionare dopo:
|
||||
|
||||
```bash
|
||||
bash scripts/garage_set_capacity.sh 85G # oppure senza argomento: calcolo automatico
|
||||
```
|
||||
|
||||
## API mobile
|
||||
|
||||
@@ -115,6 +115,7 @@ services:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- recordings:/recordings
|
||||
- stream_overlays:/app/tmp/stream_overlays
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"]
|
||||
interval: 15s
|
||||
@@ -150,6 +151,7 @@ services:
|
||||
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
|
||||
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
|
||||
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
|
||||
OVERLAY_RELAY_PROCESS_CHECK: "false"
|
||||
depends_on:
|
||||
rails:
|
||||
condition: service_healthy
|
||||
@@ -157,6 +159,7 @@ services:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- recordings:/recordings
|
||||
- stream_overlays:/app/tmp/stream_overlays
|
||||
|
||||
garage:
|
||||
image: dxflrs/garage:v1.0.1
|
||||
@@ -172,5 +175,6 @@ volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
recordings:
|
||||
stream_overlays:
|
||||
garage_meta:
|
||||
garage_data:
|
||||
|
||||
@@ -22,11 +22,15 @@ rtmpAddress: :1935
|
||||
hls: yes
|
||||
hlsAddress: :8888
|
||||
hlsVariant: mpegts
|
||||
# Allineato a slate (1s) e camera; evita salti 1s→6s che glitchano HLS.js
|
||||
hlsSegmentDuration: 1s
|
||||
hlsSegmentMaxSize: 50M
|
||||
|
||||
pathDefaults:
|
||||
source: publisher
|
||||
overridePublisher: yes
|
||||
record: yes
|
||||
# Recording solo via job dedicato: il recorder inline crasha il muxer HLS al switch slate→live
|
||||
record: false
|
||||
recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S
|
||||
recordSegmentDuration: 60s
|
||||
recordDeleteAfter: 48h
|
||||
@@ -37,9 +41,5 @@ paths:
|
||||
# match_{uuid}:
|
||||
# alwaysAvailable: true
|
||||
# alwaysAvailableFile: /slates/offline.mp4
|
||||
# alwaysAvailableTracks:
|
||||
# - codec: H264
|
||||
# - codec: MPEG4Audio
|
||||
# sampleRate: 48000
|
||||
# channelCount: 2
|
||||
# (non usare alwaysAvailableTracks insieme al file — MediaMTX >= 2026.02)
|
||||
all_others:
|
||||
|
||||
9
infra/scripts/cron/run_rails_task.sh
Executable file
9
infra/scripts/cron/run_rails_task.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Esegue un task Rails in produzione (usato da crontab).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="${MATCHLIVETV_INFRA:-/opt/matchlivetv/infra}"
|
||||
cd "$ROOT"
|
||||
|
||||
exec docker compose -f docker-compose.prod.yml --env-file .env exec -T rails \
|
||||
bundle exec rails "$@"
|
||||
37
infra/scripts/garage_set_capacity.sh
Executable file
37
infra/scripts/garage_set_capacity.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Ridimensiona capacità Garage (layout assign + apply). Uso: garage_set_capacity.sh 85G
|
||||
set -euo pipefail
|
||||
|
||||
CAPACITY="${1:-}"
|
||||
RESERVE_GB="${GARAGE_RESERVE_GB:-20}"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
|
||||
ENV_FILE="${ENV_FILE:-${ROOT}/.env}"
|
||||
GARAGE_CONTAINER="${GARAGE_CONTAINER:-}"
|
||||
|
||||
if [ -z "$CAPACITY" ]; then
|
||||
avail="$(df -BG "${ROOT}" 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G' || echo 0)"
|
||||
if [ "$avail" -lt 1 ]; then
|
||||
avail="$(df -BG / 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G')"
|
||||
fi
|
||||
cap_gb=$((avail - RESERVE_GB))
|
||||
[ "$cap_gb" -lt 10 ] && cap_gb=10
|
||||
CAPACITY="${cap_gb}G"
|
||||
echo "Spazio disponibile ~${avail}G, riservo ${RESERVE_GB}G → capacità Garage ${CAPACITY}"
|
||||
fi
|
||||
|
||||
cd "$ROOT"
|
||||
if [ -z "$GARAGE_CONTAINER" ]; then
|
||||
GARAGE_CONTAINER="$(docker ps --format '{{.Names}}' | grep -E 'garage-1$' | head -1)"
|
||||
fi
|
||||
[ -n "$GARAGE_CONTAINER" ] || { echo "Container garage non trovato"; exit 1; }
|
||||
|
||||
node_id="$(docker exec "$GARAGE_CONTAINER" /garage status 2>/dev/null | awk '/^[0-9a-f]{16,}/{print $1; exit}')"
|
||||
[ -n "$node_id" ] || { echo "Node id non trovato"; exit 1; }
|
||||
|
||||
echo "Node: $node_id → capacità $CAPACITY"
|
||||
docker exec "$GARAGE_CONTAINER" /garage layout assign -z dc1 -c "$CAPACITY" "$node_id"
|
||||
ver="$(docker exec "$GARAGE_CONTAINER" /garage layout show 2>/dev/null | awk '/layout version:/{print $NF; exit}')"
|
||||
next=$((ver + 1))
|
||||
docker exec "$GARAGE_CONTAINER" /garage layout apply --version "$next"
|
||||
docker exec "$GARAGE_CONTAINER" /garage layout show
|
||||
@@ -1,13 +1,33 @@
|
||||
#!/bin/sh
|
||||
# Generate offline slate MP4 for MediaMTX alwaysAvailable (pausa / attesa segnale)
|
||||
# Slate MP4 per MediaMTX alwaysAvailable (pausa / attesa / perdita rete).
|
||||
# H.264 1280x720 30fps + AAC 48kHz mono (allineato all'app Android).
|
||||
set -e
|
||||
OUT_DIR="$(dirname "$0")/../slates"
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
OUT_DIR="$(cd "$(dirname "$0")/../slates" && pwd)"
|
||||
BRAND_IMAGE="${BRAND_IMAGE:-$ROOT/brand/CopertinaCanale_6.png}"
|
||||
OUT="$OUT_DIR/offline.mp4"
|
||||
|
||||
if [ ! -f "$BRAND_IMAGE" ]; then
|
||||
echo "Manca immagine brand: $BRAND_IMAGE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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
|
||||
echo "Created $OUT"
|
||||
BRAND_DIR="$(cd "$(dirname "$BRAND_IMAGE")" && pwd)"
|
||||
BRAND_FILE="$(basename "$BRAND_IMAGE")"
|
||||
|
||||
docker run --rm \
|
||||
-v "$OUT_DIR:/out" \
|
||||
-v "$BRAND_DIR:/brand:ro" \
|
||||
linuxserver/ffmpeg:latest \
|
||||
-loop 1 -framerate 30 -i "/brand/$BRAND_FILE" \
|
||||
-f lavfi -i anullsrc=r=48000:cl=mono \
|
||||
-filter:v "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=0x0a0a0e" \
|
||||
-map 0:v -map 1:a \
|
||||
-t 30 -c:v libx264 -pix_fmt yuv420p -profile:v baseline -level 3.1 \
|
||||
-x264-params "keyint=30:min-keyint=30:scenecut=0:bframes=0" \
|
||||
-g 30 -keyint_min 30 -force_key_frames "expr:gte(t,n_forced*1)" \
|
||||
-preset fast \
|
||||
-c:a aac -b:a 128k -ac 1 -shortest -y /out/offline.mp4
|
||||
|
||||
echo "Created $OUT from $BRAND_IMAGE"
|
||||
|
||||
35
infra/scripts/install_production_cron.sh
Executable file
35
infra/scripts/install_production_cron.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installa crontab replay su server produzione (utente corrente).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
RUNNER="${ROOT}/scripts/cron/run_rails_task.sh"
|
||||
LOG_DIR="${MATCHLIVETV_LOG_DIR:-/opt/matchlivetv/log}"
|
||||
LOG_FILE="${LOG_DIR}/cron-replay.log"
|
||||
|
||||
chmod +x "$RUNNER"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
MARKER="# matchlivetv-replay-cron"
|
||||
CRON_BLOCK="${MARKER}
|
||||
0 3 * * * ${RUNNER} recordings:purge_expired >> ${LOG_FILE} 2>&1
|
||||
0 8 * * * ${RUNNER} recordings:expiry_warnings >> ${LOG_FILE} 2>&1
|
||||
"
|
||||
|
||||
existing="$(crontab -l 2>/dev/null || true)"
|
||||
if echo "$existing" | grep -q "$MARKER"; then
|
||||
echo "Crontab replay già presente, aggiorno..."
|
||||
echo "$existing" | awk -v block="$CRON_BLOCK" '
|
||||
BEGIN { skip=0 }
|
||||
/# matchlivetv-replay-cron/ { skip=1; next }
|
||||
skip && /^[^#]/ && $0 !~ /^$/ { skip=0 }
|
||||
skip { next }
|
||||
{ print }
|
||||
END { print block }
|
||||
' | crontab -
|
||||
else
|
||||
(echo "$existing"; echo "$CRON_BLOCK") | crontab -
|
||||
fi
|
||||
|
||||
echo "Crontab installato:"
|
||||
crontab -l | grep -A3 "$MARKER"
|
||||
@@ -14,6 +14,13 @@ bash "${ROOT}/scripts/ensure_garage_prod_config.sh"
|
||||
echo "Avvio Garage..."
|
||||
docker compose -f docker-compose.prod.yml --env-file .env up -d garage
|
||||
|
||||
avail_gb="$(df -BG / 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G')"
|
||||
reserve="${GARAGE_RESERVE_GB:-20}"
|
||||
cap_gb=$((avail_gb - reserve))
|
||||
[ "$cap_gb" -lt 10 ] && cap_gb=10
|
||||
export GARAGE_NODE_CAPACITY="${GARAGE_NODE_CAPACITY:-${cap_gb}G}"
|
||||
echo "Capacità nodo Garage: ${GARAGE_NODE_CAPACITY} (disco ~${avail_gb}G, riserva ${reserve}G)"
|
||||
|
||||
bash "${ROOT}/scripts/setup_garage_replays.sh"
|
||||
|
||||
echo "Riavvio Rails e Sidekiq..."
|
||||
|
||||
@@ -46,7 +46,8 @@ ensure_layout() {
|
||||
exit 1
|
||||
fi
|
||||
echo "Node id: $node_id"
|
||||
gexec layout assign -z dc1 -c 1G "$node_id" 2>/dev/null || true
|
||||
local cap="${GARAGE_NODE_CAPACITY:-1G}"
|
||||
gexec layout assign -z dc1 -c "$cap" "$node_id" 2>/dev/null || true
|
||||
gexec layout apply --version 1 2>/dev/null || gexec layout apply 2>/dev/null || true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
# Slate video (offline)
|
||||
|
||||
Place `offline.mp4` here — H.264 + AAC, 1280x720, 30fps, 48kHz stereo.
|
||||
Genera da `brand/CopertinaCanale_6.png`:
|
||||
|
||||
```bash
|
||||
cd infra && bash scripts/generate_slate.sh
|
||||
```
|
||||
|
||||
Output: `offline.mp4` — H.264 + AAC, 1280×720, 30fps, 48 kHz mono.
|
||||
Must match phone encoder settings for seamless `alwaysAvailable` merge.
|
||||
|
||||
Generate a test slate with ffmpeg:
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,7 @@
|
||||
package com.matchlivetv.match_live_tv
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodecInfo
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -21,8 +22,9 @@ data class StreamingConfig(
|
||||
val audioBitrate: Int = 128_000,
|
||||
val fps: Int = 30,
|
||||
val rotation: Int = 0,
|
||||
val sampleRate: Int = 44_100,
|
||||
val stereo: Boolean = true,
|
||||
// Allineato a MediaMTX alwaysAvailable slate (offline.mp4: 48 kHz mono)
|
||||
val sampleRate: Int = 48_000,
|
||||
val stereo: Boolean = false,
|
||||
val maxReconnectAttempts: Int = 10,
|
||||
val reconnectDelayMs: Long = 5_000L,
|
||||
)
|
||||
@@ -144,11 +146,14 @@ class StreamingEngine(
|
||||
stopPreviewAndStreamForPrepare(stream)
|
||||
val prepared = try {
|
||||
stream.prepareVideo(
|
||||
cfg.width,
|
||||
cfg.height,
|
||||
cfg.videoBitrate,
|
||||
cfg.fps,
|
||||
width = cfg.width,
|
||||
height = cfg.height,
|
||||
bitrate = cfg.videoBitrate,
|
||||
fps = cfg.fps,
|
||||
iFrameInterval = 1,
|
||||
rotation = cfg.rotation,
|
||||
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
|
||||
level = MediaCodecInfo.CodecProfileLevel.AVCLevel31,
|
||||
) && stream.prepareAudio(
|
||||
cfg.sampleRate,
|
||||
cfg.stereo,
|
||||
@@ -181,6 +186,45 @@ class StreamingEngine(
|
||||
}
|
||||
}
|
||||
|
||||
/** Ripresa dopo pausa: stop completo poi ripubblica (evita doppio start e RTMP bloccato). */
|
||||
fun resumeStream(streamConfig: StreamingConfig) {
|
||||
ensureMainThread()
|
||||
config = streamConfig
|
||||
lastError = null
|
||||
reconnectAttempts.set(0)
|
||||
stopMetricsLoop()
|
||||
|
||||
genericStream?.let { stream ->
|
||||
pausingIntentionally.set(true)
|
||||
try {
|
||||
if (stream.isStreaming) {
|
||||
stream.stopStream()
|
||||
}
|
||||
} finally {
|
||||
pausingIntentionally.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (state == StreamingState.STREAMING ||
|
||||
state == StreamingState.CONNECTING ||
|
||||
state == StreamingState.RECONNECTING
|
||||
) {
|
||||
pauseStream()
|
||||
}
|
||||
|
||||
streamStartedAtMs = 0L
|
||||
currentBitrateKbps = 0L
|
||||
currentFps = 0
|
||||
updateState(StreamingState.PREVIEWING)
|
||||
startMetricsLoop()
|
||||
|
||||
mainHandler.postDelayed({
|
||||
if (!isReleased.get()) {
|
||||
startStream(streamConfig)
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
fun startStream(streamConfig: StreamingConfig) {
|
||||
ensureMainThread()
|
||||
if (isReleased.get()) {
|
||||
@@ -204,11 +248,14 @@ class StreamingEngine(
|
||||
|
||||
val prepared = try {
|
||||
stream.prepareVideo(
|
||||
streamConfig.width,
|
||||
streamConfig.height,
|
||||
streamConfig.videoBitrate,
|
||||
streamConfig.fps,
|
||||
width = streamConfig.width,
|
||||
height = streamConfig.height,
|
||||
bitrate = streamConfig.videoBitrate,
|
||||
fps = streamConfig.fps,
|
||||
iFrameInterval = 1,
|
||||
rotation = streamConfig.rotation,
|
||||
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
|
||||
level = MediaCodecInfo.CodecProfileLevel.AVCLevel31,
|
||||
) && stream.prepareAudio(
|
||||
streamConfig.sampleRate,
|
||||
streamConfig.stereo,
|
||||
@@ -347,12 +394,7 @@ class StreamingEngine(
|
||||
override fun onNewBitrate(bitrate: Long) {
|
||||
postMain {
|
||||
currentBitrateKbps = bitrate / 1000
|
||||
genericStream?.let { stream ->
|
||||
bitrateAdapter.adaptBitrate(
|
||||
bitrate,
|
||||
stream.getStreamClient().hasCongestion(),
|
||||
)
|
||||
}
|
||||
// Bitrate fissa: l'adattamento on-the-fly causa glitch e GOP instabili su MediaMTX/HLS.
|
||||
emitMetrics()
|
||||
}
|
||||
}
|
||||
@@ -470,8 +512,8 @@ class StreamingEngine(
|
||||
|
||||
val elapsedMs = now - lastFpsSampleAtMs
|
||||
if (elapsedMs >= 1_000L) {
|
||||
val frameDelta = sentFrames - lastVideoFrameCount
|
||||
currentFps = ((frameDelta * 1000L) / elapsedMs).toInt()
|
||||
val frameDelta = (sentFrames - lastVideoFrameCount).coerceAtLeast(0L)
|
||||
currentFps = ((frameDelta * 1000L) / elapsedMs).toInt().coerceIn(0, 120)
|
||||
lastVideoFrameCount = sentFrames
|
||||
lastFpsSampleAtMs = now
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
|
||||
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||
when (call.method) {
|
||||
"startStream" -> startStream(call, result)
|
||||
"resumeStream" -> resumeStream(call, result)
|
||||
"stopStream" -> stopStream(result)
|
||||
"pauseStream" -> pauseStream(result)
|
||||
"getMetrics" -> getMetrics(result)
|
||||
@@ -194,6 +195,53 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
|
||||
}
|
||||
}
|
||||
|
||||
private fun resumeStream(call: MethodCall, result: Result) {
|
||||
if (activityBinding?.activity == null) {
|
||||
result.error("no_activity", "Activity non disponibile per lo streaming", null)
|
||||
return
|
||||
}
|
||||
|
||||
val args = call.arguments as? Map<*, *>
|
||||
val rtmpUrl = args?.get("rtmpUrl") as? String
|
||||
if (rtmpUrl.isNullOrBlank()) {
|
||||
result.error("invalid_args", "rtmpUrl is required", null)
|
||||
return
|
||||
}
|
||||
|
||||
val config = StreamingConfig(
|
||||
rtmpUrl = rtmpUrl,
|
||||
width = (args["width"] as? Number)?.toInt() ?: 1280,
|
||||
height = (args["height"] as? Number)?.toInt() ?: 720,
|
||||
videoBitrate = (args["videoBitrate"] as? Number)?.toInt() ?: 2_500_000,
|
||||
audioBitrate = (args["audioBitrate"] as? Number)?.toInt() ?: 128_000,
|
||||
fps = (args["fps"] as? Number)?.toInt() ?: 30,
|
||||
rotation = (args["rotation"] as? Number)?.toInt() ?: 0,
|
||||
maxReconnectAttempts = (args["maxReconnectAttempts"] as? Number)?.toInt() ?: 10,
|
||||
reconnectDelayMs = (args["reconnectDelayMs"] as? Number)?.toLong() ?: 5_000L,
|
||||
)
|
||||
|
||||
val engine = getOrCreateEngine()
|
||||
engine.removeListener(engineListener)
|
||||
engine.addListener(engineListener)
|
||||
attachPreviewIfPossible()
|
||||
|
||||
val intent = StreamingForegroundService.startIntent(appContext)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
appContext.startForegroundService(intent)
|
||||
} else {
|
||||
appContext.startService(intent)
|
||||
}
|
||||
bindServiceIfNeeded()
|
||||
|
||||
try {
|
||||
engine.resumeStream(config)
|
||||
result.success(true)
|
||||
} catch (exception: Exception) {
|
||||
appContext.startService(StreamingForegroundService.stopIntent(appContext))
|
||||
result.error("resume_failed", exception.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopStream(result: Result) {
|
||||
appContext.startService(StreamingForegroundService.stopIntent(appContext))
|
||||
getEngine()?.let { engine ->
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../platform/streaming_channel.dart';
|
||||
import '../../platform/streaming_preview.dart';
|
||||
import '../../providers/match_rules_provider.dart';
|
||||
import '../../providers/score_provider.dart';
|
||||
import '../../providers/score_sync_provider.dart';
|
||||
import '../../shared/api_client.dart';
|
||||
import '../../shared/regia_share.dart';
|
||||
import '../../shared/watch_share.dart';
|
||||
@@ -42,6 +43,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
Timer? _elapsedTimer;
|
||||
Timer? _telemetryTimer;
|
||||
Timer? _statsTimer;
|
||||
Timer? _scorePullTimer;
|
||||
int _batteryLevel = 100;
|
||||
int? _lastDelta;
|
||||
int _prevHomePoints = 0;
|
||||
@@ -55,6 +57,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
StreamSubscription<Map<String, dynamic>>? _metricsSub;
|
||||
Orientation? _lastOrientation;
|
||||
bool _pauseInFlight = false;
|
||||
bool _resumeInFlight = false;
|
||||
String? _lastStreamErrorShown;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -73,6 +77,9 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
});
|
||||
_telemetryTimer = Timer.periodic(const Duration(seconds: 10), (_) => _sendTelemetry());
|
||||
_statsTimer = Timer.periodic(const Duration(seconds: 30), (_) => _fetchStats());
|
||||
_scorePullTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
||||
unawaited(ref.read(scoreSyncProvider).pullFromServer());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -121,15 +128,18 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
}
|
||||
|
||||
final match = await client.fetchMatch(session.matchId);
|
||||
final statusAtOpen = session.status;
|
||||
ref.read(sessionProvider.notifier).setSession(session, match: match);
|
||||
if (session.score != null) {
|
||||
ref.read(scoreProvider.notifier).reset(session.score!);
|
||||
}
|
||||
|
||||
// Ripresa dopo crash: solo idle va in connecting; in pausa restiamo fermi.
|
||||
// idle → startSession; wizard può aver già messo connecting prima della camera.
|
||||
var justStartedSession = false;
|
||||
if (session.status == 'idle') {
|
||||
session = await client.startSession(widget.sessionId);
|
||||
ref.read(sessionProvider.notifier).setSession(session, match: match);
|
||||
justStartedSession = true;
|
||||
}
|
||||
|
||||
final ws = ref.read(websocketServiceProvider);
|
||||
@@ -140,18 +150,41 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
|
||||
final fresh = await client.fetchSession(widget.sessionId);
|
||||
ref.read(sessionProvider.notifier).setSession(fresh, match: match);
|
||||
if (fresh.score != null) {
|
||||
ref.read(scoreProvider.notifier).reset(fresh.score!);
|
||||
}
|
||||
|
||||
if (fresh.status != 'paused' && fresh.rtmpIngestUrl != null) {
|
||||
// Avvio RTMP: wizard chiama startSession prima della camera → stato "connecting".
|
||||
// In pausa all'apertura: niente auto-publish (serve "RIPRENDI DIRETTA").
|
||||
final wasPausedAtOpen = statusAtOpen == 'paused';
|
||||
final shouldAutoPublish = fresh.rtmpIngestUrl != null &&
|
||||
!wasPausedAtOpen &&
|
||||
fresh.status != 'paused' &&
|
||||
(justStartedSession ||
|
||||
fresh.status == 'connecting' ||
|
||||
fresh.status == 'live' ||
|
||||
fresh.status == 'reconnecting');
|
||||
if (shouldAutoPublish) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
await StreamingChannel.startStream(
|
||||
rtmpUrl: fresh.rtmpIngestUrl!,
|
||||
targetBitrate: fresh.targetBitrate,
|
||||
targetFps: 30,
|
||||
);
|
||||
if (mounted && fresh.canResumeBroadcast && fresh.status != 'paused') {
|
||||
final useFreshStart =
|
||||
justStartedSession || fresh.status == 'connecting';
|
||||
if (useFreshStart) {
|
||||
await StreamingChannel.startStream(
|
||||
rtmpUrl: fresh.rtmpIngestUrl!,
|
||||
targetBitrate: fresh.targetBitrate,
|
||||
targetFps: 30,
|
||||
);
|
||||
} else {
|
||||
await StreamingChannel.resumeStream(
|
||||
rtmpUrl: fresh.rtmpIngestUrl!,
|
||||
targetBitrate: fresh.targetBitrate,
|
||||
targetFps: 30,
|
||||
);
|
||||
}
|
||||
if (mounted && (justStartedSession || fresh.status == 'connecting')) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Diretta ripresa — stai di nuovo in onda'),
|
||||
content: Text('Diretta avviata — connessione al server…'),
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
@@ -166,10 +199,25 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
final metrics = Map<String, dynamic>.from(
|
||||
(event['metrics'] as Map?)?.map((k, v) => MapEntry(k.toString(), v)) ?? {},
|
||||
);
|
||||
final streamState = metrics['state'] as String?;
|
||||
final lastError = metrics['lastError'] as String?;
|
||||
setState(() {
|
||||
_bitrateMbps = (metrics['bitrateKbps'] as num? ?? 0) / 1000;
|
||||
_fps = (metrics['fps'] as num?)?.toInt() ?? 0;
|
||||
});
|
||||
if (lastError != null &&
|
||||
lastError.isNotEmpty &&
|
||||
lastError != _lastStreamErrorShown &&
|
||||
(streamState == 'ERROR' || streamState == 'RECONNECTING') &&
|
||||
mounted) {
|
||||
_lastStreamErrorShown = lastError;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Connessione RTMP: $lastError'),
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
if (mounted) {
|
||||
@@ -339,7 +387,39 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _applyRemoteResume() async {
|
||||
if (_resumeInFlight) return;
|
||||
final session = ref.read(sessionProvider).session;
|
||||
if (session == null || session.rtmpIngestUrl == null) return;
|
||||
_resumeInFlight = true;
|
||||
try {
|
||||
await StreamingChannel.resumeStream(
|
||||
rtmpUrl: session.rtmpIngestUrl!,
|
||||
targetBitrate: session.targetBitrate,
|
||||
targetFps: 30,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Ripresa dalla regia — di nuovo in onda'),
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Ripresa RTMP: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
_resumeInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resumeStream() async {
|
||||
if (_resumeInFlight) return;
|
||||
_resumeInFlight = true;
|
||||
try {
|
||||
final match = ref.read(sessionProvider).match;
|
||||
var session =
|
||||
@@ -347,7 +427,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
ref.read(sessionProvider.notifier).setSession(session, match: match);
|
||||
|
||||
if (session.rtmpIngestUrl != null) {
|
||||
await StreamingChannel.startStream(
|
||||
await StreamingChannel.resumeStream(
|
||||
rtmpUrl: session.rtmpIngestUrl!,
|
||||
targetBitrate: session.targetBitrate,
|
||||
targetFps: 30,
|
||||
@@ -371,6 +451,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
SnackBar(content: Text('Ripresa diretta: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
_resumeInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +495,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
_elapsedTimer?.cancel();
|
||||
_telemetryTimer?.cancel();
|
||||
_statsTimer?.cancel();
|
||||
_scorePullTimer?.cancel();
|
||||
_metricsSub?.cancel();
|
||||
WakelockPlus.disable();
|
||||
_unlockOrientation();
|
||||
@@ -427,6 +510,11 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
if (next == 'paused' && previous != 'paused' && !_pauseInFlight) {
|
||||
_applyRemotePause();
|
||||
}
|
||||
if (previous == 'paused' &&
|
||||
(next == 'connecting' || next == 'live') &&
|
||||
!_resumeInFlight) {
|
||||
_applyRemoteResume();
|
||||
}
|
||||
if (next == 'ended' && previous != 'ended' && mounted) {
|
||||
_leaveSession();
|
||||
}
|
||||
@@ -459,6 +547,26 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
platformLabel: isPaused ? 'PAUSA' : 'LIVE',
|
||||
),
|
||||
),
|
||||
if (isPaused)
|
||||
Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: isLandscape ? 100 : 200,
|
||||
child: Center(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _resumeStream,
|
||||
icon: const Icon(Icons.play_arrow, size: 28),
|
||||
label: const Text(
|
||||
'RIPRENDI DIRETTA',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryRed,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isLandscape)
|
||||
_LandscapeOverlay(
|
||||
elapsed: sessionState.elapsed,
|
||||
|
||||
@@ -35,6 +35,19 @@ class StreamingChannel {
|
||||
});
|
||||
}
|
||||
|
||||
/// Ripresa dopo pausa: resetta stati RTMP residui (CONNECTING/RECONNECTING).
|
||||
static Future<void> resumeStream({
|
||||
required String rtmpUrl,
|
||||
int targetBitrate = 2500000,
|
||||
int targetFps = 30,
|
||||
}) async {
|
||||
await _channel.invokeMethod('resumeStream', {
|
||||
'rtmpUrl': rtmpUrl,
|
||||
'videoBitrate': targetBitrate,
|
||||
'fps': targetFps,
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> stopStream() async {
|
||||
await _channel.invokeMethod('stopStream');
|
||||
}
|
||||
|
||||
77
mobile/lib/providers/score_sync_provider.dart
Normal file
77
mobile/lib/providers/score_sync_provider.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../shared/api_client.dart';
|
||||
import '../shared/models/score_state.dart';
|
||||
import 'score_provider.dart';
|
||||
import 'session_provider.dart';
|
||||
|
||||
/// Coda sync HTTP: evita richieste fuori ordine e allinea l'app al DB (pagina live).
|
||||
class ScoreSyncService {
|
||||
ScoreSyncService(this._ref);
|
||||
|
||||
final Ref _ref;
|
||||
bool _busy = false;
|
||||
bool _dirty = false;
|
||||
DateTime? _suppressStaleRemoteUntil;
|
||||
|
||||
/// Ignora broadcast WebSocket obsoleti subito dopo un push HTTP.
|
||||
bool acceptCableScore(ScoreState remote) {
|
||||
final until = _suppressStaleRemoteUntil;
|
||||
if (until == null || DateTime.now().isAfter(until)) return true;
|
||||
final local = _ref.read(scoreProvider);
|
||||
return !_isClearlyBehind(remote, local);
|
||||
}
|
||||
|
||||
bool _isClearlyBehind(ScoreState remote, ScoreState local) {
|
||||
final r = remote.homeSets * 1000 +
|
||||
remote.awaySets * 1000 +
|
||||
remote.homePoints +
|
||||
remote.awayPoints;
|
||||
final l = local.homeSets * 1000 +
|
||||
local.awaySets * 1000 +
|
||||
local.homePoints +
|
||||
local.awayPoints;
|
||||
return r < l;
|
||||
}
|
||||
|
||||
Future<void> push() async {
|
||||
_dirty = true;
|
||||
if (_busy) return;
|
||||
_busy = true;
|
||||
while (_dirty) {
|
||||
_dirty = false;
|
||||
final sessionId = _ref.read(sessionProvider).session?.id;
|
||||
if (sessionId == null) continue;
|
||||
|
||||
final payload = _ref.read(scoreProvider).toCablePayload();
|
||||
try {
|
||||
final remote = await _ref.read(apiClientProvider).syncScore(sessionId, payload);
|
||||
_suppressStaleRemoteUntil = DateTime.now().add(const Duration(milliseconds: 500));
|
||||
if (remote != null) {
|
||||
_ref.read(scoreProvider.notifier).applyRemote(remote);
|
||||
}
|
||||
} catch (_) {
|
||||
// Mantieni stato locale; il prossimo pull periodico riallinea se possibile.
|
||||
}
|
||||
}
|
||||
_busy = false;
|
||||
}
|
||||
|
||||
Future<void> pullFromServer() async {
|
||||
if (_busy) return;
|
||||
final sessionId = _ref.read(sessionProvider).session?.id;
|
||||
if (sessionId == null) return;
|
||||
try {
|
||||
final session = await _ref.read(apiClientProvider).fetchSession(sessionId);
|
||||
if (session.score != null) {
|
||||
_ref.read(scoreProvider.notifier).applyRemote(session.score!);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
final scoreSyncProvider = Provider<ScoreSyncService>((ref) {
|
||||
return ScoreSyncService(ref);
|
||||
});
|
||||
@@ -73,11 +73,17 @@ class SessionNotifier extends StateNotifier<SessionState> {
|
||||
}
|
||||
if (device.status != null && state.session != null) {
|
||||
final s = state.session!;
|
||||
final incoming = device.status!;
|
||||
// Non sovrascrivere una pausa intenzionale con stati transitori (live/connecting).
|
||||
if (s.status == 'paused' && incoming != 'paused' && incoming != 'ended') {
|
||||
state = state.copyWith(cameraDevice: device);
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(
|
||||
session: StreamSession(
|
||||
id: s.id,
|
||||
matchId: s.matchId,
|
||||
status: device.status!,
|
||||
status: incoming,
|
||||
platform: s.platform,
|
||||
rtmpIngestUrl: s.rtmpIngestUrl,
|
||||
hlsPlaybackUrl: s.hlsPlaybackUrl,
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../providers/auth_provider.dart';
|
||||
import 'models/invite_preview.dart';
|
||||
import 'models/match.dart';
|
||||
import 'models/recording_item.dart';
|
||||
import 'models/score_state.dart';
|
||||
import 'models/stream_session.dart';
|
||||
import 'models/team.dart';
|
||||
import 'models/user.dart';
|
||||
@@ -210,6 +211,19 @@ class ApiClient {
|
||||
return StreamSession.fromJson(response.data!);
|
||||
}
|
||||
|
||||
/// Persiste il tabellone e restituisce lo stato salvato sul server.
|
||||
Future<ScoreState?> syncScore(String sessionId, Map<String, dynamic> payload) async {
|
||||
final response = await _dio.patch<Map<String, dynamic>>(
|
||||
'/sessions/$sessionId/score',
|
||||
data: payload,
|
||||
);
|
||||
final score = response.data?['score'];
|
||||
if (score is Map<String, dynamic>) {
|
||||
return ScoreState.fromJson(score);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<RegiaLinkResponse> createRegiaLink(String sessionId) async {
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
'/sessions/$sessionId/regia_link',
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:x_action_cable_v2/x_action_cable.dart';
|
||||
import '../core/config.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../providers/score_provider.dart';
|
||||
import '../providers/score_sync_provider.dart';
|
||||
import '../providers/session_provider.dart';
|
||||
import 'models/device_state.dart';
|
||||
import 'models/score_state.dart';
|
||||
@@ -88,7 +89,10 @@ class WebsocketService {
|
||||
|
||||
switch (type) {
|
||||
case 'score_update':
|
||||
_ref.read(scoreProvider.notifier).applyRemote(ScoreState.fromJson(data));
|
||||
final remote = ScoreState.fromJson(data);
|
||||
if (_ref.read(scoreSyncProvider).acceptCableScore(remote)) {
|
||||
_ref.read(scoreProvider.notifier).applyRemote(remote);
|
||||
}
|
||||
break;
|
||||
case 'device_state':
|
||||
_ref
|
||||
@@ -109,6 +113,14 @@ class WebsocketService {
|
||||
_ref.read(sessionProvider.notifier).applyCommand(action);
|
||||
}
|
||||
break;
|
||||
case 'stream_event':
|
||||
final event = data['event'] as String?;
|
||||
if (event == 'paused') {
|
||||
_ref.read(sessionProvider.notifier).applyCommand('pause_stream');
|
||||
} else if (event == 'resumed') {
|
||||
_ref.read(sessionProvider.notifier).applyCommand('resume_stream');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/theme.dart';
|
||||
import '../../features/shared/live_score_actions.dart';
|
||||
import '../../providers/score_provider.dart';
|
||||
import '../websocket_service.dart';
|
||||
import '../../providers/score_sync_provider.dart';
|
||||
import 'score_overlay_bar.dart';
|
||||
|
||||
/// Tabellone interattivo: aggiorna punteggio via WebSocket (senza link regia).
|
||||
@@ -27,8 +29,8 @@ class LiveScoreControls extends ConsumerWidget {
|
||||
final Future<void> Function()? onStopStream;
|
||||
|
||||
void _sync(WidgetRef ref) {
|
||||
if (!ref.read(websocketServiceProvider).isConnected) return;
|
||||
ref.read(websocketServiceProvider).sendScoreUpdate(ref.read(scoreProvider));
|
||||
// HTTP serializzato; il server fa broadcast WebSocket. Pagina live = status.json.
|
||||
unawaited(ref.read(scoreSyncProvider).push());
|
||||
}
|
||||
|
||||
Future<void> _pointHome(BuildContext context, WidgetRef ref) async {
|
||||
|
||||
103
scripts/test/adb_phone_stream_e2e.sh
Executable file
103
scripts/test/adb_phone_stream_e2e.sh
Executable file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# E2E streaming: installa APK, login demo, apre camera sessione attiva.
|
||||
# Richiede: USB debug + su MIUI/Xiaomi abilitare "Installazione via USB".
|
||||
set -euo pipefail
|
||||
|
||||
export PATH="$HOME/flutter/bin:$HOME/Android/Sdk/platform-tools:$PATH"
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
DEVICE="${ANDROID_DEVICE:-$(adb devices | awk '/device$/{print $1; exit}')}"
|
||||
SESSION_ID="${SESSION_ID:-fe09a29f-79db-44d5-864f-397942c0a2c5}"
|
||||
EMAIL="${TEST_EMAIL:-coach@matchlivetv.test}"
|
||||
PASSWORD="${TEST_PASSWORD:-password123}"
|
||||
APK="$ROOT/mobile/build/app/outputs/flutter-apk/app-release.apk"
|
||||
PKG="com.matchlivetv.match_live_tv"
|
||||
|
||||
if [[ -z "$DEVICE" ]]; then
|
||||
echo "Nessun telefono USB. Collega il device e abilita debug USB."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== Device: $DEVICE =="
|
||||
|
||||
if [[ ! -f "$APK" ]]; then
|
||||
echo "Build APK release..."
|
||||
(cd "$ROOT/mobile" && flutter build apk --release --dart-define=API_BASE_URL=https://www.matchlivetv.it)
|
||||
fi
|
||||
|
||||
echo "== Install APK (conferma sul telefono se richiesto) =="
|
||||
if ! adb -s "$DEVICE" install -r -g "$APK"; then
|
||||
echo ""
|
||||
echo "Installazione bloccata. Su Xiaomi/MIUI:"
|
||||
echo " Impostazioni → Impostazioni aggiuntive → Opzioni sviluppatore → Installazione via USB → Attiva"
|
||||
echo "Poi rilancia questo script."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== Grant permissions =="
|
||||
adb -s "$DEVICE" shell pm grant "$PKG" android.permission.CAMERA 2>/dev/null || true
|
||||
adb -s "$DEVICE" shell pm grant "$PKG" android.permission.RECORD_AUDIO 2>/dev/null || true
|
||||
adb -s "$DEVICE" shell pm grant "$PKG" android.permission.POST_NOTIFICATIONS 2>/dev/null || true
|
||||
|
||||
echo "== Launch app =="
|
||||
adb -s "$DEVICE" shell am force-stop "$PKG"
|
||||
adb -s "$DEVICE" shell am start -n "$PKG/.MainActivity"
|
||||
sleep 4
|
||||
|
||||
tap_text() {
|
||||
local text="$1"
|
||||
adb -s "$DEVICE" shell uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || true
|
||||
local coords
|
||||
coords=$(adb -s "$DEVICE" shell "grep -o \"text=\\\"$text\\\"[^>]*bounds=\\\"\\[[0-9,]*\\]\\[[0-9,]*\\]\\\"\" /sdcard/ui.xml 2>/dev/null | head -1" || true)
|
||||
if [[ -z "$coords" ]]; then return 1; fi
|
||||
local b
|
||||
b=$(echo "$coords" | sed -n 's/.*bounds=\"\[\([div0-9,]*\)\]\[\([0-9,]*\)\]\".*/\1 \2/p')
|
||||
local x1 y1 x2 y2
|
||||
IFS=',' read -r x1 y1 <<< "${b%% *}"
|
||||
IFS=',' read -r x2 y2 <<< "${b##* }"
|
||||
local cx=$(( (x1 + x2) / 2 ))
|
||||
local cy=$(( (y1 + y2) / 2 ))
|
||||
adb -s "$DEVICE" shell input tap "$cx" "$cy"
|
||||
return 0
|
||||
}
|
||||
|
||||
tap_contains() {
|
||||
local needle="$1"
|
||||
adb -s "$DEVICE" shell uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || true
|
||||
local line
|
||||
line=$(adb -s "$DEVICE" shell "grep -i '$needle' /sdcard/ui.xml | head -1" || true)
|
||||
[[ -n "$line" ]] || return 1
|
||||
local bounds
|
||||
bounds=$(echo "$line" | sed -n 's/.*bounds=\"\[\([0-9,]*\)\]\[\([0-9,]*\)\]\".*/\1 \2/p')
|
||||
local x1 y1 x2 y2
|
||||
IFS=',' read -r x1 y1 <<< "${bounds%% *}"
|
||||
IFS=',' read -r x2 y2 <<< "${bounds##* }"
|
||||
adb -s "$DEVICE" shell input tap $(( (x1 + x2) / 2 )) $(( (y1 + y2) / 2 ))
|
||||
}
|
||||
|
||||
echo "== Login (demo) =="
|
||||
# Email field
|
||||
adb -s "$DEVICE" shell uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
# Tap center-left for email field (Flutter fallback coords 720p)
|
||||
adb -s "$DEVICE" shell input tap 540 520
|
||||
adb -s "$DEVICE" shell input keyevent KEYCODE_MOVE_END
|
||||
adb -s "$DEVICE" shell input keyevent --longpress $(printf '%s' "$EMAIL" | od -An -tuC | tr -s ' ' '\n' | while read -r c; do echo "KEYCODE_DEL"; done | head -40) 2>/dev/null || true
|
||||
adb -s "$DEVICE" shell input text "${EMAIL//@/%40}"
|
||||
sleep 1
|
||||
tap_contains "Accedi" || tap_contains "Entra" || adb -s "$DEVICE" shell input tap 540 900
|
||||
sleep 5
|
||||
|
||||
echo "== Open camera for session $SESSION_ID =="
|
||||
# Deep link via am start with route — apri wizard/camera se già loggato
|
||||
adb -s "$DEVICE" shell am start -a android.intent.action.VIEW \
|
||||
-d "https://www.matchlivetv.it/live/$SESSION_ID" "$PKG" 2>/dev/null || true
|
||||
sleep 3
|
||||
tap_contains "Riprendi" || tap_contains "RIPRENDI" || tap_contains "camera" || true
|
||||
sleep 2
|
||||
|
||||
echo "== Session status =="
|
||||
curl -sS "https://www.matchlivetv.it/live/$SESSION_ID/status.json" | python3 -m json.tool | head -20
|
||||
|
||||
echo ""
|
||||
echo "Apri nel browser: https://www.matchlivetv.it/live/$SESSION_ID"
|
||||
echo "Controlla bitrate/fps dispositivo via API sessions."
|
||||
95
scripts/test/live_e2e_cycle.sh
Executable file
95
scripts/test/live_e2e_cycle.sh
Executable file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# E2E ciclo diretta web: status, HLS, pausa, ripresa.
|
||||
set -euo pipefail
|
||||
|
||||
SESSION_ID="${SESSION_ID:-fe09a29f-79db-44d5-864f-397942c0a2c5}"
|
||||
API="${API:-https://www.matchlivetv.it}"
|
||||
EMAIL="${TEST_EMAIL:-coach@matchlivetv.test}"
|
||||
PASSWORD="${TEST_PASSWORD:-password123}"
|
||||
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
ok() { echo "OK: $*"; }
|
||||
|
||||
echo "== Login =="
|
||||
TOKEN=$(curl -sf -X POST "$API/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
|
||||
AUTH="Authorization: Bearer $TOKEN"
|
||||
|
||||
echo "== Status iniziale =="
|
||||
INIT=$(curl -sf "$API/live/$SESSION_ID/status.json")
|
||||
echo "$INIT" | python3 -m json.tool
|
||||
python3 -c "
|
||||
import json,sys
|
||||
d=json.loads('''$INIT''')
|
||||
assert not d.get('stream_closed'), d
|
||||
print('status', d.get('status'), 'on_air', d.get('on_air'), 'publisher', d.get('publisher_online'))
|
||||
"
|
||||
|
||||
echo "== HLS master =="
|
||||
MASTER=$(curl -sf "$API/hls/live/match_${SESSION_ID}/index.m3u8")
|
||||
echo "$MASTER" | head -8
|
||||
echo "$MASTER" | grep -q main_stream || fail "master senza variant"
|
||||
VARIANT_LINE=$(echo "$MASTER" | grep main_stream | head -1 | tr -d '\r')
|
||||
VARIANT_URL="$API/hls/live/match_${SESSION_ID}/${VARIANT_LINE}"
|
||||
MEDIA=$(curl -sf "$VARIANT_URL")
|
||||
SEG=$(echo "$MEDIA" | grep '_main_seg' | tail -1 | tr -d '\r')
|
||||
[[ -n "$SEG" ]] || fail "nessun segmento in playlist"
|
||||
SEG_URL="$API/hls/live/match_${SESSION_ID}/${SEG}"
|
||||
SZ=$(curl -sf -o /tmp/live_e2e_seg.ts -w '%{size_download}' "$SEG_URL")
|
||||
[[ "${SZ:-0}" -gt 1000 ]] || fail "segmento troppo piccolo ($SZ bytes)"
|
||||
ok "HLS segmento ${SZ} bytes"
|
||||
|
||||
if [[ "$(echo "$INIT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('paused'))")" == "True" ]]; then
|
||||
echo "== Sessione in pausa: test resume =="
|
||||
curl -sf -X PATCH -H "$AUTH" "$API/api/v1/sessions/$SESSION_ID/resume" >/dev/null
|
||||
sleep 6
|
||||
fi
|
||||
|
||||
echo "== Verifica live (publisher atteso) =="
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
ST=$(curl -sf "$API/live/$SESSION_ID/status.json")
|
||||
PUB=$(echo "$ST" | python3 -c "import sys,json; print(json.load(sys.stdin).get('publisher_online'))")
|
||||
PAUSED=$(echo "$ST" | python3 -c "import sys,json; print(json.load(sys.stdin).get('paused'))")
|
||||
echo " poll $i paused=$PAUSED publisher_online=$PUB"
|
||||
if [[ "$PAUSED" == "False" && "$PUB" == "True" ]]; then
|
||||
ok "live attivo"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
if [[ "$i" == "10" ]]; then
|
||||
echo "WARN: live non confermato entro 20s (continuo pausa test)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "== Pausa API =="
|
||||
curl -sf -X PATCH -H "$AUTH" "$API/api/v1/sessions/$SESSION_ID/pause" >/dev/null
|
||||
sleep 8
|
||||
PAUSE_ST=$(curl -sf "$API/live/$SESSION_ID/status.json")
|
||||
echo "$PAUSE_ST" | python3 -m json.tool | head -12
|
||||
python3 -c "
|
||||
import json,sys
|
||||
d=json.loads('''$PAUSE_ST''')
|
||||
assert d.get('paused') or d.get('status')=='paused', d
|
||||
print('paused OK')
|
||||
"
|
||||
|
||||
echo "== Ripresa API =="
|
||||
curl -sf -X PATCH -H "$AUTH" "$API/api/v1/sessions/$SESSION_ID/resume" >/dev/null
|
||||
sleep 8
|
||||
RES=$(curl -sf "$API/live/$SESSION_ID/status.json")
|
||||
echo "$RES" | python3 -m json.tool | head -12
|
||||
python3 -c "
|
||||
import json,sys
|
||||
d=json.loads('''$RES''')
|
||||
assert not d.get('paused'), d
|
||||
assert d.get('status') in ('live','connecting'), d
|
||||
print('resume OK')
|
||||
"
|
||||
|
||||
echo "== Telemetry device =="
|
||||
curl -sf -H "$AUTH" "$API/api/v1/sessions/$SESSION_ID" \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); dev=d.get('devices') or []; print('devices', dev[:2])"
|
||||
|
||||
ok "Ciclo E2E API/HLS completato"
|
||||
40
scripts/test/rtmp_test_pattern.sh
Executable file
40
scripts/test/rtmp_test_pattern.sh
Executable file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pubblica un pattern colorato su RTMP per testare il player web (senza telefono).
|
||||
# Uso:
|
||||
# SESSION_ID=fe09a29f-79db-44d5-864f-397942c0a2c5 ./scripts/test/rtmp_test_pattern.sh
|
||||
# RTMP_URL=rtmp://www.matchlivetv.it:1935/live/match_UUID ./scripts/test/rtmp_test_pattern.sh
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
OUT_DIR="${TMPDIR:-/tmp}/matchlivetv-test"
|
||||
PATTERN="$OUT_DIR/test_pattern.mp4"
|
||||
DURATION="${DURATION_SEC:-0}" # 0 = loop infinito
|
||||
|
||||
if [[ -z "${RTMP_URL:-}" ]]; then
|
||||
if [[ -z "${SESSION_ID:-}" ]]; then
|
||||
echo "Imposta SESSION_ID o RTMP_URL"
|
||||
exit 1
|
||||
fi
|
||||
RTMP_URL="rtmp://www.matchlivetv.it:1935/live/match_${SESSION_ID}"
|
||||
fi
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
if [[ ! -f "$PATTERN" ]]; then
|
||||
echo "Genero video di test $PATTERN ..."
|
||||
ffmpeg -y -hide_banner -loglevel warning \
|
||||
-f lavfi -i "testsrc2=size=1280x720:rate=30" \
|
||||
-f lavfi -i "sine=frequency=880:sample_rate=48000" \
|
||||
-t 30 \
|
||||
-c:v libx264 -pix_fmt yuv420p -profile:v baseline -level 3.1 -g 30 -keyint_min 30 \
|
||||
-c:a aac -b:a 128k -ac 1 \
|
||||
"$PATTERN"
|
||||
fi
|
||||
|
||||
echo "Pubblico su $RTMP_URL (Ctrl+C per fermare)"
|
||||
ARGS=(-re -stream_loop -1 -i "$PATTERN" -c copy -f flv "$RTMP_URL")
|
||||
if [[ "$DURATION" != "0" ]]; then
|
||||
ARGS=(-re -t "$DURATION" -i "$PATTERN" -c copy -f flv "$RTMP_URL")
|
||||
fi
|
||||
|
||||
exec ffmpeg -hide_banner "${ARGS[@]}"
|
||||
Reference in New Issue
Block a user