chatgui: плеер — фикс drag, центр/fullscreen при открытии, resize за края с сохранением пропорций

- m_render WA_TransparentForMouseEvents — события мыши напрямую в окно (drag работает)
- открытие: видео влезает — натуральный размер по центру; больше экрана — fullscreen
- resize мышью за 4 края и 4 угла, пропорции сохраняются (якорь = противоположный край)
- нижний край доступен через eventFilter на панели контролов (пустые поля пропагируют)
- сброс fullscreen при повторном playFile
This commit is contained in:
evgeny committed 2026-09-08 01:31:12 +03:00
1 parent 76182d018f
commit d09a6af52a
7 files changed
+513 -17

No files matched your search

@@ -21,6 +21,7 @@ import androidx.lifecycle.compose.LifecycleStartEffect
import com.utun.chat.data.Channel
import com.utun.chat.data.InviteData
import com.utun.chat.data.LogManager
import com.utun.chat.data.Message
import com.utun.chat.data.NativeLib
import com.utun.chat.headless.HeadlessService
import com.utun.chat.ui.screens.*
@@ -60,6 +61,7 @@ class MainActivity : ComponentActivity() {
var showJoinDialog by remember { mutableStateOf(false) }
var joinAutoLink by remember { mutableStateOf<String?>(null) }
var inviteChannelId by remember { mutableStateOf<String?>(null) }
var videoMessage by remember { mutableStateOf<Message?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
@@ -80,6 +82,10 @@ class MainActivity : ComponentActivity() {
}
}
BackHandler(enabled = videoMessage != null) {
videoMessage = null
}
val connected = NativeLib.isReady()
/* Subscribe native logs only when LogScreen is open */
@@ -118,7 +124,8 @@ class MainActivity : ComponentActivity() {
onSendMessage = { vm.sendMessage(it) },
onMembersClick = { screen = Screen.MemberList },
onInvite = { showInvite(channel) },
onBack = { screen = Screen.ChannelList }
onBack = { screen = Screen.ChannelList },
onPlayVideo = { msg -> videoMessage = msg }
)
}
}
@@ -149,6 +156,12 @@ class MainActivity : ComponentActivity() {
}
}
SnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter).navigationBarsPadding())
videoMessage?.let { msg ->
if (msg.filePath.isNotEmpty()) {
VideoPlayerScreen(filePath = msg.filePath, onClose = { videoMessage = null })
}
}
}
/* Show join dialog after QR scan returns */
@@ -308,7 +308,7 @@ class ChatRepository {
} catch (_: Exception) {}
}
if (contentType == "application/octet-stream") {
if (contentType == "application/octet-stream" || contentType.startsWith("video/")) {
try {
val pipeIdx = text.indexOf('|')
if (pipeIdx > 0) {
@@ -2,10 +2,13 @@ package com.utun.chat.ui.components
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import androidx.core.content.ContextCompat
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -19,8 +22,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -31,14 +36,17 @@ import androidx.compose.ui.unit.sp
import com.utun.chat.data.Message
import com.utun.chat.data.NativeLib
import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@Composable
fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(),
onPlayVoice: (String, Int) -> Unit = { _, _ -> },
onSeekVoice: (String, Int, Int) -> Unit = { _, _, _ -> },
onDownloadFile: (Message) -> Unit = {},
onOpenFile: (Message) -> Unit = {}) {
onOpenFile: (Message) -> Unit = {},
onPlayVideo: (Message) -> Unit = {}) {
val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8)
val textColor = if (message.isOutgoing) Color.White else Color.Black
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start
@@ -46,6 +54,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
val voiceMaxWidth = (LocalConfiguration.current.screenWidthDp * 0.75f).dp
val isVoice = message.contentType == "audio/opus"
val isVideo = isVideoMessage(message)
val isMedia = message.contentType.isNotEmpty() && !isVoice && message.contentType != "text/plain"
val isActive = isVoice && playbackState.filePath == message.filePath
@@ -65,6 +74,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
when {
isVoice -> VoiceBubble(message, textColor, isActive, playbackState, onPlayVoice, onSeekVoice, onDownloadFile)
isVideo -> VideoBubble(message, textColor, onDownloadFile, onPlayVideo)
isMedia -> FileBubble(message, textColor, onDownloadFile, onOpenFile)
else -> {
Text(message.text, fontSize = 17.sp, color = textColor)
@@ -228,6 +238,99 @@ private fun FileBubble(message: Message, textColor: Color, onDownload: (Message)
}
}
private val VIDEO_EXTENSIONS = setOf("mp4", "m4v", "mov", "webm", "3gp", "mkv", "avi", "ts", "mpg", "mpeg")
private fun isVideoMessage(message: Message): Boolean {
if (message.contentType.startsWith("video/")) return true
if (message.contentType == "application/octet-stream") {
val ext = message.text.substringAfterLast('.', "").lowercase()
return ext in VIDEO_EXTENSIONS
}
return false
}
private data class VideoMeta(val thumb: Bitmap?, val durationMs: Int)
private fun extractVideoMeta(path: String): VideoMeta {
var thumb: Bitmap? = null
var durationMs = 0
val mmr = MediaMetadataRetriever()
try {
mmr.setDataSource(path)
thumb = mmr.getFrameAtTime(1_000_000)
durationMs = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toIntOrNull() ?: 0
} catch (_: Exception) {
} finally {
try { mmr.release() } catch (_: Exception) {}
}
return VideoMeta(thumb, durationMs)
}
@Composable
private fun VideoBubble(message: Message, textColor: Color,
onDownload: (Message) -> Unit = {},
onPlay: (Message) -> Unit = {}) {
val downloading = message.downloadState == "downloading"
val downloaded = message.filePath.isNotEmpty()
var thumb by remember(message.filePath) { mutableStateOf<Bitmap?>(null) }
var durMs by remember(message.filePath) { mutableStateOf(0) }
LaunchedEffect(message.filePath) {
if (message.filePath.isNotEmpty()) {
val meta = withContext(Dispatchers.IO) { extractVideoMeta(message.filePath) }
thumb = meta.thumb
durMs = meta.durationMs
}
}
Column(
modifier = Modifier.clickable {
when {
downloading -> {}
!downloaded -> onDownload(message)
else -> onPlay(message)
}
}
) {
Box(
modifier = Modifier.size(width = 220.dp, height = 140.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF1E1E23))
) {
thumb?.let { Image(it.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.size(44.dp).background(Color(0x88000000), CircleShape), contentAlignment = Alignment.Center) {
Text(
when { downloading -> "\u23F3"; !downloaded -> "\u2B07"; else -> "\u25B6" },
fontSize = 18.sp, color = Color.White
)
}
}
if (durMs > 0) {
Text(
formatDurationMs(durMs), fontSize = 11.sp, color = Color.White,
modifier = Modifier.align(Alignment.BottomEnd).padding(6.dp)
.background(Color(0x88000000), RoundedCornerShape(4.dp))
.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
Spacer(Modifier.height(4.dp))
Text(message.text, fontSize = 15.sp, color = textColor, fontWeight = FontWeight.Bold,
maxLines = 1, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(2.dp))
Text(
"${formatFileSize(message.fileSize)} · " + when {
downloading -> "Downloading..."
!downloaded -> "Tap to download"
else -> "Tap to play"
},
fontSize = 12.sp, color = textColor.copy(alpha = 0.5f)
)
}
}
private fun formatFileSize(bytes: Long): String {
if (bytes <= 0) return ""
if (bytes < 1024) return "${bytes}B"
@@ -39,7 +39,8 @@ fun ChatScreen(
onSendMessage: (String) -> Unit,
onMembersClick: () -> Unit,
onInvite: () -> Unit,
onBack: () -> Unit
onBack: () -> Unit,
onPlayVideo: (Message) -> Unit = {}
) {
var text by remember { mutableStateOf("") }
val listState = rememberLazyListState()
@@ -132,7 +133,8 @@ fun ChatScreen(
pendingSaveFilePath = msg.filePath
saveFileLauncher.launch(msg.text) /* suggest original filename */
}
})
},
onPlayVideo = { msg -> onPlayVideo(msg) })
}
item { Spacer(Modifier.height(8.dp)) }
}
@@ -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)
}