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