From fe1726ebeadcd4dc2f4cd567e07bc2d7041e9514 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 8 Sep 2026 16:39:59 +0300 Subject: [PATCH] =?UTF-8?q?chatgui-android:=20=D0=B2=D0=B8=D0=B4=D0=B5?= =?UTF-8?q?=D0=BE=D0=BF=D0=BB=D0=B5=D0=B5=D1=80=20=E2=80=94=20=D0=B7=D1=83?= =?UTF-8?q?=D0=BC,=20=D0=BF=D0=BE=D0=B2=D0=BE=D1=80=D0=BE=D1=82,=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D0=BE=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utun/chat/ui/screens/VideoPlayerScreen.kt | 453 +++++++++++++++--- 1 file changed, 377 insertions(+), 76 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 94b29122..4fa1c06e 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,33 +1,74 @@ package com.utun.chat.ui.screens +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.OrientationEventListener import android.view.Surface import android.view.TextureView +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.ScreenRotation import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext + +private 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 +} + +private fun clampOffset(scale: Float, o: Offset, vW: Float, vH: Float, sW: Float, sH: Float): Offset { + val mx = ((vW * scale - sW) / 2f).coerceAtLeast(0f) + val my = ((vH * scale - sH) / 2f).coerceAtLeast(0f) + return Offset(o.x.coerceIn(-mx, mx), o.y.coerceIn(-my, my)) +} @Composable fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { @@ -42,10 +83,93 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { 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) } + var zoomScale by remember(filePath) { mutableStateOf(1f) } + var zoomOffset by remember(filePath) { mutableStateOf(Offset.Zero) } + 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") + } + } + + /* ── 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) {} + } + } + videoInfo = info + if (info != null) { + LogManager.addLog( + "INFO", "VideoPlayer", + "video ${info.width}x${info.height} rot=${info.rotation} -> display ${info.displayWidth}x${info.displayHeight} aspect=%.3f".format(info.aspect) + ) + } + } + + /* ── auto-rotate screen to match video orientation ── */ + DisposableEffect(videoInfo) { + val act = context.findActivity() + val prev = act?.requestedOrientation + val info = videoInfo + var oel: OrientationEventListener? = null + if (act != null && info != null && info.displayWidth > 0 && info.displayHeight > 0) { + if (info.displayWidth > info.displayHeight) { + /* landscape: determine side from sensor once, then lock */ + oel = object : OrientationEventListener(context) { + override fun onOrientationChanged(angle: Int) { + if (angle == ORIENTATION_UNKNOWN) return + val reverse = angle in 45..134 + val orient = if (reverse) ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + else ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + LogManager.addLog("INFO", "VideoPlayer", "auto-orient sensor=$angle -> ${if (reverse) "REVERSE_LANDSCAPE" else "LANDSCAPE"} (prev=$prev)") + act.requestedOrientation = orient + disable() + } + } + oel.enable() + } else { + LogManager.addLog("INFO", "VideoPlayer", "auto-orient -> PORTRAIT (prev=$prev)") + act.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + } + } + onDispose { + oel?.disable() + if (act != null && prev != null) { + act.requestedOrientation = prev + LogManager.addLog("INFO", "VideoPlayer", "restore orientation $prev") + } + } + } /* ── 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) { @@ -54,9 +178,8 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { } } player.setOnCompletionListener { - isPlaying = false - positionMs = 0 - try { it.seekTo(0) } catch (_: Exception) {} + LogManager.addLog("INFO", "VideoSeek", "onCompletion, closing player") + onClose() } player.setOnErrorListener { _, what, extra -> LogManager.addLog("ERROR", "VideoPlayer", "MediaPlayer error what=$what extra=$extra file=$filePath") @@ -85,16 +208,29 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { } } - /* ── controls auto-hide ── */ - LaunchedEffect(controlsVisible, isPlaying) { + /* ── controls auto-hide (only while playing) ── */ + LaunchedEffect(controlsVisible, isPlaying, hideTick) { if (controlsVisible && isPlaying) { delay(3000) controlsVisible = false } } + /* ── controls always visible while paused ── */ + LaunchedEffect(isPlaying) { + if (!isPlaying) { + controlsVisible = true + } + } + + fun onInteraction() { + hideTick++ + controlsVisible = true + } + fun togglePlay() { if (error != null) return + onInteraction() if (isPlaying) { try { player.pause() } catch (_: Exception) {} isPlaying = false @@ -108,9 +244,53 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { } } - Box( + fun flipLandscape() { + onInteraction() + val act = context.findActivity() ?: return + val cur = act.requestedOrientation + val newOrient = if (cur == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) + ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + else ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + LogManager.addLog("INFO", "VideoPlayer", "flip 180 -> $newOrient") + act.requestedOrientation = newOrient + } + + val isLandscape = (videoInfo?.displayWidth ?: 0) > (videoInfo?.displayHeight ?: 0) + + BoxWithConstraints( modifier = Modifier.fillMaxSize().background(Color.Black) ) { + val ratio = videoInfo?.aspect ?: 0f + val w: Dp + val h: Dp + if (ratio > 0f) { + if (maxWidth / ratio <= maxHeight) { + w = maxWidth + h = maxWidth / ratio + } else { + h = maxHeight + w = maxHeight * ratio + } + } else { + w = maxWidth + h = maxHeight + } + val videoModifier = if (ratio > 0f) { + Modifier.size(w, h).align(Alignment.Center) + } else { + Modifier.fillMaxSize() + } + val density = LocalDensity.current + val screenW = with(density) { maxWidth.toPx() } + val screenH = with(density) { maxHeight.toPx() } + val videoW = with(density) { w.toPx() } + val videoH = with(density) { h.toPx() } + + /* re-clamp zoom offset when screen size changes (e.g. flip 180) */ + LaunchedEffect(screenW, screenH) { + zoomOffset = clampOffset(zoomScale, zoomOffset, videoW, videoH, screenW, screenH) + } + AndroidView( factory = { ctx -> TextureView(ctx).apply { @@ -133,7 +313,12 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { } } }, - modifier = Modifier.fillMaxSize() + modifier = videoModifier.graphicsLayer { + scaleX = zoomScale + scaleY = zoomScale + translationX = zoomOffset.x + translationY = zoomOffset.y + } ) if (error != null) { @@ -145,84 +330,200 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { } } - /* tap surface toggles controls */ + /* gestures: tap toggles controls; pinch zooms; drag pans */ if (error == null) { Box( modifier = Modifier.fillMaxSize() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { controlsVisible = !controlsVisible } + .pointerInput(Unit) { + detectTapGestures { + hideTick++ + if (isPlaying) controlsVisible = !controlsVisible + } + } + .pointerInput(screenW, screenH, videoW, videoH) { + detectTransformGestures { centroid, pan, zoom, _ -> + onInteraction() + val old = zoomScale + val new = (zoomScale * zoom).coerceIn(1f, 4f) + val z = new / old + val rel = centroid - Offset(screenW / 2f, screenH / 2f) + val raw = Offset( + zoomOffset.x * z + rel.x * (1f - z) + pan.x, + zoomOffset.y * z + rel.y * (1f - z) + pan.y + ) + zoomScale = new + zoomOffset = clampOffset(new, raw, videoW, videoH, screenW, screenH) + } + } ) } - if (controlsVisible) { - /* top bar: close */ - Row( - modifier = Modifier.fillMaxWidth() - .background(Color(0xAA000000)) - .statusBarsPadding() - .padding(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = onClose) { - Icon(Icons.Filled.Close, "Закрыть", tint = Color.White) + AnimatedVisibility( + visible = controlsVisible, + enter = fadeIn(tween(1000)), + exit = fadeOut(tween(1000)), + modifier = Modifier.fillMaxSize() + ) { + Box(modifier = Modifier.fillMaxSize()) { + /* top bar: close (right) */ + Row( + modifier = Modifier.fillMaxWidth() + .background(Color(0xAA000000)) + .statusBarsPadding() + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + IconButton(onClick = onClose) { + Icon(Icons.Filled.Close, "Закрыть", tint = Color.White) + } } - } - /* center: play/pause */ - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - if (error == null) { - IconButton( - onClick = { togglePlay() }, - modifier = Modifier.size(72.dp).background(Color(0x66000000), MaterialTheme.shapes.extraLarge) + /* center: play/pause */ + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (error == null) { + IconButton( + onClick = { togglePlay() }, + modifier = Modifier.size(72.dp).background(Color(0x66000000), MaterialTheme.shapes.extraLarge) + ) { + Icon( + if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = if (isPlaying) "Пауза" else "Плей", + tint = Color.White, + modifier = Modifier.size(48.dp) + ) + } + } + } + + /* center-left + center-right: rotate 180 (landscape only) */ + if (isLandscape) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterStart ) { - Icon( - if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, - contentDescription = if (isPlaying) "Пауза" else "Плей", - tint = Color.White, - modifier = Modifier.size(48.dp) - ) + IconButton( + onClick = { flipLandscape() }, + modifier = Modifier.padding(start = 12.dp) + .size(56.dp) + .background(Color(0x66000000), MaterialTheme.shapes.extraLarge) + ) { + Icon( + Icons.Filled.ScreenRotation, + contentDescription = "Перевернуть", + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + } + } + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterEnd + ) { + IconButton( + onClick = { flipLandscape() }, + modifier = Modifier.padding(end = 12.dp) + .size(56.dp) + .background(Color(0x66000000), MaterialTheme.shapes.extraLarge) + ) { + Icon( + Icons.Filled.ScreenRotation, + contentDescription = "Перевернуть", + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + } } } - } - /* bottom bar: seek + time */ - Row( - modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter) - .background(Color(0xAA000000)) - .navigationBarsPadding() - .padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - formatDurationMs(if (dragging) dragPosition else positionMs), - color = Color.White, fontSize = 12.sp - ) - Slider( - value = (if (dragging) dragPosition else positionMs).toFloat(), - onValueChange = { v -> - dragging = true - dragPosition = v.toInt().coerceIn(0, durationMs.coerceAtLeast(0)) - }, - onValueChangeFinished = { - val target = dragPosition - try { player.seekTo(target) } catch (_: Exception) {} - positionMs = target - dragging = false - }, - valueRange = 0f..durationMs.coerceAtLeast(1).toFloat(), - modifier = Modifier.weight(1f).padding(horizontal = 8.dp), - colors = SliderDefaults.colors( - thumbColor = Color.White, - activeTrackColor = Color.White, - inactiveTrackColor = Color.White.copy(alpha = 0.4f) + /* bottom bar: seek + time */ + Row( + modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter) + .background(Color(0xAA000000)) + .navigationBarsPadding() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + formatDurationMs(if (dragging) dragPosition else positionMs), + color = Color.White, fontSize = 12.sp ) - ) - Text(formatDurationMs(durationMs), color = Color.White, fontSize = 12.sp) + + val fraction = if (durationMs > 0) + ((if (dragging) dragPosition else positionMs).toFloat() / durationMs).coerceIn(0f, 1f) + else 0f + + Box( + modifier = Modifier + .weight(1f) + .height(32.dp) + .pointerInput(durationMs) { + detectTapGestures { offset -> + onInteraction() + val pos = (offset.x / size.width * durationMs).toInt().coerceIn(0, durationMs) + try { player.seekTo(pos) } catch (_: Exception) {} + positionMs = pos + } + } + .pointerInput(durationMs) { + detectDragGestures( + onDragStart = { offset -> + wasPlayingBeforeDrag = isPlaying + if (isPlaying) { + try { player.pause() } catch (_: Exception) {} + isPlaying = false + } + 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) {} + }, + onDragEnd = { + try { player.seekTo(dragPosition) } catch (_: Exception) {} + positionMs = dragPosition + dragging = false + if (wasPlayingBeforeDrag) { + try { player.start() } catch (_: Exception) {} + isPlaying = true + } + }, + onDragCancel = { + try { player.seekTo(dragPosition) } catch (_: Exception) {} + positionMs = dragPosition + dragging = false + if (wasPlayingBeforeDrag) { + try { player.start() } catch (_: Exception) {} + isPlaying = true + } + } + ) + } + ) { + Box( + Modifier.align(Alignment.CenterStart).fillMaxWidth().height(4.dp) + .background(Color.White.copy(alpha = 0.4f), RoundedCornerShape(2.dp)) + ) + Box( + Modifier.align(Alignment.CenterStart).fillMaxWidth(fraction).height(4.dp) + .background(Color.White, RoundedCornerShape(2.dp)) + ) + Box( + Modifier.align(Alignment.CenterStart).fillMaxWidth(fraction).height(32.dp), + contentAlignment = Alignment.CenterEnd + ) { + Box(Modifier.size(14.dp).background(Color.White, CircleShape)) + } + } + + Text(formatDurationMs(durationMs), color = Color.White, fontSize = 12.sp) + } } } }