Browse Source

chatgui-android: видеоплеер — зум, поворот, полноэкранный режим

v2
evgeny 3 weeks ago
parent
commit
fe1726ebea
  1. 453
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt

453
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<VideoInfo?>(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)
}
}
}
}

Loading…
Cancel
Save