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:
@@ -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) {
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 |
Reference in New Issue
Block a user