Browse Source

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

- m_render WA_TransparentForMouseEvents — события мыши напрямую в окно (drag работает)
- открытие: видео влезает — натуральный размер по центру; больше экрана — fullscreen
- resize мышью за 4 края и 4 угла, пропорции сохраняются (якорь = противоположный край)
- нижний край доступен через eventFilter на панели контролов (пустые поля пропагируют)
- сброс fullscreen при повторном playFile
v2
evgeny 3 weeks ago
parent
commit
d09a6af52a
  1. 15
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  2. 2
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  3. 105
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
  4. 6
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  5. 234
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt
  6. 148
      tools/chatgui/src/videoplayer_window.cpp
  7. 14
      tools/chatgui/src/videoplayer_window.h

15
tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt

@ -21,6 +21,7 @@ import androidx.lifecycle.compose.LifecycleStartEffect
import com.utun.chat.data.Channel import com.utun.chat.data.Channel
import com.utun.chat.data.InviteData import com.utun.chat.data.InviteData
import com.utun.chat.data.LogManager import com.utun.chat.data.LogManager
import com.utun.chat.data.Message
import com.utun.chat.data.NativeLib import com.utun.chat.data.NativeLib
import com.utun.chat.headless.HeadlessService import com.utun.chat.headless.HeadlessService
import com.utun.chat.ui.screens.* import com.utun.chat.ui.screens.*
@ -60,6 +61,7 @@ class MainActivity : ComponentActivity() {
var showJoinDialog by remember { mutableStateOf(false) } var showJoinDialog by remember { mutableStateOf(false) }
var joinAutoLink by remember { mutableStateOf<String?>(null) } var joinAutoLink by remember { mutableStateOf<String?>(null) }
var inviteChannelId by remember { mutableStateOf<String?>(null) } var inviteChannelId by remember { mutableStateOf<String?>(null) }
var videoMessage by remember { mutableStateOf<Message?>(null) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -80,6 +82,10 @@ class MainActivity : ComponentActivity() {
} }
} }
BackHandler(enabled = videoMessage != null) {
videoMessage = null
}
val connected = NativeLib.isReady() val connected = NativeLib.isReady()
/* Subscribe native logs only when LogScreen is open */ /* Subscribe native logs only when LogScreen is open */
@ -118,7 +124,8 @@ class MainActivity : ComponentActivity() {
onSendMessage = { vm.sendMessage(it) }, onSendMessage = { vm.sendMessage(it) },
onMembersClick = { screen = Screen.MemberList }, onMembersClick = { screen = Screen.MemberList },
onInvite = { showInvite(channel) }, 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()) 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 */ /* Show join dialog after QR scan returns */

2
tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt

