Stabilizza live YouTube/RTMP e fix crash rotazione mobile.

Pipeline YouTube con relay, overlay e publisher sync lato backend; fix GL pauseGlDuringRotation su Android per evitare crash in rotazione durante lo streaming Flutter.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-06 15:30:55 +02:00
parent 1058c644bd
commit 854738b46d
65 changed files with 2606 additions and 579 deletions

View File

@@ -56,6 +56,7 @@ module Api
fps: params[:fps], fps: params[:fps],
last_seen_at: Time.current last_seen_at: Time.current
) )
sync_publisher_when_streaming!(params[:fps].to_f)
SessionChannel.broadcast_message(@session, state.as_cable_payload) SessionChannel.broadcast_message(@session, state.as_cable_payload)
head :no_content head :no_content
end end
@@ -172,6 +173,19 @@ module Api
occurred_at: event.occurred_at occurred_at: event.occurred_at
} }
end end
# L'app invia fps ogni ~10s; senza poll status.json l'overlay YouTube non partiva.
def sync_publisher_when_streaming!(fps)
return if fps < 1
return if @session.terminal? || @session.paused?
return unless @session.platform == "youtube"
return unless @session.status.in?(%w[connecting live reconnecting])
Mediamtx::PublisherSync.new(@session).call
if @session.platform == "youtube" && @session.status.in?(%w[live connecting reconnecting])
Mediamtx::PublisherSync.schedule_youtube_pipeline!(@session, force: false)
end
end
end end
end end
end end

View File

@@ -15,7 +15,7 @@ module MediamtxPlayback
session.effective_hls_path_name session.effective_hls_path_name
end end
# Player HLS: path _air con overlay quando il relay è attivo. # Player HLS: path grezzo MediaMTX (live/match_{uuid}).
def mediamtx_stream_playable?(session) def mediamtx_stream_playable?(session)
return false if session.terminal? return false if session.terminal?

View File

@@ -46,7 +46,10 @@ module Public
@session = Mediamtx::PublisherSync.new(@session).call unless @session.terminal? @session = Mediamtx::PublisherSync.new(@session).call unless @session.terminal?
@match = @session.match @match = @session.match
@stream_closed = @session.terminal? @stream_closed = @session.terminal?
@on_air = !@stream_closed && mediamtx_stream_playable?(@session) @on_air = !@stream_closed && (
mediamtx_stream_playable?(@session) ||
@session.status.in?(%w[connecting live reconnecting paused])
)
@publisher_online = !@stream_closed && mediamtx_publisher_online?(@session) @publisher_online = !@stream_closed && mediamtx_publisher_online?(@session)
end end
@@ -68,7 +71,7 @@ module Public
session.live? || session.paused? || session.connecting? || session.reconnecting? || session.live? || session.paused? || session.connecting? || session.reconnecting? ||
mediamtx_publisher_online?(session) mediamtx_publisher_online?(session)
), ),
on_air: playable, on_air: playable || (!closed && session.status.in?(%w[connecting live reconnecting paused])),
publisher_online: publisher_online, publisher_online: publisher_online,
awaiting_signal: !closed && !mediamtx_path_has_output?(session), awaiting_signal: !closed && !mediamtx_path_has_output?(session),
showing_cover: !closed && mediamtx_path_has_output?(session) && showing_cover: !closed && mediamtx_path_has_output?(session) &&

View File

@@ -27,9 +27,6 @@ class OverlayRefreshJob < ApplicationJob
Streams::Overlay::Refresh.call(session) Streams::Overlay::Refresh.call(session)
Mediamtx::PublisherSync.new(session).call if session.status.in?(%w[connecting reconnecting live paused]) Mediamtx::PublisherSync.new(session).call if session.status.in?(%w[connecting reconnecting live paused])
if session.platform == "youtube"
Streams::YoutubeRelay.ensure_publishing!(session)
end
self.class.set(wait: INTERVAL).perform_later(session_id) self.class.set(wait: INTERVAL).perform_later(session_id)
end end
end end

View File

@@ -0,0 +1,80 @@
# Avvia overlay ffmpeg solo in Sidekiq (un solo job per sessione alla volta).
class OverlayRelayEnsureJob < ApplicationJob
queue_as :default
LOCK_KEY = "overlay_ensure:lock:%s"
LOCK_TTL = 55
RETRY_WAIT = 5.seconds
class << self
def enqueue_unique(session_id, attempt: 1, force_restart: false, wait: 0.seconds)
key = format(LOCK_KEY, session_id)
r = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
return unless r.set(key, "1", nx: true, ex: LOCK_TTL)
set(wait: wait).perform_later(session_id, attempt, force_restart: force_restart)
end
end
def perform(session_id, attempt = 1, force_restart: false)
session = StreamSession.find_by(id: session_id)
unless session && !session.terminal?
clear_lock(session_id)
return
end
return unless session.status.in?(%w[live connecting reconnecting paused])
begin
run_ensure(session, session_id, attempt, force_restart)
ensure
clear_lock(session_id)
end
end
private
def run_ensure(session, session_id, attempt, force_restart)
if Mediamtx::PublisherOnline.video_publishing?(session) && !session.paused?
session.go_live! if session.may_go_live?
session.reconnect! if session.reconnecting? && session.may_reconnect?
Mediamtx::Client.new.set_always_available(session, enabled: false)
end
if force_restart
Streams::OverlayRelay.stop(session)
Streams::OverlayRelay.terminate_all_session_processes!(session.id)
end
Streams::OverlayRelay.ensure_publishing!(session)
if Streams::OverlayRelay.healthy?(session)
Youtube::LivePipeline.schedule_activate!(session, force: true, wait: 5.seconds)
return
end
return if Streams::OverlayRelay.running?(session.id)
Rails.logger.info("[OverlayRelayEnsureJob] overlay not running, retry session=#{session_id} attempt=#{attempt}")
reschedule(session_id, attempt, false) if attempt < 30
rescue Mediamtx::Client::Error => e
Rails.logger.warn("[OverlayRelayEnsureJob] session=#{session_id}: #{e.message}")
end
def reschedule(session_id, attempt, force_restart)
clear_lock(session_id)
self.class.enqueue_unique(
session_id,
attempt: attempt + 1,
force_restart: force_restart,
wait: RETRY_WAIT
)
end
def clear_lock(session_id)
redis.del(format(LOCK_KEY, session_id))
end
def redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
end
end

View File

@@ -0,0 +1,12 @@
# Aggiorna tabellone overlay in background (evita blocchi HTTP al +1 punto).
class OverlayScoreRefreshJob < ApplicationJob
queue_as :default
def perform(session_id)
session = StreamSession.find_by(id: session_id)
return unless session
return if session.terminal?
Streams::Overlay::Refresh.call(session)
end
end

View File

@@ -0,0 +1,37 @@
# Poll MediaMTX + pipeline YouTube automatica (webhook MediaMTX non disponibile in prod).
class StreamPublisherSyncJob < ApplicationJob
queue_as :default
INTERVAL = 5.seconds
ACTIVE_STATUSES = %w[connecting live reconnecting paused].freeze
REDIS_CHAIN_KEY = "stream_publisher_sync:chain"
RUN_LOCK_KEY = "stream_publisher_sync:run_lock"
def self.ensure_chain
return if redis.get(REDIS_CHAIN_KEY)
redis.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL.to_i * 4)
set(wait: INTERVAL).perform_later
end
def self.redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
end
def perform
return unless self.class.redis.set(RUN_LOCK_KEY, "1", nx: true, ex: INTERVAL.to_i)
begin
self.class.redis.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL.to_i * 4)
StreamSession
.where(status: ACTIVE_STATUSES)
.find_each do |session|
Mediamtx::PublisherSync.new(session).call
end
ensure
self.class.set(wait: INTERVAL).perform_later
self.class.redis.del(RUN_LOCK_KEY)
end
end
end

View File

@@ -1,32 +1,54 @@
class YoutubeBroadcastActivateJob < ApplicationJob class YoutubeBroadcastActivateJob < ApplicationJob
queue_as :default queue_as :default
MAX_ATTEMPTS = 36 MAX_ATTEMPTS = 30
RETRY_WAIT = 10.seconds RETRY_WAIT = 15.seconds
class << self RATE_LIMIT_WAIT = 90.seconds
def schedule_with_retries(session_id, attempt: 1) LIVE_STATUSES = %w[live connecting reconnecting paused].freeze
set(wait: attempt == 1 ? 8.seconds : RETRY_WAIT).perform_later(session_id, attempt)
end
end
def perform(session_id, attempt = 1) def perform(session_id, attempt = 1)
session = StreamSession.find_by(id: session_id) session = StreamSession.find_by(id: session_id)
return unless session&.platform == "youtube" return unless session&.platform == "youtube"
return if session.terminal? return if session.terminal?
return if session.youtube_broadcast_id.blank? return if session.youtube_broadcast_id.blank?
return unless Streams::YoutubeRelay.running?(session.id) return unless LIVE_STATUSES.include?(session.status)
return unless Youtube::LivePipeline.broadcast_ready?(session)
result = Youtube::BroadcastService.new(session.match.team).activate_broadcast!(session.youtube_broadcast_id) unless Streams::YoutubeRelay.running?(session.id)
return if result == :live || result == :transitioned Streams::YoutubeRelay.ensure_publishing!(session)
return reschedule(session_id, attempt + 1) if attempt < MAX_ATTEMPTS
if result == :broadcast_ended
Youtube::SetupBroadcast.recreate!(session.reload)
return return
end end
return unless result == :waiting_ingest result = Youtube::BroadcastService.new(session.match.team).activate_broadcast!(session.youtube_broadcast_id)
Rails.logger.info("[YoutubeBroadcastActivate] session=#{session.id} attempt=#{attempt} result=#{result}")
return if result == :live || result == :transitioned
if result == :rate_limited
reschedule(session_id, attempt, wait: RATE_LIMIT_WAIT)
return
end
if result == :broadcast_ended
Youtube::SetupBroadcast.recreate!(session.reload)
Youtube::LivePipeline.schedule!(session.reload, force: true)
return
end
if result == :waiting_ingest && (attempt % 4).zero?
Streams::YoutubeRelay.ensure_publishing!(session)
end
return if attempt >= MAX_ATTEMPTS return if attempt >= MAX_ATTEMPTS
self.class.schedule_with_retries(session_id, attempt: attempt + 1) reschedule(session_id, attempt + 1)
end
private
def reschedule(session_id, attempt, wait: RETRY_WAIT)
self.class.set(wait: wait).perform_later(session_id, attempt)
end end
end end

View File

@@ -1,12 +1,61 @@
class YoutubeBroadcastSetupJob < ApplicationJob class YoutubeBroadcastSetupJob < ApplicationJob
queue_as :default queue_as :default
retry_on Youtube::SetupBroadcast::Error, wait: 10.seconds, attempts: 3 RUN_LOCK_KEY = "youtube_setup:running:%s"
MAX_RATE_LIMIT_ATTEMPTS = 15
RATE_LIMIT_WAITS = [30, 45, 60, 90, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120].freeze
def perform(session_id, youtube_channel = nil) def perform(session_id, youtube_channel = nil, attempt = 1)
session = StreamSession.find_by(id: session_id) session = StreamSession.find_by(id: session_id)
return unless session return unless session&.platform == "youtube"
return if session.terminal?
return if session.youtube_broadcast_id.present? && session.stream_key.present?
return unless acquire_run_lock!(session_id, attempt)
Youtube::SetupBroadcast.call(session, youtube_channel: youtube_channel.presence) Youtube::SetupBroadcast.call(session, youtube_channel: youtube_channel.presence)
release_run_lock!(session_id)
rescue Youtube::SetupBroadcast::Error => e
if rate_limit?(e) && attempt < MAX_RATE_LIMIT_ATTEMPTS
Youtube::ApiThrottle.mark_rate_limited!
wait = RATE_LIMIT_WAITS[attempt - 1] || 120
extend_run_lock!(session_id, wait + 90)
Rails.logger.warn(
"[YoutubeBroadcastSetupJob] rate limit session=#{session_id} attempt=#{attempt} retry_in=#{wait}s"
)
self.class.set(wait: wait.seconds).perform_later(session_id, youtube_channel, attempt + 1)
return
end
release_run_lock!(session_id)
raise
end
private
def rate_limit?(error)
msg = error.message.to_s
msg.include?("rate limit") ||
msg.include?("userRequestsExceedRateLimit") ||
msg.include?("temporaneamente occupato")
end
def acquire_run_lock!(session_id, attempt)
key = format(RUN_LOCK_KEY, session_id)
if attempt > 1
return redis.exists?(key).positive?
end
redis.set(key, "1", nx: true, ex: 90)
end
def extend_run_lock!(session_id, seconds)
redis.expire(format(RUN_LOCK_KEY, session_id), seconds)
end
def release_run_lock!(session_id)
redis.del(format(RUN_LOCK_KEY, session_id))
end
def redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
end end
end end

View File

@@ -0,0 +1,57 @@
# Controlli ripetuti finché YouTube non ha ingest active (poi attiva la broadcast).
class YoutubeIngestWatchdogJob < ApplicationJob
queue_as :default
FIRST_WAIT = 20.seconds
NEXT_WAITS = [35.seconds, 45.seconds, 60.seconds].freeze
def perform(session_id, phase = 0)
session = StreamSession.find_by(id: session_id)
return unless Youtube::LivePipeline.broadcast_ready?(session)
return if Youtube::ApiThrottle.rate_limited?
ingest = youtube_ingest_status(session)
if ingest == "active"
Youtube::LivePipeline.schedule_activate!(session, force: true, wait: 3.seconds)
return
end
if Streams::YoutubeRelay.running?(session.id)
Rails.logger.info("[YoutubeIngestWatchdog] relay ok ingest=#{ingest} phase=#{phase} session=#{session.id}")
else
Rails.logger.warn("[YoutubeIngestWatchdog] avvio relay ingest=#{ingest} session=#{session.id}")
Streams::YoutubeRelay.ensure_publishing!(session)
end
schedule_next(session_id, phase)
rescue StandardError => e
Rails.logger.warn("[YoutubeIngestWatchdog] session=#{session_id}: #{e.message}")
schedule_next(session_id, phase)
end
def self.schedule_chain(session_id)
set(wait: FIRST_WAIT).perform_later(session_id, 0)
end
private
def schedule_next(session_id, phase)
wait = NEXT_WAITS[phase]
return unless wait
self.class.set(wait: wait).perform_later(session_id, phase + 1)
end
def youtube_ingest_status(session)
return nil if session.youtube_broadcast_id.blank?
client = Youtube::BroadcastService.new(session.match.team).send(:authorized_client)
b = client.list_live_broadcasts("status,contentDetails", id: session.youtube_broadcast_id).items&.first
return nil unless b
sid = b.content_details.bound_stream_id
client.list_live_streams("status", id: sid).items.first&.status&.stream_status
rescue StandardError
nil
end
end

View File

@@ -0,0 +1,11 @@
# Avvia/riavvia il relay YouTube solo nel container sidekiq (YOUTUBE_RELAY_WORKER=1).
class YoutubeRelayEnsureJob < ApplicationJob
queue_as :default
def perform(session_id)
session = StreamSession.find_by(id: session_id)
return unless session
Streams::YoutubeRelay.ensure_on_worker!(session)
end
end

View File

@@ -0,0 +1,10 @@
class YoutubeRelayStopJob < ApplicationJob
queue_as :default
def perform(session_id)
session = StreamSession.find_by(id: session_id)
return unless session
Streams::YoutubeRelay.stop_on_worker!(session) if Streams::YoutubeRelay.worker?
end
end

View File

@@ -18,7 +18,7 @@ class StreamSession < ApplicationRecord
before_validation :normalize_privacy_status before_validation :normalize_privacy_status
before_validation :ensure_publish_token, on: :create before_validation :ensure_publish_token, on: :create
scope :broadcasting, -> { where(status: %w[live connecting reconnecting]) } scope :broadcasting, -> { where(status: %w[live connecting reconnecting paused]) }
scope :publicly_listed, -> { where(privacy_status: "public") } scope :publicly_listed, -> { where(privacy_status: "public") }
scope :search_by_team_or_opponent, lambda { |query| scope :search_by_team_or_opponent, lambda { |query|
q = query.to_s.strip q = query.to_s.strip
@@ -95,18 +95,9 @@ class StreamSession < ApplicationRecord
end end
def effective_hls_path_name 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 mediamtx_path_name
end 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 def watch_page_url
"#{MatchLiveTv.app_public_url.chomp('/')}/live/#{id}" "#{MatchLiveTv.app_public_url.chomp('/')}/live/#{id}"
end end

View File

@@ -31,28 +31,32 @@ module Mediamtx
path = session.mediamtx_path_name path = session.mediamtx_path_name
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe # record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
# solo la slate (schermo nero) in pausa/attesa. # solo la slate (schermo nero) in pausa/attesa.
# YouTube: niente slate sul path camera (maschera il video al relay ffmpeg).
body = recording_body(session, enabled: false).merge( body = recording_body(session, enabled: false).merge(
source: "publisher", source: "publisher",
overridePublisher: true, overridePublisher: true
alwaysAvailable: true,
alwaysAvailableFile: slate_file_path
) )
# YouTube: relay continuo gestito da Streams::YoutubeRelay (ffmpeg in sidekiq/rails). body[:alwaysAvailable] = true
body[:alwaysAvailableFile] = slate_file_path
# YouTube: telefono → MediaMTX; relay copy verso RTMPS in sidekiq.
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body) response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
unless response.success? unless response.success?
err = response.body.is_a?(Hash) ? response.body["error"] : response.body err = response.body.is_a?(Hash) ? response.body["error"] : response.body
raise Error, "MediaMTX path create failed: #{response.status} #{err}" raise Error, "MediaMTX path create failed: #{response.status} #{err}"
end end
remember_always_available(path, enabled: true)
true true
end end
def delete_path(session) def delete_path(session)
delete_path_name(session.mediamtx_path_name) delete_path_name(session.mediamtx_path_name)
delete_path_name(session.mediamtx_overlay_path_name) delete_path_name(session.mediamtx_overlay_path_name)
forget_always_available(session.mediamtx_path_name)
end end
def delete_path_name(path_name) def delete_path_name(path_name)
@conn.post("/v3/config/paths/delete/#{CGI.escape(path_name)}") @conn.post("/v3/config/paths/delete/#{CGI.escape(path_name)}")
forget_always_available(path_name)
end end
def set_path_recording(session, enabled:) def set_path_recording(session, enabled:)
@@ -67,16 +71,26 @@ module Mediamtx
end end
def patch_path(session) def patch_path(session)
set_always_available(session, enabled: true)
end
# Slate alwaysAvailable: copertina sullo stesso path quando il telefono è offline.
# Disattivare quando il publisher è in onda; riattivare in pausa/disconnessione.
def set_always_available(session, enabled:)
path = session.mediamtx_path_name path = session.mediamtx_path_name
body = { return true if always_available_remembered?(path, enabled: enabled)
alwaysAvailable: true,
alwaysAvailableFile: slate_file_path body = if enabled
} { alwaysAvailable: true, alwaysAvailableFile: slate_file_path }
else
{ alwaysAvailable: false }
end
response = @conn.patch("/v3/config/paths/patch/#{CGI.escape(path)}", body) response = @conn.patch("/v3/config/paths/patch/#{CGI.escape(path)}", body)
unless response.success? unless response.success?
err = response.body.is_a?(Hash) ? response.body["error"] : response.body err = response.body.is_a?(Hash) ? response.body["error"] : response.body
raise Error, "MediaMTX path patch failed: #{response.status} #{err}" raise Error, "MediaMTX alwaysAvailable patch failed: #{response.status} #{err}"
end end
remember_always_available(path, enabled: enabled)
true true
end end
@@ -152,5 +166,35 @@ module Mediamtx
def slate_file_path def slate_file_path
ENV.fetch("MEDIAMTX_SLATE_FILE", "/slates/offline.mp4") ENV.fetch("MEDIAMTX_SLATE_FILE", "/slates/offline.mp4")
end end
def always_available_remembered?(path, enabled:)
redis.get(always_available_key(path)) == always_available_value(enabled)
rescue Redis::BaseError
false
end
def remember_always_available(path, enabled:)
redis.set(always_available_key(path), always_available_value(enabled), ex: 48.hours.to_i)
rescue Redis::BaseError
nil
end
def forget_always_available(path)
redis.del(always_available_key(path))
rescue Redis::BaseError
nil
end
def always_available_key(path)
"mediamtx:always_available:#{path}"
end
def always_available_value(enabled)
enabled ? "1" : "0"
end
def redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
end
end end
end end

View File

@@ -0,0 +1,37 @@
module Mediamtx
# Telefono connesso al path RTMP (non solo slate alwaysAvailable).
module PublisherOnline
module_function
def active?(session)
active_path?(path_info(session))
end
def path_info(session)
Client.new.list_paths.find { |i| i["name"] == session.mediamtx_path_name }
end
def active_path?(info)
return false unless info
info["online"] == true && info.dig("source", "type") == "rtmpConn"
end
def h264_video?(info)
return false unless info
(info["tracks2"] || []).any? do |track|
track["codec"] == "H264" && track.dig("codecProps", "width").to_i.positive?
end
end
def video_publishing?(session)
info = path_info(session)
return false unless active_path?(info)
# Slate alwaysAvailable ha H264 ma non è il telefono.
return false unless info.dig("source", "type") == "rtmpConn"
h264_video?(info)
end
end
end

View File

@@ -10,40 +10,47 @@ module Mediamtx
return @session if @session.terminal? return @session if @session.terminal?
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == @session.mediamtx_path_name } info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == @session.mediamtx_path_name }
publisher_online = publisher_online?(info) publisher_online = Mediamtx::PublisherOnline.active_path?(info)
if publisher_online if publisher_online
clear_publisher_misses!(@session.id)
if @session.paused? if @session.paused?
# RTMP ancora connesso in pausa: non forzare live/reconnect. # RTMP ancora connesso in pausa: non forzare live/reconnect.
elsif @session.may_go_live? else
@session.go_live! enable_live_path!(@session)
@session.reload if @session.may_go_live?
Streams::Overlay::Refresh.call(@session) @session.go_live!
Streams::YoutubeRelay.ensure_publishing!(@session) if @session.platform == "youtube" @session.reload
elsif @session.reconnecting? && @session.may_reconnect? kick_youtube!(@session) if @session.platform == "youtube"
@session.reconnect! elsif @session.reconnecting? && @session.may_reconnect?
cancel_timeout @session.reconnect!
Streams::Overlay::Refresh.call(@session.reload) cancel_timeout
kick_youtube!(@session)
end
kick_youtube!(@session) if @session.platform == "youtube"
end end
elsif !@session.paused? && @session.live? && @session.may_lose_connection? elsif !@session.paused? && @session.live? && @session.may_lose_connection?
@session.lose_connection! if publisher_offline_sustained?(@session.id)
schedule_timeout unless @session.paused? clear_publisher_misses!(@session.id)
restore_slate_path!(@session)
@session.lose_connection!
schedule_timeout unless @session.paused?
end
elsif !publisher_online && @session.status.in?(%w[reconnecting connecting]) && !@session.paused?
restore_slate_path!(@session)
end end
@session.reload @session.reload.tap do |session|
schedule_youtube_relay_if_needed(session)
end
end end
private private
def publisher_online?(info) def kick_youtube!(session)
return false unless info return unless Youtube::LivePipeline.pipeline_eligible?(session)
return true if info["online"] Youtube::LivePipeline.tick_session!(session)
return true if info["source"].present?
# MediaMTX a volte lascia online=false con publisher attivo; i byte in ingresso confermano il segnale.
return true if info["ready"] && info["bytesReceived"].to_i > 1_000_000
false
end end
def cancel_timeout def cancel_timeout
@@ -60,5 +67,55 @@ module Mediamtx
) )
@session.update!(timeout_job_id: job) @session.update!(timeout_job_id: job)
end end
# Compatibilità con webhook / controller.
def self.schedule_youtube_pipeline!(session, force: false)
Youtube::LivePipeline.schedule!(session, force: force)
end
# Match Live TV: lascia alwaysAvailable attivo — MediaMTX usa il publisher quando c'è
# e la copertina nei gap RTMP (telefono instabile). Disattivare la slate lasciava HLS
# senza stream ("no stream is available") e il player web in pausa/spinner.
def enable_live_path!(session)
return unless session.platform == "youtube"
Client.new.set_always_available(session, enabled: false)
rescue Client::Error => e
Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}")
end
def restore_slate_path!(session)
return if session.platform == "matchlivetv"
Client.new.set_always_available(session, enabled: true)
rescue Client::Error => e
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
end
# Evita reconnect su un singolo poll offline (RTMP instabile dal telefono).
def publisher_offline_sustained?(session_id)
misses = redis.incr(format("publisher_offline_miss:%s", session_id)).to_i
redis.expire(format("publisher_offline_miss:%s", session_id), 120)
misses >= 4
end
def clear_publisher_misses!(session_id)
redis.del(format("publisher_offline_miss:%s", session_id))
end
def schedule_youtube_relay_if_needed(session)
return unless session.platform == "youtube"
return unless session.status.in?(%w[connecting live reconnecting paused])
return unless Youtube::LivePipeline.broadcast_ready?(session)
key = format("youtube_relay:sched:%s", session.id)
return unless redis.set(key, "1", nx: true, ex: 10)
YoutubeRelayEnsureJob.perform_later(session.id)
end
def redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
end
end end
end end

