Browse Source

chatgui: воспроизведение видео — FFmpeg-плеер + frameless окно

- videoplayer_engine: декод в worker-потоке (FFmpeg), A/V sync по аудио-курсору,
  seek с epoch-защитой от устаревших кадров
- videoplayer_window: окно без рамки, drag-перенос, dblclick/Alt+Enter fullscreen,
  Esc закрытие, timeline + play/pause/close, авто-скрытие контролов
- видео-баббл в списке: превью-кадр (jpg), play-оверлей, длительность, прогресс
- превью извлекается после скачивания (video_extract_thumbnail) в фоне
- mainwindow: синглтон-плеер, openVideoRequested → openVideo
- CMake: HAVE_FFMPEG для vibechat
v2
evgeny 3 weeks ago
parent
commit
6542618927
  1. 5
      tools/chatgui/CMakeLists.txt
  2. 16
      tools/chatgui/src/mainwindow.cpp
  3. 3
      tools/chatgui/src/mainwindow.h
  4. 151
      tools/chatgui/src/messagedelegate.cpp
  5. 9
      tools/chatgui/src/messagedelegate.h
  6. 59
      tools/chatgui/src/messagelist.cpp
  7. 1
      tools/chatgui/src/messagelist.h
  8. 450
      tools/chatgui/src/videoplayer_engine.cpp
  9. 92
      tools/chatgui/src/videoplayer_engine.h
  10. 300
      tools/chatgui/src/videoplayer_window.cpp
  11. 83
      tools/chatgui/src/videoplayer_window.h

5
tools/chatgui/CMakeLists.txt

