Keep Android stream surfaces alive until decoder release and fit grid geometry

This commit is contained in:
evgeny committed 2026-10-20 17:07:12 +02:00
1 parent 75806249bd
commit 14b152bb5a
3 files changed
+80 -14

No files matched your search

@@ -0,0 +1,25 @@
package com.utun.chat.data
import kotlin.math.min
internal data class StreamGridShape(val columns: Int, val rows: Int)
/** Максимизирует видимую площадь видео; портретный экран не принуждается к квадратной сетке. */
internal fun streamGridShape(count: Int, width: Double, height: Double, aspect: Double = 16.0 / 9): StreamGridShape {
if (count <= 0 || width <= 0 || height <= 0) return StreamGridShape(1, 1)
var best = StreamGridShape(1, count)
var bestArea = -1.0
var bestEmpty = count
for (columns in 1..count) {
val rows = (count + columns - 1) / columns
val cellWidth = width / columns
val cellHeight = height / rows
val videoWidth = min(cellWidth, cellHeight * aspect)
val area = count * videoWidth * videoWidth / aspect
val empty = rows * columns - count
if (area > bestArea + 0.001 || (kotlin.math.abs(area - bestArea) <= 0.001 && empty < bestEmpty)) {
best = StreamGridShape(columns, rows); bestArea = area; bestEmpty = empty
}
}
return best
}
@@ -49,7 +49,12 @@ class StreamPlayerEngine(private val group: Long, private val source: Long, priv
val status: StateFlow<Int> = mutableStatus
private val id = StreamController.nextId()
@Volatile private var running = false
@Volatile var drainTimeoutMs = 2000L
private set
private var worker: Thread? = null
private val releaseLock = Any()
private var released = false
private val releaseCallbacks = mutableListOf<() -> Unit>()
fun start(surface: Surface) {
if (worker != null) return
@@ -62,10 +67,24 @@ class StreamPlayerEngine(private val group: Long, private val source: Long, priv
running = true
worker = Thread({
try { if (running) play(surface) }
finally { main.post { players.remove(this); mutableViewing.value = players.isNotEmpty() } }
finally {
val callbacks = synchronized(releaseLock) { released = true; releaseCallbacks.toList().also { releaseCallbacks.clear() } }
for (callback in callbacks) try { callback() }
catch (e: Exception) { LogManager.addLog("WARN", "stream", "viewer surface release id=$id: ${e.message}") }
main.post { players.remove(this); mutableViewing.value = players.isNotEmpty() }
}
}, "stream-player").apply { start() }
}
fun stop() { running = false; NativeLib.streamStop(id) }
/** Surface/SurfaceTexture освобождаются callback только после остановки decoder и native DSP. */
fun stop(onReleased: (() -> Unit)? = null) {
running = false; NativeLib.streamStop(id)
if (onReleased != null) {
val immediate = synchronized(releaseLock) {
if (worker == null || released) true else { releaseCallbacks.add(onReleased); false }
}
if (immediate) onReleased()
}
}
private fun play(surface: Surface) {
var decoder: MediaCodec? = null
@@ -189,6 +208,7 @@ class StreamPlayerEngine(private val group: Long, private val source: Long, priv
while (running && surface.isValid) {
check(NativeLib.isReady()) { "Core stopped" }
observeStatus(NativeLib.streamStatus(id))
drainTimeoutMs = ((ready - position) / 1000 + 2000).coerceIn(2000, 30000)
val deviceLead = playback?.leadUs ?: 0
for (work in 0 until 4) {
video.step(position, System.nanoTime() / 1000)
@@ -145,29 +145,50 @@ internal fun StreamViewerScreen(group: Long, source: Long, epoch: Long, title: S
/** Смена размеров окна и скрытие controls сохраняют TextureView, decoder и сетевой reader. */
@Composable
private fun StreamViewerPlayback(group: Long, source: Long, epoch: Long, onAspect: (Float) -> Unit, onStatus: (Int) -> Unit,
modifier: Modifier) {
val engine = remember(group, source, epoch) { StreamPlayerEngine(group, source, epoch) }
val aspect by engine.aspect.collectAsState()
val status by engine.status.collectAsState()
var surface by remember { mutableStateOf<Surface?>(null) }
internal fun StreamViewerPlayback(group: Long, source: Long, epoch: Long, onAspect: (Float) -> Unit, onStatus: (Int) -> Unit,
modifier: Modifier, onEngine: (StreamPlayerEngine) -> Unit = {}) {
var engine by remember(group, source, epoch) { mutableStateOf<StreamPlayerEngine?>(null) }
val aspect = engine?.aspect?.collectAsState()?.value ?: 0f
val status = engine?.status?.collectAsState()?.value ?: R.string.stream_connecting
val resources = remember(group, source, epoch) { StreamViewerSurface() }
fun releaseSurface(texture: SurfaceTexture? = null) {
val surface = resources.surface ?: return
val currentTexture = resources.texture ?: return
if (texture != null && texture !== currentTexture) return
resources.surface = null; resources.texture = null
val currentEngine = resources.engine
resources.engine = null
if (currentEngine != null) currentEngine.stop { surface.release(); currentTexture.release() }
else { surface.release(); currentTexture.release() }
}
LaunchedEffect(aspect, status) { onAspect(aspect); onStatus(status) }
DisposableEffect(engine) { onDispose { engine.stop() } }
DisposableEffect(resources) { onDispose { resources.disposed = true; releaseSurface() } }
AndroidView(factory = { context ->
TextureView(context).apply {
keepScreenOn = true
surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) {
surface = Surface(texture); engine.start(surface!!)
if (resources.disposed) { texture.release(); return }
releaseSurface()
val player = StreamPlayerEngine(group, source, epoch)
val surface = Surface(texture)
resources.texture = texture; resources.surface = surface; resources.engine = player
engine = player; onEngine(player); player.start(surface)
}
override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) {}
override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean {
engine.stop(); surface?.release(); surface = null; return true
releaseSurface(texture); return false
}
override fun onSurfaceTextureUpdated(texture: SurfaceTexture) {}
}
}
}, modifier = modifier, onRelease = { view ->
engine.stop(); view.surfaceTextureListener = null; surface?.release(); surface = null
})
}, modifier = modifier, onRelease = { resources.disposed = true; releaseSurface() })
}
/** TextureView передаёт освобождение texture приложению; repeated dispose не закрывает новый decoder. */
private class StreamViewerSurface {
var disposed = false
var engine: StreamPlayerEngine? = null
var surface: Surface? = null
var texture: SurfaceTexture? = null
}