chatgui: плеер — фикс drag, центр/fullscreen при открытии, resize за края с сохранением пропорций
- m_render WA_TransparentForMouseEvents — события мыши напрямую в окно (drag работает) - открытие: видео влезает — натуральный размер по центру; больше экрана — fullscreen - resize мышью за 4 края и 4 угла, пропорции сохраняются (якорь = противоположный край) - нижний край доступен через eventFilter на панели контролов (пустые поля пропагируют) - сброс fullscreen при повторном playFile
This commit is contained in:
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) {
|
||||
|
||||
+104
-1
@@ -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)) }
|
||||
}
|
||||
|
||||
+234
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user