@ -121,6 +121,8 @@ add_executable(vibechat
src/voicemessageencoder.cpp
src/voiceplayback.cpp
src/media_blocks.cpp
src/videoplayer_engine.cpp
src/videoplayer_window.cpp
src/audiodevicesettingspage.cpp
src/storagesettingspage.cpp
src/connmonitorwindow.cpp
@ -136,6 +138,9 @@ add_executable(vibechat
target_include_directories(vibechat PRIVATE ${CMAKE_SOURCE_DIR}/../../lib ${CMAKE_SOURCE_DIR}/../../src ${CMAKE_SOURCE_DIR}/db ${CMAKE_SOURCE_DIR}/../../lib/libopus/include ${FFMPEG_INCLUDE_DIRS})
target_compile_definitions(vibechat PRIVATE SQLITE_THREADSAFE=1 USE_SQLITE)
if(FFMPEG_FOUND)
target_compile_definitions(vibechat PRIVATE HAVE_FFMPEG)
endif()
set_source_files_properties(../../lib/sqlite3.c transport/miniaudio_impl.c PROPERTIES LANGUAGE C)
if(WIN32)
target_link_libraries(vibechat PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun pthread ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES})

16
tools/chatgui/src/mainwindow.cpp

@ -9,6 +9,7 @@
#include "settingsdialog.h"
#include "channelsettingsdialog.h"
#include "connmonitorwindow.h"
#include "videoplayer_window.h"
#include "creategroupdialog.h"
#include "invite_link.h"
#include "sound_manager.h"
@ -377,6 +378,7 @@ void MainWindow::setupConnects() {
m_channelList->setChannelUnreadCount(channelId,
m_db->getUnreadCount(channelId, lastReadMsgId));
});
connect(m_messageList, &MessageList::openVideoRequested, this, &MainWindow::openVideo);
}
void MainWindow::setupTray() {
@ -481,6 +483,20 @@ void MainWindow::showConnMonitor() {
m_connMonitor->show();
}
void MainWindow::openVideo(const QString& filePath) {
if (!m_videoPlayer) {
m_videoPlayer = new VideoPlayerWindow(nullptr);
connect(m_videoPlayer, &VideoPlayerWindow::closed, this, [this] {
m_videoPlayer->deleteLater();
m_videoPlayer = nullptr;
});
} else {
m_videoPlayer->raise();
m_videoPlayer->activateWindow();
}
m_videoPlayer->playFile(filePath);
}
void MainWindow::onMessageReceived(const char* ch_id, int ch_id_len, uint64_t author) {
QString cid = QString::fromUtf8(ch_id, ch_id_len);
if (m_db && author != m_db->myNodeId())

3
tools/chatgui/src/mainwindow.h

@ -14,6 +14,7 @@ class DbManager;
class UtunNode;
class AudioRecorder;
class ConnMonitorWindow;
class VideoPlayerWindow;
struct AutoPlayItem {
QString channelId;
@ -43,6 +44,7 @@ private slots:
void onCreateGroupRequested();
void showSettings();
void showConnMonitor();
void openVideo(const QString& filePath);
void showChannelSettings(const QString& channelId);
void autoPlayTimerTick();
@ -84,6 +86,7 @@ private:
QString m_currentChannelId;
AudioRecorder *m_recorder = nullptr;
ConnMonitorWindow *m_connMonitor = nullptr;
VideoPlayerWindow *m_videoPlayer = nullptr;
QQueue<AutoPlayItem> m_autoPlayQueue;
QTimer* m_autoPlayTimer = nullptr;

151
tools/chatgui/src/messagedelegate.cpp

@ -80,6 +80,32 @@ bool MessageDelegate::editorEvent(QEvent *event, QAbstractItemModel *model,
if (event->type() != QEvent::MouseButtonRelease) return false;
Layout L = calcLayout(option, index);
if (L.isVideo) {
int dlState = index.data(MsgFileDownloadStateRole).toInt();
qint64 msgId = index.data(MsgIdRole).toLongLong();
QString chId = index.data(MsgChannelIdRole).toString();
if (dlState == 2) { /* скачано — открыть плеер */
QString fp = index.data(MsgVoiceFileRole).toString();
if (!fp.isEmpty() && QFile::exists(fp) && m_videoOpenCb) {
m_videoOpenCb(fp);
return true;
}
return false;
}
if (dlState == 1) return false; /* скачивается */
if (chId.isEmpty() || msgId <= 0) return false;
struct attachment_dl_req* req = (struct attachment_dl_req*)
u_malloc(sizeof(struct attachment_dl_req));
if (!req) return false;
memset(req, 0, sizeof(*req));
strncpy(req->channel_id, chId.toUtf8().constData(), sizeof(req->channel_id) - 1);
req->msg_id = msgId;
gui_bridge_post_uasync_fn(chat_core_attachment_download_trampoline, req);
return true;
}
if (L.isFile) {
int dlState = index.data(MsgFileDownloadStateRole).toInt();
qint64 msgId = index.data(MsgIdRole).toLongLong();
@ -345,6 +371,42 @@ MessageDelegate::Layout MessageDelegate::calcLayout(
return L;
}
/* Video message detection */
L.isVideo = (contentType == "video/mp4");
if (L.isVideo) {
int barW = 3, barPad = 4;
int thW = 200, thH = 112; /* 16:9 превью */
L.bubbleWidth = thW;
L.totalHeight = qMax(thH, kAvatar) + kItemGap;
int contentEdge = L.isOutgoing
? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap))
: (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap));
int bx = L.isOutgoing ? (contentEdge - thW - barW - barPad) : contentEdge;
L.bubbleRect = QRect(bx, 0, thW, thH);
L.videoThumbRect = L.bubbleRect;
L.mediaBarRect = QRect(L.isOutgoing ? bx + thW + 1 : bx - barW - 1, 0, barW, thH);
int dlState = index.data(MsgFileDownloadStateRole).toInt();
int bd = index.data(MsgMediaBlocksDoneRole).toInt();
int nb = index.data(MsgMediaNumBlocksRole).toInt();
L.mediaBarColor = (dlState == 2) ? 2 : (dlState == 1 || (bd > 0 && nb > 0)) ? 1 : 0;
if (dlState == 1 && nb > 0) L.progressFrac = (float)bd / nb;
if (dlState == 1) { L.progressBarRect = QRect(bx, thH - 3, thW, 3); }
L.videoPlayBtnRect = QRect(bx + thW / 2 - 24, thH / 2 - 24, 48, 48);
L.videoDurBadgeRect = QRect(bx + thW - 56, thH - 26, 48, 18);
if (!L.narrow) {
int avatarY = L.bubbleRect.bottom() - kAvatar;
if (!L.isOutgoing)
L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar);
else
L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar);
}
return L;
}
/* File attachment detection */
L.isFile = (contentType == "application/octet-stream");
if (L.isFile) {
@ -899,6 +961,95 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
return;
}
if (L.isVideo) {
drawBubble(painter, L, option);
if (!L.narrow) drawAvatar(painter, L, index);
QString filePath = index.data(MsgVoiceFileRole).toString();
int dlState = index.data(MsgFileDownloadStateRole).toInt();
QString thumbPath = filePath + ".jpg";
painter->save();
painter->setRenderHint(QPainter::Antialiasing);
QPainterPath clip;
clip.addRoundedRect(L.videoThumbRect, kRadius, kRadius);
painter->setClipPath(clip);
QPixmap thumb;
if (!thumbPath.isEmpty() && QFile::exists(thumbPath))
thumb.load(thumbPath);
if (!thumb.isNull()) {
QPixmap scaled = thumb.scaled(L.videoThumbRect.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation);
int dx = (scaled.width() - L.videoThumbRect.width()) / 2;
int dy = (scaled.height() - L.videoThumbRect.height()) / 2;
painter->drawPixmap(L.videoThumbRect.topLeft(), scaled, QRect(dx, dy, L.videoThumbRect.width(), L.videoThumbRect.height()));
} else {
painter->fillRect(L.videoThumbRect, QColor(30, 30, 35));
QFont iconFont = option.font;
iconFont.setPointSize(iconFont.pointSize() + 10);
painter->setFont(iconFont);
painter->setPen(QColor(150, 150, 150));
painter->drawText(L.videoThumbRect, Qt::AlignCenter, QString::fromUtf8("\xF0\x9F\x8E\xAC"));
}
/* play-оверлей */
if (dlState == 2) {
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0, 0, 0, 110));
painter->drawEllipse(L.videoPlayBtnRect);
QPainterPath tri;
int cx = L.videoPlayBtnRect.center().x() - L.videoPlayBtnRect.width() / 12;
int cy = L.videoPlayBtnRect.center().y();
int hs = L.videoPlayBtnRect.height() / 4;
tri.moveTo(cx, cy - hs);
tri.lineTo(cx + hs * 2, cy);
tri.lineTo(cx, cy + hs);
tri.closeSubpath();
painter->setBrush(Qt::white);
painter->drawPath(tri);
}
/* duration-бейдж */
QString durStr = index.data(MsgVoiceDurationRole).toString();
if (!durStr.isEmpty()) {
QFont durFont = option.font;
durFont.setPointSize(durFont.pointSize() - 1);
painter->setFont(durFont);
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0, 0, 0, 160));
painter->drawRoundedRect(L.videoDurBadgeRect, 5, 5);
painter->setPen(Qt::white);
painter->drawText(L.videoDurBadgeRect, Qt::AlignCenter, durStr);
}
painter->restore();
/* progress overlay (скачивание) */
if (dlState == 1) {
painter->setBrush(QColor(0, 0, 0, 140));
painter->drawRect(L.videoThumbRect);
QFont pfont = option.font;
pfont.setPointSize(pfont.pointSize() + 2);
painter->setFont(pfont);
painter->setPen(Qt::white);
painter->drawText(L.videoThumbRect, Qt::AlignCenter,
QStringLiteral("%1%").arg((int)(L.progressFrac * 100)));
}
/* media bar (левая кромка) */
{ QColor barC = L.mediaBarColor == 2 ? QColor(0x40, 0xC0, 0x40) : L.mediaBarColor == 1 ? QColor(0xF0, 0xC0, 0x40) : QColor(0x80, 0x80, 0x80);
painter->setPen(Qt::NoPen); painter->setBrush(barC); painter->drawRect(L.mediaBarRect); }
if (L.progressBarRect.isValid()) {
painter->setBrush(QColor(0x44, 0x44, 0x44));
painter->drawRoundedRect(L.progressBarRect, 2, 2);
int pw = (int)(L.progressBarRect.width() * L.progressFrac);
if (pw > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(L.progressBarRect.left(), L.progressBarRect.top(), pw, 3), 2, 2); }
}
painter->restore();
return;
}
if (L.isFile) {
drawBubble(painter, L, option);
if (!L.narrow) drawAvatar(painter, L, index);

9
tools/chatgui/src/messagedelegate.h

@ -1,6 +1,7 @@
#pragma once
#include <QStyledItemDelegate>
#include <functional>
enum MessageDataRole {
MsgAuthorRole = Qt::UserRole + 1,
@ -51,6 +52,8 @@ public:
static void startAutoPlay(const QString& filePath, float actualDurationSec);
static void stopAllAutoPlay();
void setVideoOpenCallback(std::function<void(const QString&)> cb) { m_videoOpenCb = std::move(cb); }
private:
struct Layout {
int viewWidth;
@ -72,6 +75,7 @@ private:
bool isOutgoing = false;
bool isVoice = false;
bool isFile = false;
bool isVideo = false;
QRect voicePlayBtnRect;
QRect voiceWaveformRect;
QRect voiceDurationRect;
@ -81,8 +85,13 @@ private:
QRect mediaBarRect;
QRect progressBarRect;
float progressFrac = 0.0f;
QRect videoThumbRect;
QRect videoPlayBtnRect;
QRect videoDurBadgeRect;
};
std::function<void(const QString&)> m_videoOpenCb;
Layout calcLayout(const QStyleOptionViewItem &option,
const QModelIndex &index) const;
void drawQuote(QPainter *p, const Layout &L,

59
tools/chatgui/src/messagelist.cpp

@ -24,12 +24,14 @@
#include <QFile>
#include <vector>
#include <memory>
#include <thread>
static std::vector<std::unique_ptr<LottieIcon>> s_icons;
extern "C" {
#include "../transport/gui_bridge.h"
#include "chat/chat_core.h"
#include "video/video.h"
#include "../../lib/u_async.h"
#include "../../lib/mem.h"
#include "../../lib/debug_config.h"
@ -195,6 +197,42 @@ static void setVoiceMessageRoles(QStandardItem* item, const QByteArray& data,
}
item->setData(dlState, MsgFileDownloadStateRole);
}
if (ct == "video/mp4") {
QString raw = QString::fromUtf8(body);
int pipePos = raw.indexOf('|');
QString fname = (pipePos > 0) ? raw.left(pipePos) : raw;
item->setData(fname, MsgFileDisplayNameRole);
if (pipePos > 0) {
QString meta = raw.mid(pipePos + 1);
QStringList parts = meta.split('|');
if (parts.size() >= 1)
item->setData(parts[0].toULongLong(), MsgFileSizeRole);
if (parts.size() >= 3)
item->setData(parts[2].toUInt(), MsgMediaNumBlocksRole);
/* dur/w/h в хвосте: |dur=<ms>|w=<w>|h=<h> */
for (const QString& p : parts) {
if (p.startsWith("dur=")) {
qint64 durMs = p.mid(4).toLongLong();
if (durMs > 0) {
QString d = QString::number(durMs / 1000.0, 'f', 0) + "s";
item->setData(d, MsgVoiceDurationRole);
}
}
}
}
QJsonObject la = QJsonDocument::fromJson(localAttrs).object();
QString st = la.value("st").toString();
QString fp = la.value("fp").toString();
int dlState = 0;
if (st == "fl" && !fp.isEmpty()) {
dlState = 2;
QString fullPath = mediaDirBase + "/" + channelId + "/" + fp;
item->setData(fullPath, MsgVoiceFileRole);
}
item->setData(dlState, MsgFileDownloadStateRole);
}
}
static void setMsg(QStandardItem *item,
@ -230,7 +268,11 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
layout->setSpacing(0);
m_view->setModel(m_model);
m_view->setItemDelegate(new MessageDelegate(this));
auto* msgDelegate = new MessageDelegate(this);
msgDelegate->setVideoOpenCallback([this](const QString& path) {
emit openVideoRequested(path);
});
m_view->setItemDelegate(msgDelegate);
m_view->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_view->setSelectionMode(QAbstractItemView::NoSelection);
m_view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
@ -702,4 +744,19 @@ void MessageList::onAttachmentDownloaded(const QString& chId, int64_t msgId) {
});
}
}
if (m.contentType == "video/mp4") {
QString fp = item->data(MsgVoiceFileRole).toString();
QString thumbPath = fp + ".jpg";
if (!fp.isEmpty() && QFile::exists(fp) && !QFile::exists(thumbPath)) {
std::thread([this, fp, thumbPath]() {
int rc = video_extract_thumbnail(fp.toUtf8().constData(),
thumbPath.toUtf8().constData(), 320);
DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "MessageList: thumbnail %s rc=%d",
qPrintable(thumbPath), rc);
QMetaObject::invokeMethod(this, [this]() { m_view->viewport()->update(); },
Qt::QueuedConnection);
}).detach();
}
}
}