View File

@@ -105,7 +105,6 @@ module Scoring
def broadcast(score) def broadcast(score)
SessionChannel.broadcast_message(@session, score.as_cable_payload) SessionChannel.broadcast_message(@session, score.as_cable_payload)
Streams::Overlay::Refresh.call(@session)
end end
end end
end end

View File

@@ -21,7 +21,6 @@ module Scoring
score.update!(attrs) score.update!(attrs)
SessionChannel.broadcast_message(@session, score.as_cable_payload) SessionChannel.broadcast_message(@session, score.as_cable_payload)
Streams::Overlay::Refresh.call(@session)
score score
end end
end end

View File

@@ -38,8 +38,6 @@ module Sessions
session.create_score_state! session.create_score_state!
mtx = Mediamtx::Client.new mtx = Mediamtx::Client.new
mtx.create_path(session) mtx.create_path(session)
mtx.create_overlay_path(session)
start_overlay_relay(session)
log_event(session, "pairing", { created: true, platform: session.platform }) log_event(session, "pairing", { created: true, platform: session.platform })
end end
@@ -66,12 +64,6 @@ module Sessions
Youtube::SetupBroadcast.call(session, youtube_channel: youtube_channel) Youtube::SetupBroadcast.call(session, youtube_channel: youtube_channel)
end 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 log_event(session, type, metadata) def log_event(session, type, metadata)
session.stream_events.create!(event_type: type, metadata: metadata, occurred_at: Time.current) session.stream_events.create!(event_type: type, metadata: metadata, occurred_at: Time.current)
end end

View File

@@ -7,11 +7,12 @@ module Sessions
def call def call
cancel_timeout_job cancel_timeout_job
@session.pause! if @session.may_pause? @session.pause! if @session.may_pause?
# Slate già su path (create_path + alwaysAvailable): stop RTMP basta, senza patch API. Mediamtx::Client.new.set_always_available(@session, enabled: true)
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
log_event("paused") log_event("paused")
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" }) SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "paused" }) SessionChannel.broadcast_message(@session, { type: "stream_event", event: "paused" })
Streams::Overlay::Refresh.call(@session) Youtube::LivePipeline.schedule!(@session, force: true) if @session.platform == "youtube"
@session @session
end end

View File

@@ -16,7 +16,7 @@ module Sessions
log_event("resumed") log_event("resumed")
SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" }) SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" })
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "resumed" }) SessionChannel.broadcast_message(@session, { type: "stream_event", event: "resumed" })
Streams::Overlay::Refresh.call(@session) Youtube::LivePipeline.schedule!(@session, force: true) if @session.platform == "youtube"
@session @session
end end

View File

@@ -8,6 +8,7 @@ module Sessions
@session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session) @session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session)
@session.begin_connect! if @session.may_begin_connect? @session.begin_connect! if @session.may_begin_connect?
@session.update!(status: "connecting") unless @session.connecting? @session.update!(status: "connecting") unless @session.connecting?
Youtube::LivePipeline.schedule!(@session, force: true) if @session.platform == "youtube"
broadcast_status("connecting") broadcast_status("connecting")
@session @session
end end

View File

@@ -6,7 +6,6 @@ module Sessions
def call def call
cancel_timeout_job cancel_timeout_job
Streams::OverlayRelay.stop(@session)
Streams::YoutubeRelay.stop(@session) Streams::YoutubeRelay.stop(@session)
Sessions::RegiaAccess.revoke!(@session) Sessions::RegiaAccess.revoke!(@session)
@session.end_stream! @session.end_stream!

View File

@@ -14,6 +14,17 @@ module Streams
overlay_dir(session.id).join("overlay.pipe") overlay_dir(session.id).join("overlay.pipe")
end end
# Logo chiaro watermark (brand/logo-white-m.png → branding/ in deploy).
def brand_logo_path
candidates = [
Rails.root.join("branding/logo-white-m.png"),
Rails.root.join("brand/logo-white-m.png"),
Rails.root.join("public/logo-white.png"),
Rails.root.join("public/logo.png")
]
candidates.find(&:file?) || candidates.first
end
def badge_away_color(team) def badge_away_color(team)
club = team.club club = team.club
candidate = club&.effective_secondary_color.presence || "#1565c0" candidate = club&.effective_secondary_color.presence || "#1565c0"

View File

@@ -16,7 +16,7 @@ module Streams
return Result.new(text: "DIRETTA CHIUSA", background: "#374151", foreground: "#dddddd") if @session.terminal? 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? return Result.new(text: "IN PAUSA", background: "#455a64", foreground: "#ffffff") if @session.paused?
if publisher_online? || (@session.live? && !@session.paused?) if Mediamtx::PublisherOnline.active?(@session) || (@session.live? && !@session.paused?)
return Result.new(text: "IN ONDA", background: "#2e7d32", foreground: "#ffffff") return Result.new(text: "IN ONDA", background: "#2e7d32", foreground: "#ffffff")
end end
@@ -27,7 +27,8 @@ module Streams
if @session.connecting? if @session.connecting?
return Result.new(text: "CONNECTING", background: "#f57c00", foreground: "#ffffff") return Result.new(text: "CONNECTING", background: "#f57c00", foreground: "#ffffff")
end end
return Result.new(text: "COPERTINA", background: "#455a64", foreground: "#ffffff") # Slate alwaysAvailable senza telefono: non bruciare «COPERTINA» su YouTube.
return Result.new(text: "IN ATTESA", background: "#455a64", foreground: "#ffffff")
end end
Result.new(text: "IN ATTESA", background: "#455a64", foreground: "#ffffff") Result.new(text: "IN ATTESA", background: "#455a64", foreground: "#ffffff")
@@ -36,12 +37,7 @@ module Streams
private private
def path_info def path_info
@path_info ||= Mediamtx::Client.new.list_paths.find { |i| i["name"] == @session.mediamtx_path_name } @path_info ||= Mediamtx::PublisherOnline.path_info(@session)
end
def publisher_online?
info = path_info
info && info["online"]
end end
def path_has_output? def path_has_output?

View File

@@ -1,6 +1,6 @@
module Streams module Streams
module Overlay module Overlay
# PNG 1280×720 trasparente: tabellone alto-sinistra, badge alto-destra, brand sotto tabellone. # PNG 1280×720 trasparente: tabellone alto-sinistra, badge alto-destra, logo chiaro (logo-white-m) basso-destra.
class SvgBuilder class SvgBuilder
CANVAS_W = 1280 CANVAS_W = 1280
CANVAS_H = 720 CANVAS_H = 720
@@ -17,9 +17,10 @@ module Streams
cols = score_columns cols = score_columns
<<~SVG <<~SVG
<?xml version="1.0" encoding="UTF-8"?> <?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}"> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="#{CANVAS_W}" height="#{CANVAS_H}" viewBox="0 0 #{CANVAS_W} #{CANVAS_H}">
#{scorebug_svg(cols)} #{scorebug_svg(cols)}
#{badge_svg} #{badge_svg}
#{brand_watermark_svg}
</svg> </svg>
SVG SVG
end end
@@ -113,6 +114,22 @@ module Streams
SVG SVG
end end
def brand_watermark_svg
path = Overlay.brand_logo_path
return "" unless path.file?
w = 108
h = 108
x = CANVAS_W - 14 - w
y = CANVAS_H - 14 - h
uri = "file://#{path.expand_path}"
<<~SVG
<g id="brand-watermark" opacity="0.94">
<image xlink:href="#{escape(uri)}" x="#{x}" y="#{y}" width="#{w}" height="#{h}" preserveAspectRatio="xMidYMid meet"/>
</g>
SVG
end
def badge_svg def badge_svg
text = @badge.text text = @badge.text
bg = @badge.background bg = @badge.background

View File

@@ -4,92 +4,115 @@ module Streams
class Error < StandardError; end class Error < StandardError; end
REDIS_KEY = "overlay_relay:pid:%s" REDIS_KEY = "overlay_relay:pid:%s"
LIVE_INTAKE_KEY = "overlay_relay:live_intake:%s"
MODE_KEY = "overlay_relay:mode:%s"
START_LOCK_KEY = "overlay_relay:start_lock:%s"
MODE_LIVE = "live".freeze
MODE_COVER = "cover".freeze
class << self class << self
def start(session) def start(session, mode: nil)
return if session.terminal? return if session.terminal?
return unless acquire_start_lock!(session.id)
stop(session) if pid_for(session.id).present? begin
terminate_all_session_processes!(session.id)
Streams::Overlay::Refresh.call(session) Streams::Overlay::Refresh.call(session)
dir = Streams::Overlay.overlay_dir(session.id) dir = Streams::Overlay.overlay_dir(session.id)
FileUtils.mkdir_p(dir) FileUtils.mkdir_p(dir)
png = Streams::Overlay.overlay_png_path(session) png = Streams::Overlay.overlay_png_path(session)
pipe = Streams::Overlay.overlay_pipe_path(session) pipe = Streams::Overlay.overlay_pipe_path(session)
ensure_pipe!(pipe) ensure_pipe!(pipe)
intake = "#{mediamtx_rtmp_url}/#{session.mediamtx_path_name}" log_path = log_file(session)
output = "#{mediamtx_rtmp_url}/#{session.mediamtx_overlay_path_name}" FileUtils.mkdir_p(File.dirname(log_path))
log_path = log_file(session)
FileUtils.mkdir_p(File.dirname(log_path))
fps = output_fps mode = normalize_mode(mode || desired_mode(session))
overlay_fps = overlay_input_fps ffmpeg_pid = Process.spawn(*ffmpeg_command(session, pipe, mode), %i[out err] => log_path, pgroup: true)
filter = "[1:v]format=rgba[ol];[0:v]fps=#{fps},format=yuv420p[vid];[vid][ol]overlay=0:0:format=auto[vout]" spawn_png_feeder!(ffmpeg_pid, pipe, png, log_path)
Process.detach(ffmpeg_pid)
ffmpeg_pid = Process.spawn( store_pid(session.id, ffmpeg_pid)
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning", store_mode(session.id, mode)
"-fflags", "+genpts+discardcorrupt", mode == MODE_LIVE ? mark_live_intake!(session.id) : clear_live_intake!(session.id)
"-analyzeduration", "10000000", "-probesize", "10000000", OverlayRefreshJob.schedule_chain(session.id)
"-rw_timeout", "15000000", if session.platform == "youtube" && session.stream_key.present?
"-i", intake, Streams::YoutubeRelay.stop(session)
"-f", "image2pipe", "-framerate", overlay_fps.to_s, "-i", pipe.to_s, Youtube::LivePipeline.schedule_activate!(session, force: true, wait: 20.seconds)
"-filter_complex", filter, end
"-map", "[vout]", "-map", "0:a?", Rails.logger.info("[OverlayRelay] started pid=#{ffmpeg_pid} mode=#{mode} session=#{session.id} youtube_tee=#{session.platform == "youtube"}")
"-fps_mode", "cfr", "-r", fps.to_s, ffmpeg_pid
"-c:v", "libx264", "-preset", x264_preset, "-bf", "0", ensure
"-profile:v", "high", "-pix_fmt", "yuv420p", release_start_lock!(session.id)
"-b:v", bitrate_for(session), "-maxrate", maxrate_for(session), "-bufsize", bufsize_for(session), end
"-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 rescue Errno::ENOENT => e
raise Error, "ffmpeg non disponibile: #{e.message}" raise Error, "ffmpeg non disponibile: #{e.message}"
end end
def stop(session) def stop(session)
pid = pid_for(session.id) pids = session_process_pids(session.id)
return false if pid.blank? redis_pid = pid_for(session.id).to_i
pids << redis_pid if redis_pid > 1
pids = pids.uniq.select { |p| p > 1 }
return false if pids.empty?
terminate_pid(pid) pids.each { |p| terminate_pid(p) }
clear_pid(session.id) clear_pid(session.id)
clear_mode(session.id)
clear_live_intake!(session.id)
pipe = Streams::Overlay.overlay_pipe_path(session) pipe = Streams::Overlay.overlay_pipe_path(session)
FileUtils.rm_f(pipe) if pipe.exist? FileUtils.rm_f(pipe) if pipe.exist?
OverlayRefreshJob.cancel_chain(session.id) OverlayRefreshJob.cancel_chain(session.id)
Rails.logger.info("[OverlayRelay] stopped pid=#{pid} session=#{session.id}") Rails.logger.info("[OverlayRelay] stopped pids=#{pids.join(",")} session=#{session.id}")
true true
end end
def running?(session_id) def running?(session_id)
pid = pid_for(session_id) return false unless overlay_relay_process_check_local?
return false if pid.blank?
return true unless overlay_relay_process_check_local? alive = session_process_pids(session_id).select { |p| process_alive?(p) }
return true if alive.any?
process_alive?(pid) redis_pid = pid_for(session_id).to_i
return false if redis_pid <= 1
process_alive?(redis_pid)
end
# Overlay ffmpeg vivo e path _air riceve dati.
def healthy?(session)
running?(session.id) && overlay_path_publishing?(session)
end end
# Riavvia il relay se il processo è morto o _air non pubblica (es. feeder bloccato). # Riavvia il relay se il processo è morto o _air non pubblica (es. feeder bloccato).
def ensure_publishing!(session) def ensure_publishing!(session)
return if session.terminal? return if session.terminal?
restart_if_missing_youtube_tee!(session)
wanted_mode = desired_mode(session)
pids = session_process_pids(session.id)
if pids.any? && pids.none? { |p| process_alive?(p) }
terminate_all_session_processes!(session.id)
end
if healthy?(session) && current_mode(session.id) == wanted_mode
Youtube::LivePipeline.schedule_activate!(session) if session.platform == "youtube"
return
end
if running?(session.id) && overlay_path_publishing?(session) if running?(session.id) && current_mode(session.id) != wanted_mode
return unless mode_switch_allowed?(session.id, wanted_mode)
Rails.logger.info("[OverlayRelay] switch mode #{current_mode(session.id)}#{wanted_mode} session=#{session.id}")
stop(session)
end
if running?(session.id) && overlay_path_publishing?(session) && current_mode(session.id) == wanted_mode
return return
end end
if running?(session.id) if running?(session.id)
misses = redis.incr(overlay_air_miss_key(session.id)).to_i misses = redis.incr(overlay_air_miss_key(session.id)).to_i
redis.expire(overlay_air_miss_key(session.id), 120) redis.expire(overlay_air_miss_key(session.id), 120)
return if misses < 3 return if misses < 4
redis.del(overlay_air_miss_key(session.id)) redis.del(overlay_air_miss_key(session.id))
Rails.logger.warn("[OverlayRelay] _air non attivo dopo #{misses} tentativi, restart session=#{session.id}") Rails.logger.warn("[OverlayRelay] _air non attivo dopo #{misses} tentativi, restart session=#{session.id}")
@@ -98,24 +121,156 @@ module Streams
redis.del(overlay_air_miss_key(session.id)) redis.del(overlay_air_miss_key(session.id))
end end
start(session) start(session, mode: wanted_mode)
rescue Error => e rescue Error => e
Rails.logger.warn("[OverlayRelay] ensure_publishing session=#{session.id}: #{e.message}") Rails.logger.warn("[OverlayRelay] ensure_publishing session=#{session.id}: #{e.message}")
end end
def desired_mode(session)
return MODE_COVER if session.paused?
return MODE_LIVE if Mediamtx::PublisherOnline.video_publishing?(session)
# RTMP a tratti: resta su live finché c'era segnale telefono (evita flip copertina↔live).
return MODE_LIVE if session.reconnecting? && live_intake?(session.id)
return MODE_LIVE if session.status.in?(%w[live connecting reconnecting]) && recent_phone_bytes?(session)
MODE_COVER
end
def terminate_all_session_processes!(session_id)
session_process_pids(session_id).each do |pid|
terminate_pid(pid)
end
redis_pid = pid_for(session_id).to_i
terminate_pid(redis_pid) if redis_pid > 1
clear_pid(session_id)
clear_mode(session_id)
end
def session_process_pids(session_id)
tag = session_match_tag(session_id)
pids = []
each_overlay_process do |pid, cmdline|
pids << pid if cmdline.include?(tag)
end
pids.uniq
end
def session_match_tag(session_id)
"match_#{session_id}"
end
def each_overlay_process
return unless overlay_relay_process_check_local?
Dir.glob("/proc/[0-9]*").each do |proc_dir|
pid = proc_dir.split("/").last.to_i
next if pid <= 1
cmdline = File.read("#{proc_dir}/cmdline").tr("\0", " ")
next unless cmdline.include?("ffmpeg") || cmdline.include?("overlay_png_feeder")
next unless cmdline.include?("match_")
yield pid, cmdline
rescue Errno::ENOENT, Errno::EPERM
nil
end
end
private private
def mediamtx_rtmp_url def mediamtx_rtmp_url
ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935").chomp("/") ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935").chomp("/")
end end
def ffmpeg_command(session, pipe, mode)
mode == MODE_LIVE ? live_command(session, pipe) : cover_command(session, pipe)
end
def live_command(session, pipe)
intake = "#{mediamtx_rtmp_url}/#{session.mediamtx_path_name}"
[
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
"-fflags", "+genpts+discardcorrupt",
"-analyzeduration", "20000000", "-probesize", "20000000",
"-rw_timeout", "15000000",
"-noautorotate",
"-i", intake,
"-thread_queue_size", "512",
"-f", "image2pipe", "-framerate", overlay_input_fps.to_s, "-i", pipe.to_s,
*common_encode_args(session, live_filter, "0:a?")
]
end
def cover_command(session, pipe)
cover = cover_image_path
raise Error, "copertina fallback non trovata: #{cover}" unless File.exist?(cover)
[
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
"-re", "-loop", "1", "-framerate", output_fps.to_s, "-i", cover,
"-thread_queue_size", "512",
"-f", "image2pipe", "-framerate", overlay_input_fps.to_s, "-i", pipe.to_s,
"-f", "lavfi", "-i", "anullsrc=channel_layout=mono:sample_rate=48000",
*common_encode_args(session, cover_filter, "2:a")
]
end
def common_encode_args(session, filter, audio_map)
fps = output_fps
[
"-filter_complex", filter,
"-map", "[vout]", "-map", audio_map,
"-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",
*output_args(session)
]
end
# Crop center 16:9 a tutto schermo (niente bande nere su TV/web).
def live_filter
fps = output_fps
"[1:v]format=rgba[ol];" \
"[0:v]scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,setsar=1,fps=#{fps},format=yuv420p[vid];" \
"[vid][ol]overlay=0:0:format=auto[vout]"
end
def cover_filter
fps = output_fps
"[1:v]format=rgba[ol];" \
"[0:v]scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,setsar=1,fps=#{fps},format=yuv420p[vid];" \
"[vid][ol]overlay=0:0:format=auto[vout]"
end
def cover_image_path
ENV.fetch(
"OVERLAY_COVER_IMAGE",
Rails.root.join("branding", "CopertinaCanale_6.png").to_s
)
end
# Un solo encode → MediaMTX _air + YouTube RTMPS (evita seconda lettura RTMP senza SPS).
def output_args(session)
air = "#{mediamtx_rtmp_url}/#{session.mediamtx_overlay_path_name}"
if session.platform == "youtube" && session.stream_key.present?
yt = "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
tee = "[f=flv:onfail=ignore]#{air}|[f=flv:onfail=ignore:use_fifo=1]#{yt}"
["-f", "tee", tee]
else
["-f", "flv", air]
end
end
# Ricodifica con overlay: serve più budget della sorgente (generational loss). # Ricodifica con overlay: serve più budget della sorgente (generational loss).
def output_video_kbps(session) def output_video_kbps(session)
override = ENV["OVERLAY_RELAY_VIDEO_KBPS"].presence&.to_i override = ENV["OVERLAY_RELAY_VIDEO_KBPS"].presence&.to_i
return override if override&.positive? return override if override&.positive?
source_kbps = [(session.target_bitrate.to_i / 1000), 2500].max source_kbps = [(session.target_bitrate.to_i / 1000), 3000].max
[(source_kbps * 1.8).to_i, 4500].max [(source_kbps * 2.0).to_i, 5500].max
end end
def bitrate_for(session) def bitrate_for(session)
@@ -166,7 +321,52 @@ module Streams
def overlay_path_publishing?(session) def overlay_path_publishing?(session)
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == session.mediamtx_overlay_path_name } info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == session.mediamtx_overlay_path_name }
info && (info["online"] || info["ready"] || info["available"]) info && info["online"] && info["bytesReceived"].to_i > 100_000
end
def acquire_start_lock!(session_id)
redis.set(format(START_LOCK_KEY, session_id), "1", nx: true, ex: 30)
end
def release_start_lock!(session_id)
redis.del(format(START_LOCK_KEY, session_id))
end
def publisher_has_video?(session)
info = Mediamtx::PublisherOnline.path_info(session)
return false unless Mediamtx::PublisherOnline.active_path?(info)
(info["tracks2"] || []).any? do |track|
track["codec"] == "H264" && track.dig("codecProps", "width").to_i.positive?
end
end
def live_intake?(session_id)
redis.get(format(LIVE_INTAKE_KEY, session_id)) == "1"
end
def current_mode(session_id)
redis.get(format(MODE_KEY, session_id))
end
def normalize_mode(mode)
mode == MODE_LIVE ? MODE_LIVE : MODE_COVER
end
def mark_live_intake!(session_id)
redis.set(format(LIVE_INTAKE_KEY, session_id), "1", ex: 48.hours.to_i)
end
def clear_live_intake!(session_id)
redis.del(format(LIVE_INTAKE_KEY, session_id))
end
def store_mode(session_id, mode)
redis.set(format(MODE_KEY, session_id), normalize_mode(mode), ex: 48.hours.to_i)
end
def clear_mode(session_id)
redis.del(format(MODE_KEY, session_id))
end end
def log_file(session) def log_file(session)
@@ -193,6 +393,46 @@ module Streams
format("overlay_relay:air_miss:%s", session_id) format("overlay_relay:air_miss:%s", session_id)
end end
def recent_phone_bytes?(session)
info = Mediamtx::PublisherOnline.path_info(session)
info && info["bytesReceived"].to_i > 1_000_000
end
def publishing_to_youtube?(session)
return false unless session.platform == "youtube" && session.stream_key.present?
session_process_pids(session.id).any? do |pid|
cmdline = File.read("/proc/#{pid}/cmdline").tr("\0", " ")
cmdline.include?("rtmps://") || cmdline.include?("youtube.com/live2")
rescue Errno::ENOENT, Errno::EPERM
false
end
end
def restart_if_missing_youtube_tee!(session)
return unless session.platform == "youtube" && session.stream_key.present?
return unless running?(session.id)
return if publishing_to_youtube?(session)
Rails.logger.warn("[OverlayRelay] restart overlay: manca tee YouTube session=#{session.id}")
stop(session)
end
def mode_switch_allowed?(session_id, wanted_mode)
key = format("overlay_relay:mode_want:%s", session_id)
prev = redis.get(key)
redis.set(key, wanted_mode, ex: 120)
return true if prev.blank? || prev == wanted_mode
ticks = redis.incr(format("overlay_relay:mode_ticks:%s", session_id)).to_i
redis.expire(format("overlay_relay:mode_ticks:%s", session_id), 60)
if ticks >= 3
redis.del(format("overlay_relay:mode_ticks:%s", session_id))
return true
end
false
end
def process_alive?(pid) def process_alive?(pid)
stat = File.read("/proc/#{pid.to_i}/stat") stat = File.read("/proc/#{pid.to_i}/stat")
return false if stat.split[2] == "Z" return false if stat.split[2] == "Z"

