Stabilizza live YouTube/RTMP e fix crash rotazione mobile.

Pipeline YouTube con relay, overlay e publisher sync lato backend; fix GL pauseGlDuringRotation su Android per evitare crash in rotazione durante lo streaming Flutter.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-06 15:30:55 +02:00
parent 1058c644bd
commit 854738b46d
65 changed files with 2606 additions and 579 deletions

View File

@@ -1,11 +1,17 @@
package com.matchlivetv.match_live_tv
import android.content.res.Configuration
import android.os.Bundle
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterFragmentActivity() {
override fun onConfigurationChanged(newConfig: Configuration) {
StreamingEngineHolder.engine?.pauseGlDuringRotation()
super.onConfigurationChanged(newConfig)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Termina eventuale diretta rimasta attiva da sessione precedente

View File

@@ -6,10 +6,12 @@ import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import androidx.annotation.RequiresApi
import com.pedro.common.ConnectChecker
import com.pedro.library.generic.GenericStream
import com.pedro.library.util.BitrateAdapter
import com.pedro.library.util.SensorRotationManager
import com.pedro.library.view.OpenGlView
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
@@ -18,10 +20,11 @@ data class StreamingConfig(
val rtmpUrl: String,
val width: Int = 1280,
val height: Int = 720,
val videoBitrate: Int = 2_500_000,
val videoBitrate: Int = 3_000_000,
val audioBitrate: Int = 128_000,
val fps: Int = 30,
val rotation: Int = 0,
val portrait: Boolean = false,
// Allineato a MediaMTX alwaysAvailable slate (offline.mp4: 48 kHz mono)
val sampleRate: Int = 48_000,
val stereo: Boolean = false,
@@ -88,14 +91,55 @@ class StreamingEngine(
private var lastError: String? = null
private var lastVideoFrameCount: Long = 0L
private var lastFpsSampleAtMs: Long = 0L
private var videoStallChecks: Int = 0
private var lastStallFrameCount: Long = -1L
private val reconnectAttempts = AtomicInteger(0)
private val isReleased = AtomicBoolean(false)
private val pausingIntentionally = AtomicBoolean(false)
private var previewRebindUntilMs = 0L
private var previewRebindAttempts = 0
private var sensorRotationManager: SensorRotationManager? = null
private var surfaceLost = false
private var rebindInProgress = false
private var pendingRotation: Int? = null
private var pendingIsPortrait: Boolean? = null
private var lastAppliedRotation = -1
private val bitrateAdapter = BitrateAdapter { adaptedBitrate ->
genericStream?.setVideoBitrateOnFly(adaptedBitrate)
}
private val idlePreviewRunnable = Runnable {
val glView = previewSurface ?: return@Runnable
if (!canAttachPreview(glView)) {
if (previewRebindAttempts < PREVIEW_REBIND_MAX_ATTEMPTS) {
scheduleIdlePreviewAttach(previewRebindAttempts + 1)
}
return@Runnable
}
finishIdlePreviewBind(glView)
}
private val surfaceReadyRunnable = Runnable {
if (isReleased.get() || rebindInProgress) {
return@Runnable
}
val glView = previewSurface ?: return@Runnable
if (!canAttachPreview(glView)) {
if (previewRebindAttempts < PREVIEW_REBIND_MAX_ATTEMPTS) {
scheduleSurfaceReady(previewRebindAttempts + 1)
}
return@Runnable
}
surfaceLost = false
val stream = genericStream
if (stream != null && (stream.isStreaming || stream.isOnPreview)) {
safeReattachPreview(glView)
flushPendingOrientation()
} else {
attachPreviewToStream()
}
}
private val metricsRunnable = object : Runnable {
override fun run() {
emitMetrics()
@@ -117,11 +161,146 @@ class StreamingEngine(
listeners.remove(listener)
}
fun setupPreviewGlView(glView: StreamingOpenGlView) {
ensureMainThread()
updatePreservePipelineFlag(glView)
glView.onPreviewSurfaceLost = { onPlatformSurfaceDestroyed() }
}
fun attachPreviewHost(glView: StreamingOpenGlView) {
ensureMainThread()
setupPreviewGlView(glView)
bindStreamPreview(glView)
}
private fun updatePreservePipelineFlag(glView: StreamingOpenGlView?) {
glView?.preservePipelineOnSurfaceLoss =
state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING
}
/** Surface OpenGL pronta (debounced). */
fun onPlatformSurfaceReady(glView: OpenGlView) {
ensureMainThread()
previewSurface = glView
StreamPreviewHolder.openGlView = glView
previewRebindAttempts = 0
mainHandler.removeCallbacks(surfaceReadyRunnable)
val live = state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING
if (live) {
scheduleSurfaceReady(0)
return
}
scheduleIdlePreviewAttach(0)
}
/**
* Chiamato da MainActivity.onConfigurationChanged *prima* di super, per fermare drawScreen
* sul thread GL encoder prima che Flutter ricrei le surface (evita GL_OUT_OF_MEMORY).
*/
fun pauseGlDuringRotation() {
ensureMainThread()
if (isReleased.get()) {
return
}
val active = state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING ||
state == StreamingState.PREVIEWING
if (!active) {
return
}
Log.i(TAG, "pauseGlDuringRotation state=$state")
beginSurfaceTransition(SURFACE_TRANSITION_GRACE_MS)
}
/** Chiamato quando Flutter distrugge la surface PlatformView durante la rotazione. */
fun onPlatformSurfaceDestroyed() {
ensureMainThread()
beginSurfaceTransition(SURFACE_TRANSITION_GRACE_MS)
}
private fun beginSurfaceTransition(graceMs: Long) {
surfaceLost = true
markPreviewRebinding(graceMs)
mainHandler.removeCallbacks(surfaceReadyRunnable)
mainHandler.removeCallbacks(idlePreviewRunnable)
mainHandler.removeCallbacks(orientationApplyRunnable)
mainHandler.removeCallbacks(postRebindOrientationRunnable)
stopPreviewGlRender()
try {
genericStream?.getGlInterface()?.setForceRender(false)
genericStream?.getGlInterface()?.deAttachPreview()
} catch (exception: Exception) {
Log.w(TAG, "beginSurfaceTransition: ${exception.message}")
}
}
private fun stopPreviewGlRender() {
listOfNotNull(previewSurface, StreamPreviewHolder.openGlView)
.distinct()
.forEach { glView ->
(glView as? StreamingOpenGlView)?.let { updatePreservePipelineFlag(it) }
try {
glView.setForceRender(false)
} catch (_: Exception) {
}
}
}
/** Solo orientamento encoder: niente rebind preview (evita crash GL in rotazione). */
fun applyOrientationFromSensor() {
ensureMainThread()
refreshOrientationSensor()
}
fun bindStreamPreview(glView: OpenGlView) {
ensureMainThread()
previewSurface = glView
StreamPreviewHolder.openGlView = glView
(glView as? StreamingOpenGlView)?.let { updatePreservePipelineFlag(it) }
val live = state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING
if (live) {
onPlatformSurfaceReady(glView)
return
}
bindPreviewForIdle(glView)
}
private fun finishIdlePreviewBind(glView: OpenGlView) {
val stream = genericStream ?: return
if (stream.isOnPreview) {
return
}
try {
attachPreviewToStream()
ensureOrientationSensor()
updateState(StreamingState.PREVIEWING)
startMetricsLoop()
} catch (exception: Exception) {
Log.w(TAG, "finishIdlePreviewBind failed: ${exception.message}")
if (previewRebindAttempts < PREVIEW_REBIND_MAX_ATTEMPTS) {
scheduleIdlePreviewAttach(previewRebindAttempts + 1)
}
}
}
private fun bindPreviewForIdle(glView: OpenGlView) {
val surfaceChanged = previewSurface != null && previewSurface !== glView
previewSurface = glView
if (state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING
) {
return
}
if (surfaceChanged) {
genericStream?.let { stream ->
if (stream.isOnPreview) {
@@ -130,14 +309,6 @@ class StreamingEngine(
}
}
if (state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING
) {
attachPreviewToStream(forceRebind = surfaceChanged)
return
}
val cfg = config ?: previewConfig()
val stream = genericStream ?: obtainGenericStream(cfg)
genericStream = stream
@@ -168,9 +339,12 @@ class StreamingEngine(
}
}
attachPreviewToStream()
updateState(StreamingState.PREVIEWING)
startMetricsLoop()
if (!canAttachPreview(glView)) {
Log.i(TAG, "bindPreviewForIdle waiting for surface state=$state")
scheduleIdlePreviewAttach(0)
return
}
finishIdlePreviewBind(glView)
}
fun unbindStreamPreview() {
@@ -186,47 +360,22 @@ class StreamingEngine(
}
}
/** Ripresa dopo pausa: stop completo poi ripubblica (evita doppio start e RTMP bloccato). */
/** Ripresa dopo pausa: stesso percorso di startStream (evita RTMP solo-audio dopo reconnect). */
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()
Log.i(TAG, "resumeStream reset url=${streamConfig.rtmpUrl}")
stopStream()
updateState(StreamingState.IDLE)
mainHandler.postDelayed({
if (!isReleased.get()) {
startStream(streamConfig)
}
}, 200)
}, 250)
}
fun startStream(streamConfig: StreamingConfig) {
ensureMainThread()
Log.i(TAG, "startStream state=$state url=${streamConfig.rtmpUrl} bitrate=${streamConfig.videoBitrate} fps=${streamConfig.fps}")
if (isReleased.get()) {
notifyError("StreamingEngine rilasciato")
return
@@ -279,17 +428,59 @@ class StreamingEngine(
}
attachPreviewToStream()
lastAppliedRotation = -1
val portraitHint = streamConfig.portrait || streamConfig.height > streamConfig.width
val cameraRotation = if (portraitHint && streamConfig.rotation == 0) 90 else streamConfig.rotation
applyGlOrientation(
rotation = cameraRotation,
isPortrait = portraitHint,
logLabel = "startStream",
)
ensureOrientationSensor()
resetFpsTracking()
videoStallChecks = 0
lastStallFrameCount = -1L
stream.getStreamClient().setReTries(streamConfig.maxReconnectAttempts)
bitrateAdapter.setMaxBitrate(streamConfig.videoBitrate + streamConfig.audioBitrate)
streamStartedAtMs = SystemClock.elapsedRealtime()
updateState(StreamingState.CONNECTING)
stream.startStream(streamConfig.rtmpUrl)
startMetricsLoop()
waitForPreviewThenPublish(streamConfig, attempt = 0)
}
private fun waitForPreviewThenPublish(streamConfig: StreamingConfig, attempt: Int) {
if (isReleased.get()) {
return
}
attachPreviewToStream()
val stream = genericStream
val previewReady = stream?.isOnPreview == true && previewGlView() != null
if (!previewReady) {
if (attempt >= 40) {
notifyError("Anteprima camera non pronta — impossibile inviare video")
updateState(StreamingState.ERROR, "preview_not_ready")
return
}
mainHandler.postDelayed({
waitForPreviewThenPublish(streamConfig, attempt + 1)
}, 50L)
return
}
mainHandler.postDelayed({
if (isReleased.get()) {
return@postDelayed
}
val activeStream = genericStream ?: return@postDelayed
streamStartedAtMs = SystemClock.elapsedRealtime()
updateState(StreamingState.CONNECTING)
Log.i(TAG, "publish RTMP url=${streamConfig.rtmpUrl}")
activeStream.startStream(streamConfig.rtmpUrl)
startMetricsLoop()
}, 350L)
}
fun pauseStream() {
ensureMainThread()
Log.i(TAG, "pauseStream state=$state")
if (state != StreamingState.STREAMING &&
state != StreamingState.CONNECTING &&
state != StreamingState.RECONNECTING
@@ -317,12 +508,15 @@ class StreamingEngine(
fun stopStream() {
ensureMainThread()
Log.i(TAG, "stopStream state=$state")
if (state == StreamingState.IDLE || state == StreamingState.STOPPING) {
return
}
updateState(StreamingState.STOPPING)
stopMetricsLoop()
stopOrientationSensor()
(previewSurface as? StreamingOpenGlView)?.preservePipelineOnSurfaceLoss = false
genericStream?.let { stream ->
if (stream.isStreaming) {
@@ -357,14 +551,20 @@ class StreamingEngine(
override fun onConnectionStarted(url: String) {
postMain {
Log.i(TAG, "onConnectionStarted url=$url")
updateState(StreamingState.CONNECTING, url)
}
}
override fun onConnectionSuccess() {
postMain {
Log.i(TAG, "onConnectionSuccess")
reconnectAttempts.set(0)
lastError = null
resetFpsTracking()
videoStallChecks = 0
lastStallFrameCount = genericStream?.getStreamClient()?.getSentVideoFrames() ?: 0L
(previewSurface as? StreamingOpenGlView)?.let { updatePreservePipelineFlag(it) }
updateState(StreamingState.STREAMING)
}
}
@@ -374,6 +574,7 @@ class StreamingEngine(
if (pausingIntentionally.get()) {
return@postMain
}
Log.w(TAG, "onConnectionFailed reason=$reason state=$state attempts=${reconnectAttempts.get()}")
lastError = reason
val stream = genericStream ?: return@postMain
val currentConfig = config ?: return@postMain
@@ -409,6 +610,7 @@ class StreamingEngine(
if (pausingIntentionally.get()) {
return@postMain
}
Log.w(TAG, "onDisconnect state=$state")
if (state == StreamingState.STREAMING || state == StreamingState.RECONNECTING) {
updateState(StreamingState.RECONNECTING, "disconnected")
}
@@ -417,6 +619,7 @@ class StreamingEngine(
override fun onAuthError() {
postMain {
Log.w(TAG, "onAuthError")
lastError = "auth_error"
updateState(StreamingState.ERROR, "auth_error")
genericStream?.stopStream()
@@ -426,6 +629,7 @@ class StreamingEngine(
override fun onAuthSuccess() {
postMain {
Log.i(TAG, "onAuthSuccess")
updateState(StreamingState.STREAMING)
}
}
@@ -439,18 +643,237 @@ class StreamingEngine(
}
}
private fun previewGlView(): OpenGlView? =
previewSurface ?: StreamPreviewHolder.openGlView
private fun resetFpsTracking() {
lastFpsSampleAtMs = 0L
lastVideoFrameCount = 0L
currentFps = 0
}
private fun attachPreviewToStream(forceRebind: Boolean = false) {
val glView = previewSurface ?: StreamPreviewHolder.openGlView ?: return
val glView = previewGlView() ?: return
previewSurface = glView
val stream = genericStream ?: return
if (forceRebind && stream.isOnPreview) {
val liveState = state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING
if (liveState && (stream.isStreaming || stream.isOnPreview)) {
safeReattachPreview(glView)
return
}
if (forceRebind && stream.isOnPreview && !liveState) {
stream.stopPreview()
}
if (!stream.isOnPreview) {
stream.startPreview(glView, autoHandle = true)
if (!canAttachPreview(glView)) {
return
}
try {
stream.startPreview(glView, autoHandle = false)
ensureOrientationSensor()
} catch (exception: Exception) {
Log.w(TAG, "attachPreviewToStream failed: ${exception.message}")
}
}
}
private fun scheduleIdlePreviewAttach(attempt: Int) {
previewRebindAttempts = attempt
if (attempt >= PREVIEW_REBIND_MAX_ATTEMPTS) {
Log.w(TAG, "idle preview attach gave up after $attempt attempts state=$state")
return
}
val delayMs = if (attempt == 0) IDLE_PREVIEW_ATTACH_MS else PREVIEW_REBIND_RETRY_MS
mainHandler.removeCallbacks(idlePreviewRunnable)
mainHandler.postDelayed(idlePreviewRunnable, delayMs)
}
private fun scheduleSurfaceReady(attempt: Int) {
previewRebindAttempts = attempt
if (attempt >= PREVIEW_REBIND_MAX_ATTEMPTS) {
Log.w(TAG, "surface rebind gave up after $attempt attempts state=$state")
return
}
val delayMs = when {
attempt == 0 -> SURFACE_READY_DEBOUNCE_MS
else -> PREVIEW_REBIND_RETRY_MS
}
mainHandler.postDelayed(surfaceReadyRunnable, delayMs)
}
private fun markPreviewRebinding(durationMs: Long = PREVIEW_REBIND_GRACE_MS) {
previewRebindUntilMs = SystemClock.elapsedRealtime() + durationMs
videoStallChecks = 0
lastStallFrameCount = -1L
}
private fun isPreviewRebinding(): Boolean =
SystemClock.elapsedRealtime() < previewRebindUntilMs
private fun canAttachPreview(glView: OpenGlView): Boolean {
if (!glView.isAttachedToWindow) {
return false
}
return try {
val surface = glView.holder?.surface
surface != null && surface.isValid && glView.width > 0 && glView.height > 0
} catch (_: Exception) {
false
}
}
private fun safeReattachPreview(glView: OpenGlView) {
if (rebindInProgress) {
return
}
val stream = genericStream ?: return
previewSurface = glView
if (!canAttachPreview(glView)) {
scheduleSurfaceReady(previewRebindAttempts + 1)
return
}
val surface = glView.holder?.surface
if (surface == null || !surface.isValid) {
scheduleSurfaceReady(previewRebindAttempts + 1)
return
}
rebindInProgress = true
markPreviewRebinding()
try {
Log.i(
TAG,
"safeReattachPreview state=$state isPreview=${stream.isOnPreview} isStreaming=${stream.isStreaming}",
)
val glInterface = stream.getGlInterface()
val width = glView.width.coerceAtLeast(2)
val height = glView.height.coerceAtLeast(2)
glInterface.setForceRender(false)
glInterface.deAttachPreview()
glInterface.attachPreview(surface)
glInterface.setPreviewResolution(width, height)
glInterface.setForceRender(true, 30)
} catch (exception: Exception) {
Log.w(TAG, "safeReattachPreview failed: ${exception.message}")
scheduleSurfaceReady(previewRebindAttempts + 1)
} finally {
rebindInProgress = false
surfaceLost = false
finishRotationTransition()
}
}
/** Orientamento camera/preview/stream via GL (senza metadata RTMP rotation). */
private fun applyGlOrientation(
rotation: Int,
isPortrait: Boolean,
logLabel: String,
forceRender: Boolean = true,
) {
if (surfaceLost || rebindInProgress || isPreviewRebinding()) {
pendingRotation = rotation
pendingIsPortrait = isPortrait
Log.i(TAG, "$logLabel deferred rotation=$rotation portrait=$isPortrait")
return
}
val stream = genericStream ?: return
val glView = previewSurface
if (glView != null && !canAttachPreview(glView)) {
return
}
try {
val glInterface = stream.getGlInterface()
if (forceRender) {
glInterface.setForceRender(false)
}
glInterface.autoHandleOrientation = false
glInterface.setIsPortrait(isPortrait)
glInterface.setCameraOrientation(rotation)
glInterface.setPreviewRotation(0)
glInterface.setStreamRotation(0)
lastAppliedRotation = rotation
pendingIsPortrait = isPortrait
Log.i(
TAG,
"$logLabel orientation rotation=$rotation portrait=$isPortrait state=$state",
)
} catch (exception: Exception) {
Log.w(TAG, "$logLabel orientation failed: ${exception.message}")
} finally {
if (forceRender) {
try {
stream.getGlInterface().setForceRender(true, 30)
} catch (_: Exception) {
}
}
}
}
private fun ensureOrientationSensor() {
if (sensorRotationManager != null) {
return
}
sensorRotationManager = SensorRotationManager(appContext, true, true) { rotation, isPortrait ->
postMain { applySensorRotation(rotation, isPortrait) }
}
sensorRotationManager?.start()
}
private fun refreshOrientationSensor() {
sensorRotationManager?.stop()
sensorRotationManager = null
ensureOrientationSensor()
}
private fun stopOrientationSensor() {
sensorRotationManager?.stop()
sensorRotationManager = null
}
private val orientationApplyRunnable = Runnable {
val rotation = pendingRotation ?: return@Runnable
val isPortrait = pendingIsPortrait ?: return@Runnable
applySensorRotationNow(rotation, isPortrait)
}
private fun applySensorRotation(rotation: Int, isPortrait: Boolean) {
pendingRotation = rotation
pendingIsPortrait = isPortrait
mainHandler.removeCallbacks(orientationApplyRunnable)
mainHandler.postDelayed(orientationApplyRunnable, ORIENTATION_DEBOUNCE_MS)
}
private fun applySensorRotationNow(rotation: Int, isPortrait: Boolean) {
if (rotation == lastAppliedRotation && !surfaceLost) {
return
}
if (surfaceLost || rebindInProgress || isPreviewRebinding()) {
Log.i(TAG, "sensorOrientation queued rotation=$rotation portrait=$isPortrait")
return
}
applyGlOrientation(rotation, isPortrait, "sensorOrientation")
}
private val postRebindOrientationRunnable = Runnable {
val rotation = pendingRotation ?: return@Runnable
val isPortrait = pendingIsPortrait ?: return@Runnable
if (surfaceLost || rebindInProgress) {
return@Runnable
}
applyGlOrientation(rotation, isPortrait, "postRebind")
}
private fun finishRotationTransition() {
mainHandler.removeCallbacks(postRebindOrientationRunnable)
mainHandler.postDelayed(postRebindOrientationRunnable, POST_REBIND_ORIENTATION_MS)
}
private fun flushPendingOrientation() {
mainHandler.removeCallbacks(orientationApplyRunnable)
mainHandler.postDelayed(orientationApplyRunnable, ORIENTATION_DEBOUNCE_MS)
}
private fun previewConfig(): StreamingConfig {
return config ?: StreamingConfig(
rtmpUrl = "rtmp://127.0.0.1/preview",
@@ -464,7 +887,7 @@ class StreamingEngine(
appContext,
this,
).apply {
getGlInterface().autoHandleOrientation = true
getGlInterface().autoHandleOrientation = false
getStreamClient().setBitrateExponentialFactor(0.5f)
getStreamClient().setReTries(streamConfig.maxReconnectAttempts)
}
@@ -501,10 +924,86 @@ class StreamingEngine(
private fun emitMetrics() {
updateFpsEstimate()
checkVideoStallAndRecover()
val metrics = buildMetrics()
listeners.forEach { it.onMetricsUpdated(metrics) }
}
/** Se RTMP è connesso ma non partono frame video, ripubblica con preview attiva. */
private fun checkVideoStallAndRecover() {
if (isPreviewRebinding()) {
return
}
if (state != StreamingState.STREAMING && state != StreamingState.RECONNECTING) {
videoStallChecks = 0
lastStallFrameCount = -1L
return
}
val stream = genericStream ?: return
if (!stream.isStreaming) {
return
}
val frames = stream.getStreamClient().getSentVideoFrames()
if (lastStallFrameCount < 0L) {
lastStallFrameCount = frames
return
}
if (frames > lastStallFrameCount) {
videoStallChecks = 0
lastStallFrameCount = frames
return
}
videoStallChecks += 1
if (videoStallChecks < 12) {
return
}
videoStallChecks = 0
lastStallFrameCount = -1L
val cfg = config ?: return
recoverVideoStream(cfg)
}
private fun recoverVideoStream(streamConfig: StreamingConfig) {
val stream = genericStream ?: return
Log.w(TAG, "recoverVideoStream state=$state url=${streamConfig.rtmpUrl}")
pausingIntentionally.set(true)
try {
if (stream.isStreaming) {
stream.stopStream()
}
} finally {
pausingIntentionally.set(false)
}
stopPreviewAndStreamForPrepare(stream)
val prepared = try {
stream.prepareVideo(
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,
streamConfig.audioBitrate,
)
} catch (_: Exception) {
false
}
if (!prepared) {
notifyError("Ripristino video fallito")
updateState(StreamingState.ERROR, "video_recover_failed")
return
}
attachPreviewToStream()
ensureOrientationSensor()
resetFpsTracking()
waitForPreviewThenPublish(streamConfig, attempt = 0)
}
private fun updateFpsEstimate() {
val stream = genericStream ?: return
val now = SystemClock.elapsedRealtime()
@@ -552,6 +1051,15 @@ class StreamingEngine(
}
companion object {
private const val TAG = "StreamingEngine"
private const val METRICS_INTERVAL_MS = 1_000L
private const val SURFACE_READY_DEBOUNCE_MS = 700L
private const val IDLE_PREVIEW_ATTACH_MS = 80L
private const val POST_REBIND_ORIENTATION_MS = 350L
private const val SURFACE_TRANSITION_GRACE_MS = 8_000L
private const val ORIENTATION_DEBOUNCE_MS = 700L
private const val PREVIEW_REBIND_RETRY_MS = 120L
private const val PREVIEW_REBIND_MAX_ATTEMPTS = 15
private const val PREVIEW_REBIND_GRACE_MS = 6_000L
}
}

View File

@@ -0,0 +1,30 @@
package com.matchlivetv.match_live_tv
import android.content.Context
import android.util.AttributeSet
import android.view.SurfaceHolder
import com.pedro.library.view.OpenGlView
/**
* OpenGlView che non distrugge encoder/GL quando la surface viene persa in rotazione.
* La SurfaceView standard di Pedro chiama stop() in surfaceDestroyed e uccide tutto il pipeline.
*/
class StreamingOpenGlView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : OpenGlView(context, attrs) {
@Volatile
var preservePipelineOnSurfaceLoss: Boolean = false
var onPreviewSurfaceLost: (() -> Unit)? = null
override fun surfaceDestroyed(holder: SurfaceHolder) {
if (preservePipelineOnSurfaceLoss && isRunning) {
setForceRender(false)
onPreviewSurfaceLost?.invoke()
return
}
super.surfaceDestroyed(holder)
}
}

View File

@@ -6,7 +6,6 @@ import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.IBinder
import com.pedro.library.view.OpenGlView
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -85,7 +84,20 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
binding.platformViewRegistry.registerViewFactory(
PREVIEW_VIEW_TYPE,
StreamingPreviewFactory { mainHandlerRebindPreview() },
StreamingPreviewFactory(
onSurfaceReady = { glView ->
android.os.Handler(android.os.Looper.getMainLooper()).post {
val engine = getOrCreateEngine()
engine.setupPreviewGlView(glView)
engine.onPlatformSurfaceReady(glView)
}
},
onSurfaceDestroyed = {
android.os.Handler(android.os.Looper.getMainLooper()).post {
getEngine()?.onPlatformSurfaceDestroyed()
}
},
),
)
ServiceEventBus.addListener(busListener)
@@ -101,7 +113,6 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
mainHandlerRebindPreview()
}
override fun onDetachedFromActivityForConfigChanges() {
@@ -110,13 +121,19 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activityBinding = binding
mainHandlerRebindPreview()
}
private fun mainHandlerRebindPreview() {
android.os.Handler(android.os.Looper.getMainLooper()).post {
attachPreviewIfPossible()
}
private fun attachPreviewIfPossible() {
val glView = StreamPreviewHolder.openGlView as? StreamingOpenGlView ?: return
val engine = getOrCreateEngine()
engine.setupPreviewGlView(glView)
engine.bindStreamPreview(glView)
}
private fun previewIsReady(): Boolean {
val engine = getEngine() ?: return false
val metrics = engine.getMetrics()
return metrics.isPreviewActive
}
override fun onDetachedFromActivity() {
@@ -134,6 +151,7 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
"startPreview" -> startPreview(result)
"stopPreview" -> stopPreview(result)
"bindPreview" -> bindPreview(result)
"syncOrientation" -> syncOrientation(result)
"unbindPreview" -> unbindPreview(result)
else -> result.notImplemented()
}
@@ -160,14 +178,18 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
return
}
val argWidth = (args["width"] as? Number)?.toInt() ?: 1280
val argHeight = (args["height"] as? Number)?.toInt() ?: 720
val portrait = args["portrait"] as? Boolean ?: (argHeight > argWidth)
val config = StreamingConfig(
rtmpUrl = rtmpUrl,
width = (args["width"] as? Number)?.toInt() ?: 1280,
height = (args["height"] as? Number)?.toInt() ?: 720,
width = (args["width"] as? Number)?.toInt() ?: if (portrait) 720 else 1280,
height = (args["height"] as? Number)?.toInt() ?: if (portrait) 1280 else 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,
portrait = portrait,
maxReconnectAttempts = (args["maxReconnectAttempts"] as? Number)?.toInt() ?: 10,
reconnectDelayMs = (args["reconnectDelayMs"] as? Number)?.toLong() ?: 5_000L,
)
@@ -208,14 +230,18 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
return
}
val argWidth = (args["width"] as? Number)?.toInt() ?: 1280
val argHeight = (args["height"] as? Number)?.toInt() ?: 720
val portrait = args["portrait"] as? Boolean ?: (argHeight > argWidth)
val config = StreamingConfig(
rtmpUrl = rtmpUrl,
width = (args["width"] as? Number)?.toInt() ?: 1280,
height = (args["height"] as? Number)?.toInt() ?: 720,
width = (args["width"] as? Number)?.toInt() ?: if (portrait) 720 else 1280,
height = (args["height"] as? Number)?.toInt() ?: if (portrait) 1280 else 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,
portrait = portrait,
maxReconnectAttempts = (args["maxReconnectAttempts"] as? Number)?.toInt() ?: 10,
reconnectDelayMs = (args["reconnectDelayMs"] as? Number)?.toLong() ?: 5_000L,
)
@@ -266,7 +292,12 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
private fun bindPreview(result: Result) {
attachPreviewIfPossible()
result.success(StreamPreviewHolder.openGlView != null)
result.success(previewIsReady())
}
private fun syncOrientation(result: Result) {
getEngine()?.applyOrientationFromSensor()
result.success(true)
}
private fun unbindPreview(result: Result) {
@@ -302,12 +333,6 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
StreamingEngineHolder.release()
}
private fun attachPreviewIfPossible() {
val glView = StreamPreviewHolder.openGlView ?: return
val engine = getOrCreateEngine()
engine.bindStreamPreview(glView)
}
private fun bindServiceIfNeeded() {
if (serviceBound) {
return

View File

@@ -1,42 +1,95 @@
package com.matchlivetv.match_live_tv
import android.content.Context
import android.view.SurfaceHolder
import android.view.View
import android.widget.FrameLayout
import com.pedro.library.view.OpenGlView
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
class StreamingPreviewPlatformView(
context: Context,
private val onPreviewReady: (OpenGlView) -> Unit,
private val onSurfaceReady: (StreamingOpenGlView) -> Unit,
private val onSurfaceDestroyed: () -> Unit,
) : PlatformView {
private val openGlView = OpenGlView(context).apply {
private val openGlView = StreamingOpenGlView(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
}
private var lastNotifiedWidth = 0
private var lastNotifiedHeight = 0
private val notifyReadyRunnable = Runnable {
if (openGlView.width <= 0 || openGlView.height <= 0) {
return@Runnable
}
val sizeChanged = openGlView.width != lastNotifiedWidth ||
openGlView.height != lastNotifiedHeight
if (!sizeChanged && lastNotifiedWidth > 0) {
return@Runnable
}
lastNotifiedWidth = openGlView.width
lastNotifiedHeight = openGlView.height
onSurfaceReady(openGlView)
}
init {
StreamPreviewHolder.openGlView = openGlView
onPreviewReady(openGlView)
openGlView.holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
StreamPreviewHolder.openGlView = openGlView
scheduleReadyDebounced()
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
StreamPreviewHolder.openGlView = openGlView
if (width > 0 && height > 0) {
scheduleReadyDebounced()
}
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
openGlView.removeCallbacks(notifyReadyRunnable)
lastNotifiedWidth = 0
lastNotifiedHeight = 0
onSurfaceDestroyed()
}
})
}
private fun scheduleReadyDebounced() {
openGlView.removeCallbacks(notifyReadyRunnable)
openGlView.postDelayed(notifyReadyRunnable, SURFACE_NOTIFY_DEBOUNCE_MS)
}
override fun getView(): View = openGlView
override fun dispose() {
// Non azzerare il holder: la rotazione ricrea la PlatformView e il plugin riaggancia.
openGlView.removeCallbacks(notifyReadyRunnable)
lastNotifiedWidth = 0
lastNotifiedHeight = 0
onSurfaceDestroyed()
if (StreamPreviewHolder.openGlView === openGlView) {
StreamPreviewHolder.openGlView = null
}
}
companion object {
private const val SURFACE_NOTIFY_DEBOUNCE_MS = 600L
}
}
class StreamingPreviewFactory(
private val onPreviewReady: (OpenGlView) -> Unit,
private val onSurfaceReady: (StreamingOpenGlView) -> Unit,
private val onSurfaceDestroyed: () -> Unit,
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
return StreamingPreviewPlatformView(context, onPreviewReady)
return StreamingPreviewPlatformView(context, onSurfaceReady, onSurfaceDestroyed)
}
}