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