diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 9396318d..3265cf5d 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/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}) diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index 2ea5e94f..d06b3b44 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/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()) diff --git a/tools/chatgui/src/mainwindow.h b/tools/chatgui/src/mainwindow.h index 2bb801aa..52588a1c 100644 --- a/tools/chatgui/src/mainwindow.h +++ b/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 m_autoPlayQueue; QTimer* m_autoPlayTimer = nullptr; diff --git a/tools/chatgui/src/messagedelegate.cpp b/tools/chatgui/src/messagedelegate.cpp index 241cc237..6e070d2b 100644 --- a/tools/chatgui/src/messagedelegate.cpp +++ b/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); diff --git a/tools/chatgui/src/messagedelegate.h b/tools/chatgui/src/messagedelegate.h index 5eae8ae1..2df1f8da 100644 --- a/tools/chatgui/src/messagedelegate.h +++ b/tools/chatgui/src/messagedelegate.h @@ -1,6 +1,7 @@ #pragma once #include +#include 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 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 m_videoOpenCb; + Layout calcLayout(const QStyleOptionViewItem &option, const QModelIndex &index) const; void drawQuote(QPainter *p, const Layout &L, diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index 32d3563e..18d00093 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -24,12 +24,14 @@ #include #include #include +#include static std::vector> 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=|w=|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(); + } + } } diff --git a/tools/chatgui/src/messagelist.h b/tools/chatgui/src/messagelist.h index d709f763..bd88c383 100644 --- a/tools/chatgui/src/messagelist.h +++ b/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; diff --git a/tools/chatgui/src/videoplayer_engine.cpp b/tools/chatgui/src/videoplayer_engine.cpp new file mode 100644 index 00000000..2885d5ff --- /dev/null +++ b/tools/chatgui/src/videoplayer_engine.cpp @@ -0,0 +1,450 @@ +#include "videoplayer_engine.h" +#include "sound_manager.h" +#include "../../lib/debug_config.h" + +#include +#include + +#ifdef HAVE_FFMPEG +extern "C" { +#include +#include +#include +#include +#include +#include +#include +#include +} +#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* seekTargetMs = nullptr; + QAtomicInt* inFlight = nullptr; + QAtomicInt* epoch = nullptr; + +signals: + void opened(int width, int height, qint64 durationMs, bool hasAudio); + void audioDecoded(std::shared_ptr> 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> 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>(); + 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"); + qRegisterMetaType>>("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> 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" diff --git a/tools/chatgui/src/videoplayer_engine.h b/tools/chatgui/src/videoplayer_engine.h new file mode 100644 index 00000000..4b56e8af --- /dev/null +++ b/tools/chatgui/src/videoplayer_engine.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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> 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 m_seekTargetMs{0}; + QAtomicInt m_inFlight{0}; + QAtomicInt m_epoch{0}; + + /* презентация (только GUI-поток) */ + std::deque> m_pending; + QImage m_renderFrame; + QTimer m_presentTimer; + int m_audioSampleRate = 0; + QElapsedTimer m_elapsed; + qint64 m_elapsedBaseMs = 0; +}; diff --git a/tools/chatgui/src/videoplayer_window.cpp b/tools/chatgui/src/videoplayer_window.cpp new file mode 100644 index 00000000..20654824 --- /dev/null +++ b/tools/chatgui/src/videoplayer_window.cpp @@ -0,0 +1,300 @@ +#include "videoplayer_window.h" +#include "videoplayer_engine.h" +#include "../../lib/debug_config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ─── 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); +} diff --git a/tools/chatgui/src/videoplayer_window.h b/tools/chatgui/src/videoplayer_window.h new file mode 100644 index 00000000..8d09d10e --- /dev/null +++ b/tools/chatgui/src/videoplayer_window.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include +#include + +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; +};