diff --git a/doc/stream.txt b/doc/stream.txt index 0aea11b5..d74c98b8 100644 --- a/doc/stream.txt +++ b/doc/stream.txt @@ -837,6 +837,9 @@ Android: Локальный preview получает прямой Camera2 output в TextureView одновременно с encoder Surface. Собственный источник не открывает viewer/cursor, decoder, AudioTrack и playback controller. Preview ориентируется по сенсору камеры и текущему положению экрана; ручной поворот трансляции его не вращает. + TextureView уже применяет ориентацию сенсора. UI Matrix компенсирует только -displayDegrees, + размеры view учитывают relative rotation; повторно поворачивать на SENSOR_ORIENTATION нельзя. + DisplayListener обновляет preview и метки кнопок также при 180°, без изменения Configuration. Верх картинки зрителей отмечается красной гранью на кнопках поворота; метка «Верх» на видео не рисуется. В обычном режиме preview выбирает поддерживаемый SurfaceTexture размер того же aspect, не более 1280x720; high-speed использует размер encoder и createHighSpeedRequestList для двух targets с фиксированным FPS range. @@ -844,10 +847,15 @@ Android: При отказе дополнительного output логируется ERROR, UI показывает ошибку preview, камера возвращается к encoder-only session. TextureView возвращает false из onSurfaceTextureDestroyed; UI, очередь команд camera thread и camera sessions держат отдельные ссылки. Surface/texture освобождаются после последнего onClosed/onConfigureFailed и ухода UI. + CameraDevice.onClosed освобождает оставшиеся ссылки всех своих sessions: Android не всегда доставляет + session.onClosed при закрытии всего устройства. Повторный callback не освобождает ссылку второй раз. Логи direct preview показывают first frame, FPS, возраст frame по SurfaceTexture timestamp и поворот; + для SENSOR_INFO_TIMESTAMP_SOURCE_REALTIME возраст считается через elapsedRealtimeNanos, + при UNKNOWN абсолютный возраст не выдумывается, логируется timestamp; начальная очистка texture с timestamp=0 не считается кадром. camera session logs показывают generation, outputs, размер preview и сохранённый encoder/origin. Удалённый viewer применяет video rotation источника. ON_STOP закрывает viewer либо отсоединяет прямой preview; - ON_START создаёт output заново. Камера при закрытии preview продолжает работать; publisher от UI не зависит. + ON_START создаёт output заново. Lifecycle отключает target сразу, независимо от приостановленной Compose recomposition. + Камера при закрытии preview продолжает работать; publisher от UI не зависит. Видимый player удерживает экран включённым. Preview ограничен оставшимся местом, кнопки управления не перекрывает. - Source list общий для Android/desktop: stream_sources_json в uasync использует stream_member_load и stream_member_online. stream_watch_sources наблюдает один выбранный канал; group=0 снимает наблюдение. diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt index 703da05f..f8cadaa0 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt @@ -44,7 +44,7 @@ class StreamCaptureEngine( private var activeConfig: StreamCaptureConfig? = null private var previewTarget: StreamPreviewTarget? = null private var sessionGeneration = 0L - private var cameraSessions = 0 + private val cameraSessions = mutableMapOf Unit>>() private var record: AudioRecord? = null private var audioThread: Thread? = null @Volatile private var audioRunning = false @@ -85,6 +85,7 @@ class StreamCaptureEngine( preview.bind { target -> if (!handler.post { if (stopping || stopped || terminalFailure) { target?.release(); return@post } + if (target === previewTarget) { target?.release(); return@post } val previous = previewTarget; previewTarget = target; previous?.release() val config = activeConfig; val device = camera LogManager.addLog("DEBUG", "video", "preview output change id=$id target=${target?.let(System::identityHashCode)} " + @@ -215,6 +216,9 @@ class StreamCaptureEngine( } override fun onClosed(device: CameraDevice) { if (camera === device) camera = null + val pending = cameraSessions.values.filter { it.first === device }.map { it.second } + LogManager.addLog("DEBUG", "video", "camera closed id=$id revision=$token retiringSessions=${pending.size}") + pending.forEach { it("cameraClosed") } if (releaseDone != null && camera == null && !opening) finishRelease(true) maybeQuit() } @@ -241,11 +245,13 @@ class StreamCaptureEngine( val output = if (withPreview) previewTarget?.retain() else null var format: StreamPreviewFormat? = null var completed = false - cameraSessions++ - fun complete() { + fun complete(reason: String) { if (completed) return - completed = true; output?.release(); cameraSessions--; maybeQuit() + completed = true; cameraSessions.remove(serial); output?.release() + LogManager.addLog("DEBUG", "video", "camera session output released id=$id session=$serial reason=$reason remaining=${cameraSessions.size}") + maybeQuit() } + cameraSessions[serial] = device to ::complete fun valid() = current(token) && serial == sessionGeneration fun rejected(detail: String) { if (!valid()) { LogManager.addLog("DEBUG", "video", "stale camera session failure id=$id session=$serial: $detail"); return } @@ -312,21 +318,25 @@ class StreamCaptureEngine( } catch (e: Exception) { configured.close(); rejected("camera request: ${e.message}") } } override fun onConfigureFailed(session: CameraCaptureSession) { - complete(); rejected("camera session configuration") + complete("configureFailed"); rejected("camera session configuration") } override fun onClosed(closed: CameraCaptureSession) { if (session === closed) session = null - complete() - LogManager.addLog("DEBUG", "video", "camera session closed id=$id session=$serial remaining=$cameraSessions") + complete("sessionClosed") } } LogManager.addLog("DEBUG", "video", "camera session configuring id=$id revision=$token session=$serial outputs=${targets.size}") if (mode.highSpeed) device.createConstrainedHighSpeedCaptureSession(targets, callback, handler) else device.createCaptureSession(targets, callback, handler) - } catch (e: Exception) { complete(); rejected("camera session: ${e.message}") } + } catch (e: Exception) { complete("exception"); rejected("camera session: ${e.message}") } } - private fun maybeQuit() { if (stopped && camera == null && !opening && cameraSessions == 0) thread.quitSafely() } + private fun maybeQuit() { + if (stopped && camera == null && !opening && cameraSessions.isEmpty()) { + LogManager.addLog("INFO", "video", "capture handler released id=$id pendingSessions=0") + thread.quitSafely() + } + } private fun captureError(token: Long, detail: String) { if (!current(token)) { LogManager.addLog("DEBUG", "video", "stale capture error revision=$token: $detail"); return }