diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamGridLayout.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamGridLayout.kt new file mode 100644 index 00000000..1754835d --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamGridLayout.kt @@ -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 +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt index 01f6ba85..6685f831 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt @@ -49,7 +49,12 @@ class StreamPlayerEngine(private val group: Long, private val source: Long, priv val status: StateFlow = 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) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt index 2ff9eec3..3197e86c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt @@ -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(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(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 }