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:
2026-06-03 23:55:17 +02:00
parent fd225fbadd
commit ab9cb02083
58 changed files with 2423 additions and 242 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View 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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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)

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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?

View 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

View 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

View 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

View 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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
end
end
end
end

View 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

View 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

View File

@@ -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

View File

@@ -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 %>>

View File

@@ -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 lultimo 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>

View File

@@ -17,7 +17,7 @@
<div class="card replay-show__status">
<p>Registrazione in elaborazione. Riceverai unemail 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>

View 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

View File

@@ -41,6 +41,7 @@ Rails.application.routes.draw do
patch :stop
patch :pause
patch :resume
patch :score
get :events
post :telemetry
post :pairing_token

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -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 {

View 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