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
This commit is contained in:
evgeny committed 2026-09-08 00:55:06 +03:00
1 parent e264b8aaa7
commit 6542618927
11 files changed
+1168 -1

No files matched your search

+151
View File
@@ -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);