Aggiunge overlay server-side burn-in e stabilizza diretta live.

Tabellone, badge e brand sono ricodificati nel flusso _air via OverlayRelay;
sync punteggio HTTP, volume condiviso rails/sidekiq, qualità 720p migliorata
e badge CONNECTING in ripresa da pausa.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 23:55:17 +02:00
parent fd225fbadd
commit ab9cb02083
58 changed files with 2423 additions and 242 deletions

View File

@@ -1,6 +1,7 @@
package com.matchlivetv.match_live_tv
import android.content.Context
import android.media.MediaCodecInfo
import android.os.Build
import android.os.Handler
import android.os.Looper
@@ -21,8 +22,9 @@ data class StreamingConfig(
val audioBitrate: Int = 128_000,
val fps: Int = 30,
val rotation: Int = 0,
val sampleRate: Int = 44_100,
val stereo: Boolean = true,
// Allineato a MediaMTX alwaysAvailable slate (offline.mp4: 48 kHz mono)
val sampleRate: Int = 48_000,
val stereo: Boolean = false,
val maxReconnectAttempts: Int = 10,
val reconnectDelayMs: Long = 5_000L,
)
@@ -144,11 +146,14 @@ class StreamingEngine(
stopPreviewAndStreamForPrepare(stream)
val prepared = try {
stream.prepareVideo(
cfg.width,
cfg.height,
cfg.videoBitrate,
cfg.fps,
width = cfg.width,
height = cfg.height,
bitrate = cfg.videoBitrate,
fps = cfg.fps,
iFrameInterval = 1,
rotation = cfg.rotation,
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
level = MediaCodecInfo.CodecProfileLevel.AVCLevel31,
) && stream.prepareAudio(
cfg.sampleRate,
cfg.stereo,
@@ -181,6 +186,45 @@ class StreamingEngine(
}
}
/** Ripresa dopo pausa: stop completo poi ripubblica (evita doppio start e RTMP bloccato). */
fun resumeStream(streamConfig: StreamingConfig) {
ensureMainThread()
config = streamConfig
lastError = null
reconnectAttempts.set(0)
stopMetricsLoop()
genericStream?.let { stream ->
pausingIntentionally.set(true)
try {
if (stream.isStreaming) {
stream.stopStream()
}
} finally {
pausingIntentionally.set(false)
}
}
if (state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING
) {
pauseStream()
}
streamStartedAtMs = 0L
currentBitrateKbps = 0L
currentFps = 0
updateState(StreamingState.PREVIEWING)
startMetricsLoop()
mainHandler.postDelayed({
if (!isReleased.get()) {
startStream(streamConfig)
}
}, 200)
}
fun startStream(streamConfig: StreamingConfig) {
ensureMainThread()
if (isReleased.get()) {
@@ -204,11 +248,14 @@ class StreamingEngine(
val prepared = try {
stream.prepareVideo(
streamConfig.width,
streamConfig.height,
streamConfig.videoBitrate,
streamConfig.fps,
width = streamConfig.width,
height = streamConfig.height,
bitrate = streamConfig.videoBitrate,
fps = streamConfig.fps,
iFrameInterval = 1,
rotation = streamConfig.rotation,
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
level = MediaCodecInfo.CodecProfileLevel.AVCLevel31,
) && stream.prepareAudio(
streamConfig.sampleRate,
streamConfig.stereo,
@@ -347,12 +394,7 @@ class StreamingEngine(
override fun onNewBitrate(bitrate: Long) {
postMain {
currentBitrateKbps = bitrate / 1000
genericStream?.let { stream ->
bitrateAdapter.adaptBitrate(
bitrate,
stream.getStreamClient().hasCongestion(),
)
}
// Bitrate fissa: l'adattamento on-the-fly causa glitch e GOP instabili su MediaMTX/HLS.
emitMetrics()
}
}
@@ -470,8 +512,8 @@ class StreamingEngine(
val elapsedMs = now - lastFpsSampleAtMs
if (elapsedMs >= 1_000L) {
val frameDelta = sentFrames - lastVideoFrameCount
currentFps = ((frameDelta * 1000L) / elapsedMs).toInt()
val frameDelta = (sentFrames - lastVideoFrameCount).coerceAtLeast(0L)
currentFps = ((frameDelta * 1000L) / elapsedMs).toInt().coerceIn(0, 120)
lastVideoFrameCount = sentFrames
lastFpsSampleAtMs = now
}

View File

@@ -127,6 +127,7 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"startStream" -> startStream(call, result)
"resumeStream" -> resumeStream(call, result)
"stopStream" -> stopStream(result)
"pauseStream" -> pauseStream(result)
"getMetrics" -> getMetrics(result)
@@ -194,6 +195,53 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
}
}
private fun resumeStream(call: MethodCall, result: Result) {
if (activityBinding?.activity == null) {
result.error("no_activity", "Activity non disponibile per lo streaming", null)
return
}
val args = call.arguments as? Map<*, *>
val rtmpUrl = args?.get("rtmpUrl") as? String
if (rtmpUrl.isNullOrBlank()) {
result.error("invalid_args", "rtmpUrl is required", null)
return
}
val config = StreamingConfig(
rtmpUrl = rtmpUrl,
width = (args["width"] as? Number)?.toInt() ?: 1280,
height = (args["height"] as? Number)?.toInt() ?: 720,
videoBitrate = (args["videoBitrate"] as? Number)?.toInt() ?: 2_500_000,
audioBitrate = (args["audioBitrate"] as? Number)?.toInt() ?: 128_000,
fps = (args["fps"] as? Number)?.toInt() ?: 30,
rotation = (args["rotation"] as? Number)?.toInt() ?: 0,
maxReconnectAttempts = (args["maxReconnectAttempts"] as? Number)?.toInt() ?: 10,
reconnectDelayMs = (args["reconnectDelayMs"] as? Number)?.toLong() ?: 5_000L,
)
val engine = getOrCreateEngine()
engine.removeListener(engineListener)
engine.addListener(engineListener)
attachPreviewIfPossible()
val intent = StreamingForegroundService.startIntent(appContext)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
appContext.startForegroundService(intent)
} else {
appContext.startService(intent)
}
bindServiceIfNeeded()
try {
engine.resumeStream(config)
result.success(true)
} catch (exception: Exception) {
appContext.startService(StreamingForegroundService.stopIntent(appContext))
result.error("resume_failed", exception.message, null)
}
}
private fun stopStream(result: Result) {
appContext.startService(StreamingForegroundService.stopIntent(appContext))
getEngine()?.let { engine ->