View File

@@ -1,62 +1,63 @@
module Streams module Streams
# Relay continuo verso YouTube: legge l'uscita MediaMTX del path (camera o slate alwaysAvailable) # Relay verso YouTube: legge RTMP/HLS da MediaMTX e inoltra su RTMPS (-c copy). Nessun overlay.
# senza interrompere FLV quando il telefono si mette in pausa o perde rete. # ffmpeg gira solo nel container sidekiq (YOUTUBE_RELAY_WORKER=1).
class YoutubeRelay class YoutubeRelay
class Error < StandardError; end class Error < StandardError; end
REDIS_KEY = "youtube_relay:pid:%s" REDIS_KEY = "youtube_relay:pid:%s"
OWNER_KEY = "youtube_relay:owner:%s"
class << self class << self
def worker?
ENV["YOUTUBE_RELAY_WORKER"] == "1"
end
def start(session) def start(session)
return unless session.platform == "youtube" return unless worker?
return if session.stream_key.blank? start_on_worker!(session)
return if session.terminal?
return pid_for(session.id).to_i if running?(session.id)
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}"
# YouTube richiede FLV H.264/AAC stabili; -c copy lascia spesso streamStatus=inactive.
pid = Process.spawn(
*youtube_ffmpeg_args(intake, 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}")
schedule_youtube_activate(session)
pid
rescue Errno::ENOENT => e
raise Error, "ffmpeg non disponibile: #{e.message}"
end end
def stop(session) def stop(session)
pid = pid_for(session.id)
return false if pid.blank?
terminate_pid(pid)
clear_pid(session.id) clear_pid(session.id)
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id}") redis.del(format(OWNER_KEY, session.id))
if worker?
stop_on_worker!(session)
else
YoutubeRelayStopJob.perform_later(session.id)
end
true true
end end
def running?(session_id) def running?(session_id)
owner = redis.get(format(OWNER_KEY, session_id))
pid = pid_for(session_id) pid = pid_for(session_id)
pid.present? && process_alive?(pid) return false if pid.blank?
return process_alive?(pid) if owner.blank? || owner == worker_id
# Relay avviato in un altro container: consideralo attivo se il lock è recente.
redis.ttl(format(OWNER_KEY, session_id)) > 30
end end
# Riavvia il relay se è morto (es. avviato prima del segnale su _air).
def ensure_publishing!(session) def ensure_publishing!(session)
return unless session.platform == "youtube" return unless session.platform == "youtube"
return if session.terminal? return if session.terminal?
return if session.stream_key.blank? return if session.stream_key.blank?
return unless Streams::OverlayRelay.running?(session.id)
if worker?
ensure_on_worker!(session)
else
YoutubeRelayEnsureJob.perform_later(session.id)
end
end
def ensure_on_worker!(session)
return unless worker?
return unless session.platform == "youtube"
return if session.terminal?
return if session.stream_key.blank?
return unless session.status.in?(%w[live connecting reconnecting paused])
return unless intake_available?(session)
pid = pid_for(session.id) pid = pid_for(session.id)
clear_pid(session.id) if pid.present? && !process_alive?(pid.to_i) clear_pid(session.id) if pid.present? && !process_alive?(pid.to_i)
@@ -66,42 +67,111 @@ module Streams
last_restart = redis.get(restart_debounce_key(session.id)).to_i last_restart = redis.get(restart_debounce_key(session.id)).to_i
return if last_restart.positive? && (Time.now.to_i - last_restart) < 5 return if last_restart.positive? && (Time.now.to_i - last_restart) < 5
start(session) start_on_worker!(session)
redis.set(restart_debounce_key(session.id), Time.now.to_i, ex: 300) redis.set(restart_debounce_key(session.id), Time.now.to_i, ex: 300)
rescue Error => e rescue Error => e
Rails.logger.warn("[YoutubeRelay] ensure_publishing session=#{session.id}: #{e.message}") Rails.logger.warn("[YoutubeRelay] ensure_on_worker session=#{session.id}: #{e.message}")
end
def stop_on_worker!(session)
pid = pid_for(session.id)
return false if pid.blank?
terminate_pid(pid)
clear_pid(session.id)
redis.del(format(OWNER_KEY, session.id))
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id}")
true
end end
private private
# Non usare ffprobe su RTMP live: si blocca e impedisce l'avvio del relay (Sidekiq in hang). def start_on_worker!(session)
# Audio: traccia AAC da anullsrc (YouTube la richiede); il mic del telefono resta su _air/HLS. return unless session.platform == "youtube"
def youtube_ffmpeg_args(intake, output) return if session.stream_key.blank?
return if session.terminal?
return unless intake_available?(session)
return pid_for(session.id).to_i if running?(session.id)
stop_on_worker!(session) if pid_for(session.id).present?
log_path = log_file(session)
FileUtils.mkdir_p(File.dirname(log_path))
intake_source = mediamtx_intake_source(session)
output = "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
pid = Process.spawn(
*youtube_ffmpeg_args(intake_source, output),
%i[out err] => log_path,
pgroup: true
)
Process.detach(pid)
store_pid(session.id, pid)
redis.set(format(OWNER_KEY, session.id), worker_id, ex: 48.hours.to_i)
Rails.logger.info("[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(":")}")
schedule_youtube_activate(session)
pid
rescue Errno::ENOENT => e
raise Error, "ffmpeg non disponibile: #{e.message}"
end
# _air RTMP (copy) se pronto; altrimenti HLS via proxy Rails.
def youtube_ffmpeg_args(intake_source, output)
mode, url = intake_source
if mode == :rtmp
return [
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
"-fflags", "+genpts+discardcorrupt",
"-analyzeduration", "10000000", "-probesize", "10000000",
"-rw_timeout", "15000000",
"-i", url,
"-c:v", "copy",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
"-bsf:a", "aac_adtstoasc",
"-f", "flv", output
]
end
[ [
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning", "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
"-fflags", "+genpts+discardcorrupt", "-use_wallclock_as_timestamps", "1", "-fflags", "+genpts+discardcorrupt",
"-analyzeduration", "10000000", "-probesize", "10000000",
"-rw_timeout", "15000000", "-rw_timeout", "15000000",
"-i", intake, "-live_start_index", "-1",
"-f", "lavfi", "-i", "anullsrc=channel_layout=mono:sample_rate=48000", "-i", url,
"-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy",
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
"-pix_fmt", "yuv420p", "-r", "30", "-g", "30", "-keyint_min", "30", "-sc_threshold", "0",
"-b:v", "4000k", "-maxrate", "4000k", "-bufsize", "8000k",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1", "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
"-bsf:a", "aac_adtstoasc",
"-f", "flv", output "-f", "flv", output
] ]
end end
def schedule_youtube_activate(session) def mediamtx_intake_source(session)
return if session.youtube_broadcast_id.blank? base = ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
if Mediamtx::PublisherOnline.active?(session)
return [:rtmp, "#{base.chomp('/')}/#{session.mediamtx_path_name}"]
end
YoutubeBroadcastActivateJob.schedule_with_retries(session.id) path = session.mediamtx_path_name
rails = ENV.fetch("RAILS_INTERNAL_URL", "http://rails:3000")
[:hls, "#{rails.chomp('/')}/hls/#{path}/index.m3u8"]
end end
def mediamtx_intake_url(session) def intake_available?(session)
base = ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935") return true if Mediamtx::PublisherOnline.active?(session)
"#{base.chomp('/')}/#{session.mediamtx_overlay_path_name}"
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == session.mediamtx_path_name }
info && (info["ready"] || info["online"] || info["available"])
rescue StandardError
false
end
def schedule_youtube_activate(session)
Youtube::LivePipeline.schedule_activate!(session, force: true)
end
def worker_id
ENV.fetch("HOSTNAME", "worker")
end end
def log_file(session) def log_file(session)

View File

@@ -25,8 +25,10 @@ module Webhooks
def handle_connect(session) def handle_connect(session)
return if session.paused? return if session.paused?
Streams::OverlayRelay.ensure_publishing!(session) if Mediamtx::PublisherOnline.active?(session) && session.platform == "youtube"
Streams::YoutubeRelay.ensure_publishing!(session) if session.platform == "youtube" Mediamtx::PublisherSync.schedule_youtube_pipeline!(session)
end
Streams::YoutubeRelay.stop(session) if session.platform == "youtube"
return if session.live? && session.stream_events.where(event_type: "connected").exists? return if session.live? && session.stream_events.where(event_type: "connected").exists?
@@ -112,7 +114,6 @@ module Webhooks
def broadcast(session, event) def broadcast(session, event)
SessionChannel.broadcast_message(session, { type: "stream_event", event: event }) SessionChannel.broadcast_message(session, { type: "stream_event", event: event })
Streams::Overlay::Refresh.call(session)
end end
end end
end end

View File

@@ -0,0 +1,28 @@
module Youtube
# Evita tempeste di chiamate YouTube Data API (quota / userRequestsExceedRateLimit).
class ApiThrottle
KEY = "youtube_api:rate_limited_until"
class << self
def rate_limited?
until_ts = redis.get(KEY).to_i
until_ts.positive? && until_ts > Time.now.to_i
end
def mark_rate_limited!(seconds: 120)
redis.set(KEY, (Time.now.to_i + seconds), ex: seconds + 30)
end
def rate_limit_error?(message)
msg = message.to_s
msg.include?("userRequestsExceedRateLimit") || msg.include?("rate limit")
end
private
def redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
end
end
end
end

View File

@@ -47,14 +47,16 @@ module Youtube
rtmp_url: stream_result.cdn.ingestion_info.ingestion_address rtmp_url: stream_result.cdn.ingestion_info.ingestion_address
} }
rescue Google::Apis::Error => e rescue Google::Apis::Error => e
ApiThrottle.mark_rate_limited! if ApiThrottle.rate_limit_error?(e.message)
raise Error, friendly_api_error(e) raise Error, friendly_api_error(e)
end end
# Porta in onda appena cè ingest su YouTube (non attendere scheduledStartTime). # Porta in onda appena cè ingest su YouTube (non attendere scheduledStartTime).
# @return [Symbol] :live, :waiting_ingest, :transitioned, :skipped # @return [Symbol] :live, :waiting_ingest, :transitioned, :skipped, :rate_limited
def activate_broadcast!(broadcast_id) def activate_broadcast!(broadcast_id)
return :skipped if broadcast_id.blank? return :skipped if broadcast_id.blank?
return :skipped if @credential.blank? || missing_oauth_config? return :skipped if @credential.blank? || missing_oauth_config?
return :rate_limited if ApiThrottle.rate_limited?
client = authorized_client client = authorized_client
item = client.list_live_broadcasts("status,contentDetails", id: broadcast_id).items&.first item = client.list_live_broadcasts("status,contentDetails", id: broadcast_id).items&.first
@@ -68,7 +70,11 @@ module Youtube
if stream_id.present? if stream_id.present?
stream = client.list_live_streams("status", id: stream_id).items&.first stream = client.list_live_streams("status", id: stream_id).items&.first
stream_status = stream&.status&.stream_status.to_s stream_status = stream&.status&.stream_status.to_s
Rails.logger.info("[Youtube::BroadcastService] activate #{broadcast_id}: lifecycle=#{status} stream_status=#{stream_status.presence || "(blank)"}")
return :waiting_ingest unless stream_status == "active" return :waiting_ingest unless stream_status == "active"
else
Rails.logger.info("[Youtube::BroadcastService] activate #{broadcast_id}: lifecycle=#{status} stream_status=(unbound)")
return :waiting_ingest
end end
if %w[ready created].include?(status) if %w[ready created].include?(status)
@@ -82,6 +88,11 @@ module Youtube
:skipped :skipped
rescue Google::Apis::Error => e rescue Google::Apis::Error => e
if ApiThrottle.rate_limit_error?(e.message)
ApiThrottle.mark_rate_limited!
Rails.logger.warn("[Youtube::BroadcastService] activate rate limited #{broadcast_id}")
return :rate_limited
end
Rails.logger.warn("[Youtube::BroadcastService] activate #{broadcast_id}: #{e.message}") Rails.logger.warn("[Youtube::BroadcastService] activate #{broadcast_id}: #{e.message}")
:skipped :skipped
end end
@@ -133,6 +144,8 @@ module Youtube
return "Orario programmazione YouTube non valido. Riprova: la diretta partirà appena invii il segnale RTMP." return "Orario programmazione YouTube non valido. Riprova: la diretta partirà appena invii il segnale RTMP."
end end
return "YouTube temporaneamente occupato. Riprova tra 12 minuti." if ApiThrottle.rate_limit_error?(msg)
msg msg
end end

View File

