Browse Source
- m_render WA_TransparentForMouseEvents — события мыши напрямую в окно (drag работает) - открытие: видео влезает — натуральный размер по центру; больше экрана — fullscreen - resize мышью за 4 края и 4 угла, пропорции сохраняются (якорь = противоположный край) - нижний край доступен через eventFilter на панели контролов (пустые поля пропагируют) - сброс fullscreen при повторном playFilev2
7 changed files with 513 additions and 17 deletions
@ -0,0 +1,234 @@ |
|||||||
|
package com.utun.chat.ui.screens |
||||||
|
|
||||||
|
import android.graphics.SurfaceTexture |
||||||
|
import android.media.MediaPlayer |
||||||
|
import android.view.Surface |
||||||
|
import android.view.TextureView |
||||||
|
import androidx.compose.foundation.background |
||||||
|
import androidx.compose.foundation.clickable |
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource |
||||||
|
import androidx.compose.foundation.layout.* |
||||||
|
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.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.graphics.Color |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import androidx.compose.ui.unit.sp |
||||||
|
import androidx.compose.ui.viewinterop.AndroidView |
||||||
|
import com.utun.chat.data.LogManager |
||||||
|
import kotlinx.coroutines.delay |
||||||
|
import kotlinx.coroutines.isActive |
||||||
|
|
||||||
|
@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) } |
||||||
|
|
||||||
|
/* ── MediaPlayer lifecycle ── */ |
||||||
|
DisposableEffect(filePath) { |
||||||
|
player.setOnPreparedListener { mp -> |
||||||
|
durationMs = mp.duration |
||||||
|
prepared.value = true |
||||||
|
if (surfaceReady.value) { |
||||||
|
mp.start() |
||||||
|
isPlaying = true |
||||||
|
} |
||||||
|
} |
||||||
|
player.setOnCompletionListener { |
||||||
|
isPlaying = false |
||||||
|
positionMs = 0 |
||||||
|
try { it.seekTo(0) } catch (_: Exception) {} |
||||||
|
} |
||||||
|
player.setOnErrorListener { _, what, extra -> |
||||||
|
LogManager.addLog("ERROR", "VideoPlayer", "MediaPlayer error what=$what extra=$extra file=$filePath") |
||||||
|
error = "Ошибка воспроизведения видео" |
||||||
|
true |
||||||
|
} |
||||||
|
try { |
||||||
|
player.setDataSource(filePath) |
||||||
|
player.prepareAsync() |
||||||
|
} catch (e: Exception) { |
||||||
|
LogManager.addLog("ERROR", "VideoPlayer", "setDataSource/prepare failed: ${e.message}") |
||||||
|
error = "Не удалось открыть видео" |
||||||
|
} |
||||||
|
|
||||||
|
onDispose { |
||||||
|
try { player.stop() } catch (_: Exception) {} |
||||||
|
player.release() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/* ── position polling ── */ |
||||||
|
LaunchedEffect(isPlaying) { |
||||||
|
while (isPlaying && isActive) { |
||||||
|
positionMs = try { player.currentPosition } catch (_: Exception) { 0 } |
||||||
|
delay(300) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/* ── controls auto-hide ── */ |
||||||
|
LaunchedEffect(controlsVisible, isPlaying) { |
||||||
|
if (controlsVisible && isPlaying) { |
||||||
|
delay(3000) |
||||||
|
controlsVisible = false |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fun togglePlay() { |
||||||
|
if (error != null) return |
||||||
|
if (isPlaying) { |
||||||
|
try { player.pause() } catch (_: Exception) {} |
||||||
|
isPlaying = false |
||||||
|
} else { |
||||||
|
if (durationMs > 0 && positionMs >= durationMs - 50) { |
||||||
|
try { player.seekTo(0) } catch (_: Exception) {} |
||||||
|
positionMs = 0 |
||||||
|
} |
||||||
|
try { player.start() } catch (_: Exception) {} |
||||||
|
isPlaying = true |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Box( |
||||||
|
modifier = Modifier.fillMaxSize().background(Color.Black) |
||||||
|
) { |
||||||
|
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 |
||||||
|
} |
||||||
|
} |
||||||
|
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {} |
||||||
|
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { |
||||||
|
player.setSurface(null) |
||||||
|
surfaceReady.value = false |
||||||
|
return true |
||||||
|
} |
||||||
|
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {} |
||||||
|
} |
||||||
|
} |
||||||
|
}, |
||||||
|
modifier = Modifier.fillMaxSize() |
||||||
|
) |
||||||
|
|
||||||
|
if (error != null) { |
||||||
|
Box( |
||||||
|
modifier = Modifier.fillMaxSize(), |
||||||
|
contentAlignment = Alignment.Center |
||||||
|
) { |
||||||
|
Text(error!!, color = Color.White, fontSize = 16.sp) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/* tap surface toggles controls */ |
||||||
|
if (error == null) { |
||||||
|
Box( |
||||||
|
modifier = Modifier.fillMaxSize() |
||||||
|
.clickable( |
||||||
|
interactionSource = remember { MutableInteractionSource() }, |
||||||
|
indication = null |
||||||
|
) { controlsVisible = !controlsVisible } |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
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) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/* 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) |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/* 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) |
||||||
|
) |
||||||
|
) |
||||||
|
Text(formatDurationMs(durationMs), color = Color.White, fontSize = 12.sp) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private fun formatDurationMs(ms: Int): String { |
||||||
|
val secs = (ms.coerceAtLeast(0)) / 1000 |
||||||
|
return "%d:%02d".format(secs / 60, secs % 60) |
||||||
|
} |
||||||
Loading…
Reference in new issue