@ -308,7 +308,7 @@ class ChatRepository {
} catch (_: Exception) {} } catch (_: Exception) {}
} }
if (contentType == "application/octet-stream") { if (contentType == "application/octet-stream" || contentType.startsWith("video/")) {
try { try {
val pipeIdx = text.indexOf('|') val pipeIdx = text.indexOf('|')
if (pipeIdx > 0) { if (pipeIdx > 0) {

105
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt

@ -2,10 +2,13 @@ package com.utun.chat.ui.components
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* 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.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity 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.Message
import com.utun.chat.data.NativeLib import com.utun.chat.data.NativeLib
import com.utun.chat.viewmodel.ChatViewModel import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@Composable @Composable
fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(), fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(),
onPlayVoice: (String, Int) -> Unit = { _, _ -> }, onPlayVoice: (String, Int) -> Unit = { _, _ -> },
onSeekVoice: (String, Int, Int) -> Unit = { _, _, _ -> }, onSeekVoice: (String, Int, Int) -> Unit = { _, _, _ -> },
onDownloadFile: (Message) -> Unit = {}, onDownloadFile: (Message) -> Unit = {},
onOpenFile: (Message) -> Unit = {}) { onOpenFile: (Message) -> Unit = {},
onPlayVideo: (Message) -> Unit = {}) {
val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8) val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8)
val textColor = if (message.isOutgoing) Color.White else Color.Black val textColor = if (message.isOutgoing) Color.White else Color.Black
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start 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 voiceMaxWidth = (LocalConfiguration.current.screenWidthDp * 0.75f).dp
val isVoice = message.contentType == "audio/opus" val isVoice = message.contentType == "audio/opus"
val isVideo = isVideoMessage(message)
val isMedia = message.contentType.isNotEmpty() && !isVoice && message.contentType != "text/plain" val isMedia = message.contentType.isNotEmpty() && !isVoice && message.contentType != "text/plain"
val isActive = isVoice && playbackState.filePath == message.filePath val isActive = isVoice && playbackState.filePath == message.filePath
@ -65,6 +74,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
when { when {
isVoice -> VoiceBubble(message, textColor, isActive, playbackState, onPlayVoice, onSeekVoice, onDownloadFile) isVoice -> VoiceBubble(message, textColor, isActive, playbackState, onPlayVoice, onSeekVoice, onDownloadFile)
isVideo -> VideoBubble(message, textColor, onDownloadFile, onPlayVideo)
isMedia -> FileBubble(message, textColor, onDownloadFile, onOpenFile) isMedia -> FileBubble(message, textColor, onDownloadFile, onOpenFile)
else -> { else -> {
Text(message.text, fontSize = 17.sp, color = textColor) 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 { private fun formatFileSize(bytes: Long): String {
if (bytes <= 0) return "" if (bytes <= 0) return ""
if (bytes < 1024) return "${bytes}B" if (bytes < 1024) return "${bytes}B"

6
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt

@ -39,7 +39,8 @@ fun ChatScreen(
onSendMessage: (String) -> Unit, onSendMessage: (String) -> Unit,
onMembersClick: () -> Unit, onMembersClick: () -> Unit,
onInvite: () -> Unit, onInvite: () -> Unit,
onBack: () -> Unit onBack: () -> Unit,
onPlayVideo: (Message) -> Unit = {}
) { ) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
val listState = rememberLazyListState() val listState = rememberLazyListState()
@ -132,7 +133,8 @@ fun ChatScreen(
pendingSaveFilePath = msg.filePath pendingSaveFilePath = msg.filePath
saveFileLauncher.launch(msg.text) /* suggest original filename */ saveFileLauncher.launch(msg.text) /* suggest original filename */
} }
}) },
onPlayVideo = { msg -> onPlayVideo(msg) })
} }
item { Spacer(Modifier.height(8.dp)) } item { Spacer(Modifier.height(8.dp)) }
} }

234
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt

@ -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)
}

148
tools/chatgui/src/videoplayer_window.cpp