@@ -0,0 +1,107 @@
module Youtube
# Broadcast YouTube (API) + relay ffmpeg copy da MediaMTX → RTMPS. Nessun overlay.
class LivePipeline
PIPELINE_DEBOUNCE_KEY = "youtube_pipeline:debounce:%s"
ACTIVATE_DEBOUNCE_KEY = "youtube_activate:debounce:%s"
SETUP_DEBOUNCE_KEY = "youtube_setup:debounce:%s"
PUBLISHER_SEEN_KEY = "publisher_online:seen:%s"
class << self
def schedule!(session, force: false)
return unless pipeline_eligible?(session)
ensure_broadcast_setup!(session) unless broadcast_ready?(session)
return unless force || redis.set(format(PIPELINE_DEBOUNCE_KEY, session.id), "1", nx: true, ex: 8)
Streams::YoutubeRelay.ensure_publishing!(session) if broadcast_ready?(session)
if broadcast_ready?(session)
YoutubeIngestWatchdogJob.schedule_chain(session.id)
schedule_activate!(session, force: true, wait: 20.seconds)
end
Rails.logger.info("[Youtube::LivePipeline] scheduled session=#{session.id} broadcast_ready=#{broadcast_ready?(session)}")
end
def schedule_activate!(session, force: false, wait: 8.seconds)
return unless pipeline_eligible?(session)
return unless broadcast_ready?(session)
debounce_ok = force || redis.set(format(ACTIVATE_DEBOUNCE_KEY, session.id), "1", nx: true, ex: 12)
return unless debounce_ok
YoutubeBroadcastActivateJob.set(wait: wait).perform_later(session.id, 1)
end
def ensure_broadcast_setup!(session)
return unless pipeline_eligible?(session)
return if broadcast_ready?(session)
return if redis.exists?(format(YoutubeBroadcastSetupJob::RUN_LOCK_KEY, session.id)).positive?
return unless redis.set(format(SETUP_DEBOUNCE_KEY, session.id), "1", nx: true, ex: 90)
YoutubeBroadcastSetupJob.perform_later(session.id)
Rails.logger.info("[Youtube::LivePipeline] queued YoutubeBroadcastSetupJob session=#{session.id}")
end
def tick_session!(session)
return unless pipeline_eligible?(session)
ensure_broadcast_setup!(session) unless broadcast_ready?(session)
online = Mediamtx::PublisherOnline.active?(session)
seen_key = format(PUBLISHER_SEEN_KEY, session.id)
was_online = redis.get(seen_key) == "1"
redis.set(seen_key, online ? "1" : "0", ex: 4.hours.to_i)
unless online
Streams::YoutubeRelay.ensure_publishing!(session) if broadcast_ready?(session)
return
end
if session.paused?
Streams::YoutubeRelay.ensure_publishing!(session) if broadcast_ready?(session)
return
end
Mediamtx::Client.new.set_always_available(session, enabled: false)
session.go_live! if session.may_go_live?
session.reconnect! if session.reconnecting? && session.may_reconnect?
if !was_online
Rails.logger.info("[Youtube::LivePipeline] publisher online session=#{session.id}")
schedule!(session, force: true)
return
end
if Streams::YoutubeRelay.running?(session.id)
schedule_activate!(session)
else
schedule!(session)
end
rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Youtube::LivePipeline] tick session=#{session.id}: #{e.message}")
end
def pipeline_eligible?(session)
session.present? &&
session.platform == "youtube" &&
!session.terminal? &&
session.status.in?(%w[live connecting reconnecting paused])
end
def broadcast_ready?(session)
session.youtube_broadcast_id.present? && session.stream_key.present?
end
def eligible?(session)
pipeline_eligible?(session) && broadcast_ready?(session)
end
private
def redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
end
end
end
end

View File

@@ -36,7 +36,7 @@ module Youtube
@session.reload @session.reload
return @session if @session.terminal? return @session if @session.terminal?
return @session if @session.platform != "youtube" return @session if @session.platform != "youtube"
return @session if @session.youtube_broadcast_id.present? return @session if @session.youtube_broadcast_id.present? && @session.stream_key.present?
yt = BroadcastService.new(@session.match.team, youtube_channel: @youtube_channel) yt = BroadcastService.new(@session.match.team, youtube_channel: @youtube_channel)
title = "#{@session.match.team.name} vs #{@session.match.opponent_name}" title = "#{@session.match.team.name} vs #{@session.match.opponent_name}"
@@ -53,10 +53,7 @@ module Youtube
rtmp_url: broadcast[:rtmp_url] rtmp_url: broadcast[:rtmp_url]
) )
# Relay solo quando _air pubblica (overlay pronto); altrimenti ffmpeg esce subito. # Relay copy MediaMTX → YouTube parte subito (copertina slate se il telefono non c'è ancora).
if Streams::OverlayRelay.running?(@session.id)
Streams::YoutubeRelay.start(@session)
end
@session.stream_events.create!( @session.stream_events.create!(
event_type: "youtube_ready", event_type: "youtube_ready",
metadata: { broadcast_id: broadcast[:broadcast_id] }, metadata: { broadcast_id: broadcast[:broadcast_id] },
@@ -71,6 +68,7 @@ module Youtube
youtube_broadcast_id: @session.youtube_broadcast_id youtube_broadcast_id: @session.youtube_broadcast_id
} }
) )
Youtube::LivePipeline.schedule!(@session, force: true)
@session @session
rescue BroadcastService::Error => e rescue BroadcastService::Error => e
@session.stream_events.create!( @session.stream_events.create!(

View File

@@ -61,7 +61,7 @@
<div class="live-grid"> <div class="live-grid">
<% @sessions.each do |session| %> <% @sessions.each do |session| %>
<% match = session.match %> <% match = session.match %>
<% on_air = @online_paths.include?(session.mediamtx_path_name) %> <% on_air = @online_paths.include?(session.mediamtx_overlay_path_name) || @online_paths.include?(session.mediamtx_path_name) %>
<article class="live-card"> <article class="live-card">
<%= live_match_card_heading(match) %> <%= live_match_card_heading(match) %>
<p class="meta"> <p class="meta">
@@ -82,6 +82,8 @@
<span class="badge badge-on-air">In onda</span> <span class="badge badge-on-air">In onda</span>
<% elsif session.status == "live" %> <% elsif session.status == "live" %>
<span class="badge badge-live">Live</span> <span class="badge badge-live">Live</span>
<% elsif session.status == "paused" %>
<span class="badge badge-connecting">In pausa</span>
<% elsif session.status == "reconnecting" %> <% elsif session.status == "reconnecting" %>
<span class="badge badge-connecting">Riconnessione</span> <span class="badge badge-connecting">Riconnessione</span>
<% else %> <% else %>

View File

@@ -40,7 +40,7 @@
</div> </div>
<% else %> <% else %>
<div class="live-player-wrap"> <div class="live-player-wrap">
<video id="player" controls playsinline muted autoplay></video> <video id="player" controls playsinline muted autoplay poster="/images/copertina-canale.png"></video>
<%# Tabellone, badge stato e brand sono bruciati nel video (Streams::OverlayRelay). %> <%# Tabellone, badge stato e brand sono bruciati nel video (Streams::OverlayRelay). %>
<button type="button" id="play-hint" class="live-play-hint" hidden> <button type="button" id="play-hint" class="live-play-hint" hidden>
▶ Avvia la diretta ▶ Avvia la diretta
@@ -65,52 +65,40 @@
let wasPublisherOnline = <%= @publisher_online ? "true" : "false" %>; let wasPublisherOnline = <%= @publisher_online ? "true" : "false" %>;
let wasPaused = <%= @session.paused? ? "true" : "false" %>; let wasPaused = <%= @session.paused? ? "true" : "false" %>;
let reloadTimer = null; let reloadTimer = null;
let liveEdgeTimer = null;
let publisherStuckPolls = 0;
let didHardReloadForLive = false;
let pendingLiveRecovery = false;
let liveRecoveryAttempts = 0;
const MAX_LIVE_RECOVERY_ATTEMPTS = 10;
let streamClosed = false; let streamClosed = false;
let stallRecoveryAt = 0;
let firstLevelSynced = false;
let lastPlaybackAt = Date.now();
let lastPlaybackTime = 0;
const hlsUrlStable = hlsUrl; const hlsUrlStable = hlsUrl;
function hlsConfig() { function hlsConfig() {
return { return {
enableWorker: false, enableWorker: true,
lowLatencyMode: false, lowLatencyMode: false,
liveDurationInfinity: true, liveDurationInfinity: true,
liveSyncDurationCount: 3, liveSyncDurationCount: 5,
liveMaxLatencyDurationCount: 8, liveMaxLatencyDurationCount: 14,
liveSyncMode: "edge", maxBufferLength: 30,
maxLiveSyncPlaybackRate: 1.08, maxMaxBufferLength: 60,
maxBufferHole: 1.0, maxBufferHole: 2.5,
backBufferLength: 20, maxLiveSyncPlaybackRate: 1.0,
nudgeMaxRetry: 8, backBufferLength: 30,
nudgeMaxRetry: 12,
xhrSetup: (xhr) => { xhr.withCredentials = true; },
}; };
} }
let lastLiveSeekAt = 0; function markPlaybackProgress() {
let firstLevelSynced = false; const t = video.currentTime;
if (t > lastPlaybackTime + 0.05) {
lastPlaybackTime = t;
lastPlaybackAt = Date.now();
}
}
function jumpToLiveEdge(force = false) { function playbackStalledMs() {
if (!hls || !playerStarted) return; return Date.now() - lastPlaybackAt;
const pos = hls.liveSyncPosition;
if (pos == null || !Number.isFinite(pos)) {
ensurePlaying();
return;
}
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;
}
if (force || lag > 2) {
video.currentTime = Math.max(0, pos - 1);
lastLiveSeekAt = now;
}
ensurePlaying();
} }
function ensurePlaying() { function ensurePlaying() {
@@ -122,11 +110,19 @@
}); });
} }
function liveEdgeLagSec() { /** Evita loop: startLoad su ogni waiting resetta il buffer e blocca HLS.js. */
if (!hls || !playerStarted) return 0; function nudgePlayback(force) {
const pos = hls.liveSyncPosition; if (!hls || !playerStarted || streamClosed) return;
if (pos == null || !Number.isFinite(pos)) return 0; const now = Date.now();
return Math.max(0, pos - video.currentTime); if (!force && playbackStalledMs() < 8000) {
ensurePlaying();
return;
}
if (now - stallRecoveryAt < 5000) return;
stallRecoveryAt = now;
lastPlaybackAt = now;
hls.startLoad(-1);
ensurePlaying();
} }
function showStreamEnded() { function showStreamEnded() {
@@ -159,103 +155,59 @@
video.load(); 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) { function bindHlsEvents(instance) {
instance.on(Hls.Events.MANIFEST_PARSED, () => ensurePlaying()); instance.on(Hls.Events.MANIFEST_PARSED, () => {
instance.on(Hls.Events.LEVEL_LOADED, () => { firstLevelSynced = true;
if (!firstLevelSynced) { ensurePlaying();
firstLevelSynced = true;
jumpToLiveEdge(true);
}
}); });
instance.on(Hls.Events.LEVEL_LOADED, () => ensurePlaying());
instance.on(Hls.Events.FRAG_BUFFERED, () => markPlaybackProgress());
instance.on(Hls.Events.ERROR, (_, data) => { instance.on(Hls.Events.ERROR, (_, data) => {
if (!data.fatal) return; if (!data.fatal) return;
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
instance.startLoad(-1); window.setTimeout(() => {
ensurePlaying(); if (!streamClosed && playerStarted) {
instance.startLoad(-1);
ensurePlaying();
}
}, 1500);
return; return;
} }
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
instance.recoverMediaError(); instance.recoverMediaError();
return; return;
} }
scheduleReload(5000); hardReloadPlayer();
}); });
} }
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() { function onPublisherBack() {
pendingLiveRecovery = true; nudgePlayback();
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() { function onResumeFromPause() {
pendingLiveRecovery = true; hardReloadPlayer();
didHardReloadForLive = false;
publisherStuckPolls = 0;
liveRecoveryAttempts = 0;
tryEscapeCoverBuffer();
} }
/** Ultima risorsa: nuova playlist quando il buffer resta sulla copertina con RTMP attivo. */ function onEnterPause() {
function hardReloadLiveOnce() { nudgePlayback();
if (didHardReloadForLive || !Hls.isSupported()) return; }
didHardReloadForLive = true;
function hardReloadPlayer() {
if (!Hls.isSupported()) return;
firstLevelSynced = false;
if (hls) { if (hls) {
hls.destroy(); hls.destroy();
hls = null; hls = null;
} }
const url = `${hlsUrlStable}${hlsUrlStable.includes("?") ? "&" : "?"}_live=${Date.now()}`; const url = `${hlsUrlStable}${hlsUrlStable.includes("?") ? "&" : "?"}_t=${Date.now()}`;
hls = new Hls(hlsConfig()); hls = new Hls(hlsConfig());
hls.loadSource(url); hls.loadSource(url);
hls.attachMedia(video); hls.attachMedia(video);
bindHlsEvents(hls); bindHlsEvents(hls);
hls.startLoad(-1); hls.startLoad(-1);
video.play().catch(() => {});
playerStarted = true; playerStarted = true;
ensurePlaying();
} }
/** Un solo attach HLS per sessione: MediaMTX concatena slate/camera sullo stesso path. */ /** Un solo attach HLS per sessione: MediaMTX concatena slate/camera sullo stesso path. */
@@ -296,53 +248,25 @@
} }
const onAir = !!data.on_air; const onAir = !!data.on_air;
const sessionLive = !!data.live;
if (onAir) { if (onAir || sessionLive) {
offlineMsg.hidden = true; offlineMsg.hidden = true;
if (!publisherOnline && showingCover && !paused) { if (!publisherOnline && showingCover && !paused) {
awaitingMsg.hidden = false; awaitingMsg.hidden = false;
awaitingMsg.textContent = "In attesa del segnale dal telefono — riprendi la diretta dall'app."; awaitingMsg.textContent = "Copertina in onda — in attesa del segnale dal telefono.";
} else { } else {
awaitingMsg.hidden = !awaitingSignal; awaitingMsg.hidden = !awaitingSignal;
} }
pausedMsg.hidden = !paused; pausedMsg.hidden = !paused;
if (!playerStarted) startPlayer(); if (!playerStarted) startPlayer();
if (paused) { if (paused && !wasPaused) onEnterPause();
pendingLiveRecovery = false; if (!paused && wasPaused && playerStarted) onResumeFromPause();
liveRecoveryAttempts = 0; if (publisherOnline && !wasPublisherOnline) onPublisherBack();
} else if (wasPaused) {
pendingLiveRecovery = true;
}
if (publisherOnline && !wasPublisherOnline) { if (video.paused) ensurePlaying();
onPublisherBack(); else if (playbackStalledMs() > 12000) nudgePlayback(true);
} 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 { } else {
offlineMsg.hidden = false; offlineMsg.hidden = false;
awaitingMsg.hidden = true; awaitingMsg.hidden = true;
@@ -358,45 +282,30 @@
} }
if (playHint) { if (playHint) {
playHint.addEventListener("click", () => { playHint.addEventListener("click", () => ensurePlaying());
jumpToLiveEdge();
ensurePlaying();
});
} }
video.addEventListener("pause", () => { video.addEventListener("pause", () => {
if (!streamClosed && playerStarted && !video.ended) { if (streamClosed || !playerStarted || video.ended || wasPaused) return;
if (playHint) playHint.hidden = false; if (playHint) playHint.hidden = false;
}
}); });
video.addEventListener("timeupdate", markPlaybackProgress);
video.addEventListener("playing", () => { video.addEventListener("playing", () => {
markPlaybackProgress();
if (playHint) playHint.hidden = true; if (playHint) playHint.hidden = true;
}); });
if (!streamClosed) { if (!streamClosed) {
startPlayer(); startPlayer();
if (!wasOnAir) offlineMsg.hidden = false; offlineMsg.hidden = wasOnAir;
window.setTimeout(() => { window.setTimeout(() => {
if (playerStarted && video.readyState < 2 && !didHardReloadForLive) { if (playerStarted && !firstLevelSynced) hardReloadPlayer();
hardReloadLiveOnce(); }, 12000);
}
}, 4500);
} else { } else {
offlineMsg.hidden = true; offlineMsg.hidden = true;
} }
pollStatus(); pollStatus();
setInterval(pollStatus, 1500); setInterval(pollStatus, 2000);
setInterval(() => {
if (!playerStarted || streamClosed || !pendingLiveRecovery) return;
if (!isCaughtUpWithLive()) tryEscapeCoverBuffer();
}, 6000);
if (!streamClosed && wasPublisherOnline && !wasPaused) {
pendingLiveRecovery = true;
window.setTimeout(() => {
if (playerStarted && !isCaughtUpWithLive()) tryEscapeCoverBuffer();
}, 2500);
}
</script> </script>
<% end %> <% end %>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

View File

@@ -12,7 +12,9 @@ Rails.application.configure do
config.force_ssl = true config.force_ssl = true
config.ssl_options = { config.ssl_options = {
redirect: { redirect: {
exclude: ->(request) { request.path == "/up" || request.path == "/cable" } exclude: ->(request) {
request.path == "/up" || request.path == "/cable" || request.path.start_with?("/hls/")
}
} }
} }
@@ -31,7 +33,9 @@ Rails.application.configure do
config.hosts = allowed_hosts if allowed_hosts.any? config.hosts = allowed_hosts if allowed_hosts.any?
config.host_authorization = { config.host_authorization = {
exclude: ->(request) { request.path == "/up" || request.path.start_with?("/cable") } exclude: ->(request) {
request.path == "/up" || request.path.start_with?("/cable") || request.path.start_with?("/hls/")
}
} }
config.logger = ActiveSupport::Logger.new(STDOUT) config.logger = ActiveSupport::Logger.new(STDOUT)

View File

@@ -2,6 +2,10 @@ require "sidekiq/api"
Sidekiq.configure_server do |config| Sidekiq.configure_server do |config|
config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") } config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
config.on(:startup) do
StreamPublisherSyncJob.ensure_chain
end
end end
Sidekiq.configure_client do |config| Sidekiq.configure_client do |config|

View File

@@ -0,0 +1,169 @@
namespace :youtube do
desc "Test pipeline YouTube (setup → RTMP test → relay copy → activate). SESSION_ID= opzionale"
task e2e: :environment do
abort "YOUTUBE_CLIENT_ID mancante" if ENV["YOUTUBE_CLIENT_ID"].blank?
session = resolve_e2e_session!
puts "Sessione: #{session.id} (#{session.match.team.name} vs #{session.match.opponent_name})"
puts "Stato iniziale: #{session.status} broadcast=#{session.youtube_broadcast_id.present?} key=#{session.stream_key.present?}"
if Youtube::ApiThrottle.rate_limited?
puts "ATTENZIONE: rate limit attivo, attendo 120s..."
sleep 120
clear_youtube_throttle!
end
unless session.youtube_broadcast_id.present? && session.stream_key.present?
puts "→ Setup broadcast YouTube..."
setup_with_retries!(session)
session.reload
abort "Setup fallito: #{session.youtube_broadcast_id.inspect} / key=#{session.stream_key.present?}" unless session.stream_key.present?
end
puts "→ Watch URL: #{session.youtube_watch_url}"
session.begin_connect! if session.may_begin_connect?
session.update!(status: "connecting") unless session.status.in?(%w[connecting live reconnecting])
puts "→ Simulo telefono (RTMP test pattern 60s)..."
push_test_rtmp!(session, duration: 60)
wait_for("publisher video su MediaMTX", 45) { Mediamtx::PublisherOnline.video_publishing?(session.reload) }
Youtube::LivePipeline.schedule!(session, force: true)
wait_for("relay YouTube attivo", 90) { Streams::YoutubeRelay.running?(session.id) }
wait_for("ingest YouTube active", 120) { youtube_ingest_active?(session) }
Youtube::LivePipeline.schedule_activate!(session, force: true, wait: 5.seconds)
result = nil
wait_for("broadcast live", 180) do
result = Youtube::BroadcastService.new(session.match.team).activate_broadcast!(session.youtube_broadcast_id)
%i[live transitioned].include?(result)
end
puts "Risultato activate: #{result}"
puts "Ingest: #{ingest_status(session)}"
puts "Relay: #{relay_summary(session)}"
puts session.terminal? ? "Sessione terminata." : "OK — YouTube verificato."
end
desc "Termina sessioni YouTube di test più vecchie di 2h"
task cleanup_test: :environment do
old = StreamSession.where(platform: "youtube", status: %w[connecting live reconnecting paused idle])
.where("created_at < ?", 2.hours.ago)
old.find_each do |s|
Sessions::Stop.new(s).call
puts "ended #{s.id}"
rescue StandardError => e
puts "skip #{s.id}: #{e.message}"
end
end
def clear_youtube_throttle!
Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")).del(Youtube::ApiThrottle::KEY)
end
def setup_with_retries!(session, max_attempts: 10)
max_attempts.times do |i|
clear_youtube_throttle!
Youtube::SetupBroadcast.call(session)
session.reload
return if session.stream_key.present?
rescue Youtube::SetupBroadcast::Error => e
raise unless e.message.include?("temporaneamente") || e.message.include?("rate limit")
raise if i >= max_attempts - 1
wait = 60 + (i * 15)
puts " rate limit Google, attendo #{wait}s (tentativo #{i + 2}/#{max_attempts})..."
sleep wait
end
end
def resolve_e2e_session!
if ENV["SESSION_ID"].present?
return StreamSession.find(ENV["SESSION_ID"])
end
user = User.find_by(email: ENV.fetch("E2E_USER_EMAIL", "coach@matchlivetv.test"))
abort "Utente E2E non trovato" unless user
team = user.teams.first || Team.first
match = team.matches.order(scheduled_at: :desc).first
match ||= team.matches.create!(
opponent_name: "E2E Test #{Time.current.strftime("%H:%M")}",
scheduled_at: 5.minutes.from_now,
location: "Test automatico"
)
Sessions::Create.new(
user: user,
match: match,
params: { platform: "youtube", privacy_status: "unlisted", youtube_channel: "platform" }
).call
end
def wait_for(label, timeout_sec)
deadline = Time.now + timeout_sec
loop do
if yield
puts "#{label}"
return true
end
break if Time.now >= deadline
sleep 3
print "."
end
puts "\n ✗ timeout: #{label}"
false
end
def push_test_rtmp!(session, duration:)
intake = "rtmp://mediamtx:1935/#{session.mediamtx_path_name}"
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error",
"-re", "-f", "lavfi", "-i", "testsrc=size=1280x720:rate=30",
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000",
"-t", duration.to_s,
"-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", "-b:v", "2500k",
"-c:a", "aac", "-b:a", "128k",
"-f", "flv", intake
]
pid = Process.spawn(*cmd)
unless Process.wait2(pid)[1].success?
warn "RTMP test inviato con warning (exit non zero)"
end
end
def ingest_status(session)
client = Youtube::BroadcastService.new(session.match.team).send(:authorized_client)
b = client.list_live_broadcasts("status,contentDetails", id: session.youtube_broadcast_id).items&.first
return "n/a" unless b
sid = b.content_details.bound_stream_id
stream = client.list_live_streams("status", id: sid).items&.first
"#{b.status.life_cycle_status} / ingest=#{stream&.status&.stream_status}"
rescue StandardError => e
e.message
end
def relay_summary(session)
pid = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
.get(format(Streams::YoutubeRelay::REDIS_KEY, session.id))
pid.present? ? "pid=#{pid} (sidekiq copy)" : "non attivo"
end
def youtube_ingest_active?(session)
client = Youtube::BroadcastService.new(session.match.team).send(:authorized_client)
b = client.list_live_broadcasts("status,contentDetails", id: session.youtube_broadcast_id).items&.first
return false unless b
sid = b.content_details.bound_stream_id
stream = client.list_live_streams("status", id: sid).items&.first
stream&.status&.stream_status == "active"
rescue StandardError
false
end
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -36,6 +36,41 @@
let pendingAction = null; let pendingAction = null;
let hls = null; let hls = null;
let previewStarted = false; let previewStarted = false;
let wasPausedPreview = false;
function hlsUrlFresh() {
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
return `${cfg.hlsUrl}${sep}_t=${Date.now()}`;
}
function hidePreviewPlaceholder() {
if (els.previewPlaceholder) els.previewPlaceholder.hidden = true;
}
function showPreviewPlaceholder(text) {
if (!els.previewPlaceholder) return;
els.previewPlaceholder.textContent = text;
els.previewPlaceholder.hidden = false;
}
function destroyPreview() {
previewStarted = false;
if (hls) {
hls.destroy();
hls = null;
}
if (els.preview) {
els.preview.removeAttribute("src");
els.preview.load();
}
}
function refreshPreviewForCover() {
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed) return;
destroyPreview();
previewStarted = false;
startPreview();
}
function toast(msg) { function toast(msg) {
els.toast.textContent = msg; els.toast.textContent = msg;
@@ -169,13 +204,23 @@
return; return;
} }
const paused = data.paused || data.status === "paused";
if (data.on_air) { if (data.on_air) {
if (!previewStarted) startPreview(); if (!previewStarted) {
else if (els.preview && els.preview.readyState >= 2) hidePreviewPlaceholder(); startPreview();
} else if (paused && !wasPausedPreview) {
refreshPreviewForCover();
} else if (paused) {
if (els.preview && els.preview.readyState >= 2) hidePreviewPlaceholder();
else els.preview?.play().catch(() => {});
} else if (els.preview && els.preview.readyState >= 2) {
hidePreviewPlaceholder();
}
} else { } else {
destroyPreview(); destroyPreview();
showPreviewPlaceholder("Anteprima in attesa del segnale…"); showPreviewPlaceholder("Anteprima in attesa del segnale…");
} }
wasPausedPreview = paused;
} }
async function pollStatus() { async function pollStatus() {
@@ -203,7 +248,13 @@
const url = hlsUrlFresh(); const url = hlsUrlFresh();
if (window.Hls && Hls.isSupported()) { if (window.Hls && Hls.isSupported()) {
hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 }); hls = new Hls({
lowLatencyMode: false,
maxBufferLength: 30,
maxMaxBufferLength: 60,
liveSyncDurationCount: 5,
maxLiveSyncPlaybackRate: 1.0
});
hls.loadSource(url); hls.loadSource(url);
hls.attachMedia(els.preview); hls.attachMedia(els.preview);
hls.on(Hls.Events.MANIFEST_PARSED, () => { hls.on(Hls.Events.MANIFEST_PARSED, () => {
@@ -283,7 +334,42 @@
document.getElementById("btn-share")?.addEventListener("click", () => shareLink()); document.getElementById("btn-share")?.addEventListener("click", () => shareLink());
document.getElementById("btn-copy")?.addEventListener("click", () => copyLink()); document.getElementById("btn-copy")?.addEventListener("click", () => copyLink());
function bindPreviewStallRecovery() {
if (!els.preview) return;
let stallAt = 0;
let lastProgressAt = Date.now();
let lastTime = 0;
const markProgress = () => {
const t = els.preview.currentTime;
if (t > lastTime + 0.05) {
lastTime = t;
lastProgressAt = Date.now();
}
};
els.preview.addEventListener("timeupdate", markProgress);
els.preview.addEventListener("playing", markProgress);
const nudge = (force) => {
if (!previewStarted || cfg.streamClosed) return;
const now = Date.now();
if (!force && now - lastProgressAt < 8000) {
els.preview.play().catch(() => {});
return;
}
if (now - stallAt < 5000) return;
stallAt = now;
lastProgressAt = now;
if (hls) {
hls.startLoad(-1);
els.preview.play().catch(() => {});
}
};
setInterval(() => {
if (previewStarted && !cfg.streamClosed && Date.now() - lastProgressAt > 12000) nudge(true);
}, 4000);
}
bindPreviewEvents(); bindPreviewEvents();
bindPreviewStallRecovery();
if (!cfg.streamClosed && cfg.hlsUrl) { if (!cfg.streamClosed && cfg.hlsUrl) {
pollStatus(); pollStatus();
} else if (cfg.streamClosed) { } else if (cfg.streamClosed) {

View File

@@ -47,7 +47,7 @@ Poi tocca la card o conferma dal foglio: si apre il wizard (Partita → Trasmiss
1. Nel wizard, seleziona **YouTube Live** (se il piano lo consente e il canale è pronto) 1. Nel wizard, seleziona **YouTube Live** (se il piano lo consente e il canale è pronto)
2. Completa i passi → **Camera** 2. Completa i passi → **Camera**
3. Il telefono invia RTMP a MediaMTX; il server inoltra su YouTube 3. Il telefono invia RTMP a MediaMTX; il server avvia **automaticamente** overlay (tabellone + logo) e RTMPS verso YouTube — nessun intervento manuale
## Sviluppo locale ## Sviluppo locale
@@ -55,6 +55,10 @@ Poi tocca la card o conferma dal foglio: si apre il wizard (Partita → Trasmiss
- Invito deep link: `matchlivetv://join/TOKEN` oppure incolla token aprendo `/login?invite=TOKEN` - Invito deep link: `matchlivetv://join/TOKEN` oppure incolla token aprendo `/login?invite=TOKEN`
- OAuth: utente in **Utenti di test** Google Cloud - OAuth: utente in **Utenti di test** Google Cloud
## Checklist test (dopo aggiornamento server/app)
Vedi [YOUTUBE_MORNING_CHECKLIST.md](./YOUTUBE_MORNING_CHECKLIST.md) — APK **1.2.10+13**, test server `bin/rails youtube:e2e`.
## Troubleshooting ## Troubleshooting
| Problema | Soluzione | | Problema | Soluzione |
@@ -63,6 +67,9 @@ Poi tocca la card o conferma dal foglio: si apre il wizard (Partita → Trasmiss
| YouTube disabilitato | Premium Light o Full | | YouTube disabilitato | Premium Light o Full |
| YouTube non selezionabile | Token canale Match Live TV sul server (`admin` → YouTube piattaforma) | | YouTube non selezionabile | Token canale Match Live TV sul server (`admin` → YouTube piattaforma) |
| OAuth «app non verificata» | Utente di test Google + Avanzate | | OAuth «app non verificata» | Utente di test Google + Avanzate |
| Live non su YouTube | Verifica relay ffmpeg / `stream_key` in sessione | | Live non su YouTube | Pipeline automatica (`Youtube::LivePipeline`): overlay → ingest active → activate. Controlla `docker logs infra-sidekiq-1` per `[OverlayRelay] started` e `[YoutubeBroadcastActivate]`. |
| YouTube «tra X minuti» / COPERTINA | Relay ffmpeg verso YouTube non attivo o ingest `inactive`. Dopo deploy, riavvia la diretta dallapp; la live dovrebbe partire entro ~30s con segnale su `_air`. | | YouTube «In programma» / copertina | Nessun intervento manuale: entro ~2090s il server avvia overlay (tee RTMPS) e attiva la broadcast quando ingest è `active`. |
| YouTube resta in «waiting» | Verifica su server `log/youtube_relay_<session_id>.log` nel container **sidekiq**; attendi che `streamStatus` diventi `active` (il job ritenta lattivazione per ~6 min). | | YouTube resta in «waiting» | `log/overlay_relay_<session_id>.log` in **sidekiq**; poll ogni 5s (`StreamPublisherSyncJob`). Attivazione ritenta ~6 min. |
| Banner «sync limitata: Cannot add event after closing» | APK aggiornato: il wizard non apre un secondo WebSocket `controller` dopo la camera. |
| Timer «IN DIRETTA» sbagliato (es. 10:33 subito) | `started_at` arriva dal server al primo frame RTMP; fino ad allora il timer resta a 0. |
| App 0 Mbps / 0 fps ma LIVE | RTMP non pubblica: controlla permessi camera, messaggio «Connessione RTMP»; relay YouTube non parte finché il telefono non è online su MediaMTX. |

View File

@@ -0,0 +1,56 @@
# YouTube Live — verificato ✅ (5 giu 2026)
## Test completato automaticamente
Pipeline end-to-end **funzionante** sul server di produzione:
| Step | Esito |
|------|--------|
| Setup broadcast + stream_key | ✅ |
| Overlay ffmpeg con tee RTMPS YouTube | ✅ |
| Copertina / tabellone su YouTube | ✅ |
| RTMP simulato (test pattern) | ✅ |
| Broadcast `lifecycle=live` | ✅ |
**Prova visiva:** https://www.youtube.com/watch?v=0WmCsW_Luik
(Titolo: *Tigers Volley vs Avversario prova* — sessione di test, ora terminata)
## APK da installare sul telefono
```bash
# Sul PC (già compilato):
mobile/build/app/outputs/flutter-apk/app-release.apk
# Versione: 1.2.10+13 — API https://www.matchlivetv.it
```
Installa sul Redmi (sostituisce versioni precedenti).
## Avvio diretta dallapp (2 minuti)
1. Partita → **YouTube Live** → wizard → **Camera**
2. Attendi sul passo rete che compaia il link YouTube (poll automatico, fino a 3 min)
3. Avvia: entro ~90s su YouTube compare **copertina**, poi video con tabellone quando il telefono pubblica RTMP
## Test server senza telefono
```bash
ssh eminux@192.168.1.146
docker exec infra-rails-1 bin/rails youtube:e2e
docker exec infra-sidekiq-1 pgrep -a ffmpeg # deve contenere rtmps://...youtube.com/live2
```
## Se qualcosa non va
| Sintomo | Cosa fare |
|---------|-----------|
| «YouTube temporaneamente occupato» | Attendi 1520 min (rate limit Google). **Non** creare molte sessioni di fila. |
| Pagina YouTube vuota | `docker logs infra-sidekiq-1 \| grep OverlayRelay` — manca `stream_key` o tee RTMPS |
| App errore avvio diretta | APK 1.2.10+13; controlla rete verso matchlivetv.it |
## Fix deployati (server)
- Retry setup broadcast su rate limit (fino a 15 tentativi)
- Un solo job setup per sessione (lock Redis)
- Throttle API YouTube globale
- Riavvio overlay quando arriva `stream_key` dopo start senza tee
- Activate solo con overlay attivo + broadcast pronta

View File

@@ -142,8 +142,20 @@ services:
YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-} YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-}
YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-} YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-}
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-} YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
YOUTUBE_RELAY_WORKER: "1"
RAILS_INTERNAL_URL: http://rails:3000
MEDIAMTX_HLS_URL: http://mediamtx:8888
MEDIAMTX_INTERNAL_RTMP_URL: rtmp://mediamtx:1935
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888} HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000} APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlive.it>}
SMTP_ADDRESS: ${SMTP_ADDRESS:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_AUTH: ${SMTP_AUTH:-plain}
SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
RAILS_LOG_LEVEL: ${RAILS_LOG_LEVEL:-info}
RECORDINGS_PATH: /recordings RECORDINGS_PATH: /recordings
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-} REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-}
REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays} REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}
@@ -151,7 +163,7 @@ services:
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-} REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-} REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true} REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
OVERLAY_RELAY_PROCESS_CHECK: "false" OVERLAY_RELAY_PROCESS_CHECK: "true"
depends_on: depends_on:
rails: rails:
condition: service_healthy condition: service_healthy