1
tools/chatgui/src/messagelist.h

@ -29,6 +29,7 @@ public:
signals:
void readPositionChanged(const QString& channelId, qint64 lastReadMsgId);
void openVideoRequested(const QString& filePath);
private:
ChatView *m_view;

450
tools/chatgui/src/videoplayer_engine.cpp

@ -0,0 +1,450 @@
#include "videoplayer_engine.h"
#include "sound_manager.h"
#include "../../lib/debug_config.h"
#include <QThread>
#include <QDateTime>
#ifdef HAVE_FFMPEG
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libavutil/avutil.h>
#include <libavutil/imgutils.h>
#include <libavutil/channel_layout.h>
#include <libavutil/samplefmt.h>
#include <libswscale/swscale.h>
#include <libswresample/swresample.h>
}
#endif
static const int kMaxInFlight = 24;
/* ── Worker: демюкс + декод в отдельном потоке ── */
class Worker : public QObject {
Q_OBJECT
public:
QString path;
QAtomicInt* abort = nullptr;
QAtomicInt* paused = nullptr;
QAtomicInt* seekReq = nullptr;
QAtomicInteger<qint64>* seekTargetMs = nullptr;
QAtomicInt* inFlight = nullptr;
QAtomicInt* epoch = nullptr;
signals:
void opened(int width, int height, qint64 durationMs, bool hasAudio);
void audioDecoded(std::shared_ptr<std::vector<int16_t>> pcm, int sampleRate, int channels, qint64 durationMs);
void frameDecoded(const QImage& image, qint64 ptsMs, int epoch);
void error(const QString& msg);
void finished();
public slots:
void run();
};
#ifdef HAVE_FFMPEG
static void worker_fail(Worker* w, const QString& msg) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "VideoEngine: %s", qPrintable(msg));
emit w->error(msg);
}
void Worker::run() {
AVFormatContext* fmt = nullptr;
AVCodecContext* vdec = nullptr;
AVCodecContext* adec = nullptr;
struct SwsContext* sws = nullptr;
SwrContext* swr = nullptr;
AVFrame* frame = nullptr;
AVFrame* aframe = nullptr;
AVPacket* pkt = nullptr;
int vidx = -1, aidx = -1;
auto cleanup = [&]() {
if (sws) sws_freeContext(sws);
if (swr) swr_free(&swr);
if (frame) av_frame_free(&frame);
if (aframe) av_frame_free(&aframe);
if (pkt) av_packet_free(&pkt);
if (vdec) avcodec_free_context(&vdec);
if (adec) avcodec_free_context(&adec);
if (fmt) avformat_close_input(&fmt);
};
if (avformat_open_input(&fmt, path.toUtf8().constData(), nullptr, nullptr) != 0) {
worker_fail(this, QString("cannot open: %1").arg(path)); return;
}
if (avformat_find_stream_info(fmt, nullptr) < 0) {
worker_fail(this, QString("no stream info: %1").arg(path)); cleanup(); return;
}
for (unsigned i = 0; i < fmt->nb_streams; i++) {
AVCodecParameters* p = fmt->streams[i]->codecpar;
if (p->codec_type == AVMEDIA_TYPE_VIDEO && vidx < 0) vidx = (int)i;
else if (p->codec_type == AVMEDIA_TYPE_AUDIO && aidx < 0) aidx = (int)i;
}
if (vidx < 0) { worker_fail(this, QString("no video stream: %1").arg(path)); cleanup(); return; }
AVStream* vst = fmt->streams[vidx];
int vw = vst->codecpar->width, vh = vst->codecpar->height;
qint64 durMs = 0;
if (fmt->duration != AV_NOPTS_VALUE) durMs = fmt->duration / 1000;
else if (vst->duration != AV_NOPTS_VALUE && vst->time_base.num > 0)
durMs = av_rescale_q(vst->duration, vst->time_base, AVRational{1, 1000});
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "VideoEngine: opened %s %dx%d dur=%lldms audio=%d",
qPrintable(path), vw, vh, (long long)durMs, aidx >= 0 ? 1 : 0);
emit opened(vw, vh, durMs, aidx >= 0);
/* ── 1. декодируем весь аудио-трек в PCM (мастер-часы) ── */
std::shared_ptr<std::vector<int16_t>> audioPcm;
int asr = 0, ach = 0;
if (aidx >= 0) {
AVStream* ast = fmt->streams[aidx];
const AVCodec* ac = avcodec_find_decoder(ast->codecpar->codec_id);
adec = avcodec_alloc_context3(ac);
if (!ac || !adec || avcodec_parameters_to_context(adec, ast->codecpar) < 0 ||
avcodec_open2(adec, ac, nullptr) < 0) {
DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "VideoEngine: audio decoder open failed, video only");
avcodec_free_context(&adec);
aidx = -1;
}
}
if (aidx >= 0) {
asr = adec->sample_rate;
ach = adec->ch_layout.nb_channels;
if (asr <= 0 || ach <= 0) { DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "VideoEngine: bad audio fmt"); avcodec_free_context(&adec); aidx = -1; }
}
if (aidx >= 0) {
audioPcm = std::make_shared<std::vector<int16_t>>();
audioPcm->reserve((size_t)(durMs > 0 ? (durMs * asr * ach / 1000) + 4096 : 4096));
int rc = swr_alloc_set_opts2(&swr, &adec->ch_layout, AV_SAMPLE_FMT_S16, asr,
&adec->ch_layout, adec->sample_fmt, adec->sample_rate, 0, nullptr);
if (rc < 0 || swr_init(swr) < 0) {
DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "VideoEngine: swr init failed, video only");
swr_free(&swr); avcodec_free_context(&adec); aidx = -1; audioPcm.reset();
}
}
if (aidx >= 0) {
aframe = av_frame_alloc();
pkt = av_packet_alloc();
if (!aframe || !pkt) { worker_fail(this, "alloc failed"); cleanup(); return; }
while (av_read_frame(fmt, pkt) >= 0) {
if (abort->loadRelaxed()) { av_packet_unref(pkt); cleanup(); return; }
if (pkt->stream_index == aidx) {
if (avcodec_send_packet(adec, pkt) == 0) {
while (avcodec_receive_frame(adec, aframe) == 0) {
int outSamples = av_rescale_rnd(swr_get_delay(swr, aframe->sample_rate) + aframe->nb_samples,
asr, aframe->sample_rate, AV_ROUND_UP);
uint8_t* out = nullptr;
av_samples_alloc(&out, nullptr, ach, outSamples, AV_SAMPLE_FMT_S16, 0);
int got = swr_convert(swr, &out, outSamples,
(const uint8_t**)aframe->data, aframe->nb_samples);
if (got > 0) {
size_t cur = audioPcm->size();
audioPcm->resize(cur + (size_t)got * ach);
memcpy(audioPcm->data() + cur, out, (size_t)got * ach * sizeof(int16_t));
}
av_freep(&out);
}
}
}
av_packet_unref(pkt);
}
av_packet_free(&pkt); pkt = nullptr;
av_frame_free(&aframe); aframe = nullptr;
avcodec_free_context(&adec); adec = nullptr;
swr_free(&swr); swr = nullptr;
qint64 audioDurMs = audioPcm->size() / (size_t)ach * 1000 / asr;
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "VideoEngine: audio decoded %zu frames %dHz %dch %.1fs",
audioPcm->size() / (size_t)ach, asr, ach, audioDurMs / 1000.0);
/* вернуть чтение в начало для видео */
av_seek_frame(fmt, -1, 0, AVSEEK_FLAG_BACKWARD);
emit audioDecoded(audioPcm, asr, ach, audioDurMs);
}
/* ── 2. декодируем видео и шлём кадры ── */
{
const AVCodec* vc = avcodec_find_decoder(vst->codecpar->codec_id);
vdec = avcodec_alloc_context3(vc);
if (!vc || !vdec || avcodec_parameters_to_context(vdec, vst->codecpar) < 0 ||
avcodec_open2(vdec, vc, nullptr) < 0) {
worker_fail(this, "video decoder open failed"); cleanup(); return;
}
frame = av_frame_alloc();
pkt = av_packet_alloc();
if (!frame || !pkt) { worker_fail(this, "alloc failed"); cleanup(); return; }
sws = sws_getContext(vw, vh, vdec->pix_fmt, vw, vh, AV_PIX_FMT_RGB24,
SWS_BILINEAR, nullptr, nullptr, nullptr);
if (!sws) { worker_fail(this, "sws init failed"); cleanup(); return; }
auto emitFrame = [&](AVFrame* fr) {
QImage img(vw, vh, QImage::Format_RGB888);
uint8_t* dst[1] = { img.bits() };
int dstStride[1] = { (int)img.bytesPerLine() };
sws_scale(sws, fr->data, fr->linesize, 0, vh, dst, dstStride);
qint64 ptsMs = av_rescale_q(fr->pts, vst->time_base, AVRational{1, 1000});
inFlight->fetchAndAddRelaxed(1);
emit frameDecoded(img, ptsMs, epoch->loadRelaxed());
};
bool eof = false;
while (!abort->loadRelaxed() && !eof) {
if (seekReq->loadRelaxed()) {
qint64 targetMs = seekTargetMs->loadRelaxed();
qint64 ts = av_rescale_q(targetMs, AVRational{1, 1000}, vst->time_base);
av_seek_frame(fmt, vidx, ts, AVSEEK_FLAG_BACKWARD);
avcodec_flush_buffers(vdec);
seekReq->storeRelaxed(0);
DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "VideoEngine: seek to %lldms", (long long)targetMs);
}
if (paused->loadRelaxed()) { QThread::msleep(10); continue; }
if (inFlight->loadRelaxed() >= kMaxInFlight) { QThread::msleep(3); continue; }
int ret = av_read_frame(fmt, pkt);
if (ret < 0) {
avcodec_send_packet(vdec, nullptr);
while (avcodec_receive_frame(vdec, frame) == 0) emitFrame(frame);
eof = true;
break;
}
if (pkt->stream_index == vidx) {
avcodec_send_packet(vdec, pkt);
while (avcodec_receive_frame(vdec, frame) == 0) emitFrame(frame);
}
av_packet_unref(pkt);
}
if (!abort->loadRelaxed()) {
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "VideoEngine: decode finished");
emit finished();
}
}
cleanup();
}
#else /* HAVE_FFMPEG */
void Worker::run() {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "VideoEngine: built without FFmpeg");
emit error(QStringLiteral("видео недоступно (сборка без FFmpeg)"));
}
#endif /* HAVE_FFMPEG */
/* ── VideoPlayerEngine ── */
VideoPlayerEngine::VideoPlayerEngine(QObject* parent) : QObject(parent) {
qRegisterMetaType<QImage>("QImage");
qRegisterMetaType<std::shared_ptr<std::vector<int16_t>>>("SharedPcmPtr");
m_presentTimer.setInterval(16);
connect(&m_presentTimer, &QTimer::timeout, this, &VideoPlayerEngine::presentTick);
}
VideoPlayerEngine::~VideoPlayerEngine() {
close();
}
void VideoPlayerEngine::open(const QString& path) {
close();
m_path = path;
m_abort.storeRelaxed(0);
m_paused.storeRelaxed(0);
m_seekReq.storeRelaxed(0);
m_inFlight.storeRelaxed(0);
clearPending();
m_renderFrame = QImage();
m_durationMs = 0; m_positionMs = 0; m_hasAudio = false;
m_videoWidth = m_videoHeight = 0;
m_audioSampleRate = 0;
m_thread = new QThread(this);
m_worker = new Worker();
m_worker->path = path;
m_worker->abort = &m_abort;
m_worker->paused = &m_paused;
m_worker->seekReq = &m_seekReq;
m_worker->seekTargetMs = &m_seekTargetMs;
m_worker->inFlight = &m_inFlight;
m_worker->epoch = &m_epoch;
m_worker->moveToThread(m_thread);
connect(m_thread, &QThread::started, m_worker, &Worker::run);
connect(m_worker, &Worker::opened, this, &VideoPlayerEngine::onWorkerOpened);
connect(m_worker, &Worker::audioDecoded, this, &VideoPlayerEngine::onAudioDecoded);
connect(m_worker, &Worker::frameDecoded, this, &VideoPlayerEngine::onFrameDecoded);
connect(m_worker, &Worker::error, this, &VideoPlayerEngine::onWorkerError);
connect(m_worker, &Worker::finished, this, &VideoPlayerEngine::onWorkerFinished);
connect(m_worker, &Worker::finished, m_thread, &QThread::quit);
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "VideoEngine: open %s", qPrintable(path));
m_thread->start();
}
void VideoPlayerEngine::play() {
if (m_state != Paused && m_state != Playing) return;
m_paused.storeRelaxed(0);
if (m_hasAudio) SoundManager::instance()->resumeCurrentPcm();
else { m_elapsedBaseMs = m_positionMs; m_elapsed.restart(); }
setState(Playing);
m_presentTimer.start();
}
void VideoPlayerEngine::pause() {
if (m_state != Playing) return;
m_paused.storeRelaxed(1);
if (m_hasAudio) SoundManager::instance()->pauseCurrentPcm();
setState(Paused);
m_presentTimer.stop();
}
void VideoPlayerEngine::togglePlayPause() {
if (m_state == Playing) pause();
else if (m_state == Paused) play();
else if (m_state == Finished) { seekMs(0); play(); }
}
void VideoPlayerEngine::seekMs(qint64 ms) {
if (ms < 0) ms = 0;
if (m_durationMs > 0 && ms > m_durationMs) ms = m_durationMs;
m_epoch.fetchAndAddRelaxed(1);
m_seekTargetMs.storeRelaxed(ms);
m_seekReq.storeRelaxed(1);
clearPending();
if (m_hasAudio) {
int sr = SoundManager::instance()->currentPcmSampleRate();
if (sr > 0) SoundManager::instance()->seekCurrentPcm((unsigned long long)(ms * sr / 1000));
} else {
m_elapsedBaseMs = ms;
m_elapsed.restart();
}
m_positionMs = ms;
m_inFlight.storeRelaxed(0);
if (m_state == Finished) setState(Paused);
emit positionChanged(ms);
}
void VideoPlayerEngine::stop() {
m_abort.storeRelaxed(1);
m_presentTimer.stop();
clearPending();
m_renderFrame = QImage();
if (m_hasAudio) SoundManager::instance()->stopCurrentPcm();
setState(Stopped);
m_positionMs = 0;
m_durationMs = 0;
}
void VideoPlayerEngine::close() {
stop();
if (m_thread) {
m_abort.storeRelaxed(1);
m_thread->quit();
m_thread->wait(2000);
delete m_worker;
m_worker = nullptr;
delete m_thread;
m_thread = nullptr;
}
}
qint64 VideoPlayerEngine::clockMs() const {
if (m_hasAudio && m_audioSampleRate > 0) {
unsigned long long cur = SoundManager::instance()->currentPcmCursor();
return (qint64)(cur * 1000 / (unsigned long long)m_audioSampleRate);
}
return m_elapsedBaseMs + m_elapsed.elapsed();
}
void VideoPlayerEngine::setState(State s) {
if (m_state == s) return;
m_state = s;
emit stateChanged((int)s);
}
void VideoPlayerEngine::clearPending() {
m_pending.clear();
}
void VideoPlayerEngine::onWorkerOpened(int width, int height, qint64 durationMs, bool hasAudio) {
m_videoWidth = width;
m_videoHeight = height;
m_durationMs = durationMs;
m_hasAudio = hasAudio;
emit opened(width, height, durationMs, hasAudio);
if (!hasAudio) {
m_elapsedBaseMs = 0;
m_elapsed.restart();
setState(Playing);
m_presentTimer.start();
}
}
void VideoPlayerEngine::onAudioDecoded(std::shared_ptr<std::vector<int16_t>> pcm,
int sampleRate, int channels, qint64 durationMs) {
if (pcm && !pcm->empty() && channels > 0) {
m_audioSampleRate = sampleRate;
SoundManager::instance()->playRawPcm(pcm->data(), (int)(pcm->size() / (size_t)channels),
sampleRate, channels);
DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "VideoEngine: audio started %dHz %dch", sampleRate, channels);
} else {
m_hasAudio = false;
DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "VideoEngine: no audio data, video only");
}
m_elapsedBaseMs = 0;
m_elapsed.restart();
setState(Playing);
m_presentTimer.start();
}
void VideoPlayerEngine::onFrameDecoded(const QImage& image, qint64 ptsMs, int epoch) {
m_inFlight.fetchAndSubRelaxed(1);
if (epoch != m_epoch.loadRelaxed()) return; /* устаревший кадр после seek */
m_pending.push_back({ image, ptsMs });
}
void VideoPlayerEngine::onWorkerError(const QString& msg) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "VideoEngine: %s", qPrintable(msg));
m_presentTimer.stop();
setState(Stopped);
emit error(msg);
}
void VideoPlayerEngine::onWorkerFinished() {
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "VideoEngine: worker finished");
/* дождёмся, пока отрисуются оставшиеся кадры в presentTick, потом Finished */
setState(Finished);
emit finished();
}
void VideoPlayerEngine::presentTick() {
qint64 clk = clockMs();
QImage show;
bool have = false;
while (!m_pending.empty()) {
if (m_state != Finished && m_pending.front().second > clk) break;
show = m_pending.front().first;
m_pending.pop_front();
have = true;
}
if (have) { m_renderFrame = show; emit framePresented(); }
if (m_state == Finished && m_pending.empty()) {
m_positionMs = m_durationMs > 0 ? m_durationMs : clk;
emit positionChanged(m_positionMs);
m_presentTimer.stop();
return;
}
m_positionMs = clk;
if (m_durationMs > 0 && clk > m_durationMs) m_positionMs = m_durationMs;
emit positionChanged(m_positionMs);
}
#include "videoplayer_engine.moc"

