From fdecfe1b786d9208b32b7e2cea9a0c0d8900e31e Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 03:23:21 +0100 Subject: [PATCH] Drive video controls from SDK outcomes and preserve paused playback --- .../utun/chat/ui/screens/VideoPlayerScreen.kt | 216 ++++++------------ .../chat/ui/screens/VideoPlayerInfoTest.kt | 2 + 2 files changed, 78 insertions(+), 140 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt index 32178cfd..f577a28a 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt @@ -1,17 +1,15 @@ package com.utun.chat.ui.screens import com.utun.chat.R -import com.utun.chat.data.AppLanguage import androidx.compose.ui.res.stringResource import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.content.pm.ActivityInfo import android.graphics.SurfaceTexture -import android.media.MediaMetadataRetriever -import android.media.MediaPlayer import android.view.Surface import android.view.TextureView +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -45,77 +43,52 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat import com.utun.chat.data.LogManager +import com.utun.chat.data.VideoFilePlayer +import com.utun.chat.data.VideoFileMetadata +import com.utun.chat.data.VideoInfo +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext -internal data class VideoInfo(val width: Int, val height: Int, val rotation: Int) { - val displayWidth: Int get() = if (rotation == 90 || rotation == 270) height else width - val displayHeight: Int get() = if (rotation == 90 || rotation == 270) width else height - val aspect: Float get() = if (displayHeight > 0) displayWidth.toFloat() / displayHeight else 0f -} - private tailrec fun Context.findActivity(): Activity? = when (this) { is Activity -> this is ContextWrapper -> baseContext.findActivity() else -> null } +/** Локальное видео: MediaPlayer принадлежит VideoFilePlayer, metadata читаются отдельно в IO. + * Controls показывают подтверждённое SDK состояние; zoom/pan и пауза сохраняются при смене размеров Surface. + * Завершение файла закрывает экран. Ориентация и панели окна восстанавливаются при выходе. */ @Composable fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { - val player = remember(filePath) { MediaPlayer() } - val surfaceReady = remember { mutableStateOf(false) } - val prepared = remember { mutableStateOf(false) } - - var error by remember { mutableStateOf(null) } - var isPlaying by remember { mutableStateOf(false) } - var durationMs by remember { mutableStateOf(0) } - var positionMs by remember { mutableStateOf(0) } - var controlsVisible by remember { mutableStateOf(true) } - var dragging by remember { mutableStateOf(false) } - var dragPosition by remember { mutableStateOf(0) } - var wasPlayingBeforeDrag by remember { mutableStateOf(false) } - var videoInfo by remember { mutableStateOf(null) } + val completed by rememberUpdatedState(onClose) + val player = remember(filePath) { VideoFilePlayer { completed() } } + val playback by player.state.collectAsState() + val error = playback.error + val isPlaying = playback.playing + val durationMs = playback.durationMs + val positionMs = playback.positionMs + var controlsVisible by remember(filePath) { mutableStateOf(true) } + var dragging by remember(filePath) { mutableStateOf(false) } + var dragPosition by remember(filePath) { mutableStateOf(0) } + var wasPlayingBeforeDrag by remember(filePath) { mutableStateOf(false) } + var videoInfo by remember(filePath) { mutableStateOf(null) } val zoom = remember(filePath) { VideoZoomState() } var hideTick by remember { mutableStateOf(0) } val context = LocalContext.current - /* ── immersive: hide system bars while playing ── */ - DisposableEffect(Unit) { - val act = context.findActivity() - val controller = act?.let { WindowCompat.getInsetsController(it.window, it.window.decorView) } - controller?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - controller?.hide(WindowInsetsCompat.Type.systemBars()) - LogManager.addLog("INFO", "VideoPlayer", "system bars hidden") - onDispose { - controller?.show(WindowInsetsCompat.Type.systemBars()) - LogManager.addLog("INFO", "VideoPlayer", "system bars restored") - } - } + BackHandler(onBack = onClose) + ViewerFullscreenWindow("video") /* ── video metadata (dimensions + rotation) ── */ LaunchedEffect(filePath) { - val info = withContext(Dispatchers.IO) { - val mmr = MediaMetadataRetriever() - try { - mmr.setDataSource(filePath) - val w = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0 - val h = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0 - val r = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0 - VideoInfo(w, h, r) - } catch (e: Exception) { - LogManager.addLog("WARN", "VideoPlayer", "metadata failed: ${e.message}") - null - } finally { - try { mmr.release() } catch (_: Exception) {} - } - } + val info = try { withContext(Dispatchers.IO) { VideoFileMetadata().use { it.read(filePath) } } } + catch (e: CancellationException) { throw e } + catch (e: Exception) { LogManager.addLog("WARN", "VideoPlayer", "metadata failed: $e"); null } videoInfo = info if (info != null) { LogManager.addLog( @@ -126,7 +99,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { } /* ── auto-rotate screen to match video orientation ── */ - DisposableEffect(videoInfo) { + DisposableEffect(context, videoInfo) { val act = context.findActivity() val prev = act?.requestedOrientation val info = videoInfo @@ -137,58 +110,33 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { else -> prev } if (orientation != null) { - act.requestedOrientation = orientation - LogManager.addLog("INFO", "VideoPlayer", "Fit screen video=${info.displayWidth}x${info.displayHeight} " + - "metadataRotation=${info.rotation} requested=$orientation previous=$prev") + try { + act.requestedOrientation = orientation + LogManager.addLog("INFO", "VideoPlayer", "Fit screen video=${info.displayWidth}x${info.displayHeight} " + + "metadataRotation=${info.rotation} requested=$orientation previous=$prev") + } catch (e: Exception) { LogManager.addLog("ERROR", "VideoPlayer", "Cannot apply orientation=$orientation: $e") } } } onDispose { if (act != null && prev != null) { - act.requestedOrientation = prev - LogManager.addLog("INFO", "VideoPlayer", "restore orientation $prev") + try { + act.requestedOrientation = prev + LogManager.addLog("INFO", "VideoPlayer", "restore orientation $prev") + } catch (e: Exception) { LogManager.addLog("ERROR", "VideoPlayer", "Cannot restore orientation=$prev: $e") } } } } /* ── MediaPlayer lifecycle ── */ - DisposableEffect(filePath) { - player.setOnPreparedListener { mp -> - try { mp.setVideoScalingMode(MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT) } catch (_: Exception) {} - LogManager.addLog("INFO", "VideoPlayer", "prepared ${mp.videoWidth}x${mp.videoHeight} dur=${mp.duration}") - durationMs = mp.duration - prepared.value = true - if (surfaceReady.value) { - mp.start() - isPlaying = true - } - } - player.setOnCompletionListener { - LogManager.addLog("INFO", "VideoSeek", "onCompletion, closing player") - onClose() - } - player.setOnErrorListener { _, what, extra -> - LogManager.addLog("ERROR", "VideoPlayer", "MediaPlayer error what=$what extra=$extra file=$filePath") - error = AppLanguage.text(R.string.video_playback_failed) - true - } - try { - player.setDataSource(filePath) - player.prepareAsync() - } catch (e: Exception) { - LogManager.addLog("ERROR", "VideoPlayer", "setDataSource/prepare failed: ${e.message}") - error = AppLanguage.text(R.string.video_open_failed) - } - - onDispose { - try { player.stop() } catch (_: Exception) {} - player.release() - } + DisposableEffect(player) { + player.start(filePath) + onDispose { player.close() } } /* ── position polling ── */ - LaunchedEffect(isPlaying) { + LaunchedEffect(player, isPlaying) { while (isPlaying && isActive) { - positionMs = try { player.currentPosition } catch (_: Exception) { 0 } + player.pollPosition() delay(300) } } @@ -214,29 +162,28 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { } fun togglePlay() { - if (error != null) return + if (error != null || !playback.prepared) return onInteraction() if (isPlaying) { - try { player.pause() } catch (_: Exception) {} - isPlaying = false + player.pause() } else { if (durationMs > 0 && positionMs >= durationMs - 50) { - try { player.seekTo(0) } catch (_: Exception) {} - positionMs = 0 + if (!player.seek(0)) return } - try { player.start() } catch (_: Exception) {} - isPlaying = true + player.play() } } fun flipLandscape() { onInteraction() val act = context.findActivity() ?: return - val rotation = act.windowManager.defaultDisplay.rotation - val reverse = rotation == Surface.ROTATION_180 || rotation == Surface.ROTATION_270 - val newOrient = if (reverse) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE else ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE - LogManager.addLog("INFO", "VideoPlayer", "flip 180 -> $newOrient") - act.requestedOrientation = newOrient + try { + val rotation = act.windowManager.defaultDisplay.rotation + val reverse = rotation == Surface.ROTATION_180 || rotation == Surface.ROTATION_270 + val newOrient = if (reverse) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE else ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + act.requestedOrientation = newOrient + LogManager.addLog("INFO", "VideoPlayer", "flip 180 -> $newOrient") + } catch (e: Exception) { LogManager.addLog("ERROR", "VideoPlayer", "Cannot flip screen orientation: $e") } } val isLandscape = (videoInfo?.displayWidth ?: 0) > (videoInfo?.displayHeight ?: 0) @@ -275,42 +222,36 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { zoom.constrain(Size(videoW, videoH), Size(screenW, screenH)) } - AndroidView( + key(player) { AndroidView( factory = { ctx -> TextureView(ctx).apply { surfaceTextureListener = object : TextureView.SurfaceTextureListener { override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) { - player.setSurface(Surface(surface)) - surfaceReady.value = true - if (prepared.value && !isPlaying) { - try { player.start() } catch (_: Exception) {} - isPlaying = true - } + player.attach(surface) } override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {} override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { - player.setSurface(null) - surfaceReady.value = false - return true + return player.detach(surface) } override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {} } } }, + update = { it.keepScreenOn = isPlaying }, onRelease = { player.close() }, modifier = videoModifier.graphicsLayer { scaleX = zoom.scale scaleY = zoom.scale translationX = zoom.offset.x translationY = zoom.offset.y } - ) + ) } if (error != null) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - Text(error!!, color = Color.White, fontSize = 16.sp) + Text(stringResource(error), color = Color.White, fontSize = 16.sp) } } @@ -362,6 +303,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { if (error == null) { IconButton( onClick = { togglePlay() }, + enabled = playback.prepared, modifier = Modifier.size(72.dp).background(Color(0xCC000000), MaterialTheme.shapes.extraLarge) ) { Icon( @@ -435,47 +377,41 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { modifier = Modifier .weight(1f) .height(32.dp) - .pointerInput(durationMs) { + .pointerInput(durationMs, playback.prepared, error) { detectTapGestures { offset -> + if (!playback.prepared || error != null) return@detectTapGestures onInteraction() val pos = (offset.x / size.width * durationMs).toInt().coerceIn(0, durationMs) - try { player.seekTo(pos) } catch (_: Exception) {} - positionMs = pos + player.seek(pos) } } - .pointerInput(durationMs) { + .pointerInput(durationMs, playback.prepared, error) { detectDragGestures( onDragStart = { offset -> wasPlayingBeforeDrag = isPlaying - if (isPlaying) { - try { player.pause() } catch (_: Exception) {} - isPlaying = false + if (playback.prepared && error == null && (!isPlaying || player.pause())) { + dragging = true + dragPosition = (offset.x / size.width * durationMs).toInt().coerceIn(0, durationMs) + player.seek(dragPosition) } - dragging = true - dragPosition = (offset.x / size.width * durationMs).toInt().coerceIn(0, durationMs) - try { player.seekTo(dragPosition) } catch (_: Exception) {} }, onDrag = { change, _ -> change.consume() - dragPosition = (change.position.x / size.width * durationMs).toInt().coerceIn(0, durationMs) - try { player.seekTo(dragPosition) } catch (_: Exception) {} + if (dragging) { + dragPosition = (change.position.x / size.width * durationMs).toInt().coerceIn(0, durationMs) + player.seek(dragPosition) + } }, onDragEnd = { - try { player.seekTo(dragPosition) } catch (_: Exception) {} - positionMs = dragPosition - dragging = false - if (wasPlayingBeforeDrag) { - try { player.start() } catch (_: Exception) {} - isPlaying = true + if (dragging) { + dragging = false + if (player.seek(dragPosition) && wasPlayingBeforeDrag) player.play() } }, onDragCancel = { - try { player.seekTo(dragPosition) } catch (_: Exception) {} - positionMs = dragPosition - dragging = false - if (wasPlayingBeforeDrag) { - try { player.start() } catch (_: Exception) {} - isPlaying = true + if (dragging) { + dragging = false + if (player.seek(dragPosition) && wasPlayingBeforeDrag) player.play() } } ) diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/ui/screens/VideoPlayerInfoTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/ui/screens/VideoPlayerInfoTest.kt index d44eca3e..5ea88bc2 100644 --- a/tools/chatgui-android/app/src/test/java/com/utun/chat/ui/screens/VideoPlayerInfoTest.kt +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/ui/screens/VideoPlayerInfoTest.kt @@ -1,5 +1,7 @@ package com.utun.chat.ui.screens +import com.utun.chat.data.VideoInfo + import org.junit.Assert.assertEquals import org.junit.Test