Drive video controls from SDK outcomes and preserve paused playback
This commit is contained in:
1 parent
d9661d0f59
commit
fdecfe1b78
2 files changed
+78
-140
No files matched your search
+76
-140
@@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user