Drive video controls from SDK outcomes and preserve paused playback

This commit is contained in:
evgeny committed 2026-10-29 13:34:25 +01:00
1 parent d9661d0f59
commit fdecfe1b78
2 files changed
+78 -140

No files matched your search

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