|
|
|
@ -1,33 +1,74 @@ |
|
|
|
package com.utun.chat.ui.screens |
|
|
|
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.graphics.SurfaceTexture |
|
|
|
|
|
|
|
import android.media.MediaMetadataRetriever |
|
|
|
import android.media.MediaPlayer |
|
|
|
import android.media.MediaPlayer |
|
|
|
|
|
|
|
import android.view.OrientationEventListener |
|
|
|
import android.view.Surface |
|
|
|
import android.view.Surface |
|
|
|
import android.view.TextureView |
|
|
|
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.background |
|
|
|
import androidx.compose.foundation.clickable |
|
|
|
import androidx.compose.foundation.gestures.detectDragGestures |
|
|
|
import androidx.compose.foundation.interaction.MutableInteractionSource |
|
|
|
import androidx.compose.foundation.gestures.detectTapGestures |
|
|
|
|
|
|
|
import androidx.compose.foundation.gestures.detectTransformGestures |
|
|
|
import androidx.compose.foundation.layout.* |
|
|
|
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.Icons |
|
|
|
import androidx.compose.material.icons.filled.Close |
|
|
|
import androidx.compose.material.icons.filled.Close |
|
|
|
import androidx.compose.material.icons.filled.Pause |
|
|
|
import androidx.compose.material.icons.filled.Pause |
|
|
|
import androidx.compose.material.icons.filled.PlayArrow |
|
|
|
import androidx.compose.material.icons.filled.PlayArrow |
|
|
|
|
|
|
|
import androidx.compose.material.icons.filled.ScreenRotation |
|
|
|
import androidx.compose.material3.Icon |
|
|
|
import androidx.compose.material3.Icon |
|
|
|
import androidx.compose.material3.IconButton |
|
|
|
import androidx.compose.material3.IconButton |
|
|
|
import androidx.compose.material3.MaterialTheme |
|
|
|
import androidx.compose.material3.MaterialTheme |
|
|
|
import androidx.compose.material3.Slider |
|
|
|
|
|
|
|
import androidx.compose.material3.SliderDefaults |
|
|
|
|
|
|
|
import androidx.compose.material3.Text |
|
|
|
import androidx.compose.material3.Text |
|
|
|
import androidx.compose.runtime.* |
|
|
|
import androidx.compose.runtime.* |
|
|
|
import androidx.compose.ui.Alignment |
|
|
|
import androidx.compose.ui.Alignment |
|
|
|
import androidx.compose.ui.Modifier |
|
|
|
import androidx.compose.ui.Modifier |
|
|
|
|
|
|
|
import androidx.compose.ui.geometry.Offset |
|
|
|
import androidx.compose.ui.graphics.Color |
|
|
|
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.dp |
|
|
|
import androidx.compose.ui.unit.sp |
|
|
|
import androidx.compose.ui.unit.sp |
|
|
|
import androidx.compose.ui.viewinterop.AndroidView |
|
|
|
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.LogManager |
|
|
|
|
|
|
|
import kotlinx.coroutines.Dispatchers |
|
|
|
import kotlinx.coroutines.delay |
|
|
|
import kotlinx.coroutines.delay |
|
|
|
import kotlinx.coroutines.isActive |
|
|
|
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 |
|
|
|
@Composable |
|
|
|
fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { |
|
|
|
fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { |
|
|
|
@ -42,10 +83,93 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { |
|
|
|
var controlsVisible by remember { mutableStateOf(true) } |
|
|
|
var controlsVisible by remember { mutableStateOf(true) } |
|
|
|
var dragging by remember { mutableStateOf(false) } |
|
|
|
var dragging by remember { mutableStateOf(false) } |
|
|
|
var dragPosition by remember { mutableStateOf(0) } |
|
|
|
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 ── */ |
|
|
|
/* ── MediaPlayer lifecycle ── */ |
|
|
|
DisposableEffect(filePath) { |
|
|
|
DisposableEffect(filePath) { |
|
|
|
player.setOnPreparedListener { mp -> |
|
|
|
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 |
|
|
|
durationMs = mp.duration |
|
|
|
prepared.value = true |
|
|
|
prepared.value = true |
|
|
|
if (surfaceReady.value) { |
|
|
|
if (surfaceReady.value) { |
|
|
|
@ -54,9 +178,8 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
player.setOnCompletionListener { |
|
|
|
player.setOnCompletionListener { |
|
|
|
isPlaying = false |
|
|
|
LogManager.addLog("INFO", "VideoSeek", "onCompletion, closing player") |
|
|
|
positionMs = 0 |
|
|
|
onClose() |
|
|
|
try { it.seekTo(0) } catch (_: Exception) {} |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
player.setOnErrorListener { _, what, extra -> |
|
|
|
player.setOnErrorListener { _, what, extra -> |
|
|
|
LogManager.addLog("ERROR", "VideoPlayer", "MediaPlayer error what=$what extra=$extra file=$filePath") |
|
|
|
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 ── */ |
|
|
|
/* ── controls auto-hide (only while playing) ── */ |
|
|
|
LaunchedEffect(controlsVisible, isPlaying) { |
|
|
|
LaunchedEffect(controlsVisible, isPlaying, hideTick) { |
|
|
|
if (controlsVisible && isPlaying) { |
|
|
|
if (controlsVisible && isPlaying) { |
|
|
|
delay(3000) |
|
|
|
delay(3000) |
|
|
|
controlsVisible = false |
|
|
|
controlsVisible = false |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* ── controls always visible while paused ── */ |
|
|
|
|
|
|
|
LaunchedEffect(isPlaying) { |
|
|
|
|
|
|
|
if (!isPlaying) { |
|
|
|
|
|
|
|
controlsVisible = true |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fun onInteraction() { |
|
|
|
|
|
|
|
hideTick++ |
|
|
|
|
|
|
|
controlsVisible = true |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
fun togglePlay() { |
|
|
|
fun togglePlay() { |
|
|
|
if (error != null) return |
|
|
|
if (error != null) return |
|
|
|
|
|
|
|
onInteraction() |
|
|
|
if (isPlaying) { |
|
|
|
if (isPlaying) { |
|
|
|
try { player.pause() } catch (_: Exception) {} |
|
|
|
try { player.pause() } catch (_: Exception) {} |
|
|
|
isPlaying = false |
|
|
|
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) |
|
|
|
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( |
|
|
|
AndroidView( |
|
|
|
factory = { ctx -> |
|
|
|
factory = { ctx -> |
|
|
|
TextureView(ctx).apply { |
|
|
|
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) { |
|
|
|
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) { |
|
|
|
if (error == null) { |
|
|
|
Box( |
|
|
|
Box( |
|
|
|
modifier = Modifier.fillMaxSize() |
|
|
|
modifier = Modifier.fillMaxSize() |
|
|
|
.clickable( |
|
|
|
.pointerInput(Unit) { |
|
|
|
interactionSource = remember { MutableInteractionSource() }, |
|
|
|
detectTapGestures { |
|
|
|
indication = null |
|
|
|
hideTick++ |
|
|
|
) { controlsVisible = !controlsVisible } |
|
|
|
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) { |
|
|
|
AnimatedVisibility( |
|
|
|
/* top bar: close */ |
|
|
|
visible = controlsVisible, |
|
|
|
Row( |
|
|
|
enter = fadeIn(tween(1000)), |
|
|
|
modifier = Modifier.fillMaxWidth() |
|
|
|
exit = fadeOut(tween(1000)), |
|
|
|
.background(Color(0xAA000000)) |
|
|
|
modifier = Modifier.fillMaxSize() |
|
|
|
.statusBarsPadding() |
|
|
|
) { |
|
|
|
.padding(horizontal = 4.dp), |
|
|
|
Box(modifier = Modifier.fillMaxSize()) { |
|
|
|
verticalAlignment = Alignment.CenterVertically |
|
|
|
/* top bar: close (right) */ |
|
|
|
) { |
|
|
|
Row( |
|
|
|
IconButton(onClick = onClose) { |
|
|
|
modifier = Modifier.fillMaxWidth() |
|
|
|
Icon(Icons.Filled.Close, "Закрыть", tint = Color.White) |
|
|
|
.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 */ |
|
|
|
/* center: play/pause */ |
|
|
|
Box( |
|
|
|
Box( |
|
|
|
modifier = Modifier.fillMaxSize(), |
|
|
|
modifier = Modifier.fillMaxSize(), |
|
|
|
contentAlignment = Alignment.Center |
|
|
|
contentAlignment = Alignment.Center |
|
|
|
) { |
|
|
|
) { |
|
|
|
if (error == null) { |
|
|
|
if (error == null) { |
|
|
|
IconButton( |
|
|
|
IconButton( |
|
|
|
onClick = { togglePlay() }, |
|
|
|
onClick = { togglePlay() }, |
|
|
|
modifier = Modifier.size(72.dp).background(Color(0x66000000), MaterialTheme.shapes.extraLarge) |
|
|
|
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( |
|
|
|
IconButton( |
|
|
|
if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, |
|
|
|
onClick = { flipLandscape() }, |
|
|
|
contentDescription = if (isPlaying) "Пауза" else "Плей", |
|
|
|
modifier = Modifier.padding(start = 12.dp) |
|
|
|
tint = Color.White, |
|
|
|
.size(56.dp) |
|
|
|
modifier = Modifier.size(48.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 */ |
|
|
|
/* bottom bar: seek + time */ |
|
|
|
Row( |
|
|
|
Row( |
|
|
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter) |
|
|
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter) |
|
|
|
.background(Color(0xAA000000)) |
|
|
|
.background(Color(0xAA000000)) |
|
|
|
.navigationBarsPadding() |
|
|
|
.navigationBarsPadding() |
|
|
|
.padding(horizontal = 8.dp, vertical = 4.dp), |
|
|
|
.padding(horizontal = 8.dp, vertical = 4.dp), |
|
|
|
verticalAlignment = Alignment.CenterVertically |
|
|
|
verticalAlignment = Alignment.CenterVertically |
|
|
|
) { |
|
|
|
) { |
|
|
|
Text( |
|
|
|
Text( |
|
|
|
formatDurationMs(if (dragging) dragPosition else positionMs), |
|
|
|
formatDurationMs(if (dragging) dragPosition else positionMs), |
|
|
|
color = Color.White, fontSize = 12.sp |
|
|
|
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) |
|
|
|
|
|
|
|
) |
|
|
|
) |
|
|
|
) |
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|