From d09a6af52a494626291c30f7eb64af96ce7d4e3e Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 8 Sep 2026 01:31:12 +0300 Subject: [PATCH] =?UTF-8?q?chatgui:=20=D0=BF=D0=BB=D0=B5=D0=B5=D1=80=20?= =?UTF-8?q?=E2=80=94=20=D1=84=D0=B8=D0=BA=D1=81=20drag,=20=D1=86=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D1=80/fullscreen=20=D0=BF=D1=80=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B8,=20resize=20=D0=B7?= =?UTF-8?q?=D0=B0=20=D0=BA=D1=80=D0=B0=D1=8F=20=D1=81=20=D1=81=D0=BE=D1=85?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=BF=D0=BE=D1=80=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - m_render WA_TransparentForMouseEvents — события мыши напрямую в окно (drag работает) - открытие: видео влезает — натуральный размер по центру; больше экрана — fullscreen - resize мышью за 4 края и 4 угла, пропорции сохраняются (якорь = противоположный край) - нижний край доступен через eventFilter на панели контролов (пустые поля пропагируют) - сброс fullscreen при повторном playFile --- .../main/java/com/utun/chat/MainActivity.kt | 15 +- .../java/com/utun/chat/data/ChatRepository.kt | 2 +- .../utun/chat/ui/components/MessageBubble.kt | 105 +++++++- .../com/utun/chat/ui/screens/ChatScreen.kt | 6 +- .../utun/chat/ui/screens/VideoPlayerScreen.kt | 234 ++++++++++++++++++ tools/chatgui/src/videoplayer_window.cpp | 154 +++++++++++- tools/chatgui/src/videoplayer_window.h | 14 ++ 7 files changed, 513 insertions(+), 17 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index ac17ad52..bdc90e59 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/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.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(null) } var inviteChannelId by remember { mutableStateOf(null) } + var videoMessage by remember { mutableStateOf(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 */ diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index 7709b726..565afde7 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -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) { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt index f384b33b..6a9f0310 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt +++ b/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.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(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" diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 72cf23b7..481dff0c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -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)) } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt new file mode 100644 index 00000000..94b29122 --- /dev/null +++ b/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(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) +} diff --git a/tools/chatgui/src/videoplayer_window.cpp b/tools/chatgui/src/videoplayer_window.cpp index 3d85fd7a..f0811fec 100644 --- a/tools/chatgui/src/videoplayer_window.cpp +++ b/tools/chatgui/src/videoplayer_window.cpp @@ -70,9 +70,11 @@ VideoPlayerWindow::VideoPlayerWindow(QWidget* parent) m_engine = new VideoPlayerEngine(this); m_render = new VideoRenderWidget(this); + m_render->setAttribute(Qt::WA_TransparentForMouseEvents, true); m_controls = new QWidget(this); m_controls->setObjectName("videoControls"); + m_controls->installEventFilter(this); m_controls->setStyleSheet( "#videoControls { background-color: rgba(0,0,0,0.65); }" "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); auto* lay = new QHBoxLayout(m_controls); - lay->setContentsMargins(8, 4, 8, 4); + lay->setContentsMargins(8, 3, 8, 6); lay->setSpacing(6); lay->addWidget(m_playBtn); lay->addWidget(m_slider, 1); @@ -123,6 +125,7 @@ void VideoPlayerWindow::playFile(const QString& path) { m_render->clearFrame(); m_slider->setRange(0, 0); m_timeLabel->setText(QStringLiteral("0:00 / 0:00")); + if (m_fullscreen) { showNormal(); m_fullscreen = false; setControlsVisible(true); unsetCursor(); } show(); raise(); activateWindow(); @@ -133,16 +136,25 @@ void VideoPlayerWindow::onEngineOpened(int width, int height, qint64 durationMs, Q_UNUSED(hasAudio); m_slider->setRange(0, (int)durationMs); updateTimeLabels(0); + + QScreen* scr = QApplication::primaryScreen(); + QRect ag = scr ? scr->availableGeometry() : QRect(0, 0, 1280, 720); QSize s = fitWindowSize(width, height); - 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); - } + + if (width <= ag.width() && height <= ag.height()) { + /* видео помещается — натуральный размер по центру */ + resize(s); + 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", width, height, (long long)durationMs); + DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "VideoWindow: %dx%d dur=%lldms size=%dx%d fullscreen=%d", + width, height, (long long)durationMs, s.width(), s.height(), m_fullscreen ? 1 : 0); } void VideoPlayerWindow::onPositionChanged(qint64 ms) { @@ -239,9 +251,10 @@ QSize VideoPlayerWindow::fitWindowSize(int vw, int vh) const { if (vw <= 0 || vh <= 0) return QSize(640, 360); QScreen* scr = QApplication::primaryScreen(); QSize avail(1280, 720); - if (scr) avail = scr->availableGeometry().size() * 0.85; + if (scr) avail = scr->availableGeometry().size(); QSize s(vw, vh); - s.scale(avail, Qt::KeepAspectRatio); + if (s.width() > avail.width() || s.height() > avail.height()) + s.scale(avail, Qt::KeepAspectRatio); return s; } @@ -268,6 +281,11 @@ void VideoPlayerWindow::mousePressEvent(QMouseEvent* e) { /* клик в fullscreen — показать/скрыть контролы */ if (m_controls->isVisible()) hideControls(); else showControls(); + return; + } + ResizeMode m = hitTestResize(e->pos()); + if (m != ResizeNone) { + beginResize(m, mouseGlobalPos(e)); } else { m_dragging = true; m_dragStart = mouseGlobalPos(e); @@ -281,19 +299,58 @@ void VideoPlayerWindow::mouseMoveEvent(QMouseEvent* e) { showControls(); return; } + if (m_resizeMode != ResizeNone && (e->buttons() & Qt::LeftButton)) { + applyResize(mouseGlobalPos(e)); + return; + } if (m_dragging && (e->buttons() & Qt::LeftButton)) { move(m_windowStart + (mouseGlobalPos(e) - m_dragStart)); + return; } + if (!(e->buttons() & Qt::LeftButton)) + updateResizeCursor(e->pos()); } 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) { 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(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) { m_engine->close(); emit closed(); @@ -307,3 +364,76 @@ void VideoPlayerWindow::resizeEvent(QResizeEvent* e) { m_controls->setGeometry(0, height() - ch, width(), ch); 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); +} diff --git a/tools/chatgui/src/videoplayer_window.h b/tools/chatgui/src/videoplayer_window.h index dc35c52f..5b8beee8 100644 --- a/tools/chatgui/src/videoplayer_window.h +++ b/tools/chatgui/src/videoplayer_window.h @@ -47,6 +47,7 @@ protected: void mouseDoubleClickEvent(QMouseEvent* event) override; void closeEvent(QCloseEvent* event) override; void resizeEvent(QResizeEvent* event) override; + bool eventFilter(QObject* obj, QEvent* event) override; private slots: void onEngineOpened(int width, int height, qint64 durationMs, bool hasAudio); @@ -62,10 +63,20 @@ private slots: void hideControls(); private: + enum ResizeMode { + ResizeNone = 0, + ResizeLeft, ResizeRight, ResizeTop, ResizeBottom, + ResizeTopLeft, ResizeTopRight, ResizeBottomLeft, ResizeBottomRight + }; + void toggleFullscreen(); void updateTimeLabels(qint64 posMs); void setControlsVisible(bool visible); 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; QWidget* m_controls = nullptr; @@ -80,5 +91,8 @@ private: bool m_dragging = false; QPoint m_dragStart; QPoint m_windowStart; + ResizeMode m_resizeMode = ResizeNone; + QRect m_resizeStartGeom; + QPoint m_resizeStartGlobal; QTimer* m_hideTimer = nullptr; };