92
tools/chatgui/src/videoplayer_engine.h

@ -0,0 +1,92 @@
#pragma once
#include <QObject>
#include <QImage>
#include <QString>
#include <QTimer>
#include <QElapsedTimer>
#include <QAtomicInt>
#include <QAtomicInteger>
#include <deque>
#include <vector>
#include <memory>
#include <utility>
#include <cstdint>
class QThread;
/* Плеер видео на FFmpeg (GUI-процесс). Декод в отдельном потоке,
* отрисовка кадров по мастер-часам (аудио-курсор или системный таймер). */
class VideoPlayerEngine : public QObject {
Q_OBJECT
public:
enum State { Stopped = 0, Playing = 1, Paused = 2, Finished = 3 };
explicit VideoPlayerEngine(QObject* parent = nullptr);
~VideoPlayerEngine() override;
void open(const QString& path);
void play();
void pause();
void togglePlayPause();
void seekMs(qint64 ms);
void stop();
void close();
State state() const { return m_state; }
qint64 durationMs() const { return m_durationMs; }
qint64 positionMs() const { return m_positionMs; }
bool hasAudio() const { return m_hasAudio; }
int videoWidth() const { return m_videoWidth; }
int videoHeight() const { return m_videoHeight; }
QString filePath() const { return m_path; }
QImage currentFrame() const { return m_renderFrame; }
signals:
void opened(int width, int height, qint64 durationMs, bool hasAudio);
void positionChanged(qint64 ms);
void stateChanged(int state);
void finished();
void error(const QString& msg);
void framePresented();
private slots:
void onWorkerOpened(int width, int height, qint64 durationMs, bool hasAudio);
void onAudioDecoded(std::shared_ptr<std::vector<int16_t>> pcm, int sampleRate, int channels, qint64 durationMs);
void onFrameDecoded(const QImage& image, qint64 ptsMs, int epoch);
void onWorkerError(const QString& msg);
void onWorkerFinished();
void presentTick();
private:
void setState(State s);
qint64 clockMs() const;
void clearPending();
QString m_path;
QThread* m_thread = nullptr;
class Worker* m_worker = nullptr;
State m_state = Stopped;
qint64 m_durationMs = 0;
qint64 m_positionMs = 0;
bool m_hasAudio = false;
int m_videoWidth = 0;
int m_videoHeight = 0;
/* управление worker'ом (атомики, доступны из обоих потоков) */
QAtomicInt m_abort{0};
QAtomicInt m_paused{0};
QAtomicInt m_seekReq{0};
QAtomicInteger<qint64> m_seekTargetMs{0};
QAtomicInt m_inFlight{0};
QAtomicInt m_epoch{0};
/* презентация (только GUI-поток) */
std::deque<std::pair<QImage, qint64>> m_pending;
QImage m_renderFrame;
QTimer m_presentTimer;
int m_audioSampleRate = 0;
QElapsedTimer m_elapsed;
qint64 m_elapsedBaseMs = 0;
};

