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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,23 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
await ref.read(authProvider.notifier).restoreSession();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
final notifier = ref.read(authProvider.notifier);
|
||||
await notifier.restoreSession();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
if (!mounted) return;
|
||||
|
||||
var auth = ref.read(authProvider);
|
||||
if (auth.isAuthenticated) {
|
||||
final ok = await notifier.validateOrRefreshSession();
|
||||
if (!mounted) return;
|
||||
auth = ref.read(authProvider);
|
||||
if (!ok || !auth.isAuthenticated) {
|
||||
context.go('/login');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final auth = ref.read(authProvider);
|
||||
if (auth.isAuthenticated) {
|
||||
context.go('/matches');
|
||||
} else {
|
||||
|
||||
@@ -56,7 +56,6 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
int _viewerCount = 0;
|
||||
|
||||
StreamSubscription<Map<String, dynamic>>? _metricsSub;
|
||||
Orientation? _lastOrientation;
|
||||
bool _pauseInFlight = false;
|
||||
bool _resumeInFlight = false;
|
||||
String? _lastStreamErrorShown;
|
||||
@@ -74,6 +73,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
ref.read(sessionProvider.notifier).setElapsed(
|
||||
DateTime.now().difference(session!.startedAt!),
|
||||
);
|
||||
} else {
|
||||
ref.read(sessionProvider.notifier).setElapsed(Duration.zero);
|
||||
}
|
||||
});
|
||||
_sessionSyncTimer = Timer.periodic(const Duration(seconds: 15), (_) {
|
||||
@@ -86,16 +87,13 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final orientation = MediaQuery.orientationOf(context);
|
||||
if (_lastOrientation != null && _lastOrientation != orientation) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
await StreamingChannel.bindPreview();
|
||||
});
|
||||
}
|
||||
_lastOrientation = orientation;
|
||||
Map<String, dynamic> _streamVideoParams(BuildContext context) {
|
||||
final portrait = MediaQuery.orientationOf(context) == Orientation.portrait;
|
||||
return {
|
||||
'width': portrait ? 720 : 1280,
|
||||
'height': portrait ? 1280 : 720,
|
||||
'portrait': portrait,
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> _ensurePermissions() async {
|
||||
@@ -112,6 +110,15 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _bindPreviewWithRetry({int attempts = 12}) async {
|
||||
for (var i = 0; i < attempts; i++) {
|
||||
final ok = await StreamingChannel.bindPreview();
|
||||
if (ok) return true;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
try {
|
||||
if (!await _ensurePermissions()) return;
|
||||
@@ -150,7 +157,17 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
await ws.connect(sessionId: widget.sessionId, deviceRole: 'camera');
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
await StreamingChannel.bindPreview();
|
||||
final previewReady = await _bindPreviewWithRetry();
|
||||
if (!previewReady && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Anteprima camera non pronta — video non disponibile. Riapri per riprovare.'),
|
||||
duration: Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
// Non return: sessione e score sono già settati, WebSocket connesso.
|
||||
// L'utente può ancora gestire il punteggio anche senza preview video.
|
||||
}
|
||||
|
||||
final fresh = await client.fetchSession(widget.sessionId);
|
||||
ref.read(sessionProvider.notifier).setSession(fresh, match: match);
|
||||
@@ -158,10 +175,13 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
ref.read(scoreProvider.notifier).reset(fresh.score!);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Avvio RTMP: wizard chiama startSession prima della camera → stato "connecting".
|
||||
// In pausa all'apertura: niente auto-publish (serve "RIPRENDI DIRETTA").
|
||||
final wasPausedAtOpen = statusAtOpen == 'paused';
|
||||
final shouldAutoPublish = fresh.rtmpIngestUrl != null &&
|
||||
final shouldAutoPublish = previewReady &&
|
||||
fresh.rtmpIngestUrl != null &&
|
||||
!wasPausedAtOpen &&
|
||||
fresh.status != 'paused' &&
|
||||
(justStartedSession ||
|
||||
@@ -174,21 +194,16 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
await StreamingChannel.stopStream();
|
||||
} catch (_) {}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
final useFreshStart =
|
||||
justStartedSession || fresh.status == 'connecting';
|
||||
if (useFreshStart) {
|
||||
await StreamingChannel.startStream(
|
||||
rtmpUrl: fresh.rtmpIngestUrl!,
|
||||
targetBitrate: fresh.targetBitrate,
|
||||
targetFps: 30,
|
||||
);
|
||||
} else {
|
||||
await StreamingChannel.resumeStream(
|
||||
rtmpUrl: fresh.rtmpIngestUrl!,
|
||||
targetBitrate: fresh.targetBitrate,
|
||||
targetFps: 30,
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
final video = _streamVideoParams(context);
|
||||
await StreamingChannel.startStream(
|
||||
rtmpUrl: fresh.rtmpIngestUrl!,
|
||||
targetBitrate: fresh.targetBitrate,
|
||||
targetFps: 30,
|
||||
width: video['width'] as int,
|
||||
height: video['height'] as int,
|
||||
portrait: video['portrait'] as bool,
|
||||
);
|
||||
if (mounted && (justStartedSession || fresh.status == 'connecting')) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -415,10 +430,16 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
if (session == null || session.rtmpIngestUrl == null) return;
|
||||
_resumeInFlight = true;
|
||||
try {
|
||||
final isPortrait = mounted &&
|
||||
MediaQuery.orientationOf(context) == Orientation.portrait;
|
||||
final video = _streamVideoParams(context);
|
||||
await StreamingChannel.resumeStream(
|
||||
rtmpUrl: session.rtmpIngestUrl!,
|
||||
targetBitrate: session.targetBitrate,
|
||||
targetFps: 30,
|
||||
width: video['width'] as int,
|
||||
height: video['height'] as int,
|
||||
portrait: video['portrait'] as bool,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -449,10 +470,15 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
ref.read(sessionProvider.notifier).setSession(session, match: match);
|
||||
|
||||
if (session.rtmpIngestUrl != null) {
|
||||
if (!mounted) return;
|
||||
final video = _streamVideoParams(context);
|
||||
await StreamingChannel.resumeStream(
|
||||
rtmpUrl: session.rtmpIngestUrl!,
|
||||
targetBitrate: session.targetBitrate,
|
||||
targetFps: 30,
|
||||
width: video['width'] as int,
|
||||
height: video['height'] as int,
|
||||
portrait: video['portrait'] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -558,9 +584,9 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
MediaQuery.orientationOf(context) == Orientation.landscape;
|
||||
final rules = ref.watch(matchScoringRulesProvider);
|
||||
final pointsTarget = rules.pointsTarget(score.currentSet);
|
||||
// Preview unica: non viene mai smontata al cambio orientamento.
|
||||
// Preview a tutto schermo; controlli in overlay sopra (AndroidView nel layout Flutter).
|
||||
return Scaffold(
|
||||
backgroundColor: isLandscape ? Colors.black : AppTheme.background,
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
@@ -672,20 +698,21 @@ class _PortraitOverlay extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final inset = ScreenInsets.cameraOverlay(context);
|
||||
final screenH = MediaQuery.sizeOf(context).height;
|
||||
// Riserva almeno 35% dello schermo all'anteprima camera, il resto ai controlli.
|
||||
final maxControlsH = screenH * 0.65;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
// ── Barra superiore: badge live + metriche ──────────────────────────
|
||||
Container(
|
||||
color: Colors.black54,
|
||||
padding: EdgeInsets.fromLTRB(inset.left, inset.top, inset.right, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
LiveBadge(elapsed: elapsed, paused: isPaused),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: onShare,
|
||||
icon: const Icon(Icons.share),
|
||||
tooltip: 'Condividi link',
|
||||
),
|
||||
CameraCompactMetrics(
|
||||
networkType: networkType,
|
||||
bitrateMbps: bitrateMbps,
|
||||
@@ -693,52 +720,84 @@ class _PortraitOverlay extends StatelessWidget {
|
||||
batteryLevel: batteryLevel,
|
||||
viewerCount: viewerCount > 0 ? viewerCount : null,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onShare,
|
||||
icon: const Icon(Icons.share, size: 20),
|
||||
tooltip: 'Condividi link',
|
||||
color: Colors.white70,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Expanded(child: SizedBox.expand()),
|
||||
Container(
|
||||
color: AppTheme.background,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
inset.left,
|
||||
12,
|
||||
inset.right,
|
||||
math.max(inset.bottom, 16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
LiveScoreControls(
|
||||
homeName: homeName,
|
||||
awayName: awayName,
|
||||
pointsTarget: pointsTarget,
|
||||
onStopStream: onCloseStream,
|
||||
// ── Area camera (AndroidView a tutto schermo sotto) ───────────────
|
||||
const Expanded(child: IgnorePointer(child: SizedBox.expand())),
|
||||
// ── Pannello controlli ───────────────────────────────────────────────
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxControlsH),
|
||||
child: Container(
|
||||
color: AppTheme.background,
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
inset.left,
|
||||
12,
|
||||
inset.right,
|
||||
math.max(inset.bottom, 16),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onShare,
|
||||
icon: const Icon(Icons.share, size: 18),
|
||||
label: const Text('Condividi link'),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Tabellone + pulsanti punteggio — primo widget, sempre visibile.
|
||||
LiveScoreControls(
|
||||
homeName: homeName,
|
||||
awayName: awayName,
|
||||
pointsTarget: pointsTarget,
|
||||
onStopStream: onCloseStream,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Azioni stream: pausa/riprendi + chiudi.
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: isPaused
|
||||
? FilledButton.icon(
|
||||
onPressed: onResume,
|
||||
icon: const Icon(Icons.play_arrow, size: 18),
|
||||
label: const Text('Riprendi'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryRed,
|
||||
),
|
||||
)
|
||||
: OutlinedButton.icon(
|
||||
onPressed: onPause,
|
||||
icon: const Icon(Icons.pause, size: 18),
|
||||
label: const Text('Pausa'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: DestructiveCta(
|
||||
label: 'Chiudi diretta',
|
||||
onPressed: () => onCloseStream(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onShare,
|
||||
icon: const Icon(Icons.share, size: 16),
|
||||
label: const Text('Condividi link diretta'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white70,
|
||||
side: const BorderSide(color: Colors.white24),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (isPaused)
|
||||
FilledButton.icon(
|
||||
onPressed: onResume,
|
||||
icon: const Icon(Icons.play_arrow, size: 18),
|
||||
label: const Text('Riprendi'),
|
||||
)
|
||||
else
|
||||
OutlinedButton.icon(
|
||||
onPressed: onPause,
|
||||
icon: const Icon(Icons.pause, size: 18),
|
||||
label: const Text('Metti in pausa'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DestructiveCta(
|
||||
label: 'Chiudi diretta',
|
||||
onPressed: () => onCloseStream(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -806,7 +865,7 @@ class _LandscapeOverlay extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const Expanded(child: SizedBox.expand()),
|
||||
const Expanded(child: IgnorePointer(child: SizedBox.expand())),
|
||||
Container(
|
||||
color: Colors.black.withValues(alpha: 0.82),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:intl/intl.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../app/theme.dart';
|
||||
import '../../core/config.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../shared/api_client.dart';
|
||||
import '../../shared/models/match.dart';
|
||||
@@ -64,7 +65,60 @@ class MatchesScreen extends ConsumerWidget {
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: AppTheme.primaryRed),
|
||||
),
|
||||
error: (e, _) => Center(child: Text('Errore squadre: $e')),
|
||||
error: (e, stack) {
|
||||
final msg = e.toString();
|
||||
final unauthorized = msg.contains('401');
|
||||
final timeout = msg.contains('timeout') || msg.contains('Timeout');
|
||||
final offline = timeout ||
|
||||
msg.contains('SocketException') ||
|
||||
msg.contains('Failed host lookup');
|
||||
String title = 'Errore nel caricamento squadre';
|
||||
String hint = 'Controlla Wi‑Fi o dati mobili e riprova.';
|
||||
if (unauthorized) {
|
||||
title = 'Sessione scaduta';
|
||||
hint = 'Accedi di nuovo con email e password.';
|
||||
} else if (offline) {
|
||||
title = 'Server non raggiungibile';
|
||||
hint = timeout
|
||||
? 'La connessione è troppo lenta o assente. Verifica la rete (serve almeno qualche Mbps).'
|
||||
: 'Impossibile contattare ${AppConfig.apiBaseUrl}. Riprova tra poco.';
|
||||
}
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
hint,
|
||||
style: theme.textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (unauthorized)
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
ref.read(authProvider.notifier).logout();
|
||||
context.go('/login');
|
||||
},
|
||||
child: const Text('VAI AL LOGIN'),
|
||||
)
|
||||
else
|
||||
TextButton(
|
||||
onPressed: () => ref.invalidate(teamsProvider),
|
||||
child: const Text('RIPROVA'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
data: (teams) {
|
||||
if (teams.isEmpty) {
|
||||
return NoTeamOnboarding(theme: theme);
|
||||
|
||||
@@ -2,12 +2,14 @@ import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../app/theme.dart';
|
||||
import '../../providers/score_provider.dart';
|
||||
import '../../providers/score_sync_provider.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../shared/api_client.dart';
|
||||
import '../../shared/models/match.dart';
|
||||
@@ -74,9 +76,9 @@ class _StepNetworkTestState extends ConsumerState<StepNetworkTest> {
|
||||
|
||||
_youtubePoll?.cancel();
|
||||
var attempts = 0;
|
||||
_youtubePoll = Timer.periodic(const Duration(seconds: 2), (_) async {
|
||||
_youtubePoll = Timer.periodic(const Duration(seconds: 3), (_) async {
|
||||
attempts++;
|
||||
if (!mounted || attempts > 30) {
|
||||
if (!mounted || attempts > 60) {
|
||||
_youtubePoll?.cancel();
|
||||
return;
|
||||
}
|
||||
@@ -150,14 +152,10 @@ class _StepNetworkTestState extends ConsumerState<StepNetworkTest> {
|
||||
if (session.score != null) {
|
||||
ref.read(scoreProvider.notifier).reset(session.score!);
|
||||
}
|
||||
// Tabellone via HTTP; WebSocket solo dalla camera (evita doppio connect controller/camera).
|
||||
try {
|
||||
await ref.read(websocketServiceProvider).connect(
|
||||
sessionId: session.id,
|
||||
deviceRole: 'controller',
|
||||
);
|
||||
} catch (_) {
|
||||
// Tabellone locale; sync quando la camera si connette.
|
||||
}
|
||||
await ref.read(scoreSyncProvider).pullFromServer();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
String _connectivityLabel(List<ConnectivityResult> results) {
|
||||
@@ -184,18 +182,22 @@ class _StepNetworkTestState extends ConsumerState<StepNetworkTest> {
|
||||
ref.read(scoreProvider.notifier).reset(const ScoreState());
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
await ref.read(websocketServiceProvider).disconnect();
|
||||
if (!mounted) return;
|
||||
context.go('/session/${started.id}/camera');
|
||||
|
||||
try {
|
||||
final ws = ref.read(websocketServiceProvider);
|
||||
await ws.connect(sessionId: started.id, deviceRole: 'controller');
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Diretta avviata ma sync limitata: $e')),
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
if (mounted) {
|
||||
final msg = e.response?.data is Map
|
||||
? (e.response!.data['error'] ?? e.response!.data['message'])?.toString()
|
||||
: null;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
msg?.isNotEmpty == true ? 'Errore avvio diretta: $msg' : 'Errore avvio diretta: ${e.message}',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
|
||||
@@ -270,7 +270,7 @@ class _StepTransmissionState extends ConsumerState<StepTransmission> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('720p · 30fps · 2.5 Mbps', style: theme.textTheme.titleMedium),
|
||||
Text('720p · 30fps · 3 Mbps', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Bitrate fisso consigliato per reti instabili',
|
||||
|
||||
@@ -19,32 +19,53 @@ class StreamingChannel {
|
||||
return ok ?? false;
|
||||
}
|
||||
|
||||
/// Dopo rotazione: riallinea preview/stream senza fermare RTMP.
|
||||
static Future<void> syncOrientation() async {
|
||||
await _channel.invokeMethod<void>('syncOrientation');
|
||||
}
|
||||
|
||||
static Future<void> stopPreview() async {
|
||||
await _channel.invokeMethod('stopPreview');
|
||||
}
|
||||
|
||||
static Future<void> startStream({
|
||||
required String rtmpUrl,
|
||||
int targetBitrate = 2500000,
|
||||
int targetBitrate = 3000000,
|
||||
int targetFps = 30,
|
||||
int width = 1280,
|
||||
int height = 720,
|
||||
bool portrait = false,
|
||||
int rotation = 0,
|
||||
}) async {
|
||||
await _channel.invokeMethod('startStream', {
|
||||
'rtmpUrl': rtmpUrl,
|
||||
'videoBitrate': targetBitrate,
|
||||
'fps': targetFps,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'portrait': portrait,
|
||||
'rotation': rotation,
|
||||
});
|
||||
}
|
||||
|
||||
/// Ripresa dopo pausa: resetta stati RTMP residui (CONNECTING/RECONNECTING).
|
||||
static Future<void> resumeStream({
|
||||
required String rtmpUrl,
|
||||
int targetBitrate = 2500000,
|
||||
int targetBitrate = 3000000,
|
||||
int targetFps = 30,
|
||||
int width = 1280,
|
||||
int height = 720,
|
||||
bool portrait = false,
|
||||
int rotation = 0,
|
||||
}) async {
|
||||
await _channel.invokeMethod('resumeStream', {
|
||||
'rtmpUrl': rtmpUrl,
|
||||
'videoBitrate': targetBitrate,
|
||||
'fps': targetFps,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'portrait': portrait,
|
||||
'rotation': rotation,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Preview camera nativa (OpenGL) o placeholder su altre piattaforme.
|
||||
/// Preview camera nativa (OpenGL) integrata nel layout Flutter.
|
||||
class NativeStreamingPreview extends StatelessWidget {
|
||||
const NativeStreamingPreview({
|
||||
super.key,
|
||||
@@ -23,7 +23,7 @@ class NativeStreamingPreview extends StatelessWidget {
|
||||
key: key,
|
||||
viewType: 'match_live_tv/streaming_preview',
|
||||
layoutDirection: TextDirection.ltr,
|
||||
creationParamsCodec: StandardMessageCodec(),
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
),
|
||||
if (showGrid) CameraPreviewOverlay(platformLabel: platformLabel),
|
||||
],
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/auth_storage.dart';
|
||||
import '../core/team_selection_storage.dart';
|
||||
import '../shared/api_client.dart';
|
||||
import '../shared/models/user.dart';
|
||||
|
||||
class AuthState {
|
||||
@@ -72,6 +74,53 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Dopo restore: verifica token o rinnova con refresh (evita 401 su /teams).
|
||||
Future<bool> validateOrRefreshSession() async {
|
||||
if (!state.isAuthenticated) return false;
|
||||
|
||||
final client = ApiClient(accessToken: state.accessToken);
|
||||
try {
|
||||
final user = await client.me();
|
||||
state = state.copyWith(user: user);
|
||||
await AuthStorage.save(
|
||||
user: user,
|
||||
accessToken: state.accessToken!,
|
||||
refreshToken: state.refreshToken!,
|
||||
);
|
||||
return true;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode != 401) {
|
||||
return true;
|
||||
}
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return refreshSession();
|
||||
}
|
||||
|
||||
Future<bool> refreshSession() async {
|
||||
final refreshToken = state.refreshToken;
|
||||
if (refreshToken == null || refreshToken.isEmpty) {
|
||||
logout();
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
final client = ApiClient();
|
||||
final result = await client.refreshSession(refreshToken);
|
||||
setSession(
|
||||
user: result.user,
|
||||
accessToken: result.tokens.accessToken,
|
||||
refreshToken: result.tokens.refreshToken,
|
||||
);
|
||||
return true;
|
||||
} catch (_) {
|
||||
logout();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void setLoading(bool loading) {
|
||||
state = state.copyWith(loading: loading, clearError: true);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,12 @@ class SessionNotifier extends StateNotifier<SessionState> {
|
||||
SessionNotifier() : super(const SessionState());
|
||||
|
||||
void setSession(StreamSession session, {MatchModel? match}) {
|
||||
state = state.copyWith(session: session, match: match);
|
||||
final prevId = state.session?.id;
|
||||
state = state.copyWith(
|
||||
session: session,
|
||||
match: match,
|
||||
elapsed: prevId != null && prevId != session.id ? Duration.zero : state.elapsed,
|
||||
);
|
||||
}
|
||||
|
||||
void updateYoutubeLinks({
|
||||
@@ -111,8 +116,6 @@ class SessionNotifier extends StateNotifier<SessionState> {
|
||||
state = state.copyWith(cameraDevice: device);
|
||||
return;
|
||||
}
|
||||
final startedAt = s.startedAt ??
|
||||
((incoming == 'live' || incoming == 'reconnecting') ? DateTime.now() : null);
|
||||
state = state.copyWith(
|
||||
session: StreamSession(
|
||||
id: s.id,
|
||||
@@ -129,7 +132,7 @@ class SessionNotifier extends StateNotifier<SessionState> {
|
||||
privacyStatus: s.privacyStatus,
|
||||
qualityPreset: s.qualityPreset,
|
||||
targetBitrate: s.targetBitrate,
|
||||
startedAt: startedAt,
|
||||
startedAt: s.startedAt,
|
||||
disconnectionCount: s.disconnectionCount,
|
||||
score: s.score,
|
||||
devices: s.devices,
|
||||
|
||||
@@ -21,8 +21,8 @@ class ApiClient {
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppConfig.apiV1,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
connectTimeout: const Duration(seconds: 30),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
@@ -62,12 +62,18 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
Future<AuthTokens> refresh(String refreshToken) async {
|
||||
Future<({User user, AuthTokens tokens})> refreshSession(
|
||||
String refreshToken,
|
||||
) async {
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
'/auth/refresh',
|
||||
data: {'refresh_token': refreshToken},
|
||||
);
|
||||
return AuthTokens.fromJson(response.data!);
|
||||
final data = response.data!;
|
||||
return (
|
||||
user: User.fromJson(data['user'] as Map<String, dynamic>),
|
||||
tokens: AuthTokens.fromJson(data),
|
||||
);
|
||||
}
|
||||
|
||||
Future<User> me() async {
|
||||
|
||||
@@ -26,6 +26,7 @@ class WebsocketService {
|
||||
ActionCable? _cable;
|
||||
ActionChannel? _channel;
|
||||
String? _sessionId;
|
||||
Future<void>? _connectOp;
|
||||
|
||||
bool get isConnected => _cable != null && _channel != null;
|
||||
|
||||
@@ -35,6 +36,24 @@ class WebsocketService {
|
||||
}) async {
|
||||
if (_sessionId == sessionId && isConnected) return;
|
||||
|
||||
final prev = _connectOp;
|
||||
if (prev != null) await prev.catchError((_) {});
|
||||
|
||||
_connectOp = _connect(sessionId: sessionId, deviceRole: deviceRole);
|
||||
final op = _connectOp!;
|
||||
try {
|
||||
await op;
|
||||
} finally {
|
||||
if (identical(_connectOp, op)) {
|
||||
_connectOp = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connect({
|
||||
required String sessionId,
|
||||
required String deviceRole,
|
||||
}) async {
|
||||
await disconnect();
|
||||
|
||||
final token = _ref.read(authProvider).accessToken;
|
||||
@@ -161,11 +180,20 @@ class WebsocketService {
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_channel?.unsubscribe();
|
||||
final channel = _channel;
|
||||
final cable = _cable;
|
||||
_channel = null;
|
||||
_cable?.disconnect();
|
||||
_cable = null;
|
||||
_sessionId = null;
|
||||
|
||||
try {
|
||||
channel?.unsubscribe();
|
||||
} catch (_) {}
|
||||
try {
|
||||
cable?.disconnect();
|
||||
} catch (_) {}
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 150));
|
||||
_ref.read(sessionProvider.notifier).setConnected(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: match_live_tv
|
||||
description: Match Live TV - Lo streaming che non muore
|
||||
publish_to: 'none'
|
||||
version: 1.2.0+3
|
||||
version: 1.2.10+13
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.0
|
||||
|
||||
Reference in New Issue
Block a user