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:
@@ -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
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|
||||||
|
|||||||
@@ -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) &&
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
80
backend/app/jobs/overlay_relay_ensure_job.rb
Normal file
80
backend/app/jobs/overlay_relay_ensure_job.rb
Normal 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
|
||||||
12
backend/app/jobs/overlay_score_refresh_job.rb
Normal file
12
backend/app/jobs/overlay_score_refresh_job.rb
Normal 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
|
||||||
37
backend/app/jobs/stream_publisher_sync_job.rb
Normal file
37
backend/app/jobs/stream_publisher_sync_job.rb
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
57
backend/app/jobs/youtube_ingest_watchdog_job.rb
Normal file
57
backend/app/jobs/youtube_ingest_watchdog_job.rb
Normal 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
|
||||||
11
backend/app/jobs/youtube_relay_ensure_job.rb
Normal file
11
backend/app/jobs/youtube_relay_ensure_job.rb
Normal 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
|
||||||
10
backend/app/jobs/youtube_relay_stop_job.rb
Normal file
10
backend/app/jobs/youtube_relay_stop_job.rb
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
37
backend/app/services/mediamtx/publisher_online.rb
Normal file
37
backend/app/services/mediamtx/publisher_online.rb
Normal 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
|
||||||
@@ -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
|
||||||
|
enable_live_path!(@session)
|
||||||
|
if @session.may_go_live?
|
||||||
@session.go_live!
|
@session.go_live!
|
||||||
@session.reload
|
@session.reload
|
||||||
Streams::Overlay::Refresh.call(@session)
|
kick_youtube!(@session) if @session.platform == "youtube"
|
||||||
Streams::YoutubeRelay.ensure_publishing!(@session) if @session.platform == "youtube"
|
|
||||||
elsif @session.reconnecting? && @session.may_reconnect?
|
elsif @session.reconnecting? && @session.may_reconnect?
|
||||||
@session.reconnect!
|
@session.reconnect!
|
||||||
cancel_timeout
|
cancel_timeout
|
||||||
Streams::Overlay::Refresh.call(@session.reload)
|
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?
|
||||||
|
if publisher_offline_sustained?(@session.id)
|
||||||
|
clear_publisher_misses!(@session.id)
|
||||||
|
restore_slate_path!(@session)
|
||||||
@session.lose_connection!
|
@session.lose_connection!
|
||||||
schedule_timeout unless @session.paused?
|
schedule_timeout unless @session.paused?
|
||||||
end
|
end
|
||||||
|
elsif !publisher_online && @session.status.in?(%w[reconnecting connecting]) && !@session.paused?
|
||||||
|
restore_slate_path!(@session)
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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!
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ 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)
|
||||||
|
|
||||||
@@ -18,78 +25,94 @@ module Streams
|
|||||||
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}"
|
|
||||||
output = "#{mediamtx_rtmp_url}/#{session.mediamtx_overlay_path_name}"
|
|
||||||
log_path = log_file(session)
|
log_path = log_file(session)
|
||||||
FileUtils.mkdir_p(File.dirname(log_path))
|
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]"
|
|
||||||
|
|
||||||
ffmpeg_pid = Process.spawn(
|
|
||||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
|
||||||
"-fflags", "+genpts+discardcorrupt",
|
|
||||||
"-analyzeduration", "10000000", "-probesize", "10000000",
|
|
||||||
"-rw_timeout", "15000000",
|
|
||||||
"-i", intake,
|
|
||||||
"-f", "image2pipe", "-framerate", overlay_fps.to_s, "-i", pipe.to_s,
|
|
||||||
"-filter_complex", filter,
|
|
||||||
"-map", "[vout]", "-map", "0:a?",
|
|
||||||
"-fps_mode", "cfr", "-r", fps.to_s,
|
|
||||||
"-c:v", "libx264", "-preset", x264_preset, "-bf", "0",
|
|
||||||
"-profile:v", "high", "-pix_fmt", "yuv420p",
|
|
||||||
"-b:v", bitrate_for(session), "-maxrate", maxrate_for(session), "-bufsize", bufsize_for(session),
|
|
||||||
"-g", fps.to_s, "-keyint_min", fps.to_s,
|
|
||||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
|
||||||
"-f", "flv", output,
|
|
||||||
%i[out err] => log_path,
|
|
||||||
pgroup: true
|
|
||||||
)
|
|
||||||
spawn_png_feeder!(ffmpeg_pid, pipe, png, log_path)
|
spawn_png_feeder!(ffmpeg_pid, pipe, png, log_path)
|
||||||
Process.detach(ffmpeg_pid)
|
Process.detach(ffmpeg_pid)
|
||||||
store_pid(session.id, ffmpeg_pid)
|
store_pid(session.id, ffmpeg_pid)
|
||||||
|
store_mode(session.id, mode)
|
||||||
|
mode == MODE_LIVE ? mark_live_intake!(session.id) : clear_live_intake!(session.id)
|
||||||
OverlayRefreshJob.schedule_chain(session.id)
|
OverlayRefreshJob.schedule_chain(session.id)
|
||||||
Rails.logger.info("[OverlayRelay] started pid=#{ffmpeg_pid} session=#{session.id}")
|
if session.platform == "youtube" && session.stream_key.present?
|
||||||
|
Streams::YoutubeRelay.stop(session)
|
||||||
|
Youtube::LivePipeline.schedule_activate!(session, force: true, wait: 20.seconds)
|
||||||
|
end
|
||||||
|
Rails.logger.info("[OverlayRelay] started pid=#{ffmpeg_pid} mode=#{mode} session=#{session.id} youtube_tee=#{session.platform == "youtube"}")
|
||||||
ffmpeg_pid
|
ffmpeg_pid
|
||||||
|
ensure
|
||||||
|
release_start_lock!(session.id)
|
||||||
|
end
|
||||||
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"
|
||||||
|
|||||||
@@ -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",
|
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||||
"-fflags", "+genpts+discardcorrupt", "-use_wallclock_as_timestamps", "1",
|
"-fflags", "+genpts+discardcorrupt",
|
||||||
"-analyzeduration", "10000000", "-probesize", "10000000",
|
"-analyzeduration", "10000000", "-probesize", "10000000",
|
||||||
"-rw_timeout", "15000000",
|
"-rw_timeout", "15000000",
|
||||||
"-i", intake,
|
"-i", url,
|
||||||
"-f", "lavfi", "-i", "anullsrc=channel_layout=mono:sample_rate=48000",
|
"-c:v", "copy",
|
||||||
"-map", "0:v:0", "-map", "1:a:0",
|
|
||||||
"-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)
|
[
|
||||||
return if session.youtube_broadcast_id.blank?
|
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||||
|
"-fflags", "+genpts+discardcorrupt",
|
||||||
YoutubeBroadcastActivateJob.schedule_with_retries(session.id)
|
"-rw_timeout", "15000000",
|
||||||
|
"-live_start_index", "-1",
|
||||||
|
"-i", url,
|
||||||
|
"-c:v", "copy",
|
||||||
|
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
||||||
|
"-bsf:a", "aac_adtstoasc",
|
||||||
|
"-f", "flv", output
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def mediamtx_intake_url(session)
|
def mediamtx_intake_source(session)
|
||||||
base = ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
|
base = ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
|
||||||
"#{base.chomp('/')}/#{session.mediamtx_overlay_path_name}"
|
if Mediamtx::PublisherOnline.active?(session)
|
||||||
|
return [:rtmp, "#{base.chomp('/')}/#{session.mediamtx_path_name}"]
|
||||||
|
end
|
||||||
|
|
||||||
|
path = session.mediamtx_path_name
|
||||||
|
rails = ENV.fetch("RAILS_INTERNAL_URL", "http://rails:3000")
|
||||||
|
[:hls, "#{rails.chomp('/')}/hls/#{path}/index.m3u8"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def intake_available?(session)
|
||||||
|
return true if Mediamtx::PublisherOnline.active?(session)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
28
backend/app/services/youtube/api_throttle.rb
Normal file
28
backend/app/services/youtube/api_throttle.rb
Normal 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
|
||||||
@@ -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 1–2 minuti." if ApiThrottle.rate_limit_error?(msg)
|
||||||
|
|
||||||
msg
|
msg
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
107
backend/app/services/youtube/live_pipeline.rb
Normal file
107
backend/app/services/youtube/live_pipeline.rb
Normal 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
|
||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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 %>
|
||||||
|
|||||||
@@ -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, () => {
|
|
||||||
if (!firstLevelSynced) {
|
|
||||||
firstLevelSynced = true;
|
firstLevelSynced = true;
|
||||||
jumpToLiveEdge(true);
|
ensurePlaying();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
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) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!streamClosed && playerStarted) {
|
||||||
instance.startLoad(-1);
|
instance.startLoad(-1);
|
||||||
ensurePlaying();
|
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 %>
|
||||||
|
|||||||
BIN
backend/branding/CopertinaCanale_6.png
Normal file
BIN
backend/branding/CopertinaCanale_6.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
backend/branding/logo-white-m.png
Normal file
BIN
backend/branding/logo-white-m.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
@@ -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)
|
||||||
|
|||||||
@@ -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|
|
||||||
|
|||||||
169
backend/lib/tasks/youtube_e2e.rake
Normal file
169
backend/lib/tasks/youtube_e2e.rake
Normal 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
|
||||||
BIN
backend/public/logo-white.png
Normal file
BIN
backend/public/logo-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
@@ -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) {
|
||||||
|
|||||||
@@ -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 dall’app; la live dovrebbe partire entro ~30s con segnale su `_air`. |
|
| YouTube «In programma» / copertina | Nessun intervento manuale: entro ~20–90s 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 l’attivazione 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. |
|
||||||
|
|||||||
56
docs/YOUTUBE_MORNING_CHECKLIST.md
Normal file
56
docs/YOUTUBE_MORNING_CHECKLIST.md
Normal 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 dall’app (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 15–20 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
waitForPreviewThenPublish(streamConfig, attempt = 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
streamStartedAtMs = SystemClock.elapsedRealtime()
|
||||||
updateState(StreamingState.CONNECTING)
|
updateState(StreamingState.CONNECTING)
|
||||||
stream.startStream(streamConfig.rtmpUrl)
|
Log.i(TAG, "publish RTMP url=${streamConfig.rtmpUrl}")
|
||||||
|
activeStream.startStream(streamConfig.rtmpUrl)
|
||||||
startMetricsLoop()
|
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,16 +643,235 @@ 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 {
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
portrait: video['portrait'] as bool,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
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,12 +720,25 @@ 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())),
|
||||||
|
// ── Pannello controlli ───────────────────────────────────────────────
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxHeight: maxControlsH),
|
||||||
|
child: Container(
|
||||||
color: AppTheme.background,
|
color: AppTheme.background,
|
||||||
|
child: SingleChildScrollView(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
inset.left,
|
inset.left,
|
||||||
12,
|
12,
|
||||||
@@ -707,39 +747,58 @@ class _PortraitOverlay extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
// Tabellone + pulsanti punteggio — primo widget, sempre visibile.
|
||||||
LiveScoreControls(
|
LiveScoreControls(
|
||||||
homeName: homeName,
|
homeName: homeName,
|
||||||
awayName: awayName,
|
awayName: awayName,
|
||||||
pointsTarget: pointsTarget,
|
pointsTarget: pointsTarget,
|
||||||
onStopStream: onCloseStream,
|
onStopStream: onCloseStream,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 12),
|
||||||
OutlinedButton.icon(
|
// Azioni stream: pausa/riprendi + chiudi.
|
||||||
onPressed: onShare,
|
Row(
|
||||||
icon: const Icon(Icons.share, size: 18),
|
children: [
|
||||||
label: const Text('Condividi link'),
|
Expanded(
|
||||||
),
|
child: isPaused
|
||||||
const SizedBox(height: 8),
|
? FilledButton.icon(
|
||||||
if (isPaused)
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: onResume,
|
onPressed: onResume,
|
||||||
icon: const Icon(Icons.play_arrow, size: 18),
|
icon: const Icon(Icons.play_arrow, size: 18),
|
||||||
label: const Text('Riprendi'),
|
label: const Text('Riprendi'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.primaryRed,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
else
|
: OutlinedButton.icon(
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: onPause,
|
onPressed: onPause,
|
||||||
icon: const Icon(Icons.pause, size: 18),
|
icon: const Icon(Icons.pause, size: 18),
|
||||||
label: const Text('Metti in pausa'),
|
label: const Text('Pausa'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
DestructiveCta(
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: DestructiveCta(
|
||||||
label: 'Chiudi diretta',
|
label: 'Chiudi diretta',
|
||||||
onPressed: () => onCloseStream(),
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 Wi‑Fi 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);
|
||||||
|
|||||||
@@ -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,19 +182,23 @@ 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 {
|
|
||||||
final ws = ref.read(websocketServiceProvider);
|
|
||||||
await ws.connect(sessionId: started.id, deviceRole: 'controller');
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
final msg = e.response?.data is Map
|
||||||
|
? (e.response!.data['error'] ?? e.response!.data['message'])?.toString()
|
||||||
|
: null;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Diretta avviata ma sync limitata: $e')),
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
msg?.isNotEmpty == true ? 'Errore avvio diretta: $msg' : 'Errore avvio diretta: ${e.message}',
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
15
scripts/build_mobile_apk_prod.sh
Executable file
15
scripts/build_mobile_apk_prod.sh
Executable 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}"
|
||||||
11
scripts/overnight_youtube_e2e.sh
Executable file
11
scripts/overnight_youtube_e2e.sh
Executable 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"
|
||||||
Reference in New Issue
Block a user