300
tools/chatgui/src/videoplayer_window.cpp

@ -0,0 +1,300 @@
#include "videoplayer_window.h"
#include "videoplayer_engine.h"
#include "../../lib/debug_config.h"
#include <QSlider>
#include <QPushButton>
#include <QLabel>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <QPainter>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QCloseEvent>
#include <QApplication>
#include <QScreen>
#include <QStyle>
/* ─── VideoRenderWidget ─── */
VideoRenderWidget::VideoRenderWidget(QWidget* parent) : QWidget(parent) {
setMouseTracking(true);
setAttribute(Qt::WA_OpaquePaintEvent);
}
void VideoRenderWidget::setFrame(const QImage& img) {
m_frame = img;
update();
}
void VideoRenderWidget::clearFrame() {
m_frame = QImage();
update();
}
void VideoRenderWidget::paintEvent(QPaintEvent*) {
QPainter p(this);
p.fillRect(rect(), Qt::black);
if (m_frame.isNull()) return;
QSize sz = m_frame.size();
sz.scale(size(), Qt::KeepAspectRatio);
QRect dst((width() - sz.width()) / 2, (height() - sz.height()) / 2, sz.width(), sz.height());
p.drawImage(dst, m_frame);
}
/* ─── VideoPlayerWindow ─── */
static QString fmtTime(qint64 ms) {
qint64 s = ms / 1000;
return QString("%1:%2").arg(s / 60).arg(s % 60, 2, 10, QChar('0'));
}
static QPoint mouseGlobalPos(const QMouseEvent* e) {
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
return e->globalPosition().toPoint();
#else
return e->globalPos();
#endif
}
VideoPlayerWindow::VideoPlayerWindow(QWidget* parent)
: QWidget(parent, Qt::Window | Qt::FramelessWindowHint)
{
setWindowTitle(QStringLiteral("Video"));
setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true);
setStyleSheet("background-color: black;");
m_engine = new VideoPlayerEngine(this);
m_render = new VideoRenderWidget(this);
m_controls = new QWidget(this);
m_controls->setObjectName("videoControls");
m_controls->setStyleSheet(
"#videoControls { background-color: rgba(0,0,0,0.65); }"
"QPushButton { background: transparent; color: white; border: none; font-size: 16px; }"
"QPushButton:hover { background: rgba(255,255,255,0.2); }"
"QLabel { color: white; font-size: 12px; }"
"QSlider::groove:horizontal { height: 4px; background: #444; }"
"QSlider::sub-page:horizontal { background: #2a9; }"
"QSlider::handle:horizontal { width: 12px; margin: -4px 0; background: white; border-radius: 6px; }");
m_playBtn = new QPushButton(QStringLiteral("⏸"), m_controls);
m_playBtn->setToolTip(QStringLiteral("Пауза"));
m_closeBtn = new QPushButton(QStringLiteral("✕"), m_controls);
m_closeBtn->setToolTip(QStringLiteral("Закрыть"));
m_slider = new QSlider(Qt::Horizontal, m_controls);
m_slider->setRange(0, 0);
m_timeLabel = new QLabel(QStringLiteral("0:00 / 0:00"), m_controls);
auto* lay = new QHBoxLayout(m_controls);
lay->setContentsMargins(8, 4, 8, 4);
lay->setSpacing(6);
lay->addWidget(m_playBtn);
lay->addWidget(m_slider, 1);
lay->addWidget(m_timeLabel);
lay->addWidget(m_closeBtn);
m_hideTimer = new QTimer(this);
m_hideTimer->setSingleShot(true);
m_hideTimer->setInterval(2500);
connect(m_hideTimer, &QTimer::timeout, this, &VideoPlayerWindow::hideControls);
connect(m_playBtn, &QPushButton::clicked, this, &VideoPlayerWindow::onPlayPause);
connect(m_closeBtn, &QPushButton::clicked, this, &VideoPlayerWindow::onClose);
connect(m_slider, &QSlider::sliderPressed, this, &VideoPlayerWindow::onSliderPressed);
connect(m_slider, &QSlider::sliderReleased, this, &VideoPlayerWindow::onSliderReleased);
connect(m_engine, &VideoPlayerEngine::opened, this, &VideoPlayerWindow::onEngineOpened);
connect(m_engine, &VideoPlayerEngine::positionChanged, this, &VideoPlayerWindow::onPositionChanged);
connect(m_engine, &VideoPlayerEngine::stateChanged, this, &VideoPlayerWindow::onStateChanged);
connect(m_engine, &VideoPlayerEngine::error, this, &VideoPlayerWindow::onError);
connect(m_engine, &VideoPlayerEngine::framePresented, this, &VideoPlayerWindow::onFramePresented);
resize(640, 360);
}
VideoPlayerWindow::~VideoPlayerWindow() = default;
void VideoPlayerWindow::playFile(const QString& path) {
m_render->clearFrame();
m_slider->setRange(0, 0);
m_timeLabel->setText(QStringLiteral("0:00 / 0:00"));
show();
raise();
activateWindow();
m_engine->open(path);
}
void VideoPlayerWindow::onEngineOpened(int width, int height, qint64 durationMs, bool hasAudio) {
Q_UNUSED(hasAudio);
m_slider->setRange(0, (int)durationMs);
updateTimeLabels(0);
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);
}
}
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "VideoWindow: %dx%d dur=%lldms", width, height, (long long)durationMs);
}
void VideoPlayerWindow::onPositionChanged(qint64 ms) {
if (!m_sliderDragging) {
m_slider->blockSignals(true);
m_slider->setValue((int)ms);
m_slider->blockSignals(false);
}
updateTimeLabels(ms);
}
void VideoPlayerWindow::onStateChanged(int state) {
VideoPlayerEngine::State s = (VideoPlayerEngine::State)state;
if (s == VideoPlayerEngine::Playing) {
m_playBtn->setText(QStringLiteral("⏸"));
m_playBtn->setToolTip(QStringLiteral("Пауза"));
} else if (s == VideoPlayerEngine::Paused || s == VideoPlayerEngine::Finished) {
m_playBtn->setText(QStringLiteral("▶"));
m_playBtn->setToolTip(QStringLiteral("Играть"));
showControls();
}
}
void VideoPlayerWindow::onError(const QString& msg) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "VideoWindow: %s", qPrintable(msg));
m_timeLabel->setText(msg);
showControls();
}
void VideoPlayerWindow::onFramePresented() {
m_render->setFrame(m_engine->currentFrame());
}
void VideoPlayerWindow::onSliderPressed() {
m_sliderDragging = true;
}
void VideoPlayerWindow::onSliderReleased() {
m_sliderDragging = false;
m_engine->seekMs(m_slider->value());
if (m_engine->state() == VideoPlayerEngine::Paused ||
m_engine->state() == VideoPlayerEngine::Finished) {
m_engine->play();
}
}
void VideoPlayerWindow::onPlayPause() {
if (m_engine->state() == VideoPlayerEngine::Playing) m_engine->pause();
else m_engine->togglePlayPause();
}
void VideoPlayerWindow::onClose() {
m_engine->close();
close();
}
void VideoPlayerWindow::toggleFullscreen() {
if (m_fullscreen) {
showNormal();
m_fullscreen = false;
setControlsVisible(true);
unsetCursor();
} else {
showFullScreen();
m_fullscreen = true;
m_hideTimer->start();
}
}
void VideoPlayerWindow::setControlsVisible(bool visible) {
m_controls->setVisible(visible);
}
void VideoPlayerWindow::showControls() {
setControlsVisible(true);
unsetCursor();
if (m_fullscreen) m_hideTimer->start();
}
void VideoPlayerWindow::hideControls() {
if (!m_fullscreen) return;
if (m_engine && (m_engine->state() == VideoPlayerEngine::Paused ||
m_engine->state() == VideoPlayerEngine::Finished)) return;
setControlsVisible(false);
setCursor(Qt::BlankCursor);
}
void VideoPlayerWindow::updateTimeLabels(qint64 posMs) {
qint64 dur = m_engine ? m_engine->durationMs() : 0;
m_timeLabel->setText(QString("%1 / %2").arg(fmtTime(posMs), fmtTime(dur)));
}
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;
QSize s(vw, vh);
s.scale(avail, Qt::KeepAspectRatio);
return s;
}
void VideoPlayerWindow::keyPressEvent(QKeyEvent* e) {
if (e->key() == Qt::Key_Escape) {
if (m_fullscreen) { toggleFullscreen(); return; }
onClose();
return;
}
if (e->key() == Qt::Key_Return && (e->modifiers() & Qt::AltModifier)) {
toggleFullscreen();
return;
}
if (e->key() == Qt::Key_Space) {
onPlayPause();
return;
}
QWidget::keyPressEvent(e);
}
void VideoPlayerWindow::mousePressEvent(QMouseEvent* e) {
if (e->button() == Qt::LeftButton) {
if (m_fullscreen) {
/* клик в fullscreen — показать/скрыть контролы */
if (m_controls->isVisible()) hideControls();
else showControls();
} else {
m_dragging = true;
m_dragStart = mouseGlobalPos(e);
m_windowStart = frameGeometry().topLeft();
}
}
}
void VideoPlayerWindow::mouseMoveEvent(QMouseEvent* e) {
if (m_fullscreen) {
showControls();
return;
}
if (m_dragging && (e->buttons() & Qt::LeftButton)) {
move(m_windowStart + (mouseGlobalPos(e) - m_dragStart));
}
}
void VideoPlayerWindow::mouseReleaseEvent(QMouseEvent* e) {
if (e->button() == Qt::LeftButton) m_dragging = false;
}
void VideoPlayerWindow::mouseDoubleClickEvent(QMouseEvent* e) {
if (e->button() == Qt::LeftButton) toggleFullscreen();
}
void VideoPlayerWindow::closeEvent(QCloseEvent* e) {
m_engine->close();
emit closed();
QWidget::closeEvent(e);
}

