Sposta overlay video sull'app Android e rimuove ffmpeg server-side.

Il tabellone stile SportCam e il watermark Match Live TV sono bruciati in GPU via RootEncoder; sul backend spariscono OverlayRelay, job Sidekiq e volume stream_overlays.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-06 18:12:35 +02:00
parent 68b4390282
commit f3ff657fc2
29 changed files with 605 additions and 975 deletions

View File

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

View File

@@ -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<Int, Boolean> {
@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}")

View File

@@ -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<OverlayElement> = 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 }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB