diff --git a/backend/app/controllers/api/v1/stream_sessions_controller.rb b/backend/app/controllers/api/v1/stream_sessions_controller.rb index d6c8fb4..b5d7266 100644 --- a/backend/app/controllers/api/v1/stream_sessions_controller.rb +++ b/backend/app/controllers/api/v1/stream_sessions_controller.rb @@ -174,7 +174,7 @@ module Api } end - # L'app invia fps ogni ~10s; senza poll status.json l'overlay YouTube non partiva. + # L'app invia fps ogni ~10s; poll status.json per allineare stato sessione e relay YouTube. def sync_publisher_when_streaming!(fps) return if fps < 1 return if @session.terminal? || @session.paused? diff --git a/backend/app/jobs/overlay_refresh_job.rb b/backend/app/jobs/overlay_refresh_job.rb deleted file mode 100644 index 3ef8b9d..0000000 --- a/backend/app/jobs/overlay_refresh_job.rb +++ /dev/null @@ -1,32 +0,0 @@ -class OverlayRefreshJob < ApplicationJob - queue_as :default - - INTERVAL = 2.seconds - REDIS_CHAIN_KEY = "overlay_refresh:chain:%s" - - def self.schedule_chain(session_id) - redis.set(format(REDIS_CHAIN_KEY, session_id), "1", ex: 48.hours.to_i) - set(wait: INTERVAL).perform_later(session_id) - end - - def self.cancel_chain(session_id) - redis.del(format(REDIS_CHAIN_KEY, session_id)) - end - - def self.redis - @redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")) - end - - def perform(session_id) - return unless self.class.redis.get(format(REDIS_CHAIN_KEY, session_id)) - - session = StreamSession.find_by(id: session_id) - return self.class.cancel_chain(session_id) unless session - return self.class.cancel_chain(session_id) if session.terminal? - return self.class.cancel_chain(session_id) unless Streams::OverlayRelay.running?(session_id) - - Streams::Overlay::Refresh.call(session) - Mediamtx::PublisherSync.new(session).call if session.status.in?(%w[connecting reconnecting live paused]) - self.class.set(wait: INTERVAL).perform_later(session_id) - end -end diff --git a/backend/app/jobs/overlay_relay_ensure_job.rb b/backend/app/jobs/overlay_relay_ensure_job.rb deleted file mode 100644 index 6cbb07d..0000000 --- a/backend/app/jobs/overlay_relay_ensure_job.rb +++ /dev/null @@ -1,80 +0,0 @@ -# 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 diff --git a/backend/app/jobs/overlay_score_refresh_job.rb b/backend/app/jobs/overlay_score_refresh_job.rb deleted file mode 100644 index 1c0b602..0000000 --- a/backend/app/jobs/overlay_score_refresh_job.rb +++ /dev/null @@ -1,12 +0,0 @@ -# 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 diff --git a/backend/app/services/mediamtx/client.rb b/backend/app/services/mediamtx/client.rb index 1cd995c..5adc96b 100644 --- a/backend/app/services/mediamtx/client.rb +++ b/backend/app/services/mediamtx/client.rb @@ -12,21 +12,6 @@ module Mediamtx end end - def create_overlay_path(session) - path = session.mediamtx_overlay_path_name - body = { - source: "publisher", - overridePublisher: true, - record: false - } - response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body) - unless response.success? - err = response.body.is_a?(Hash) ? response.body["error"] : response.body - raise Error, "MediaMTX overlay path create failed: #{response.status} #{err}" - end - true - end - def create_path(session) path = session.mediamtx_path_name # record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe diff --git a/backend/app/services/streams/overlay.rb b/backend/app/services/streams/overlay.rb deleted file mode 100644 index 90ddceb..0000000 --- a/backend/app/services/streams/overlay.rb +++ /dev/null @@ -1,46 +0,0 @@ -module Streams - module Overlay - module_function - - def overlay_dir(session_id) - Rails.root.join("tmp/stream_overlays", session_id.to_s) - end - - def overlay_png_path(session) - overlay_dir(session.id).join("overlay.png") - end - - def overlay_pipe_path(session) - overlay_dir(session.id).join("overlay.pipe") - end - - # 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) - club = team.club - candidate = club&.effective_secondary_color.presence || "#1565c0" - return candidate unless light_hex_color?(candidate) - - "#1565c0" - end - - def light_hex_color?(hex) - h = hex.to_s.delete_prefix("#") - return false unless h.match?(/\A\h{6}\z/i) - - r = h[0..1].to_i(16) - g = h[2..3].to_i(16) - b = h[4..5].to_i(16) - (0.299 * r + 0.587 * g + 0.114 * b) > 200 - end - end -end diff --git a/backend/app/services/streams/overlay/badge_label.rb b/backend/app/services/streams/overlay/badge_label.rb deleted file mode 100644 index 20c5a55..0000000 --- a/backend/app/services/streams/overlay/badge_label.rb +++ /dev/null @@ -1,49 +0,0 @@ -module Streams - module Overlay - # Etichetta stato broadcast (stessa logica della pagina live / badge HTML). - class BadgeLabel - Result = Struct.new(:text, :background, :foreground, keyword_init: true) - - def self.for(session) - new(session).call - end - - def initialize(session) - @session = session - end - - def call - return Result.new(text: "DIRETTA CHIUSA", background: "#374151", foreground: "#dddddd") if @session.terminal? - return Result.new(text: "IN PAUSA", background: "#455a64", foreground: "#ffffff") if @session.paused? - - if Mediamtx::PublisherOnline.active?(@session) || (@session.live? && !@session.paused?) - return Result.new(text: "IN ONDA", background: "#2e7d32", foreground: "#ffffff") - end - - if path_has_output? - if @session.reconnecting? - return Result.new(text: "RICONNESSIONE", background: "#f57c00", foreground: "#ffffff") - end - if @session.connecting? - return Result.new(text: "CONNECTING", background: "#f57c00", foreground: "#ffffff") - end - # Slate alwaysAvailable senza telefono: non bruciare «COPERTINA» su YouTube. - return Result.new(text: "IN ATTESA", background: "#455a64", foreground: "#ffffff") - end - - Result.new(text: "IN ATTESA", background: "#455a64", foreground: "#ffffff") - end - - private - - def path_info - @path_info ||= Mediamtx::PublisherOnline.path_info(@session) - end - - def path_has_output? - info = path_info - info && (info["ready"] || info["available"] || info["online"]) - end - end - end -end diff --git a/backend/app/services/streams/overlay/refresh.rb b/backend/app/services/streams/overlay/refresh.rb deleted file mode 100644 index 685fb7f..0000000 --- a/backend/app/services/streams/overlay/refresh.rb +++ /dev/null @@ -1,50 +0,0 @@ -module Streams - module Overlay - class Refresh - class Error < StandardError; end - - def self.call(session) - new(session).call - end - - def initialize(session) - @session = session.reload - end - - def call - return false if @session.terminal? - - dir = Streams::Overlay.overlay_dir(@session.id) - FileUtils.mkdir_p(dir) - badge = BadgeLabel.for(@session) - svg = SvgBuilder.new(@session, badge: badge).to_svg - svg_path = dir.join("overlay.svg") - png_path = Streams::Overlay.overlay_png_path(@session) - atomic_write(svg_path, svg) - render_png!(svg_path, png_path) - true - rescue StandardError => e - Rails.logger.warn("[Overlay::Refresh] session=#{@session.id} #{e.class}: #{e.message}") - false - end - - private - - def atomic_write(path, content) - tmp = "#{path}.tmp" - File.write(tmp, content) - File.rename(tmp, path) - end - - def render_png!(svg_path, png_path) - tmp = "#{png_path}.tmp" - unless system("rsvg-convert", "-w", "1280", "-h", "720", "-o", tmp, svg_path.to_s, out: File::NULL, err: File::NULL) - raise Error, "rsvg-convert fallito per session #{@session.id}" - end - - File.rename(tmp, png_path) - FileUtils.touch(png_path) - end - end - end -end diff --git a/backend/app/services/streams/overlay/svg_builder.rb b/backend/app/services/streams/overlay/svg_builder.rb deleted file mode 100644 index 0eae9b6..0000000 --- a/backend/app/services/streams/overlay/svg_builder.rb +++ /dev/null @@ -1,162 +0,0 @@ -module Streams - module Overlay - # PNG 1280×720 trasparente: tabellone alto-sinistra, badge alto-destra, logo chiaro (logo-white-m) basso-destra. - class SvgBuilder - CANVAS_W = 1280 - CANVAS_H = 720 - - def initialize(session, badge:) - @session = session - @match = session.match - @team = @match.team - @score = session.score_state - @badge = badge - end - - def to_svg - cols = score_columns - <<~SVG - - - #{scorebug_svg(cols)} - #{badge_svg} - #{brand_watermark_svg} - - SVG - end - - private - - def score_columns - score = @score - unless score - return { - labels: ["1"], home: ["0"], away: ["0"], home_name: abbrev(@match.team.name), - away_name: abbrev(@match.opponent_name), sets_won: "0-0", current_set: 1 - } - end - - partials = Array(score.set_partials) - labels = partials.map { |p| p["set"].to_s } - home = partials.map { |p| p["home"].to_s } - away = partials.map { |p| p["away"].to_s } - labels << score.current_set.to_s - home << score.home_points.to_s - away << score.away_points.to_s - { - labels: labels, - home: home, - away: away, - home_name: abbrev(@match.team.name), - away_name: abbrev(@match.opponent_name), - sets_won: "#{score.home_sets}-#{score.away_sets}", - current_set: score.current_set - } - end - - def scorebug_svg(cols) - x0 = 12 - y0 = 12 - col_w = 26 - team_w = 118 - row_h = 18 - head_h = 16 - pad = 8 - n = cols[:labels].length - box_w = team_w + n * col_w + pad * 2 - box_h = head_h + row_h * 2 + 22 + pad * 2 - live_i = n - 1 - home_primary = @team.effective_primary_color - away_primary = Overlay.badge_away_color(@team) - - header_cells = cols[:labels].map.with_index do |label, i| - cx = x0 + pad + team_w + i * col_w + col_w / 2 - "#{escape(label)}" - end.join - - home_pts = cols[:home].map.with_index do |val, i| - cx = x0 + pad + team_w + i * col_w + col_w / 2 - y = y0 + pad + head_h + 14 - fill = i == live_i ? home_primary : "#111111" - weight = i == live_i ? "900" : "800" - bg = i == live_i ? "" : "" - "#{bg}#{escape(val)}" - end.join - - away_pts = cols[:away].map.with_index do |val, i| - cx = x0 + pad + team_w + i * col_w + col_w / 2 - y = y0 + pad + head_h + row_h + 14 - fill = i == live_i ? away_primary : "#111111" - weight = i == live_i ? "900" : "800" - bg = i == live_i ? "" : "" - "#{bg}#{escape(val)}" - end.join - - <<~SVG - - - - #{header_cells} - #{escape(cols[:home_name])} - #{home_pts} - #{escape(cols[:away_name])} - #{away_pts} - - MATCH LIVE TV - - - - - - - - - SVG - 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 - - - - SVG - end - - def badge_svg - text = @badge.text - bg = @badge.background - fg = @badge.foreground - pad_x = 12 - pad_y = 6 - fs = 11 - tw = text.length * 6.5 + pad_x * 2 - th = fs + pad_y * 2 - x = CANVAS_W - 12 - tw - y = 12 - <<~SVG - - - #{escape(text)} - - SVG - end - - def abbrev(name, max = 16) - n = name.to_s - n.length <= max ? n : "#{n[0, max - 1]}…" - end - - def escape(str) - str.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """) - end - end - end -end diff --git a/backend/app/services/streams/overlay_relay.rb b/backend/app/services/streams/overlay_relay.rb deleted file mode 100644 index 8a1381c..0000000 --- a/backend/app/services/streams/overlay_relay.rb +++ /dev/null @@ -1,460 +0,0 @@ -module Streams - # Transcodifica path grezzo → path _air con overlay PNG (tabellone + badge) bruciato nel video. - class OverlayRelay - class Error < StandardError; end - - REDIS_KEY = "overlay_relay:pid:%s" - 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 - def start(session, mode: nil) - return if session.terminal? - return unless acquire_start_lock!(session.id) - - begin - terminate_all_session_processes!(session.id) - - Streams::Overlay::Refresh.call(session) - - dir = Streams::Overlay.overlay_dir(session.id) - FileUtils.mkdir_p(dir) - png = Streams::Overlay.overlay_png_path(session) - pipe = Streams::Overlay.overlay_pipe_path(session) - ensure_pipe!(pipe) - log_path = log_file(session) - FileUtils.mkdir_p(File.dirname(log_path)) - - mode = normalize_mode(mode || desired_mode(session)) - ffmpeg_pid = Process.spawn(*ffmpeg_command(session, pipe, mode), %i[out err] => log_path, pgroup: true) - spawn_png_feeder!(ffmpeg_pid, pipe, png, log_path) - Process.detach(ffmpeg_pid) - store_pid(session.id, ffmpeg_pid) - store_mode(session.id, mode) - mode == MODE_LIVE ? mark_live_intake!(session.id) : clear_live_intake!(session.id) - OverlayRefreshJob.schedule_chain(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 - ensure - release_start_lock!(session.id) - end - rescue Errno::ENOENT => e - raise Error, "ffmpeg non disponibile: #{e.message}" - end - - def stop(session) - pids = session_process_pids(session.id) - 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? - - pids.each { |p| terminate_pid(p) } - clear_pid(session.id) - clear_mode(session.id) - clear_live_intake!(session.id) - pipe = Streams::Overlay.overlay_pipe_path(session) - FileUtils.rm_f(pipe) if pipe.exist? - OverlayRefreshJob.cancel_chain(session.id) - Rails.logger.info("[OverlayRelay] stopped pids=#{pids.join(",")} session=#{session.id}") - true - end - - def running?(session_id) - return false unless overlay_relay_process_check_local? - - alive = session_process_pids(session_id).select { |p| process_alive?(p) } - return true if alive.any? - - 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 - - # Riavvia il relay se il processo è morto o _air non pubblica (es. feeder bloccato). - def ensure_publishing!(session) - 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) && 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 - end - - if running?(session.id) - misses = redis.incr(overlay_air_miss_key(session.id)).to_i - redis.expire(overlay_air_miss_key(session.id), 120) - return if misses < 4 - - redis.del(overlay_air_miss_key(session.id)) - Rails.logger.warn("[OverlayRelay] _air non attivo dopo #{misses} tentativi, restart session=#{session.id}") - stop(session) - else - redis.del(overlay_air_miss_key(session.id)) - end - - start(session, mode: wanted_mode) - rescue Error => e - Rails.logger.warn("[OverlayRelay] ensure_publishing session=#{session.id}: #{e.message}") - 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 - - def mediamtx_rtmp_url - ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935").chomp("/") - 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). - def output_video_kbps(session) - override = ENV["OVERLAY_RELAY_VIDEO_KBPS"].presence&.to_i - return override if override&.positive? - - source_kbps = [(session.target_bitrate.to_i / 1000), 3000].max - [(source_kbps * 2.0).to_i, 5500].max - end - - def bitrate_for(session) - "#{output_video_kbps(session)}k" - end - - def maxrate_for(session) - kbps = (output_video_kbps(session) * 1.15).to_i - "#{kbps}k" - end - - def bufsize_for(session) - kbps = output_video_kbps(session) * 2 - "#{kbps}k" - end - - def output_fps - ENV.fetch("OVERLAY_RELAY_FPS", "30").to_i - end - - def x264_preset - ENV.fetch("OVERLAY_RELAY_X264_PRESET", "fast") - end - - def overlay_input_fps - ENV.fetch("OVERLAY_INPUT_FPS", "2").to_i - end - - def ensure_pipe!(path) - FileUtils.rm_f(path) if path.exist? && !path.pipe? - system("mkfifo", path.to_s) unless path.exist? - raise Error, "impossibile creare fifo overlay #{path}" unless path.pipe? - end - - def spawn_png_feeder!(ffmpeg_pid, pipe, png, log_path) - feeder = Rails.root.join("bin/overlay_png_feeder.rb") - poll = (1.0 / overlay_input_fps).round(2) - Process.spawn( - RbConfig.ruby, feeder.to_s, pipe.to_s, png.to_s, poll.to_s, - %i[out err] => log_path, - pgroup: ffmpeg_pid - ) - end - - def overlay_relay_process_check_local? - ENV["OVERLAY_RELAY_PROCESS_CHECK"] != "false" - end - - def overlay_path_publishing?(session) - info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == session.mediamtx_overlay_path_name } - info && info["online"] && info["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 - - def log_file(session) - Rails.root.join("log", "overlay_relay_#{session.id}.log").to_s - end - - def redis - @redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")) - end - - def store_pid(session_id, pid) - redis.set(format(REDIS_KEY, session_id), pid, ex: 48.hours.to_i) - end - - def clear_pid(session_id) - redis.del(format(REDIS_KEY, session_id)) - end - - def pid_for(session_id) - redis.get(format(REDIS_KEY, session_id)) - end - - def overlay_air_miss_key(session_id) - format("overlay_relay:air_miss:%s", session_id) - 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) - stat = File.read("/proc/#{pid.to_i}/stat") - return false if stat.split[2] == "Z" - - Process.kill(0, pid.to_i) - true - rescue Errno::ESRCH, Errno::EPERM, Errno::ENOENT - false - end - - def terminate_pid(pid) - Process.kill("TERM", -pid.to_i) - sleep 0.5 - rescue Errno::ESRCH - nil - else - begin - Process.kill("KILL", -pid.to_i) - rescue Errno::ESRCH - nil - end - end - end - end -end diff --git a/backend/app/services/streams/youtube_relay.rb b/backend/app/services/streams/youtube_relay.rb index dc18b0a..71d8871 100644 --- a/backend/app/services/streams/youtube_relay.rb +++ b/backend/app/services/streams/youtube_relay.rb @@ -116,7 +116,7 @@ module Streams raise Error, "ffmpeg non disponibile: #{e.message}" end - # _air RTMP (copy) se pronto; altrimenti HLS via proxy Rails. + # RTMP grezzo da telefono (overlay bruciato lato app); fallback HLS via proxy Rails. def youtube_ffmpeg_args(intake_source, output) mode, url = intake_source if mode == :rtmp diff --git a/backend/app/views/public/live/index.html.erb b/backend/app/views/public/live/index.html.erb index 428cd3a..a726362 100644 --- a/backend/app/views/public/live/index.html.erb +++ b/backend/app/views/public/live/index.html.erb @@ -61,7 +61,7 @@
<% @sessions.each do |session| %> <% match = session.match %> - <% on_air = @online_paths.include?(session.mediamtx_overlay_path_name) || @online_paths.include?(session.mediamtx_path_name) %> + <% on_air = @online_paths.include?(session.mediamtx_path_name) %>
<%= live_match_card_heading(match) %>