@ -70,9 +70,11 @@ VideoPlayerWindow::VideoPlayerWindow(QWidget* parent)
m_engine = new VideoPlayerEngine(this); m_engine = new VideoPlayerEngine(this);
m_render = new VideoRenderWidget(this); m_render = new VideoRenderWidget(this);
m_render->setAttribute(Qt::WA_TransparentForMouseEvents, true);
m_controls = new QWidget(this); m_controls = new QWidget(this);
m_controls->setObjectName("videoControls"); m_controls->setObjectName("videoControls");
m_controls->installEventFilter(this);
m_controls->setStyleSheet( m_controls->setStyleSheet(
"#videoControls { background-color: rgba(0,0,0,0.65); }" "#videoControls { background-color: rgba(0,0,0,0.65); }"
"QPushButton { background: transparent; color: white; border: none; font-size: 16px; }" "QPushButton { background: transparent; color: white; border: none; font-size: 16px; }"
@ -91,7 +93,7 @@ VideoPlayerWindow::VideoPlayerWindow(QWidget* parent)
m_timeLabel = new QLabel(QStringLiteral("0:00 / 0:00"), m_controls); m_timeLabel = new QLabel(QStringLiteral("0:00 / 0:00"), m_controls);
auto* lay = new QHBoxLayout(m_controls); auto* lay = new QHBoxLayout(m_controls);
lay->setContentsMargins(8, 4, 8, 4); lay->setContentsMargins(8, 3, 8, 6);
lay->setSpacing(6); lay->setSpacing(6);
lay->addWidget(m_playBtn); lay->addWidget(m_playBtn);
lay->addWidget(m_slider, 1); lay->addWidget(m_slider, 1);
@ -123,6 +125,7 @@ void VideoPlayerWindow::playFile(const QString& path) {
m_render->clearFrame(); m_render->clearFrame();
m_slider->setRange(0, 0); m_slider->setRange(0, 0);
m_timeLabel->setText(QStringLiteral("0:00 / 0:00")); m_timeLabel->setText(QStringLiteral("0:00 / 0:00"));
if (m_fullscreen) { showNormal(); m_fullscreen = false; setControlsVisible(true); unsetCursor(); }
show(); show();
raise(); raise();
activateWindow(); activateWindow();
@ -133,16 +136,25 @@ void VideoPlayerWindow::onEngineOpened(int width, int height, qint64 durationMs,
Q_UNUSED(hasAudio); Q_UNUSED(hasAudio);
m_slider->setRange(0, (int)durationMs); m_slider->setRange(0, (int)durationMs);
updateTimeLabels(0); updateTimeLabels(0);
QScreen* scr = QApplication::primaryScreen();
QRect ag = scr ? scr->availableGeometry() : QRect(0, 0, 1280, 720);
QSize s = fitWindowSize(width, height); QSize s = fitWindowSize(width, height);
if (width <= ag.width() && height <= ag.height()) {
/* видео помещается — натуральный размер по центру */
resize(s); resize(s);
if (!m_fullscreen) {
QScreen* scr = QApplication::primaryScreen();
if (scr) {
QRect ag = scr->availableGeometry();
move(ag.center().x() - s.width() / 2, ag.center().y() - s.height() / 2); move(ag.center().x() - s.width() / 2, ag.center().y() - s.height() / 2);
} else {
/* видео больше экрана — fullscreen (нормальная геометрия для выхода уже задана) */
resize(s);
move(ag.center().x() - s.width() / 2, ag.center().y() - s.height() / 2);
showFullScreen();
m_fullscreen = true;
m_hideTimer->start();
} }
} DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "VideoWindow: %dx%d dur=%lldms size=%dx%d fullscreen=%d",
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "VideoWindow: %dx%d dur=%lldms", width, height, (long long)durationMs); width, height, (long long)durationMs, s.width(), s.height(), m_fullscreen ? 1 : 0);
} }
void VideoPlayerWindow::onPositionChanged(qint64 ms) { void VideoPlayerWindow::onPositionChanged(qint64 ms) {
@ -239,8 +251,9 @@ QSize VideoPlayerWindow::fitWindowSize(int vw, int vh) const {
if (vw <= 0 || vh <= 0) return QSize(640, 360); if (vw <= 0 || vh <= 0) return QSize(640, 360);
QScreen* scr = QApplication::primaryScreen(); QScreen* scr = QApplication::primaryScreen();
QSize avail(1280, 720); QSize avail(1280, 720);
if (scr) avail = scr->availableGeometry().size() * 0.85; if (scr) avail = scr->availableGeometry().size();
QSize s(vw, vh); QSize s(vw, vh);
if (s.width() > avail.width() || s.height() > avail.height())
s.scale(avail, Qt::KeepAspectRatio); s.scale(avail, Qt::KeepAspectRatio);
return s; return s;
} }
@ -268,6 +281,11 @@ void VideoPlayerWindow::mousePressEvent(QMouseEvent* e) {
/* клик в fullscreen — показать/скрыть контролы */ /* клик в fullscreen — показать/скрыть контролы */
if (m_controls->isVisible()) hideControls(); if (m_controls->isVisible()) hideControls();
else showControls(); else showControls();
return;
}
ResizeMode m = hitTestResize(e->pos());
if (m != ResizeNone) {
beginResize(m, mouseGlobalPos(e));
} else { } else {
m_dragging = true; m_dragging = true;
m_dragStart = mouseGlobalPos(e); m_dragStart = mouseGlobalPos(e);
@ -281,19 +299,58 @@ void VideoPlayerWindow::mouseMoveEvent(QMouseEvent* e) {
showControls(); showControls();
return; return;
} }
if (m_resizeMode != ResizeNone && (e->buttons() & Qt::LeftButton)) {
applyResize(mouseGlobalPos(e));
return;
}
if (m_dragging && (e->buttons() & Qt::LeftButton)) { if (m_dragging && (e->buttons() & Qt::LeftButton)) {
move(m_windowStart + (mouseGlobalPos(e) - m_dragStart)); move(m_windowStart + (mouseGlobalPos(e) - m_dragStart));
return;
} }
if (!(e->buttons() & Qt::LeftButton))
updateResizeCursor(e->pos());
} }
void VideoPlayerWindow::mouseReleaseEvent(QMouseEvent* e) { void VideoPlayerWindow::mouseReleaseEvent(QMouseEvent* e) {
if (e->button() == Qt::LeftButton) m_dragging = false; if (e->button() == Qt::LeftButton) {
m_dragging = false;
m_resizeMode = ResizeNone;
}
} }
void VideoPlayerWindow::mouseDoubleClickEvent(QMouseEvent* e) { void VideoPlayerWindow::mouseDoubleClickEvent(QMouseEvent* e) {
if (e->button() == Qt::LeftButton) toggleFullscreen(); if (e->button() == Qt::LeftButton) toggleFullscreen();
} }
bool VideoPlayerWindow::eventFilter(QObject* obj, QEvent* ev) {
/* Резрез за нижний край: панель контролов перекрывает нижнюю кромку окна,
* поэтому маршрутизируем события из её пустых полей в логику resize. */
if (obj == m_controls && !m_fullscreen) {
QEvent::Type t = ev->type();
if (t == QEvent::MouseButtonPress || t == QEvent::MouseMove || t == QEvent::MouseButtonRelease) {
QMouseEvent* me = static_cast<QMouseEvent*>(ev);
QPoint winPos = m_controls->mapTo(this, me->pos());
ResizeMode m = hitTestResize(winPos);
if (m == ResizeNone) return QWidget::eventFilter(obj, ev);
QPoint gp = mouseGlobalPos(me);
if (t == QEvent::MouseButtonPress && me->button() == Qt::LeftButton) {
beginResize(m, gp);
return true;
}
if (t == QEvent::MouseMove && m_resizeMode != ResizeNone && (me->buttons() & Qt::LeftButton)) {
applyResize(gp);
return true;
}
if (t == QEvent::MouseButtonRelease && me->button() == Qt::LeftButton) {
m_resizeMode = ResizeNone;
return true;
}
}
}
return QWidget::eventFilter(obj, ev);
}
void VideoPlayerWindow::closeEvent(QCloseEvent* e) { void VideoPlayerWindow::closeEvent(QCloseEvent* e) {
m_engine->close(); m_engine->close();
emit closed(); emit closed();
@ -307,3 +364,76 @@ void VideoPlayerWindow::resizeEvent(QResizeEvent* e) {
m_controls->setGeometry(0, height() - ch, width(), ch); m_controls->setGeometry(0, height() - ch, width(), ch);
m_controls->raise(); m_controls->raise();
} }
VideoPlayerWindow::ResizeMode VideoPlayerWindow::hitTestResize(const QPoint& pos) const {
const int M = 6;
int w = width(), h = height();
bool l = pos.x() <= M;
bool r = pos.x() >= w - M - 1;
bool t = pos.y() <= M;
bool b = pos.y() >= h - M - 1;
if (t && l) return ResizeTopLeft;
if (t && r) return ResizeTopRight;
if (b && l) return ResizeBottomLeft;
if (b && r) return ResizeBottomRight;
if (l) return ResizeLeft;
if (r) return ResizeRight;
if (t) return ResizeTop;
if (b) return ResizeBottom;
return ResizeNone;
}
void VideoPlayerWindow::updateResizeCursor(const QPoint& pos) {
switch (hitTestResize(pos)) {
case ResizeTopLeft: case ResizeBottomRight: setCursor(Qt::SizeFDiagCursor); break;
case ResizeTopRight: case ResizeBottomLeft: setCursor(Qt::SizeBDiagCursor); break;
case ResizeLeft: case ResizeRight: setCursor(Qt::SizeHorCursor); break;
case ResizeTop: case ResizeBottom: setCursor(Qt::SizeVerCursor); break;
default: unsetCursor(); break;
}
}
void VideoPlayerWindow::beginResize(ResizeMode m, const QPoint& globalPos) {
m_resizeMode = m;
m_resizeStartGeom = geometry();
m_resizeStartGlobal = globalPos;
}
void VideoPlayerWindow::applyResize(const QPoint& globalPos) {
const int kMinW = 160;
QRect g = m_resizeStartGeom;
double aspect = (g.height() > 0) ? (double)g.width() / g.height() : 1.0;
int minH = qMax(1, qRound(kMinW / aspect));
int dx = globalPos.x() - m_resizeStartGlobal.x();
int dy = globalPos.y() - m_resizeStartGlobal.y();
int newW = g.width(), newH = g.height();
bool widthDriven = true;
switch (m_resizeMode) {
case ResizeRight: case ResizeTopRight: case ResizeBottomRight: newW = g.width() + dx; break;
case ResizeLeft: case ResizeTopLeft: case ResizeBottomLeft: newW = g.width() - dx; break;
case ResizeBottom: newH = g.height() + dy; widthDriven = false; break;
case ResizeTop: newH = g.height() - dy; widthDriven = false; break;
default: return;
}
if (widthDriven) newH = qRound(newW / aspect);
else newW = qRound(newH * aspect);
if (newW < kMinW) { newW = kMinW; newH = minH; }
if (newH < minH) { newH = minH; newW = kMinW; }
int cx = g.center().x(), cy = g.center().y();
int left = g.left(), top = g.top();
switch (m_resizeMode) {
case ResizeRight: left = g.left(); top = cy - newH / 2; break;
case ResizeLeft: left = g.right() - newW + 1; top = cy - newH / 2; break;
case ResizeBottom: left = cx - newW / 2; top = g.top(); break;
case ResizeTop: left = cx - newW / 2; top = g.bottom() - newH + 1; break;
case ResizeTopLeft: left = g.right() - newW + 1; top = g.bottom() - newH + 1; break;
case ResizeTopRight: left = g.left(); top = g.bottom() - newH + 1; break;
case ResizeBottomLeft: left = g.right() - newW + 1; top = g.top(); break;
case ResizeBottomRight: left = g.left(); top = g.top(); break;
default: return;
}
setGeometry(left, top, newW, newH);
}

14
tools/chatgui/src/videoplayer_window.h

@ -47,6 +47,7 @@ protected:
void mouseDoubleClickEvent(QMouseEvent* event) override; void mouseDoubleClickEvent(QMouseEvent* event) override;
void closeEvent(QCloseEvent* event) override; void closeEvent(QCloseEvent* event) override;
void resizeEvent(QResizeEvent* event) override; void resizeEvent(QResizeEvent* event) override;
bool eventFilter(QObject* obj, QEvent* event) override;
private slots: private slots:
void onEngineOpened(int width, int height, qint64 durationMs, bool hasAudio); void onEngineOpened(int width, int height, qint64 durationMs, bool hasAudio);
@ -62,10 +63,20 @@ private slots:
void hideControls(); void hideControls();
private: private:
enum ResizeMode {
ResizeNone = 0,
ResizeLeft, ResizeRight, ResizeTop, ResizeBottom,
ResizeTopLeft, ResizeTopRight, ResizeBottomLeft, ResizeBottomRight
};
void toggleFullscreen(); void toggleFullscreen();
void updateTimeLabels(qint64 posMs); void updateTimeLabels(qint64 posMs);
void setControlsVisible(bool visible); void setControlsVisible(bool visible);
QSize fitWindowSize(int vw, int vh) const; QSize fitWindowSize(int vw, int vh) const;
ResizeMode hitTestResize(const QPoint& localPos) const;
void updateResizeCursor(const QPoint& localPos);
void applyResize(const QPoint& globalPos);
void beginResize(ResizeMode m, const QPoint& globalPos);
VideoRenderWidget* m_render = nullptr; VideoRenderWidget* m_render = nullptr;
QWidget* m_controls = nullptr; QWidget* m_controls = nullptr;
@ -80,5 +91,8 @@ private:
bool m_dragging = false; bool m_dragging = false;
QPoint m_dragStart; QPoint m_dragStart;
QPoint m_windowStart; QPoint m_windowStart;
ResizeMode m_resizeMode = ResizeNone;
QRect m_resizeStartGeom;
QPoint m_resizeStartGlobal;
QTimer* m_hideTimer = nullptr; QTimer* m_hideTimer = nullptr;
}; };

Loading…
Cancel
Save