83
tools/chatgui/src/videoplayer_window.h

@ -0,0 +1,83 @@
#pragma once
#include <QWidget>
#include <QImage>
#include <QString>
#include <QPoint>
#include <QTimer>
class QSlider;
class QPushButton;
class QLabel;
class VideoPlayerEngine;
/* Виджет отрисовки кадра (aspect-fit на чёрном фоне). */
class VideoRenderWidget : public QWidget {
Q_OBJECT
public:
explicit VideoRenderWidget(QWidget* parent = nullptr);
void setFrame(const QImage& img);
void clearFrame();
protected:
void paintEvent(QPaintEvent* event) override;
private:
QImage m_frame;
};
/* Отдельное окно плеера без рамки: drag-перенос, dblclick/Alt+Enter — fullscreen,
* Esc — выход/закрытие, timeline + play/pause/close. */
class VideoPlayerWindow : public QWidget {
Q_OBJECT
public:
explicit VideoPlayerWindow(QWidget* parent = nullptr);
~VideoPlayerWindow() override;
void playFile(const QString& path);
signals:
void closed();
protected:
void keyPressEvent(QKeyEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
void mouseDoubleClickEvent(QMouseEvent* event) override;
void closeEvent(QCloseEvent* event) override;
private slots:
void onEngineOpened(int width, int height, qint64 durationMs, bool hasAudio);
void onPositionChanged(qint64 ms);
void onStateChanged(int state);
void onError(const QString& msg);
void onFramePresented();
void onSliderPressed();
void onSliderReleased();
void onPlayPause();
void onClose();
void showControls();
void hideControls();
private:
void toggleFullscreen();
void updateTimeLabels(qint64 posMs);
void setControlsVisible(bool visible);
QSize fitWindowSize(int vw, int vh) const;
VideoRenderWidget* m_render = nullptr;
QWidget* m_controls = nullptr;
QSlider* m_slider = nullptr;
QPushButton* m_playBtn = nullptr;
QPushButton* m_closeBtn = nullptr;
QLabel* m_timeLabel = nullptr;
VideoPlayerEngine* m_engine = nullptr;
bool m_fullscreen = false;
bool m_sliderDragging = false;
bool m_dragging = false;
QPoint m_dragStart;
QPoint m_windowStart;
QTimer* m_hideTimer = nullptr;
};
Loading…
Cancel
Save