diff --git a/backend/app/views/public/live/show.html.erb b/backend/app/views/public/live/show.html.erb index 354627f..65209ca 100644 --- a/backend/app/views/public/live/show.html.erb +++ b/backend/app/views/public/live/show.html.erb @@ -41,7 +41,7 @@ <% else %>

- <%# Tabellone, badge stato e brand sono bruciati nel video (Streams::OverlayRelay). %> + <%# Tabellone e watermark sono bruciati nel video dall'app mobile (overlay client-side). %> diff --git a/backend/bin/overlay_png_feeder.rb b/backend/bin/overlay_png_feeder.rb deleted file mode 100644 index 4ead83a..0000000 --- a/backend/bin/overlay_png_feeder.rb +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env ruby -# Invia overlay.png aggiornata a ffmpeg via image2pipe (FIFO). -# ffmpeg con -loop 1 legge il file una sola volta: questo processo rilegge il PNG -# quando cambia mtime (punteggio, badge stato, …). -require "fileutils" - -pipe_path = ARGV.fetch(0) -png_path = ARGV.fetch(1) -poll_sec = (ARGV[2] || ENV.fetch("OVERLAY_PIPE_POLL_SEC", "0.4")).to_f - -abort "pipe mancante: #{pipe_path}" unless File.exist?(pipe_path) -abort "png mancante: #{png_path}" unless File.exist?(png_path) - -stop = false -trap("TERM") { stop = true } -trap("INT") { stop = true } - -# image2pipe richiede frame continui: inviamo la PNG a ritmo fisso ( anche se invariata ). -last_bytes = nil -File.open(pipe_path, "wb") do |pipe| - loop do - break if stop - - if File.exist?(png_path) - bytes = File.binread(png_path) - # Evita frame corrotti durante rsvg-convert (file parziale prima del rename atomico). - next if bytes.bytesize < 512 - - if bytes != last_bytes - pipe.write(bytes) - pipe.flush - last_bytes = bytes - elsif last_bytes - pipe.write(last_bytes) - pipe.flush - end - end - - sleep poll_sec - end -rescue Errno::EPIPE - # ffmpeg chiuso -end diff --git a/backend/lib/tasks/stream_overlay.rake b/backend/lib/tasks/stream_overlay.rake deleted file mode 100644 index 981fae1..0000000 --- a/backend/lib/tasks/stream_overlay.rake +++ /dev/null @@ -1,18 +0,0 @@ -namespace :stream_overlay do - desc "Avvia path _air e OverlayRelay per sessioni attive (dopo deploy overlay server)" - task start_active: :environment do - sessions = StreamSession.where(status: %w[live connecting reconnecting paused]) - mtx = Mediamtx::Client.new - sessions.find_each do |session| - begin - mtx.create_overlay_path(session) - rescue Mediamtx::Client::Error => e - Rails.logger.info("[stream_overlay:start_active] path #{session.id}: #{e.message}") - end - Streams::OverlayRelay.start(session) - puts "Overlay avviato per sessione #{session.id}" - rescue StandardError => e - warn "Sessione #{session.id}: #{e.message}" - end - end -end diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index c4a9abd..65c4feb 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -115,7 +115,6 @@ services: condition: service_started volumes: - recordings:/recordings - - stream_overlays:/app/tmp/stream_overlays healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"] interval: 15s @@ -163,7 +162,6 @@ services: REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-} REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-} REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true} - OVERLAY_RELAY_PROCESS_CHECK: "true" depends_on: rails: condition: service_healthy @@ -171,7 +169,6 @@ services: condition: service_started volumes: - recordings:/recordings - - stream_overlays:/app/tmp/stream_overlays garage: image: dxflrs/garage:v1.0.1 @@ -187,6 +184,5 @@ volumes: postgres_data: redis_data: recordings: - stream_overlays: garage_meta: garage_data: diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt index b02168c..59bf562 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt @@ -2,6 +2,7 @@ package com.matchlivetv.match_live_tv.streaming import android.content.Context import android.os.Build +import com.matchlivetv.match_live_tv.streaming.overlay.OverlayState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -28,6 +29,10 @@ class LiveBroadcastCoordinator(context: Context) { engine.resumeAfterConfigurationChange() } + fun updateOverlay(state: OverlayState) { + engine.updateOverlay(state) + } + fun startService() { val intent = LiveBroadcastService.startIntent(appContext) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt index 2d503b5..ec22575 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt @@ -10,6 +10,8 @@ import android.view.WindowManager import com.pedro.common.ConnectChecker import com.pedro.library.generic.GenericStream import com.pedro.library.util.SensorRotationManager +import com.matchlivetv.match_live_tv.streaming.overlay.OverlayRenderer +import com.matchlivetv.match_live_tv.streaming.overlay.OverlayState import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger @@ -39,6 +41,7 @@ class LiveBroadcastEngine( private var currentFps: Int = 0 private val pausingIntentionally = AtomicBoolean(false) private val reconnectAttempts = AtomicInteger(0) + private val overlayRenderer = OverlayRenderer(appContext) private val rebindRunnable = Runnable { reattachPreviewIfNeeded() @@ -56,6 +59,13 @@ class LiveBroadcastEngine( emitMetrics() } + /** Aggiorna tabellone, watermark e futuri elementi grafici sul frame video. */ + fun updateOverlay(state: OverlayState) { + mainHandler.post { + overlayRenderer.update(state, isPortrait = lastAppliedPortrait == true) + } + } + fun bindPreview(view: LivePreviewView) { mainHandler.post { previewView = view @@ -208,6 +218,7 @@ class LiveBroadcastEngine( if (stream.isStreaming) stream.stopStream() if (stream.isOnPreview) stream.stopPreview() } + overlayRenderer.detach() genericStream = null surfaceSuspended = false rebindPending = false @@ -303,10 +314,26 @@ class LiveBroadcastEngine( resetOrientationTracking() applyInitialOrientation() + attachOverlayFilter(cfg) startSensor() return true } + private fun attachOverlayFilter(cfg: BroadcastConfig) { + val stream = genericStream ?: return + overlayRenderer.detach() + overlayRenderer.attach( + gl = stream.getGlInterface(), + width = cfg.width, + height = cfg.height, + isPortrait = lastAppliedPortrait == true, + ) + } + + private fun refreshOverlayLayout(isPortrait: Boolean) { + overlayRenderer.refreshOrientation(isPortrait) + } + /** Stessa convenzione di SensorRotationManager (0° = portrait naturale). */ private fun orientationFromDisplayRotation(): Pair { @Suppress("DEPRECATION") @@ -402,6 +429,7 @@ class LiveBroadcastEngine( lastAppliedRotation = rotation lastAppliedPortrait = isPortrait pendingIsPortrait = isPortrait + config?.let { refreshOverlayLayout(isPortrait) } Log.i(TAG, "$logLabel orientation rotation=$rotation portrait=$isPortrait phase=$phase") }.getOrElse { Log.w(TAG, "$logLabel orientation failed: ${it.message}") diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt new file mode 100644 index 0000000..ae79f7d --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt @@ -0,0 +1,71 @@ +package com.matchlivetv.match_live_tv.streaming.overlay + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import kotlin.math.max +import kotlin.math.roundToInt + +/** + * Compone gli [OverlayElement] su un bitmap trasparente alle dimensioni del frame encoder. + */ +class OverlayCanvasRenderer(context: Context) { + + private val elements: List = listOf( + WatermarkElement(context), + ScoreboardElement(), + TimerElement(), + SponsorElement(), + ) + + private var bitmap: Bitmap? = null + private var canvasWidth = 0 + private var canvasHeight = 0 + + fun render(state: OverlayState, isPortraitContent: Boolean): Bitmap { + val width = canvasWidth + val height = canvasHeight + require(width > 0 && height > 0) { "Canvas size not configured" } + + val bmp = obtainBitmap(width, height) + bmp.eraseColor(Color.TRANSPARENT) + val canvas = Canvas(bmp) + + val margin = max(12, (width * 0.012f).roundToInt()) + val layout = OverlayLayout( + canvasWidth = width, + canvasHeight = height, + marginPx = margin, + isPortraitContent = isPortraitContent, + ) + elements.forEach { it.draw(canvas, state, layout) } + return bmp + } + + fun setCanvasSize(width: Int, height: Int) { + if (width == canvasWidth && height == canvasHeight) return + canvasWidth = width + canvasHeight = height + bitmap?.recycle() + bitmap = null + } + + fun release() { + bitmap?.recycle() + bitmap = null + canvasWidth = 0 + canvasHeight = 0 + } + + private fun obtainBitmap(width: Int, height: Int): Bitmap { + val existing = bitmap + if (existing != null && !existing.isRecycled && + existing.width == width && existing.height == height + ) { + return existing + } + existing?.recycle() + return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { bitmap = it } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayElement.kt new file mode 100644 index 0000000..c779f33 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayElement.kt @@ -0,0 +1,16 @@ +package com.matchlivetv.match_live_tv.streaming.overlay + +import android.graphics.Canvas + +/** Layout normalizzato sul canvas encoder (720p, 1080p, …). */ +data class OverlayLayout( + val canvasWidth: Int, + val canvasHeight: Int, + val marginPx: Int, + val isPortraitContent: Boolean, +) + +/** Componente grafico modulare da comporre sull'overlay. */ +interface OverlayElement { + fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt new file mode 100644 index 0000000..ca22854 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt @@ -0,0 +1,40 @@ +package com.matchlivetv.match_live_tv.streaming.overlay + +import com.matchlivetv.match_live_tv.domain.ScoreState +import com.matchlivetv.match_live_tv.streaming.BroadcastPhase + +fun ScoreState.toScoreboardState(homeTeamName: String, awayTeamName: String): ScoreboardState { + val columns = buildList { + setPartials.forEach { partial -> + add( + ScoreboardSetColumn( + setNumber = partial.set, + homePoints = partial.home, + awayPoints = partial.away, + isCurrent = false, + ), + ) + } + add( + ScoreboardSetColumn( + setNumber = currentSet, + homePoints = homePoints, + awayPoints = awayPoints, + isCurrent = true, + ), + ) + } + return ScoreboardState( + homeTeamName = homeTeamName, + awayTeamName = awayTeamName, + columns = columns, + ) +} + +fun BroadcastPhase.toOverlayStatus(): BroadcastOverlayStatus = when (this) { + BroadcastPhase.LIVE -> BroadcastOverlayStatus.LIVE + BroadcastPhase.PAUSED -> BroadcastOverlayStatus.PAUSED + BroadcastPhase.CONNECTING, BroadcastPhase.RECONNECTING -> BroadcastOverlayStatus.CONNECTING + BroadcastPhase.PREVIEW, BroadcastPhase.IDLE -> BroadcastOverlayStatus.PREVIEW + BroadcastPhase.ERROR -> BroadcastOverlayStatus.CONNECTING +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayRenderer.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayRenderer.kt new file mode 100644 index 0000000..a5226cd --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayRenderer.kt @@ -0,0 +1,94 @@ +package com.matchlivetv.match_live_tv.streaming.overlay + +import android.content.Context +import android.util.Log +import com.pedro.encoder.input.gl.render.filters.`object`.ImageObjectFilterRender +import com.pedro.encoder.utils.gl.TranslateTo +import com.pedro.library.view.GlInterface + +/** + * Applica l'overlay grafico nella pipeline OpenGL di RootEncoder (preview + RTMP + recording), + * senza ricodifica aggiuntiva: un solo [ImageObjectFilterRender] composita il bitmap sul frame camera. + */ +class OverlayRenderer(context: Context) { + + private val canvasRenderer = OverlayCanvasRenderer(context.applicationContext) + private var filter: ImageObjectFilterRender? = null + private var glInterface: GlInterface? = null + private var streamWidth = 0 + private var streamHeight = 0 + private var isPortraitContent = false + private var attached = false + private var lastFingerprint = Int.MIN_VALUE + private var pendingState: OverlayState? = null + + fun attach(gl: GlInterface, width: Int, height: Int, isPortrait: Boolean = false) { + if (attached && gl === glInterface && width == streamWidth && height == streamHeight) { + isPortraitContent = isPortrait + pendingState?.let { pushState(it) } + return + } + + detach() + glInterface = gl + streamWidth = width + streamHeight = height + isPortraitContent = isPortrait + canvasRenderer.setCanvasSize(width, height) + + val overlayFilter = ImageObjectFilterRender() + gl.addFilter(overlayFilter) + overlayFilter.setPosition(TranslateTo.TOP_LEFT) + overlayFilter.setDefaultScale(width, height) + filter = overlayFilter + attached = true + lastFingerprint = Int.MIN_VALUE + + pendingState?.let { pushState(it) } + Log.i(TAG, "overlay attached ${width}x$height portrait=$isPortrait") + } + + fun update(state: OverlayState, isPortrait: Boolean = isPortraitContent) { + isPortraitContent = isPortrait + pendingState = state + if (!attached) return + pushState(state) + } + + /** Ridisegna l'overlay dopo rotazione dispositivo (senza ri-attach GL). */ + fun refreshOrientation(isPortrait: Boolean) { + if (isPortraitContent == isPortrait && attached) return + isPortraitContent = isPortrait + lastFingerprint = Int.MIN_VALUE + pendingState?.let { pushState(it) } + } + + fun detach() { + if (attached) { + runCatching { glInterface?.clearFilters() } + .onFailure { Log.w(TAG, "clearFilters: ${it.message}") } + } + filter = null + glInterface = null + attached = false + lastFingerprint = Int.MIN_VALUE + canvasRenderer.release() + } + + private fun pushState(state: OverlayState) { + val fp = state.fingerprint() + if (fp == lastFingerprint) return + lastFingerprint = fp + + val bmp = canvasRenderer.render(state, isPortraitContent) + filter?.let { overlayFilter -> + overlayFilter.setDefaultScale(streamWidth, streamHeight) + overlayFilter.setPosition(TranslateTo.TOP_LEFT) + overlayFilter.setImage(bmp) + } + } + + companion object { + private const val TAG = "OverlayRenderer" + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt new file mode 100644 index 0000000..c3605ac --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt @@ -0,0 +1,58 @@ +package com.matchlivetv.match_live_tv.streaming.overlay + +/** + * Stato grafico dell'overlay video. Indipendente dalla logica sportiva: + * il tabellone è descritto da [ScoreboardState], non da regole di gioco. + */ +data class ScoreboardSetColumn( + val setNumber: Int, + val homePoints: Int, + val awayPoints: Int, + val isCurrent: Boolean, +) + +data class ScoreboardState( + val homeTeamName: String, + val awayTeamName: String, + val columns: List, + val homeAccentColor: Int = 0xFFFF2D2D.toInt(), + val awayAccentColor: Int = 0xFF1E3A8A.toInt(), +) + +enum class BroadcastOverlayStatus { + PREVIEW, + CONNECTING, + LIVE, + PAUSED, +} + +data class OverlayState( + val scoreboard: ScoreboardState? = null, + val watermarkVisible: Boolean = true, + val broadcastStatus: BroadcastOverlayStatus = BroadcastOverlayStatus.LIVE, + val timerText: String? = null, + val sponsorText: String? = null, +) { + fun fingerprint(): Int { + var result = watermarkVisible.hashCode() + result = 31 * result + broadcastStatus.hashCode() + result = 31 * result + (timerText?.hashCode() ?: 0) + result = 31 * result + (sponsorText?.hashCode() ?: 0) + result = 31 * result + (scoreboard?.fingerprint() ?: 0) + return result + } +} + +private fun ScoreboardState.fingerprint(): Int { + var result = homeTeamName.hashCode() + result = 31 * result + awayTeamName.hashCode() + result = 31 * result + homeAccentColor + result = 31 * result + awayAccentColor + columns.forEach { col -> + result = 31 * result + col.setNumber + result = 31 * result + col.homePoints + result = 31 * result + col.awayPoints + result = 31 * result + col.isCurrent.hashCode() + } + return result +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt new file mode 100644 index 0000000..15cb625 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt @@ -0,0 +1,193 @@ +package com.matchlivetv.match_live_tv.streaming.overlay + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import android.text.TextPaint +import android.text.TextUtils +import kotlin.math.max +import kotlin.math.roundToInt + +/** + * Tabellone stile broadcast (SportCam): alto-sinistra, una riga per squadra, + * colonne allineate per set conclusi + set in corso. + */ +class ScoreboardElement : OverlayElement { + + private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(210, 12, 12, 12) + } + private val headerPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#888888") + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + } + private val teamPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + } + private val scorePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + textAlign = Paint.Align.CENTER + } + private val accentBarPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val winPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#FF2D2D") + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + textAlign = Paint.Align.CENTER + } + private val footerPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#CCCCCC") + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + } + private val rect = RectF() + + override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) { + val board = state.scoreboard ?: return + if (board.columns.isEmpty()) return + + val scale = layout.canvasHeight / 720f * SIZE_BOOST + val pad = (8f * scale).roundToInt().coerceAtLeast(6) + val colorBarW = (5f * scale).roundToInt().coerceAtLeast(4) + val teamW = (118f * scale).roundToInt() + val colW = (28f * scale).roundToInt().coerceAtLeast(22) + val headH = (16f * scale).roundToInt().coerceAtLeast(12) + val rowH = (20f * scale).roundToInt().coerceAtLeast(16) + val footerH = (18f * scale).roundToInt().coerceAtLeast(14) + val n = board.columns.size + + val boxW = pad * 2 + colorBarW + teamW + n * colW + val boxH = pad * 2 + headH + rowH * 2 + footerH + val left = layout.marginPx + val top = layout.marginPx + + rect.set(left.toFloat(), top.toFloat(), (left + boxW).toFloat(), (top + boxH).toFloat()) + canvas.drawRoundRect(rect, 6f * scale, 6f * scale, backgroundPaint) + + val headerSize = (10f * scale).coerceAtLeast(8f) + val teamSize = (11f * scale).coerceAtLeast(9f) + val scoreSize = (13f * scale).coerceAtLeast(10f) + val footerSize = (10f * scale).coerceAtLeast(8f) + headerPaint.textSize = headerSize + teamPaint.textSize = teamSize + scorePaint.textSize = scoreSize + winPaint.textSize = scoreSize + footerPaint.textSize = footerSize + + val scoresLeft = left + pad + colorBarW + teamW + val headerBaseline = top + pad + headH - (2f * scale) + + board.columns.forEachIndexed { index, column -> + val cx = scoresLeft + index * colW + colW / 2f + canvas.drawText(column.setNumber.toString(), cx, headerBaseline, headerPaint) + } + + val homeRowTop = top + pad + headH + val awayRowTop = homeRowTop + rowH + drawTeamRow( + canvas = canvas, + board = board, + isHome = true, + rowTop = homeRowTop, + left = left, + pad = pad, + colorBarW = colorBarW, + teamW = teamW, + colW = colW, + rowH = rowH, + scoresLeft = scoresLeft, + scale = scale, + ) + drawTeamRow( + canvas = canvas, + board = board, + isHome = false, + rowTop = awayRowTop, + left = left, + pad = pad, + colorBarW = colorBarW, + teamW = teamW, + colW = colW, + rowH = rowH, + scoresLeft = scoresLeft, + scale = scale, + ) + + val currentSet = board.columns.lastOrNull { it.isCurrent }?.setNumber ?: board.columns.size + val footerText = ordinalSetLabel(currentSet) + val footerBaseline = rect.bottom - pad - (2f * scale) + canvas.drawText(footerText, rect.left + pad + colorBarW + 4f * scale, footerBaseline, footerPaint) + } + + private fun drawTeamRow( + canvas: Canvas, + board: ScoreboardState, + isHome: Boolean, + rowTop: Int, + left: Int, + pad: Int, + colorBarW: Int, + teamW: Int, + colW: Int, + rowH: Int, + scoresLeft: Int, + scale: Float, + ) { + accentBarPaint.color = if (isHome) board.homeAccentColor else board.awayAccentColor + val barLeft = (left + pad).toFloat() + val barTop = rowTop + (2f * scale) + val barBottom = rowTop + rowH - (2f * scale) + canvas.drawRect(barLeft, barTop, barLeft + colorBarW, barBottom, accentBarPaint) + + val rawName = if (isHome) board.homeTeamName else board.awayTeamName + val name = abbreviate(rawName).uppercase() + val nameMaxW = teamW - (8f * scale) + val displayName = ellipsize(name, teamPaint, nameMaxW) + val nameBaseline = rowTop + rowH * 0.72f + canvas.drawText(displayName, barLeft + colorBarW + 6f * scale, nameBaseline, teamPaint) + + board.columns.forEachIndexed { index, column -> + val cx = scoresLeft + index * colW + colW / 2f + val score = if (isHome) column.homePoints else column.awayPoints + val paint = scorePaintFor(column, isHome) + canvas.drawText(score.toString(), cx, nameBaseline, paint) + } + } + + private fun scorePaintFor(column: ScoreboardSetColumn, isHome: Boolean): TextPaint { + if (column.isCurrent) return scorePaint + val homeWon = column.homePoints > column.awayPoints + val awayWon = column.awayPoints > column.homePoints + return when { + isHome && homeWon -> winPaint + !isHome && awayWon -> winPaint + else -> scorePaint + } + } + + private fun abbreviate(name: String): String { + val trimmed = name.trim() + if (trimmed.length <= 12) return trimmed + return trimmed.take(11) + "…" + } + + private fun ellipsize(text: String, paint: TextPaint, maxWidth: Float): String { + val safeMax = max(32f, maxWidth) + return TextUtils.ellipsize(text, paint, safeMax, TextUtils.TruncateAt.END)?.toString() ?: text + } + + companion object { + private const val SIZE_BOOST = 1.1f + } + + private fun ordinalSetLabel(setNumber: Int): String = when (setNumber) { + 1 -> "1° set" + 2 -> "2° set" + 3 -> "3° set" + 4 -> "4° set" + 5 -> "5° set" + else -> "Set $setNumber" + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/SponsorElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/SponsorElement.kt new file mode 100644 index 0000000..4afd5bc --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/SponsorElement.kt @@ -0,0 +1,24 @@ +package com.matchlivetv.match_live_tv.streaming.overlay + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface + +/** Placeholder lower-third sponsor (MVP: disegna solo se [OverlayState.sponsorText] è valorizzato). */ +class SponsorElement : OverlayElement { + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(220, 245, 197, 24) + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + } + + override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) { + val text = state.sponsorText?.takeIf { it.isNotBlank() } ?: return + paint.textSize = layout.canvasHeight * 0.045f + val width = paint.measureText(text) + val x = (layout.canvasWidth - width) / 2f + val y = layout.canvasHeight - layout.marginPx * 2.2f + canvas.drawText(text, x, y, paint) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt new file mode 100644 index 0000000..865c7e2 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt @@ -0,0 +1,23 @@ +package com.matchlivetv.match_live_tv.streaming.overlay + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface + +/** Placeholder per cronometro / timeout (MVP: disegna solo se [OverlayState.timerText] è valorizzato). */ +class TimerElement : OverlayElement { + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + } + + override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) { + val text = state.timerText?.takeIf { it.isNotBlank() } ?: return + paint.textSize = layout.canvasHeight * 0.05f + val x = layout.marginPx.toFloat() + val y = layout.canvasHeight - layout.marginPx.toFloat() + canvas.drawText(text, x, y, paint) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/WatermarkElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/WatermarkElement.kt new file mode 100644 index 0000000..6bb2843 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/WatermarkElement.kt @@ -0,0 +1,35 @@ +package com.matchlivetv.match_live_tv.streaming.overlay + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Paint +import com.matchlivetv.match_live_tv.R +import kotlin.math.roundToInt + +class WatermarkElement(context: Context) : OverlayElement { + + private val logo: Bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.logo_white) + private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { + alpha = 255 + } + + override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) { + if (!state.watermarkVisible) return + + val targetWidth = (layout.canvasWidth * LOGO_WIDTH_RATIO).roundToInt().coerceIn(28, 72) + val scale = targetWidth.toFloat() / logo.width + val targetHeight = (logo.height * scale).roundToInt() + + val left = layout.canvasWidth - layout.marginPx - targetWidth + val top = layout.canvasHeight - layout.marginPx - targetHeight + + val dest = android.graphics.Rect(left, top, left + targetWidth, top + targetHeight) + canvas.drawBitmap(logo, null, dest, paint) + } + + companion object { + private const val LOGO_WIDTH_RATIO = 0.055f + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt index 408b2ca..26ebdf7 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt @@ -44,6 +44,9 @@ import com.matchlivetv.match_live_tv.domain.StreamSession import com.matchlivetv.match_live_tv.streaming.BroadcastConfig import com.matchlivetv.match_live_tv.streaming.BroadcastPhase import com.matchlivetv.match_live_tv.streaming.LivePreviewView +import com.matchlivetv.match_live_tv.streaming.overlay.OverlayState +import com.matchlivetv.match_live_tv.streaming.overlay.toOverlayStatus +import com.matchlivetv.match_live_tv.streaming.overlay.toScoreboardState import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton @@ -187,6 +190,17 @@ fun BroadcastScreen( loading = false } + LaunchedEffect(score, match, metrics.phase) { + val currentMatch = match ?: return@LaunchedEffect + container.broadcastCoordinator.updateOverlay( + OverlayState( + scoreboard = score.toScoreboardState(currentMatch.teamName, currentMatch.opponentName), + watermarkVisible = true, + broadcastStatus = metrics.phase.toOverlayStatus(), + ), + ) + } + LaunchedEffect(sessionId) { while (true) { delay(10_000) diff --git a/native/android/app/src/main/res/drawable/logo_white.png b/native/android/app/src/main/res/drawable/logo_white.png index 64281bc..94dd73f 100644 Binary files a/native/android/app/src/main/res/drawable/logo_white.png and b/native/android/app/src/main/res/drawable/logo_white.png differ