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