View File

@@ -22,6 +22,8 @@ rtmpAddress: :1935
hls: yes hls: yes
hlsAddress: :8888 hlsAddress: :8888
hlsVariant: mpegts hlsVariant: mpegts
rtsp: yes
rtspAddress: :8554
# Allineato a slate (1s) e camera; evita salti 1s→6s che glitchano HLS.js # Allineato a slate (1s) e camera; evita salti 1s→6s che glitchano HLS.js
hlsSegmentDuration: 1s hlsSegmentDuration: 1s
hlsSegmentMaxSize: 50M hlsSegmentMaxSize: 50M

View File

@@ -1,11 +1,17 @@
package com.matchlivetv.match_live_tv package com.matchlivetv.match_live_tv
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterFragmentActivity() { class MainActivity : FlutterFragmentActivity() {
override fun onConfigurationChanged(newConfig: Configuration) {
StreamingEngineHolder.engine?.pauseGlDuringRotation()
super.onConfigurationChanged(newConfig)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Termina eventuale diretta rimasta attiva da sessione precedente // Termina eventuale diretta rimasta attiva da sessione precedente

View File

@@ -6,10 +6,12 @@ import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.SystemClock import android.os.SystemClock
import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.pedro.common.ConnectChecker import com.pedro.common.ConnectChecker
import com.pedro.library.generic.GenericStream import com.pedro.library.generic.GenericStream
import com.pedro.library.util.BitrateAdapter import com.pedro.library.util.BitrateAdapter
import com.pedro.library.util.SensorRotationManager
import com.pedro.library.view.OpenGlView import com.pedro.library.view.OpenGlView
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
@@ -18,10 +20,11 @@ data class StreamingConfig(
val rtmpUrl: String, val rtmpUrl: String,
val width: Int = 1280, val width: Int = 1280,
val height: Int = 720, val height: Int = 720,
val videoBitrate: Int = 2_500_000, val videoBitrate: Int = 3_000_000,
val audioBitrate: Int = 128_000, val audioBitrate: Int = 128_000,
val fps: Int = 30, val fps: Int = 30,
val rotation: Int = 0, val rotation: Int = 0,
val portrait: Boolean = false,
// Allineato a MediaMTX alwaysAvailable slate (offline.mp4: 48 kHz mono) // Allineato a MediaMTX alwaysAvailable slate (offline.mp4: 48 kHz mono)
val sampleRate: Int = 48_000, val sampleRate: Int = 48_000,
val stereo: Boolean = false, val stereo: Boolean = false,
@@ -88,14 +91,55 @@ class StreamingEngine(
private var lastError: String? = null private var lastError: String? = null
private var lastVideoFrameCount: Long = 0L private var lastVideoFrameCount: Long = 0L
private var lastFpsSampleAtMs: Long = 0L private var lastFpsSampleAtMs: Long = 0L
private var videoStallChecks: Int = 0
private var lastStallFrameCount: Long = -1L
private val reconnectAttempts = AtomicInteger(0) private val reconnectAttempts = AtomicInteger(0)
private val isReleased = AtomicBoolean(false) private val isReleased = AtomicBoolean(false)
private val pausingIntentionally = AtomicBoolean(false) private val pausingIntentionally = AtomicBoolean(false)
private var previewRebindUntilMs = 0L
private var previewRebindAttempts = 0
private var sensorRotationManager: SensorRotationManager? = null
private var surfaceLost = false
private var rebindInProgress = false
private var pendingRotation: Int? = null
private var pendingIsPortrait: Boolean? = null
private var lastAppliedRotation = -1
private val bitrateAdapter = BitrateAdapter { adaptedBitrate -> private val bitrateAdapter = BitrateAdapter { adaptedBitrate ->
genericStream?.setVideoBitrateOnFly(adaptedBitrate) genericStream?.setVideoBitrateOnFly(adaptedBitrate)
} }
private val idlePreviewRunnable = Runnable {
val glView = previewSurface ?: return@Runnable
if (!canAttachPreview(glView)) {
if (previewRebindAttempts < PREVIEW_REBIND_MAX_ATTEMPTS) {
scheduleIdlePreviewAttach(previewRebindAttempts + 1)
}
return@Runnable
}
finishIdlePreviewBind(glView)
}
private val surfaceReadyRunnable = Runnable {
if (isReleased.get() || rebindInProgress) {
return@Runnable
}
val glView = previewSurface ?: return@Runnable
if (!canAttachPreview(glView)) {
if (previewRebindAttempts < PREVIEW_REBIND_MAX_ATTEMPTS) {
scheduleSurfaceReady(previewRebindAttempts + 1)
}
return@Runnable
}
surfaceLost = false
val stream = genericStream
if (stream != null && (stream.isStreaming || stream.isOnPreview)) {
safeReattachPreview(glView)
flushPendingOrientation()
} else {
attachPreviewToStream()
}
}
private val metricsRunnable = object : Runnable { private val metricsRunnable = object : Runnable {
override fun run() { override fun run() {
emitMetrics() emitMetrics()
@@ -117,11 +161,146 @@ class StreamingEngine(
listeners.remove(listener) listeners.remove(listener)
} }
fun setupPreviewGlView(glView: StreamingOpenGlView) {
ensureMainThread()
updatePreservePipelineFlag(glView)
glView.onPreviewSurfaceLost = { onPlatformSurfaceDestroyed() }
}
fun attachPreviewHost(glView: StreamingOpenGlView) {
ensureMainThread()
setupPreviewGlView(glView)
bindStreamPreview(glView)
}
private fun updatePreservePipelineFlag(glView: StreamingOpenGlView?) {
glView?.preservePipelineOnSurfaceLoss =
state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING
}
/** Surface OpenGL pronta (debounced). */
fun onPlatformSurfaceReady(glView: OpenGlView) {
ensureMainThread()
previewSurface = glView
StreamPreviewHolder.openGlView = glView
previewRebindAttempts = 0
mainHandler.removeCallbacks(surfaceReadyRunnable)
val live = state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING
if (live) {
scheduleSurfaceReady(0)
return
}
scheduleIdlePreviewAttach(0)
}
/**
* Chiamato da MainActivity.onConfigurationChanged *prima* di super, per fermare drawScreen
* sul thread GL encoder prima che Flutter ricrei le surface (evita GL_OUT_OF_MEMORY).
*/
fun pauseGlDuringRotation() {
ensureMainThread()
if (isReleased.get()) {
return
}
val active = state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING ||
state == StreamingState.PREVIEWING
if (!active) {
return
}
Log.i(TAG, "pauseGlDuringRotation state=$state")
beginSurfaceTransition(SURFACE_TRANSITION_GRACE_MS)
}
/** Chiamato quando Flutter distrugge la surface PlatformView durante la rotazione. */
fun onPlatformSurfaceDestroyed() {
ensureMainThread()
beginSurfaceTransition(SURFACE_TRANSITION_GRACE_MS)
}
private fun beginSurfaceTransition(graceMs: Long) {
surfaceLost = true
markPreviewRebinding(graceMs)
mainHandler.removeCallbacks(surfaceReadyRunnable)
mainHandler.removeCallbacks(idlePreviewRunnable)
mainHandler.removeCallbacks(orientationApplyRunnable)
mainHandler.removeCallbacks(postRebindOrientationRunnable)
stopPreviewGlRender()
try {
genericStream?.getGlInterface()?.setForceRender(false)
genericStream?.getGlInterface()?.deAttachPreview()
} catch (exception: Exception) {
Log.w(TAG, "beginSurfaceTransition: ${exception.message}")
}
}
private fun stopPreviewGlRender() {
listOfNotNull(previewSurface, StreamPreviewHolder.openGlView)
.distinct()
.forEach { glView ->
(glView as? StreamingOpenGlView)?.let { updatePreservePipelineFlag(it) }
try {
glView.setForceRender(false)
} catch (_: Exception) {
}
}
}
/** Solo orientamento encoder: niente rebind preview (evita crash GL in rotazione). */
fun applyOrientationFromSensor() {
ensureMainThread()
refreshOrientationSensor()
}
fun bindStreamPreview(glView: OpenGlView) { fun bindStreamPreview(glView: OpenGlView) {
ensureMainThread() ensureMainThread()
previewSurface = glView
StreamPreviewHolder.openGlView = glView
(glView as? StreamingOpenGlView)?.let { updatePreservePipelineFlag(it) }
val live = state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING
if (live) {
onPlatformSurfaceReady(glView)
return
}
bindPreviewForIdle(glView)
}
private fun finishIdlePreviewBind(glView: OpenGlView) {
val stream = genericStream ?: return
if (stream.isOnPreview) {
return
}
try {
attachPreviewToStream()
ensureOrientationSensor()
updateState(StreamingState.PREVIEWING)
startMetricsLoop()
} catch (exception: Exception) {
Log.w(TAG, "finishIdlePreviewBind failed: ${exception.message}")
if (previewRebindAttempts < PREVIEW_REBIND_MAX_ATTEMPTS) {
scheduleIdlePreviewAttach(previewRebindAttempts + 1)
}
}
}
private fun bindPreviewForIdle(glView: OpenGlView) {
val surfaceChanged = previewSurface != null && previewSurface !== glView val surfaceChanged = previewSurface != null && previewSurface !== glView
previewSurface = glView previewSurface = glView
if (state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING
) {
return
}
if (surfaceChanged) { if (surfaceChanged) {
genericStream?.let { stream -> genericStream?.let { stream ->
if (stream.isOnPreview) { if (stream.isOnPreview) {
@@ -130,14 +309,6 @@ class StreamingEngine(
} }
} }
if (state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING
) {
attachPreviewToStream(forceRebind = surfaceChanged)
return
}
val cfg = config ?: previewConfig() val cfg = config ?: previewConfig()
val stream = genericStream ?: obtainGenericStream(cfg) val stream = genericStream ?: obtainGenericStream(cfg)
genericStream = stream genericStream = stream
@@ -168,9 +339,12 @@ class StreamingEngine(
} }
} }
attachPreviewToStream() if (!canAttachPreview(glView)) {
updateState(StreamingState.PREVIEWING) Log.i(TAG, "bindPreviewForIdle waiting for surface state=$state")
startMetricsLoop() scheduleIdlePreviewAttach(0)
return
}
finishIdlePreviewBind(glView)
} }
fun unbindStreamPreview() { fun unbindStreamPreview() {
@@ -186,47 +360,22 @@ class StreamingEngine(
} }
} }
/** Ripresa dopo pausa: stop completo poi ripubblica (evita doppio start e RTMP bloccato). */ /** Ripresa dopo pausa: stesso percorso di startStream (evita RTMP solo-audio dopo reconnect). */
fun resumeStream(streamConfig: StreamingConfig) { fun resumeStream(streamConfig: StreamingConfig) {
ensureMainThread() ensureMainThread()
config = streamConfig Log.i(TAG, "resumeStream reset url=${streamConfig.rtmpUrl}")
lastError = null stopStream()
reconnectAttempts.set(0) updateState(StreamingState.IDLE)
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({ mainHandler.postDelayed({
if (!isReleased.get()) { if (!isReleased.get()) {
startStream(streamConfig) startStream(streamConfig)
} }
}, 200) }, 250)
} }
fun startStream(streamConfig: StreamingConfig) { fun startStream(streamConfig: StreamingConfig) {
ensureMainThread() ensureMainThread()
Log.i(TAG, "startStream state=$state url=${streamConfig.rtmpUrl} bitrate=${streamConfig.videoBitrate} fps=${streamConfig.fps}")
if (isReleased.get()) { if (isReleased.get()) {
notifyError("StreamingEngine rilasciato") notifyError("StreamingEngine rilasciato")
return return
@@ -279,17 +428,59 @@ class StreamingEngine(
} }
attachPreviewToStream() attachPreviewToStream()
lastAppliedRotation = -1
val portraitHint = streamConfig.portrait || streamConfig.height > streamConfig.width
val cameraRotation = if (portraitHint && streamConfig.rotation == 0) 90 else streamConfig.rotation
applyGlOrientation(
rotation = cameraRotation,
isPortrait = portraitHint,
logLabel = "startStream",
)
ensureOrientationSensor()
resetFpsTracking()
videoStallChecks = 0
lastStallFrameCount = -1L
stream.getStreamClient().setReTries(streamConfig.maxReconnectAttempts) stream.getStreamClient().setReTries(streamConfig.maxReconnectAttempts)
bitrateAdapter.setMaxBitrate(streamConfig.videoBitrate + streamConfig.audioBitrate) bitrateAdapter.setMaxBitrate(streamConfig.videoBitrate + streamConfig.audioBitrate)
streamStartedAtMs = SystemClock.elapsedRealtime() waitForPreviewThenPublish(streamConfig, attempt = 0)
updateState(StreamingState.CONNECTING) }
stream.startStream(streamConfig.rtmpUrl)
startMetricsLoop() private fun waitForPreviewThenPublish(streamConfig: StreamingConfig, attempt: Int) {
if (isReleased.get()) {
return
}
attachPreviewToStream()
val stream = genericStream
val previewReady = stream?.isOnPreview == true && previewGlView() != null
if (!previewReady) {
if (attempt >= 40) {
notifyError("Anteprima camera non pronta — impossibile inviare video")
updateState(StreamingState.ERROR, "preview_not_ready")
return
}
mainHandler.postDelayed({
waitForPreviewThenPublish(streamConfig, attempt + 1)
}, 50L)
return
}
mainHandler.postDelayed({
if (isReleased.get()) {
return@postDelayed
}
val activeStream = genericStream ?: return@postDelayed
streamStartedAtMs = SystemClock.elapsedRealtime()
updateState(StreamingState.CONNECTING)
Log.i(TAG, "publish RTMP url=${streamConfig.rtmpUrl}")
activeStream.startStream(streamConfig.rtmpUrl)
startMetricsLoop()
}, 350L)
} }
fun pauseStream() { fun pauseStream() {
ensureMainThread() ensureMainThread()
Log.i(TAG, "pauseStream state=$state")
if (state != StreamingState.STREAMING && if (state != StreamingState.STREAMING &&
state != StreamingState.CONNECTING && state != StreamingState.CONNECTING &&
state != StreamingState.RECONNECTING state != StreamingState.RECONNECTING
@@ -317,12 +508,15 @@ class StreamingEngine(
fun stopStream() { fun stopStream() {
ensureMainThread() ensureMainThread()
Log.i(TAG, "stopStream state=$state")
if (state == StreamingState.IDLE || state == StreamingState.STOPPING) { if (state == StreamingState.IDLE || state == StreamingState.STOPPING) {
return return
} }
updateState(StreamingState.STOPPING) updateState(StreamingState.STOPPING)
stopMetricsLoop() stopMetricsLoop()
stopOrientationSensor()
(previewSurface as? StreamingOpenGlView)?.preservePipelineOnSurfaceLoss = false
genericStream?.let { stream -> genericStream?.let { stream ->
if (stream.isStreaming) { if (stream.isStreaming) {
@@ -357,14 +551,20 @@ class StreamingEngine(
override fun onConnectionStarted(url: String) { override fun onConnectionStarted(url: String) {
postMain { postMain {
Log.i(TAG, "onConnectionStarted url=$url")
updateState(StreamingState.CONNECTING, url) updateState(StreamingState.CONNECTING, url)
} }
} }
override fun onConnectionSuccess() { override fun onConnectionSuccess() {
postMain { postMain {
Log.i(TAG, "onConnectionSuccess")
reconnectAttempts.set(0) reconnectAttempts.set(0)
lastError = null lastError = null
resetFpsTracking()
videoStallChecks = 0
lastStallFrameCount = genericStream?.getStreamClient()?.getSentVideoFrames() ?: 0L
(previewSurface as? StreamingOpenGlView)?.let { updatePreservePipelineFlag(it) }
updateState(StreamingState.STREAMING) updateState(StreamingState.STREAMING)
} }
} }
@@ -374,6 +574,7 @@ class StreamingEngine(
if (pausingIntentionally.get()) { if (pausingIntentionally.get()) {
return@postMain return@postMain
} }
Log.w(TAG, "onConnectionFailed reason=$reason state=$state attempts=${reconnectAttempts.get()}")
lastError = reason lastError = reason
val stream = genericStream ?: return@postMain val stream = genericStream ?: return@postMain
val currentConfig = config ?: return@postMain val currentConfig = config ?: return@postMain
@@ -409,6 +610,7 @@ class StreamingEngine(
if (pausingIntentionally.get()) { if (pausingIntentionally.get()) {
return@postMain return@postMain
} }
Log.w(TAG, "onDisconnect state=$state")
if (state == StreamingState.STREAMING || state == StreamingState.RECONNECTING) { if (state == StreamingState.STREAMING || state == StreamingState.RECONNECTING) {
updateState(StreamingState.RECONNECTING, "disconnected") updateState(StreamingState.RECONNECTING, "disconnected")
} }
@@ -417,6 +619,7 @@ class StreamingEngine(
override fun onAuthError() { override fun onAuthError() {
postMain { postMain {
Log.w(TAG, "onAuthError")
lastError = "auth_error" lastError = "auth_error"
updateState(StreamingState.ERROR, "auth_error") updateState(StreamingState.ERROR, "auth_error")
genericStream?.stopStream() genericStream?.stopStream()
@@ -426,6 +629,7 @@ class StreamingEngine(
override fun onAuthSuccess() { override fun onAuthSuccess() {
postMain { postMain {
Log.i(TAG, "onAuthSuccess")
updateState(StreamingState.STREAMING) updateState(StreamingState.STREAMING)
} }
} }
@@ -439,18 +643,237 @@ class StreamingEngine(
} }
} }
private fun previewGlView(): OpenGlView? =
previewSurface ?: StreamPreviewHolder.openGlView
private fun resetFpsTracking() {
lastFpsSampleAtMs = 0L
lastVideoFrameCount = 0L
currentFps = 0
}
private fun attachPreviewToStream(forceRebind: Boolean = false) { private fun attachPreviewToStream(forceRebind: Boolean = false) {
val glView = previewSurface ?: StreamPreviewHolder.openGlView ?: return val glView = previewGlView() ?: return
previewSurface = glView previewSurface = glView
val stream = genericStream ?: return val stream = genericStream ?: return
if (forceRebind && stream.isOnPreview) { val liveState = state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING
if (liveState && (stream.isStreaming || stream.isOnPreview)) {
safeReattachPreview(glView)
return
}
if (forceRebind && stream.isOnPreview && !liveState) {
stream.stopPreview() stream.stopPreview()
} }
if (!stream.isOnPreview) { if (!stream.isOnPreview) {
stream.startPreview(glView, autoHandle = true) if (!canAttachPreview(glView)) {
return
}
try {
stream.startPreview(glView, autoHandle = false)
ensureOrientationSensor()
} catch (exception: Exception) {
Log.w(TAG, "attachPreviewToStream failed: ${exception.message}")
}
} }
} }
private fun scheduleIdlePreviewAttach(attempt: Int) {
previewRebindAttempts = attempt
if (attempt >= PREVIEW_REBIND_MAX_ATTEMPTS) {
Log.w(TAG, "idle preview attach gave up after $attempt attempts state=$state")
return
}
val delayMs = if (attempt == 0) IDLE_PREVIEW_ATTACH_MS else PREVIEW_REBIND_RETRY_MS
mainHandler.removeCallbacks(idlePreviewRunnable)
mainHandler.postDelayed(idlePreviewRunnable, delayMs)
}
private fun scheduleSurfaceReady(attempt: Int) {
previewRebindAttempts = attempt
if (attempt >= PREVIEW_REBIND_MAX_ATTEMPTS) {
Log.w(TAG, "surface rebind gave up after $attempt attempts state=$state")
return
}
val delayMs = when {
attempt == 0 -> SURFACE_READY_DEBOUNCE_MS
else -> PREVIEW_REBIND_RETRY_MS
}
mainHandler.postDelayed(surfaceReadyRunnable, delayMs)
}
private fun markPreviewRebinding(durationMs: Long = PREVIEW_REBIND_GRACE_MS) {
previewRebindUntilMs = SystemClock.elapsedRealtime() + durationMs
videoStallChecks = 0
lastStallFrameCount = -1L
}
private fun isPreviewRebinding(): Boolean =
SystemClock.elapsedRealtime() < previewRebindUntilMs
private fun canAttachPreview(glView: OpenGlView): Boolean {
if (!glView.isAttachedToWindow) {
return false
}
return try {
val surface = glView.holder?.surface
surface != null && surface.isValid && glView.width > 0 && glView.height > 0
} catch (_: Exception) {
false
}
}
private fun safeReattachPreview(glView: OpenGlView) {
if (rebindInProgress) {
return
}
val stream = genericStream ?: return
previewSurface = glView
if (!canAttachPreview(glView)) {
scheduleSurfaceReady(previewRebindAttempts + 1)
return
}
val surface = glView.holder?.surface
if (surface == null || !surface.isValid) {
scheduleSurfaceReady(previewRebindAttempts + 1)
return
}
rebindInProgress = true
markPreviewRebinding()
try {
Log.i(
TAG,
"safeReattachPreview state=$state isPreview=${stream.isOnPreview} isStreaming=${stream.isStreaming}",
)
val glInterface = stream.getGlInterface()
val width = glView.width.coerceAtLeast(2)
val height = glView.height.coerceAtLeast(2)
glInterface.setForceRender(false)
glInterface.deAttachPreview()
glInterface.attachPreview(surface)
glInterface.setPreviewResolution(width, height)
glInterface.setForceRender(true, 30)
} catch (exception: Exception) {
Log.w(TAG, "safeReattachPreview failed: ${exception.message}")
scheduleSurfaceReady(previewRebindAttempts + 1)
} finally {
rebindInProgress = false
surfaceLost = false
finishRotationTransition()
}
}
/** Orientamento camera/preview/stream via GL (senza metadata RTMP rotation). */
private fun applyGlOrientation(
rotation: Int,
isPortrait: Boolean,
logLabel: String,
forceRender: Boolean = true,
) {
if (surfaceLost || rebindInProgress || isPreviewRebinding()) {
pendingRotation = rotation
pendingIsPortrait = isPortrait
Log.i(TAG, "$logLabel deferred rotation=$rotation portrait=$isPortrait")
return
}
val stream = genericStream ?: return
val glView = previewSurface
if (glView != null && !canAttachPreview(glView)) {
return
}
try {
val glInterface = stream.getGlInterface()
if (forceRender) {
glInterface.setForceRender(false)
}
glInterface.autoHandleOrientation = false
glInterface.setIsPortrait(isPortrait)
glInterface.setCameraOrientation(rotation)
glInterface.setPreviewRotation(0)
glInterface.setStreamRotation(0)
lastAppliedRotation = rotation
pendingIsPortrait = isPortrait
Log.i(
TAG,
"$logLabel orientation rotation=$rotation portrait=$isPortrait state=$state",
)
} catch (exception: Exception) {
Log.w(TAG, "$logLabel orientation failed: ${exception.message}")
} finally {
if (forceRender) {
try {
stream.getGlInterface().setForceRender(true, 30)
} catch (_: Exception) {
}
}
}
}
private fun ensureOrientationSensor() {
if (sensorRotationManager != null) {
return
}
sensorRotationManager = SensorRotationManager(appContext, true, true) { rotation, isPortrait ->
postMain { applySensorRotation(rotation, isPortrait) }
}
sensorRotationManager?.start()
}
private fun refreshOrientationSensor() {
sensorRotationManager?.stop()
sensorRotationManager = null
ensureOrientationSensor()
}
private fun stopOrientationSensor() {
sensorRotationManager?.stop()
sensorRotationManager = null
}
private val orientationApplyRunnable = Runnable {
val rotation = pendingRotation ?: return@Runnable
val isPortrait = pendingIsPortrait ?: return@Runnable
applySensorRotationNow(rotation, isPortrait)
}
private fun applySensorRotation(rotation: Int, isPortrait: Boolean) {
pendingRotation = rotation
pendingIsPortrait = isPortrait
mainHandler.removeCallbacks(orientationApplyRunnable)
mainHandler.postDelayed(orientationApplyRunnable, ORIENTATION_DEBOUNCE_MS)
}
private fun applySensorRotationNow(rotation: Int, isPortrait: Boolean) {
if (rotation == lastAppliedRotation && !surfaceLost) {
return
}
if (surfaceLost || rebindInProgress || isPreviewRebinding()) {
Log.i(TAG, "sensorOrientation queued rotation=$rotation portrait=$isPortrait")
return
}
applyGlOrientation(rotation, isPortrait, "sensorOrientation")
}
private val postRebindOrientationRunnable = Runnable {
val rotation = pendingRotation ?: return@Runnable
val isPortrait = pendingIsPortrait ?: return@Runnable
if (surfaceLost || rebindInProgress) {
return@Runnable
}
applyGlOrientation(rotation, isPortrait, "postRebind")
}
private fun finishRotationTransition() {
mainHandler.removeCallbacks(postRebindOrientationRunnable)
mainHandler.postDelayed(postRebindOrientationRunnable, POST_REBIND_ORIENTATION_MS)
}
private fun flushPendingOrientation() {
mainHandler.removeCallbacks(orientationApplyRunnable)
mainHandler.postDelayed(orientationApplyRunnable, ORIENTATION_DEBOUNCE_MS)
}
private fun previewConfig(): StreamingConfig { private fun previewConfig(): StreamingConfig {
return config ?: StreamingConfig( return config ?: StreamingConfig(
rtmpUrl = "rtmp://127.0.0.1/preview", rtmpUrl = "rtmp://127.0.0.1/preview",
@@ -464,7 +887,7 @@ class StreamingEngine(
appContext, appContext,
this, this,
).apply { ).apply {
getGlInterface().autoHandleOrientation = true getGlInterface().autoHandleOrientation = false
getStreamClient().setBitrateExponentialFactor(0.5f) getStreamClient().setBitrateExponentialFactor(0.5f)
getStreamClient().setReTries(streamConfig.maxReconnectAttempts) getStreamClient().setReTries(streamConfig.maxReconnectAttempts)
} }
@@ -501,10 +924,86 @@ class StreamingEngine(
private fun emitMetrics() { private fun emitMetrics() {
updateFpsEstimate() updateFpsEstimate()
checkVideoStallAndRecover()
val metrics = buildMetrics() val metrics = buildMetrics()
listeners.forEach { it.onMetricsUpdated(metrics) } listeners.forEach { it.onMetricsUpdated(metrics) }
} }
/** Se RTMP è connesso ma non partono frame video, ripubblica con preview attiva. */
private fun checkVideoStallAndRecover() {
if (isPreviewRebinding()) {
return
}
if (state != StreamingState.STREAMING && state != StreamingState.RECONNECTING) {
videoStallChecks = 0
lastStallFrameCount = -1L
return
}
val stream = genericStream ?: return
if (!stream.isStreaming) {
return
}
val frames = stream.getStreamClient().getSentVideoFrames()
if (lastStallFrameCount < 0L) {
lastStallFrameCount = frames
return
}
if (frames > lastStallFrameCount) {
videoStallChecks = 0
lastStallFrameCount = frames
return
}
videoStallChecks += 1
if (videoStallChecks < 12) {
return
}
videoStallChecks = 0
lastStallFrameCount = -1L
val cfg = config ?: return
recoverVideoStream(cfg)
}
private fun recoverVideoStream(streamConfig: StreamingConfig) {
val stream = genericStream ?: return
Log.w(TAG, "recoverVideoStream state=$state url=${streamConfig.rtmpUrl}")
pausingIntentionally.set(true)
try {
if (stream.isStreaming) {
stream.stopStream()
}
} finally {
pausingIntentionally.set(false)
}
stopPreviewAndStreamForPrepare(stream)
val prepared = try {
stream.prepareVideo(
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,
streamConfig.audioBitrate,
)
} catch (_: Exception) {
false
}
if (!prepared) {
notifyError("Ripristino video fallito")
updateState(StreamingState.ERROR, "video_recover_failed")
return
}
attachPreviewToStream()
ensureOrientationSensor()
resetFpsTracking()
waitForPreviewThenPublish(streamConfig, attempt = 0)
}
private fun updateFpsEstimate() { private fun updateFpsEstimate() {
val stream = genericStream ?: return val stream = genericStream ?: return
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
@@ -552,6 +1051,15 @@ class StreamingEngine(
} }
companion object { companion object {
private const val TAG = "StreamingEngine"
private const val METRICS_INTERVAL_MS = 1_000L private const val METRICS_INTERVAL_MS = 1_000L
private const val SURFACE_READY_DEBOUNCE_MS = 700L
private const val IDLE_PREVIEW_ATTACH_MS = 80L
private const val POST_REBIND_ORIENTATION_MS = 350L
private const val SURFACE_TRANSITION_GRACE_MS = 8_000L
private const val ORIENTATION_DEBOUNCE_MS = 700L
private const val PREVIEW_REBIND_RETRY_MS = 120L
private const val PREVIEW_REBIND_MAX_ATTEMPTS = 15
private const val PREVIEW_REBIND_GRACE_MS = 6_000L
} }
} }

View File

@@ -0,0 +1,30 @@
package com.matchlivetv.match_live_tv
import android.content.Context
import android.util.AttributeSet
import android.view.SurfaceHolder
import com.pedro.library.view.OpenGlView
/**
* OpenGlView che non distrugge encoder/GL quando la surface viene persa in rotazione.
* La SurfaceView standard di Pedro chiama stop() in surfaceDestroyed e uccide tutto il pipeline.
*/
class StreamingOpenGlView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : OpenGlView(context, attrs) {
@Volatile
var preservePipelineOnSurfaceLoss: Boolean = false
var onPreviewSurfaceLost: (() -> Unit)? = null
override fun surfaceDestroyed(holder: SurfaceHolder) {
if (preservePipelineOnSurfaceLoss && isRunning) {
setForceRender(false)
onPreviewSurfaceLost?.invoke()
return
}
super.surfaceDestroyed(holder)
}
}

View File

@@ -6,7 +6,6 @@ import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import com.pedro.library.view.OpenGlView
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -85,7 +84,20 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
binding.platformViewRegistry.registerViewFactory( binding.platformViewRegistry.registerViewFactory(
PREVIEW_VIEW_TYPE, PREVIEW_VIEW_TYPE,
StreamingPreviewFactory { mainHandlerRebindPreview() }, StreamingPreviewFactory(
onSurfaceReady = { glView ->
android.os.Handler(android.os.Looper.getMainLooper()).post {
val engine = getOrCreateEngine()
engine.setupPreviewGlView(glView)
engine.onPlatformSurfaceReady(glView)
}
},
onSurfaceDestroyed = {
android.os.Handler(android.os.Looper.getMainLooper()).post {
getEngine()?.onPlatformSurfaceDestroyed()
}
},
),
) )
ServiceEventBus.addListener(busListener) ServiceEventBus.addListener(busListener)
@@ -101,7 +113,6 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
override fun onAttachedToActivity(binding: ActivityPluginBinding) { override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding activityBinding = binding
mainHandlerRebindPreview()
} }
override fun onDetachedFromActivityForConfigChanges() { override fun onDetachedFromActivityForConfigChanges() {
@@ -110,13 +121,19 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activityBinding = binding activityBinding = binding
mainHandlerRebindPreview()
} }
private fun mainHandlerRebindPreview() { private fun attachPreviewIfPossible() {
android.os.Handler(android.os.Looper.getMainLooper()).post { val glView = StreamPreviewHolder.openGlView as? StreamingOpenGlView ?: return
attachPreviewIfPossible() val engine = getOrCreateEngine()
} engine.setupPreviewGlView(glView)
engine.bindStreamPreview(glView)
}
private fun previewIsReady(): Boolean {
val engine = getEngine() ?: return false
val metrics = engine.getMetrics()
return metrics.isPreviewActive
} }
override fun onDetachedFromActivity() { override fun onDetachedFromActivity() {
@@ -134,6 +151,7 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
"startPreview" -> startPreview(result) "startPreview" -> startPreview(result)
"stopPreview" -> stopPreview(result) "stopPreview" -> stopPreview(result)
"bindPreview" -> bindPreview(result) "bindPreview" -> bindPreview(result)
"syncOrientation" -> syncOrientation(result)
"unbindPreview" -> unbindPreview(result) "unbindPreview" -> unbindPreview(result)
else -> result.notImplemented() else -> result.notImplemented()
} }
@@ -160,14 +178,18 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
return return
} }
val argWidth = (args["width"] as? Number)?.toInt() ?: 1280
val argHeight = (args["height"] as? Number)?.toInt() ?: 720
val portrait = args["portrait"] as? Boolean ?: (argHeight > argWidth)
val config = StreamingConfig( val config = StreamingConfig(
rtmpUrl = rtmpUrl, rtmpUrl = rtmpUrl,
width = (args["width"] as? Number)?.toInt() ?: 1280, width = (args["width"] as? Number)?.toInt() ?: if (portrait) 720 else 1280,
height = (args["height"] as? Number)?.toInt() ?: 720, height = (args["height"] as? Number)?.toInt() ?: if (portrait) 1280 else 720,
videoBitrate = (args["videoBitrate"] as? Number)?.toInt() ?: 2_500_000, videoBitrate = (args["videoBitrate"] as? Number)?.toInt() ?: 2_500_000,
audioBitrate = (args["audioBitrate"] as? Number)?.toInt() ?: 128_000, audioBitrate = (args["audioBitrate"] as? Number)?.toInt() ?: 128_000,
fps = (args["fps"] as? Number)?.toInt() ?: 30, fps = (args["fps"] as? Number)?.toInt() ?: 30,
rotation = (args["rotation"] as? Number)?.toInt() ?: 0, rotation = (args["rotation"] as? Number)?.toInt() ?: 0,
portrait = portrait,
maxReconnectAttempts = (args["maxReconnectAttempts"] as? Number)?.toInt() ?: 10, maxReconnectAttempts = (args["maxReconnectAttempts"] as? Number)?.toInt() ?: 10,
reconnectDelayMs = (args["reconnectDelayMs"] as? Number)?.toLong() ?: 5_000L, reconnectDelayMs = (args["reconnectDelayMs"] as? Number)?.toLong() ?: 5_000L,
) )
@@ -208,14 +230,18 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
return return
} }
val argWidth = (args["width"] as? Number)?.toInt() ?: 1280
val argHeight = (args["height"] as? Number)?.toInt() ?: 720
val portrait = args["portrait"] as? Boolean ?: (argHeight > argWidth)
val config = StreamingConfig( val config = StreamingConfig(
rtmpUrl = rtmpUrl, rtmpUrl = rtmpUrl,
width = (args["width"] as? Number)?.toInt() ?: 1280, width = (args["width"] as? Number)?.toInt() ?: if (portrait) 720 else 1280,
height = (args["height"] as? Number)?.toInt() ?: 720, height = (args["height"] as? Number)?.toInt() ?: if (portrait) 1280 else 720,
videoBitrate = (args["videoBitrate"] as? Number)?.toInt() ?: 2_500_000, videoBitrate = (args["videoBitrate"] as? Number)?.toInt() ?: 2_500_000,
audioBitrate = (args["audioBitrate"] as? Number)?.toInt() ?: 128_000, audioBitrate = (args["audioBitrate"] as? Number)?.toInt() ?: 128_000,
fps = (args["fps"] as? Number)?.toInt() ?: 30, fps = (args["fps"] as? Number)?.toInt() ?: 30,
rotation = (args["rotation"] as? Number)?.toInt() ?: 0, rotation = (args["rotation"] as? Number)?.toInt() ?: 0,
portrait = portrait,
maxReconnectAttempts = (args["maxReconnectAttempts"] as? Number)?.toInt() ?: 10, maxReconnectAttempts = (args["maxReconnectAttempts"] as? Number)?.toInt() ?: 10,
reconnectDelayMs = (args["reconnectDelayMs"] as? Number)?.toLong() ?: 5_000L, reconnectDelayMs = (args["reconnectDelayMs"] as? Number)?.toLong() ?: 5_000L,
) )
@@ -266,7 +292,12 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
private fun bindPreview(result: Result) { private fun bindPreview(result: Result) {
attachPreviewIfPossible() attachPreviewIfPossible()
result.success(StreamPreviewHolder.openGlView != null) result.success(previewIsReady())
}
private fun syncOrientation(result: Result) {
getEngine()?.applyOrientationFromSensor()
result.success(true)
} }
private fun unbindPreview(result: Result) { private fun unbindPreview(result: Result) {
@@ -302,12 +333,6 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
StreamingEngineHolder.release() StreamingEngineHolder.release()
} }
private fun attachPreviewIfPossible() {
val glView = StreamPreviewHolder.openGlView ?: return
val engine = getOrCreateEngine()
engine.bindStreamPreview(glView)
}
private fun bindServiceIfNeeded() { private fun bindServiceIfNeeded() {
if (serviceBound) { if (serviceBound) {
return return

View File

@@ -1,42 +1,95 @@
package com.matchlivetv.match_live_tv package com.matchlivetv.match_live_tv
import android.content.Context import android.content.Context
import android.view.SurfaceHolder
import android.view.View import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import com.pedro.library.view.OpenGlView
import io.flutter.plugin.common.StandardMessageCodec import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory import io.flutter.plugin.platform.PlatformViewFactory
class StreamingPreviewPlatformView( class StreamingPreviewPlatformView(
context: Context, context: Context,
private val onPreviewReady: (OpenGlView) -> Unit, private val onSurfaceReady: (StreamingOpenGlView) -> Unit,
private val onSurfaceDestroyed: () -> Unit,
) : PlatformView { ) : PlatformView {
private val openGlView = OpenGlView(context).apply { private val openGlView = StreamingOpenGlView(context).apply {
layoutParams = FrameLayout.LayoutParams( layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT,
) )
} }
private var lastNotifiedWidth = 0
private var lastNotifiedHeight = 0
private val notifyReadyRunnable = Runnable {
if (openGlView.width <= 0 || openGlView.height <= 0) {
return@Runnable
}
val sizeChanged = openGlView.width != lastNotifiedWidth ||
openGlView.height != lastNotifiedHeight
if (!sizeChanged && lastNotifiedWidth > 0) {
return@Runnable
}
lastNotifiedWidth = openGlView.width
lastNotifiedHeight = openGlView.height
onSurfaceReady(openGlView)
}
init { init {
StreamPreviewHolder.openGlView = openGlView StreamPreviewHolder.openGlView = openGlView
onPreviewReady(openGlView) openGlView.holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
StreamPreviewHolder.openGlView = openGlView
scheduleReadyDebounced()
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
StreamPreviewHolder.openGlView = openGlView
if (width > 0 && height > 0) {
scheduleReadyDebounced()
}
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
openGlView.removeCallbacks(notifyReadyRunnable)
lastNotifiedWidth = 0
lastNotifiedHeight = 0
onSurfaceDestroyed()
}
})
}
private fun scheduleReadyDebounced() {
openGlView.removeCallbacks(notifyReadyRunnable)
openGlView.postDelayed(notifyReadyRunnable, SURFACE_NOTIFY_DEBOUNCE_MS)
} }
override fun getView(): View = openGlView override fun getView(): View = openGlView
override fun dispose() { override fun dispose() {
// Non azzerare il holder: la rotazione ricrea la PlatformView e il plugin riaggancia. openGlView.removeCallbacks(notifyReadyRunnable)
lastNotifiedWidth = 0
lastNotifiedHeight = 0
onSurfaceDestroyed()
if (StreamPreviewHolder.openGlView === openGlView) {
StreamPreviewHolder.openGlView = null
}
}
companion object {
private const val SURFACE_NOTIFY_DEBOUNCE_MS = 600L
} }
} }
class StreamingPreviewFactory( class StreamingPreviewFactory(
private val onPreviewReady: (OpenGlView) -> Unit, private val onSurfaceReady: (StreamingOpenGlView) -> Unit,
private val onSurfaceDestroyed: () -> Unit,
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) { ) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView { override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
return StreamingPreviewPlatformView(context, onPreviewReady) return StreamingPreviewPlatformView(context, onSurfaceReady, onSurfaceDestroyed)
} }
} }

View File

@@ -23,10 +23,23 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
} }
Future<void> _bootstrap() async { Future<void> _bootstrap() async {
await ref.read(authProvider.notifier).restoreSession(); final notifier = ref.read(authProvider.notifier);
await Future<void>.delayed(const Duration(milliseconds: 800)); await notifier.restoreSession();
await Future<void>.delayed(const Duration(milliseconds: 400));
if (!mounted) return;
var auth = ref.read(authProvider);
if (auth.isAuthenticated) {
final ok = await notifier.validateOrRefreshSession();
if (!mounted) return;
auth = ref.read(authProvider);
if (!ok || !auth.isAuthenticated) {
context.go('/login');
return;
}
}
if (!mounted) return; if (!mounted) return;
final auth = ref.read(authProvider);
if (auth.isAuthenticated) { if (auth.isAuthenticated) {
context.go('/matches'); context.go('/matches');
} else { } else {

View File

@@ -56,7 +56,6 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
int _viewerCount = 0; int _viewerCount = 0;
StreamSubscription<Map<String, dynamic>>? _metricsSub; StreamSubscription<Map<String, dynamic>>? _metricsSub;
Orientation? _lastOrientation;
bool _pauseInFlight = false; bool _pauseInFlight = false;
bool _resumeInFlight = false; bool _resumeInFlight = false;
String? _lastStreamErrorShown; String? _lastStreamErrorShown;
@@ -74,6 +73,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
ref.read(sessionProvider.notifier).setElapsed( ref.read(sessionProvider.notifier).setElapsed(
DateTime.now().difference(session!.startedAt!), DateTime.now().difference(session!.startedAt!),
); );
} else {
ref.read(sessionProvider.notifier).setElapsed(Duration.zero);
} }
}); });
_sessionSyncTimer = Timer.periodic(const Duration(seconds: 15), (_) { _sessionSyncTimer = Timer.periodic(const Duration(seconds: 15), (_) {
@@ -86,16 +87,13 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
}); });
} }
@override Map<String, dynamic> _streamVideoParams(BuildContext context) {
void didChangeDependencies() { final portrait = MediaQuery.orientationOf(context) == Orientation.portrait;
super.didChangeDependencies(); return {
final orientation = MediaQuery.orientationOf(context); 'width': portrait ? 720 : 1280,
if (_lastOrientation != null && _lastOrientation != orientation) { 'height': portrait ? 1280 : 720,
WidgetsBinding.instance.addPostFrameCallback((_) async { 'portrait': portrait,
await StreamingChannel.bindPreview(); };
});
}
_lastOrientation = orientation;
} }
Future<bool> _ensurePermissions() async { Future<bool> _ensurePermissions() async {
@@ -112,6 +110,15 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
return false; return false;
} }
Future<bool> _bindPreviewWithRetry({int attempts = 12}) async {
for (var i = 0; i < attempts; i++) {
final ok = await StreamingChannel.bindPreview();
if (ok) return true;
await Future<void>.delayed(const Duration(milliseconds: 100));
}
return false;
}
Future<void> _bootstrap() async { Future<void> _bootstrap() async {
try { try {
if (!await _ensurePermissions()) return; if (!await _ensurePermissions()) return;
@@ -150,7 +157,17 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
await ws.connect(sessionId: widget.sessionId, deviceRole: 'camera'); await ws.connect(sessionId: widget.sessionId, deviceRole: 'camera');
await Future<void>.delayed(const Duration(milliseconds: 300)); await Future<void>.delayed(const Duration(milliseconds: 300));
await StreamingChannel.bindPreview(); final previewReady = await _bindPreviewWithRetry();
if (!previewReady && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Anteprima camera non pronta — video non disponibile. Riapri per riprovare.'),
duration: Duration(seconds: 4),
),
);
// Non return: sessione e score sono già settati, WebSocket connesso.
// L'utente può ancora gestire il punteggio anche senza preview video.
}
final fresh = await client.fetchSession(widget.sessionId); final fresh = await client.fetchSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(fresh, match: match); ref.read(sessionProvider.notifier).setSession(fresh, match: match);
@@ -158,10 +175,13 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
ref.read(scoreProvider.notifier).reset(fresh.score!); ref.read(scoreProvider.notifier).reset(fresh.score!);
} }
if (!mounted) return;
// Avvio RTMP: wizard chiama startSession prima della camera → stato "connecting". // Avvio RTMP: wizard chiama startSession prima della camera → stato "connecting".
// In pausa all'apertura: niente auto-publish (serve "RIPRENDI DIRETTA"). // In pausa all'apertura: niente auto-publish (serve "RIPRENDI DIRETTA").
final wasPausedAtOpen = statusAtOpen == 'paused'; final wasPausedAtOpen = statusAtOpen == 'paused';
final shouldAutoPublish = fresh.rtmpIngestUrl != null && final shouldAutoPublish = previewReady &&
fresh.rtmpIngestUrl != null &&
!wasPausedAtOpen && !wasPausedAtOpen &&
fresh.status != 'paused' && fresh.status != 'paused' &&
(justStartedSession || (justStartedSession ||
@@ -174,21 +194,16 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
await StreamingChannel.stopStream(); await StreamingChannel.stopStream();
} catch (_) {} } catch (_) {}
await Future<void>.delayed(const Duration(milliseconds: 400)); await Future<void>.delayed(const Duration(milliseconds: 400));
final useFreshStart = if (!mounted) return;
justStartedSession || fresh.status == 'connecting'; final video = _streamVideoParams(context);
if (useFreshStart) { await StreamingChannel.startStream(
await StreamingChannel.startStream( rtmpUrl: fresh.rtmpIngestUrl!,
rtmpUrl: fresh.rtmpIngestUrl!, targetBitrate: fresh.targetBitrate,
targetBitrate: fresh.targetBitrate, targetFps: 30,
targetFps: 30, width: video['width'] as int,
); height: video['height'] as int,
} else { portrait: video['portrait'] as bool,
await StreamingChannel.resumeStream( );
rtmpUrl: fresh.rtmpIngestUrl!,
targetBitrate: fresh.targetBitrate,
targetFps: 30,
);
}
if (mounted && (justStartedSession || fresh.status == 'connecting')) { if (mounted && (justStartedSession || fresh.status == 'connecting')) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -415,10 +430,16 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
if (session == null || session.rtmpIngestUrl == null) return; if (session == null || session.rtmpIngestUrl == null) return;
_resumeInFlight = true; _resumeInFlight = true;
try { try {
final isPortrait = mounted &&
MediaQuery.orientationOf(context) == Orientation.portrait;
final video = _streamVideoParams(context);
await StreamingChannel.resumeStream( await StreamingChannel.resumeStream(
rtmpUrl: session.rtmpIngestUrl!, rtmpUrl: session.rtmpIngestUrl!,
targetBitrate: session.targetBitrate, targetBitrate: session.targetBitrate,
targetFps: 30, targetFps: 30,
width: video['width'] as int,
height: video['height'] as int,
portrait: video['portrait'] as bool,
); );
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -449,10 +470,15 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
ref.read(sessionProvider.notifier).setSession(session, match: match); ref.read(sessionProvider.notifier).setSession(session, match: match);
if (session.rtmpIngestUrl != null) { if (session.rtmpIngestUrl != null) {
if (!mounted) return;
final video = _streamVideoParams(context);
await StreamingChannel.resumeStream( await StreamingChannel.resumeStream(
rtmpUrl: session.rtmpIngestUrl!, rtmpUrl: session.rtmpIngestUrl!,
targetBitrate: session.targetBitrate, targetBitrate: session.targetBitrate,
targetFps: 30, targetFps: 30,
width: video['width'] as int,
height: video['height'] as int,
portrait: video['portrait'] as bool,
); );
} }
@@ -558,9 +584,9 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
MediaQuery.orientationOf(context) == Orientation.landscape; MediaQuery.orientationOf(context) == Orientation.landscape;
final rules = ref.watch(matchScoringRulesProvider); final rules = ref.watch(matchScoringRulesProvider);
final pointsTarget = rules.pointsTarget(score.currentSet); final pointsTarget = rules.pointsTarget(score.currentSet);
// Preview unica: non viene mai smontata al cambio orientamento. // Preview a tutto schermo; controlli in overlay sopra (AndroidView nel layout Flutter).
return Scaffold( return Scaffold(
backgroundColor: isLandscape ? Colors.black : AppTheme.background, backgroundColor: Colors.black,
body: Stack( body: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@@ -672,20 +698,21 @@ class _PortraitOverlay extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final inset = ScreenInsets.cameraOverlay(context); final inset = ScreenInsets.cameraOverlay(context);
final screenH = MediaQuery.sizeOf(context).height;
// Riserva almeno 35% dello schermo all'anteprima camera, il resto ai controlli.
final maxControlsH = screenH * 0.65;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Padding( // ── Barra superiore: badge live + metriche ──────────────────────────
Container(
color: Colors.black54,
padding: EdgeInsets.fromLTRB(inset.left, inset.top, inset.right, 6), padding: EdgeInsets.fromLTRB(inset.left, inset.top, inset.right, 6),
child: Row( child: Row(
children: [ children: [
LiveBadge(elapsed: elapsed, paused: isPaused), LiveBadge(elapsed: elapsed, paused: isPaused),
const Spacer(), const Spacer(),
IconButton(
onPressed: onShare,
icon: const Icon(Icons.share),
tooltip: 'Condividi link',
),
CameraCompactMetrics( CameraCompactMetrics(
networkType: networkType, networkType: networkType,
bitrateMbps: bitrateMbps, bitrateMbps: bitrateMbps,
@@ -693,52 +720,84 @@ class _PortraitOverlay extends StatelessWidget {
batteryLevel: batteryLevel, batteryLevel: batteryLevel,
viewerCount: viewerCount > 0 ? viewerCount : null, viewerCount: viewerCount > 0 ? viewerCount : null,
), ),
IconButton(
onPressed: onShare,
icon: const Icon(Icons.share, size: 20),
tooltip: 'Condividi link',
color: Colors.white70,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
], ],
), ),
), ),
const Expanded(child: SizedBox.expand()), // ── Area camera (AndroidView a tutto schermo sotto) ───────────────
Container( const Expanded(child: IgnorePointer(child: SizedBox.expand())),
color: AppTheme.background, // ── Pannello controlli ───────────────────────────────────────────────
padding: EdgeInsets.fromLTRB( ConstrainedBox(
inset.left, constraints: BoxConstraints(maxHeight: maxControlsH),
12, child: Container(
inset.right, color: AppTheme.background,
math.max(inset.bottom, 16), child: SingleChildScrollView(
), padding: EdgeInsets.fromLTRB(
child: Column( inset.left,
mainAxisSize: MainAxisSize.min, 12,
children: [ inset.right,
LiveScoreControls( math.max(inset.bottom, 16),
homeName: homeName,
awayName: awayName,
pointsTarget: pointsTarget,
onStopStream: onCloseStream,
), ),
const SizedBox(height: 10), child: Column(
OutlinedButton.icon( mainAxisSize: MainAxisSize.min,
onPressed: onShare, crossAxisAlignment: CrossAxisAlignment.stretch,
icon: const Icon(Icons.share, size: 18), children: [
label: const Text('Condividi link'), // Tabellone + pulsanti punteggio — primo widget, sempre visibile.
LiveScoreControls(
homeName: homeName,
awayName: awayName,
pointsTarget: pointsTarget,
onStopStream: onCloseStream,
),
const SizedBox(height: 12),
// Azioni stream: pausa/riprendi + chiudi.
Row(
children: [
Expanded(
child: isPaused
? FilledButton.icon(
onPressed: onResume,
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('Riprendi'),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
),
)
: OutlinedButton.icon(
onPressed: onPause,
icon: const Icon(Icons.pause, size: 18),
label: const Text('Pausa'),
),
),
const SizedBox(width: 8),
Expanded(
child: DestructiveCta(
label: 'Chiudi diretta',
onPressed: () => onCloseStream(),
),
),
],
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: onShare,
icon: const Icon(Icons.share, size: 16),
label: const Text('Condividi link diretta'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white70,
side: const BorderSide(color: Colors.white24),
),
),
],
), ),
const SizedBox(height: 8), ),
if (isPaused)
FilledButton.icon(
onPressed: onResume,
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('Riprendi'),
)
else
OutlinedButton.icon(
onPressed: onPause,
icon: const Icon(Icons.pause, size: 18),
label: const Text('Metti in pausa'),
),
const SizedBox(height: 8),
DestructiveCta(
label: 'Chiudi diretta',
onPressed: () => onCloseStream(),
),
],
), ),
), ),
], ],
@@ -806,7 +865,7 @@ class _LandscapeOverlay extends StatelessWidget {
], ],
), ),
), ),
const Expanded(child: SizedBox.expand()), const Expanded(child: IgnorePointer(child: SizedBox.expand())),
Container( Container(
color: Colors.black.withValues(alpha: 0.82), color: Colors.black.withValues(alpha: 0.82),
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(

View File

@@ -5,6 +5,7 @@ import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../app/theme.dart'; import '../../app/theme.dart';
import '../../core/config.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../shared/api_client.dart'; import '../../shared/api_client.dart';
import '../../shared/models/match.dart'; import '../../shared/models/match.dart';
@@ -64,7 +65,60 @@ class MatchesScreen extends ConsumerWidget {
loading: () => const Center( loading: () => const Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed), child: CircularProgressIndicator(color: AppTheme.primaryRed),
), ),
error: (e, _) => Center(child: Text('Errore squadre: $e')), error: (e, stack) {
final msg = e.toString();
final unauthorized = msg.contains('401');
final timeout = msg.contains('timeout') || msg.contains('Timeout');
final offline = timeout ||
msg.contains('SocketException') ||
msg.contains('Failed host lookup');
String title = 'Errore nel caricamento squadre';
String hint = 'Controlla WiFi o dati mobili e riprova.';
if (unauthorized) {
title = 'Sessione scaduta';
hint = 'Accedi di nuovo con email e password.';
} else if (offline) {
title = 'Server non raggiungibile';
hint = timeout
? 'La connessione è troppo lenta o assente. Verifica la rete (serve almeno qualche Mbps).'
: 'Impossibile contattare ${AppConfig.apiBaseUrl}. Riprova tra poco.';
}
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
hint,
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
if (unauthorized)
FilledButton(
onPressed: () {
ref.read(authProvider.notifier).logout();
context.go('/login');
},
child: const Text('VAI AL LOGIN'),
)
else
TextButton(
onPressed: () => ref.invalidate(teamsProvider),
child: const Text('RIPROVA'),
),
],
),
),
);
},
data: (teams) { data: (teams) {
if (teams.isEmpty) { if (teams.isEmpty) {
return NoTeamOnboarding(theme: theme); return NoTeamOnboarding(theme: theme);

View File

@@ -2,12 +2,14 @@ import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../app/theme.dart'; import '../../app/theme.dart';
import '../../providers/score_provider.dart'; import '../../providers/score_provider.dart';
import '../../providers/score_sync_provider.dart';
import '../../providers/session_provider.dart'; import '../../providers/session_provider.dart';
import '../../shared/api_client.dart'; import '../../shared/api_client.dart';
import '../../shared/models/match.dart'; import '../../shared/models/match.dart';
@@ -74,9 +76,9 @@ class _StepNetworkTestState extends ConsumerState<StepNetworkTest> {
_youtubePoll?.cancel(); _youtubePoll?.cancel();
var attempts = 0; var attempts = 0;
_youtubePoll = Timer.periodic(const Duration(seconds: 2), (_) async { _youtubePoll = Timer.periodic(const Duration(seconds: 3), (_) async {
attempts++; attempts++;
if (!mounted || attempts > 30) { if (!mounted || attempts > 60) {
_youtubePoll?.cancel(); _youtubePoll?.cancel();
return; return;
} }
@@ -150,14 +152,10 @@ class _StepNetworkTestState extends ConsumerState<StepNetworkTest> {
if (session.score != null) { if (session.score != null) {
ref.read(scoreProvider.notifier).reset(session.score!); ref.read(scoreProvider.notifier).reset(session.score!);
} }
// Tabellone via HTTP; WebSocket solo dalla camera (evita doppio connect controller/camera).
try { try {
await ref.read(websocketServiceProvider).connect( await ref.read(scoreSyncProvider).pullFromServer();
sessionId: session.id, } catch (_) {}
deviceRole: 'controller',
);
} catch (_) {
// Tabellone locale; sync quando la camera si connette.
}
} }
String _connectivityLabel(List<ConnectivityResult> results) { String _connectivityLabel(List<ConnectivityResult> results) {
@@ -184,18 +182,22 @@ class _StepNetworkTestState extends ConsumerState<StepNetworkTest> {
ref.read(scoreProvider.notifier).reset(const ScoreState()); ref.read(scoreProvider.notifier).reset(const ScoreState());
} }
if (!mounted) return;
await ref.read(websocketServiceProvider).disconnect();
if (!mounted) return; if (!mounted) return;
context.go('/session/${started.id}/camera'); context.go('/session/${started.id}/camera');
} on DioException catch (e) {
try { if (mounted) {
final ws = ref.read(websocketServiceProvider); final msg = e.response?.data is Map
await ws.connect(sessionId: started.id, deviceRole: 'controller'); ? (e.response!.data['error'] ?? e.response!.data['message'])?.toString()
} catch (e) { : null;
if (mounted) { ScaffoldMessenger.of(context).showSnackBar(
ScaffoldMessenger.of(context).showSnackBar( SnackBar(
SnackBar(content: Text('Diretta avviata ma sync limitata: $e')), content: Text(
); msg?.isNotEmpty == true ? 'Errore avvio diretta: $msg' : 'Errore avvio diretta: ${e.message}',
} ),
),
);
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {

View File

@@ -270,7 +270,7 @@ class _StepTransmissionState extends ConsumerState<StepTransmission> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('720p · 30fps · 2.5 Mbps', style: theme.textTheme.titleMedium), Text('720p · 30fps · 3 Mbps', style: theme.textTheme.titleMedium),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Bitrate fisso consigliato per reti instabili', 'Bitrate fisso consigliato per reti instabili',

View File

@@ -19,32 +19,53 @@ class StreamingChannel {
return ok ?? false; return ok ?? false;
} }
/// Dopo rotazione: riallinea preview/stream senza fermare RTMP.
static Future<void> syncOrientation() async {
await _channel.invokeMethod<void>('syncOrientation');
}
static Future<void> stopPreview() async { static Future<void> stopPreview() async {
await _channel.invokeMethod('stopPreview'); await _channel.invokeMethod('stopPreview');
} }
static Future<void> startStream({ static Future<void> startStream({
required String rtmpUrl, required String rtmpUrl,
int targetBitrate = 2500000, int targetBitrate = 3000000,
int targetFps = 30, int targetFps = 30,
int width = 1280,
int height = 720,
bool portrait = false,
int rotation = 0,
}) async { }) async {
await _channel.invokeMethod('startStream', { await _channel.invokeMethod('startStream', {
'rtmpUrl': rtmpUrl, 'rtmpUrl': rtmpUrl,
'videoBitrate': targetBitrate, 'videoBitrate': targetBitrate,
'fps': targetFps, 'fps': targetFps,
'width': width,
'height': height,
'portrait': portrait,
'rotation': rotation,
}); });
} }
/// Ripresa dopo pausa: resetta stati RTMP residui (CONNECTING/RECONNECTING). /// Ripresa dopo pausa: resetta stati RTMP residui (CONNECTING/RECONNECTING).
static Future<void> resumeStream({ static Future<void> resumeStream({
required String rtmpUrl, required String rtmpUrl,
int targetBitrate = 2500000, int targetBitrate = 3000000,
int targetFps = 30, int targetFps = 30,
int width = 1280,
int height = 720,
bool portrait = false,
int rotation = 0,
}) async { }) async {
await _channel.invokeMethod('resumeStream', { await _channel.invokeMethod('resumeStream', {
'rtmpUrl': rtmpUrl, 'rtmpUrl': rtmpUrl,
'videoBitrate': targetBitrate, 'videoBitrate': targetBitrate,
'fps': targetFps, 'fps': targetFps,
'width': width,
'height': height,
'portrait': portrait,
'rotation': rotation,
}); });
} }

View File

@@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
/// Preview camera nativa (OpenGL) o placeholder su altre piattaforme. /// Preview camera nativa (OpenGL) integrata nel layout Flutter.
class NativeStreamingPreview extends StatelessWidget { class NativeStreamingPreview extends StatelessWidget {
const NativeStreamingPreview({ const NativeStreamingPreview({
super.key, super.key,
@@ -23,7 +23,7 @@ class NativeStreamingPreview extends StatelessWidget {
key: key, key: key,
viewType: 'match_live_tv/streaming_preview', viewType: 'match_live_tv/streaming_preview',
layoutDirection: TextDirection.ltr, layoutDirection: TextDirection.ltr,
creationParamsCodec: StandardMessageCodec(), creationParamsCodec: const StandardMessageCodec(),
), ),
if (showGrid) CameraPreviewOverlay(platformLabel: platformLabel), if (showGrid) CameraPreviewOverlay(platformLabel: platformLabel),
], ],

View File

@@ -1,7 +1,9 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/auth_storage.dart'; import '../core/auth_storage.dart';
import '../core/team_selection_storage.dart'; import '../core/team_selection_storage.dart';
import '../shared/api_client.dart';
import '../shared/models/user.dart'; import '../shared/models/user.dart';
class AuthState { class AuthState {
@@ -72,6 +74,53 @@ class AuthNotifier extends StateNotifier<AuthState> {
); );
} }
/// Dopo restore: verifica token o rinnova con refresh (evita 401 su /teams).
Future<bool> validateOrRefreshSession() async {
if (!state.isAuthenticated) return false;
final client = ApiClient(accessToken: state.accessToken);
try {
final user = await client.me();
state = state.copyWith(user: user);
await AuthStorage.save(
user: user,
accessToken: state.accessToken!,
refreshToken: state.refreshToken!,
);
return true;
} on DioException catch (e) {
if (e.response?.statusCode != 401) {
return true;
}
} catch (_) {
return true;
}
return refreshSession();
}
Future<bool> refreshSession() async {
final refreshToken = state.refreshToken;
if (refreshToken == null || refreshToken.isEmpty) {
logout();
return false;
}
try {
final client = ApiClient();
final result = await client.refreshSession(refreshToken);
setSession(
user: result.user,
accessToken: result.tokens.accessToken,
refreshToken: result.tokens.refreshToken,
);
return true;
} catch (_) {
logout();
return false;
}
}
void setLoading(bool loading) { void setLoading(bool loading) {
state = state.copyWith(loading: loading, clearError: true); state = state.copyWith(loading: loading, clearError: true);
} }

View File

@@ -44,7 +44,12 @@ class SessionNotifier extends StateNotifier<SessionState> {
SessionNotifier() : super(const SessionState()); SessionNotifier() : super(const SessionState());
void setSession(StreamSession session, {MatchModel? match}) { void setSession(StreamSession session, {MatchModel? match}) {
state = state.copyWith(session: session, match: match); final prevId = state.session?.id;
state = state.copyWith(
session: session,
match: match,
elapsed: prevId != null && prevId != session.id ? Duration.zero : state.elapsed,
);
} }
void updateYoutubeLinks({ void updateYoutubeLinks({
@@ -111,8 +116,6 @@ class SessionNotifier extends StateNotifier<SessionState> {
state = state.copyWith(cameraDevice: device); state = state.copyWith(cameraDevice: device);
return; return;
} }
final startedAt = s.startedAt ??
((incoming == 'live' || incoming == 'reconnecting') ? DateTime.now() : null);
state = state.copyWith( state = state.copyWith(
session: StreamSession( session: StreamSession(
id: s.id, id: s.id,
@@ -129,7 +132,7 @@ class SessionNotifier extends StateNotifier<SessionState> {
privacyStatus: s.privacyStatus, privacyStatus: s.privacyStatus,
qualityPreset: s.qualityPreset, qualityPreset: s.qualityPreset,
targetBitrate: s.targetBitrate, targetBitrate: s.targetBitrate,
startedAt: startedAt, startedAt: s.startedAt,
disconnectionCount: s.disconnectionCount, disconnectionCount: s.disconnectionCount,
score: s.score, score: s.score,
devices: s.devices, devices: s.devices,

View File

@@ -21,8 +21,8 @@ class ApiClient {
_dio = Dio( _dio = Dio(
BaseOptions( BaseOptions(
baseUrl: AppConfig.apiV1, baseUrl: AppConfig.apiV1,
connectTimeout: const Duration(seconds: 15), connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 30),
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', 'Accept': 'application/json',
@@ -62,12 +62,18 @@ class ApiClient {
); );
} }
Future<AuthTokens> refresh(String refreshToken) async { Future<({User user, AuthTokens tokens})> refreshSession(
String refreshToken,
) async {
final response = await _dio.post<Map<String, dynamic>>( final response = await _dio.post<Map<String, dynamic>>(
'/auth/refresh', '/auth/refresh',
data: {'refresh_token': refreshToken}, data: {'refresh_token': refreshToken},
); );
return AuthTokens.fromJson(response.data!); final data = response.data!;
return (
user: User.fromJson(data['user'] as Map<String, dynamic>),
tokens: AuthTokens.fromJson(data),
);
} }
Future<User> me() async { Future<User> me() async {

View File

@@ -26,6 +26,7 @@ class WebsocketService {
ActionCable? _cable; ActionCable? _cable;
ActionChannel? _channel; ActionChannel? _channel;
String? _sessionId; String? _sessionId;
Future<void>? _connectOp;
bool get isConnected => _cable != null && _channel != null; bool get isConnected => _cable != null && _channel != null;
@@ -35,6 +36,24 @@ class WebsocketService {
}) async { }) async {
if (_sessionId == sessionId && isConnected) return; if (_sessionId == sessionId && isConnected) return;
final prev = _connectOp;
if (prev != null) await prev.catchError((_) {});
_connectOp = _connect(sessionId: sessionId, deviceRole: deviceRole);
final op = _connectOp!;
try {
await op;
} finally {
if (identical(_connectOp, op)) {
_connectOp = null;
}
}
}
Future<void> _connect({
required String sessionId,
required String deviceRole,
}) async {
await disconnect(); await disconnect();
final token = _ref.read(authProvider).accessToken; final token = _ref.read(authProvider).accessToken;
@@ -161,11 +180,20 @@ class WebsocketService {
} }
Future<void> disconnect() async { Future<void> disconnect() async {
_channel?.unsubscribe(); final channel = _channel;
final cable = _cable;
_channel = null; _channel = null;
_cable?.disconnect();
_cable = null; _cable = null;
_sessionId = null; _sessionId = null;
try {
channel?.unsubscribe();
} catch (_) {}
try {
cable?.disconnect();
} catch (_) {}
await Future<void>.delayed(const Duration(milliseconds: 150));
_ref.read(sessionProvider.notifier).setConnected(false); _ref.read(sessionProvider.notifier).setConnected(false);
} }

View File

@@ -1,7 +1,7 @@
name: match_live_tv name: match_live_tv
description: Match Live TV - Lo streaming che non muore description: Match Live TV - Lo streaming che non muore
publish_to: 'none' publish_to: 'none'
version: 1.2.0+3 version: 1.2.10+13
environment: environment:
sdk: ^3.12.0 sdk: ^3.12.0

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# APK release per telefono → API produzione
set -euo pipefail
export PATH="${HOME}/flutter/bin:${PATH}"
API_BASE_URL="${API_BASE_URL:-https://www.matchlivetv.it}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT/mobile"
flutter pub get
flutter build apk --release \
--dart-define="API_BASE_URL=${API_BASE_URL}"
echo "APK: mobile/build/app/outputs/flutter-apk/app-release.apk"
echo "API_BASE_URL=${API_BASE_URL}"

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Test YouTube notturno sul server (retry fino a successo).
set -euo pipefail
HOST="${DEPLOY_HOST:-eminux@192.168.1.146}"
LOG="/tmp/youtube_e2e_overnight.log"
echo "Avvio test YouTube → $LOG"
ssh "$HOST" "docker cp /opt/matchlivetv/backend/lib/tasks/youtube_e2e.rake infra-rails-1:/app/lib/tasks/youtube_e2e.rake"
ssh "$HOST" "docker exec infra-rails-1 bin/rails runner \"Redis.new(url: ENV['REDIS_URL']).del('youtube_api:rate_limited_until')\""
ssh "$HOST" "nohup docker exec infra-rails-1 bin/rails youtube:e2e >> $LOG 2>&1 &"
echo "Monitor: ssh $HOST tail -f $LOG"