diff --git a/tools/chatgui/src/videoplayer_engine.cpp b/tools/chatgui/src/videoplayer_engine.cpp index 3ac826f7..f32e5ec2 100644 --- a/tools/chatgui/src/videoplayer_engine.cpp +++ b/tools/chatgui/src/videoplayer_engine.cpp @@ -18,7 +18,7 @@ extern "C" { } #endif -static const int kMaxInFlight = 24; +static const int kMaxBuffered = 30; /* ── Worker: демюкс + декод в отдельном потоке ── */ @@ -30,7 +30,7 @@ public: QAtomicInt* paused = nullptr; QAtomicInt* seekReq = nullptr; QAtomicInteger* seekTargetMs = nullptr; - QAtomicInt* inFlight = nullptr; + QAtomicInt* buffered = nullptr; QAtomicInt* epoch = nullptr; signals: @@ -184,35 +184,45 @@ void Worker::run() { SWS_BILINEAR, nullptr, nullptr, nullptr); if (!sws) { worker_fail(this, "sws init failed"); cleanup(); return; } + int gen = epoch->loadRelaxed(); auto emitFrame = [&](AVFrame* fr) { + if (fr->pts == AV_NOPTS_VALUE) return; /* кадр без pts ломает очередь */ 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()); + buffered->fetchAndAddRelaxed(1); + emit frameDecoded(img, ptsMs, gen); }; bool eof = false; - while (!abort->loadRelaxed() && !eof) { + while (!abort->loadRelaxed()) { 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); + int sret = av_seek_frame(fmt, -1, targetMs * 1000, AVSEEK_FLAG_BACKWARD); + if (sret < 0) + DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "VideoEngine: seek to %lldms failed ret=%d", + (long long)targetMs, sret); + else + DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "VideoEngine: seek to %lldms", (long long)targetMs); avcodec_flush_buffers(vdec); + gen = epoch->loadRelaxed(); + eof = false; 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; } + if (eof) { QThread::msleep(10); continue; } /* ждём seek или abort */ + if (buffered->loadRelaxed() >= kMaxBuffered) { 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; + DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "VideoEngine: decode finished"); + emit finished(); + continue; } if (pkt->stream_index == vidx) { avcodec_send_packet(vdec, pkt); @@ -220,11 +230,6 @@ void Worker::run() { } av_packet_unref(pkt); } - - if (!abort->loadRelaxed()) { - DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "VideoEngine: decode finished"); - emit finished(); - } } cleanup(); @@ -258,7 +263,7 @@ void VideoPlayerEngine::open(const QString& path) { m_abort.storeRelaxed(0); m_paused.storeRelaxed(0); m_seekReq.storeRelaxed(0); - m_inFlight.storeRelaxed(0); + m_buffered.storeRelaxed(0); clearPending(); m_renderFrame = QImage(); m_durationMs = 0; m_positionMs = 0; m_hasAudio = false; @@ -272,7 +277,7 @@ void VideoPlayerEngine::open(const QString& path) { m_worker->paused = &m_paused; m_worker->seekReq = &m_seekReq; m_worker->seekTargetMs = &m_seekTargetMs; - m_worker->inFlight = &m_inFlight; + m_worker->buffered = &m_buffered; m_worker->epoch = &m_epoch; m_worker->moveToThread(m_thread); @@ -282,7 +287,6 @@ void VideoPlayerEngine::open(const QString& path) { 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(); @@ -326,7 +330,6 @@ void VideoPlayerEngine::seekMs(qint64 ms) { m_elapsed.restart(); } m_positionMs = ms; - m_inFlight.storeRelaxed(0); if (m_state == Finished) setState(Paused); emit positionChanged(ms); } @@ -372,6 +375,7 @@ void VideoPlayerEngine::setState(State s) { } void VideoPlayerEngine::clearPending() { + m_buffered.fetchAndSubRelaxed((int)m_pending.size()); m_pending.clear(); } @@ -414,8 +418,10 @@ void VideoPlayerEngine::onAudioDecoded(std::shared_ptr> pcm } void VideoPlayerEngine::onFrameDecoded(const QImage& image, qint64 ptsMs, int epoch) { - m_inFlight.fetchAndSubRelaxed(1); - if (epoch != m_epoch.loadRelaxed()) return; /* устаревший кадр после seek */ + if (epoch != m_epoch.loadRelaxed()) { + m_buffered.fetchAndSubRelaxed(1); /* устаревший кадр после seek — не будет показан */ + return; + } m_pending.push_back({ image, ptsMs }); } @@ -442,6 +448,7 @@ void VideoPlayerEngine::presentTick() { while (!m_pending.empty() && m_pending.front().second <= clk) { show = m_pending.front().first; m_pending.pop_front(); + m_buffered.fetchAndSubRelaxed(1); have = true; } if (have) { m_renderFrame = show; emit framePresented(); } diff --git a/tools/chatgui/src/videoplayer_engine.h b/tools/chatgui/src/videoplayer_engine.h index 4b56e8af..dfc7ff49 100644 --- a/tools/chatgui/src/videoplayer_engine.h +++ b/tools/chatgui/src/videoplayer_engine.h @@ -79,7 +79,7 @@ private: QAtomicInt m_paused{0}; QAtomicInt m_seekReq{0}; QAtomicInteger m_seekTargetMs{0}; - QAtomicInt m_inFlight{0}; + QAtomicInt m_buffered{0}; QAtomicInt m_epoch{0}; /* презентация (только GUI-поток) */ diff --git a/tools/chatgui/src/videoplayer_window.cpp b/tools/chatgui/src/videoplayer_window.cpp index f0811fec..c5d453a0 100644 --- a/tools/chatgui/src/videoplayer_window.cpp +++ b/tools/chatgui/src/videoplayer_window.cpp @@ -15,6 +15,7 @@ #include #include #include +#include /* ─── VideoRenderWidget ─── */ @@ -51,13 +52,25 @@ static QString fmtTime(qint64 ms) { 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 -} +/* Слайдер перемотки: клик по любому месту шкалы = прыжок к позиции (драг кружка сохраняется). */ +class SeekSlider : public QSlider { +public: + using QSlider::QSlider; + +protected: + void mousePressEvent(QMouseEvent* e) override { + if (e->button() == Qt::LeftButton) { + setSliderDown(true); + int v = QStyle::sliderValueFromPosition(minimum(), maximum(), + (int)e->position().x(), width()); + setValue(v); + emit sliderPressed(); + e->accept(); + } else { + QSlider::mousePressEvent(e); + } + } +}; VideoPlayerWindow::VideoPlayerWindow(QWidget* parent) : QWidget(parent, Qt::Window | Qt::FramelessWindowHint) @@ -74,6 +87,7 @@ VideoPlayerWindow::VideoPlayerWindow(QWidget* parent) m_controls = new QWidget(this); m_controls->setObjectName("videoControls"); + m_controls->setCursor(Qt::ArrowCursor); m_controls->installEventFilter(this); m_controls->setStyleSheet( "#videoControls { background-color: rgba(0,0,0,0.65); }" @@ -86,9 +100,7 @@ VideoPlayerWindow::VideoPlayerWindow(QWidget* parent) 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 = new SeekSlider(Qt::Horizontal, m_controls); m_slider->setRange(0, 0); m_timeLabel = new QLabel(QStringLiteral("0:00 / 0:00"), m_controls); @@ -98,13 +110,26 @@ VideoPlayerWindow::VideoPlayerWindow(QWidget* parent) lay->addWidget(m_playBtn); lay->addWidget(m_slider, 1); lay->addWidget(m_timeLabel); - lay->addWidget(m_closeBtn); + + /* крестик закрытия — отдельно, справа вверху */ + m_closeBtn = new QPushButton(QStringLiteral("✕"), this); + m_closeBtn->setToolTip(QStringLiteral("Закрыть")); + m_closeBtn->setCursor(Qt::ArrowCursor); + m_closeBtn->setStyleSheet( + "QPushButton { background: rgba(0,0,0,0.5); color: white; border: none; " + "border-radius: 4px; font-size: 14px; }" + "QPushButton:hover { background: rgba(255,255,255,0.25); }"); m_hideTimer = new QTimer(this); m_hideTimer->setSingleShot(true); m_hideTimer->setInterval(2500); connect(m_hideTimer, &QTimer::timeout, this, &VideoPlayerWindow::hideControls); + m_snapTimer = new QTimer(this); + m_snapTimer->setSingleShot(true); + m_snapTimer->setInterval(150); + connect(m_snapTimer, &QTimer::timeout, this, &VideoPlayerWindow::snapToAspect); + connect(m_playBtn, &QPushButton::clicked, this, &VideoPlayerWindow::onPlayPause); connect(m_closeBtn, &QPushButton::clicked, this, &VideoPlayerWindow::onClose); connect(m_slider, &QSlider::sliderPressed, this, &VideoPlayerWindow::onSliderPressed); @@ -136,6 +161,7 @@ void VideoPlayerWindow::onEngineOpened(int width, int height, qint64 durationMs, Q_UNUSED(hasAudio); m_slider->setRange(0, (int)durationMs); updateTimeLabels(0); + m_videoAspect = (height > 0) ? (double)width / height : 0.0; QScreen* scr = QApplication::primaryScreen(); QRect ag = scr ? scr->availableGeometry() : QRect(0, 0, 1280, 720); @@ -226,6 +252,7 @@ void VideoPlayerWindow::toggleFullscreen() { void VideoPlayerWindow::setControlsVisible(bool visible) { m_controls->setVisible(visible); + m_closeBtn->setVisible(visible); } void VideoPlayerWindow::showControls() { @@ -285,11 +312,10 @@ void VideoPlayerWindow::mousePressEvent(QMouseEvent* e) { } ResizeMode m = hitTestResize(e->pos()); if (m != ResizeNone) { - beginResize(m, mouseGlobalPos(e)); + m_resizeEdges = edgesForMode(m); + if (windowHandle()) windowHandle()->startSystemResize(m_resizeEdges); } else { - m_dragging = true; - m_dragStart = mouseGlobalPos(e); - m_windowStart = frameGeometry().topLeft(); + if (windowHandle()) windowHandle()->startSystemMove(); } } } @@ -299,51 +325,25 @@ 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; - 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); + * поэтому маршрутизируем press из её пустых полей в нативный resize. */ + if (obj == m_controls && !m_fullscreen && ev->type() == QEvent::MouseButtonPress) { + QMouseEvent* me = static_cast(ev); + if (me->button() == Qt::LeftButton) { 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; + if (m != ResizeNone) { + m_resizeEdges = edgesForMode(m); + if (windowHandle()) windowHandle()->startSystemResize(m_resizeEdges); return true; } } @@ -363,6 +363,10 @@ void VideoPlayerWindow::resizeEvent(QResizeEvent* e) { int ch = 48; m_controls->setGeometry(0, height() - ch, width(), ch); m_controls->raise(); + m_closeBtn->setGeometry(width() - 40, 8, 32, 32); + m_closeBtn->raise(); + /* во время нативного resize снэпаем пропорцию после окончания жеста */ + if (m_resizeEdges != 0) m_snapTimer->start(); } VideoPlayerWindow::ResizeMode VideoPlayerWindow::hitTestResize(const QPoint& pos) const { @@ -393,47 +397,33 @@ void VideoPlayerWindow::updateResizeCursor(const QPoint& pos) { } } -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; +Qt::Edges VideoPlayerWindow::edgesForMode(ResizeMode m) { + switch (m) { + case ResizeLeft: return Qt::LeftEdge; + case ResizeRight: return Qt::RightEdge; + case ResizeTop: return Qt::TopEdge; + case ResizeBottom: return Qt::BottomEdge; + case ResizeTopLeft: return Qt::TopEdge | Qt::LeftEdge; + case ResizeTopRight: return Qt::TopEdge | Qt::RightEdge; + case ResizeBottomLeft: return Qt::BottomEdge | Qt::LeftEdge; + case ResizeBottomRight: return Qt::BottomEdge | Qt::RightEdge; + 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; +} + +void VideoPlayerWindow::snapToAspect() { + if (m_videoAspect <= 0.0 || m_resizeEdges == 0) return; + Qt::Edges edges = m_resizeEdges; + m_resizeEdges = {}; /* сброс до resize(), чтобы снэп не ре-триггерил сам себя */ + int w = width(), h = height(); + if (w <= 0 || h <= 0) return; + + bool verticalOnly = !(edges & (Qt::LeftEdge | Qt::RightEdge)); + if (verticalOnly) { + int newW = qRound(h * m_videoAspect); + if (qAbs(newW - w) > 1) resize(newW, h); + } else { + int newH = qRound(w / m_videoAspect); + if (qAbs(newH - h) > 1) resize(w, newH); } - setGeometry(left, top, newW, newH); } diff --git a/tools/chatgui/src/videoplayer_window.h b/tools/chatgui/src/videoplayer_window.h index 5b8beee8..58235a84 100644 --- a/tools/chatgui/src/videoplayer_window.h +++ b/tools/chatgui/src/videoplayer_window.h @@ -43,7 +43,6 @@ 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; void resizeEvent(QResizeEvent* event) override; @@ -75,8 +74,8 @@ private: 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); + static Qt::Edges edgesForMode(ResizeMode m); + void snapToAspect(); VideoRenderWidget* m_render = nullptr; QWidget* m_controls = nullptr; @@ -88,11 +87,8 @@ private: bool m_fullscreen = false; bool m_sliderDragging = false; - bool m_dragging = false; - QPoint m_dragStart; - QPoint m_windowStart; - ResizeMode m_resizeMode = ResizeNone; - QRect m_resizeStartGeom; - QPoint m_resizeStartGlobal; + double m_videoAspect = 0.0; + Qt::Edges m_resizeEdges = {}; QTimer* m_hideTimer = nullptr; + QTimer* m_snapTimer = nullptr; }; diff --git a/tools/chatgui/tests/test_video_engine.cpp b/tools/chatgui/tests/test_video_engine.cpp index 1b04418f..c50e5da8 100644 --- a/tools/chatgui/tests/test_video_engine.cpp +++ b/tools/chatgui/tests/test_video_engine.cpp @@ -7,15 +7,23 @@ #include #include +extern "C" { +#include "../../lib/debug_config.h" +} + int main(int argc, char** argv) { + debug_config_init(); + debug_set_level(DEBUG_LEVEL_TRACE); + debug_enable_console(1); QApplication app(argc, argv); if (argc < 2) { qCritical("usage: test_video_engine "); return 2; } QString path = argv[1]; VideoPlayerEngine eng; int frames = 0, opened = 0, errors = 0, finished = 0; - qint64 lastPos = -1; + qint64 lastPos = -1, posAfterSeek = -1; int width = 0, height = 0; qint64 dur = 0; bool hasAudio = false; + int framesAfterSeek = 0; QObject::connect(&eng, &VideoPlayerEngine::opened, [&](int w, int h, qint64 d, bool a) { opened++; width = w; height = h; dur = d; hasAudio = a; }); @@ -26,22 +34,38 @@ int main(int argc, char** argv) { eng.open(path); + /* через 1с — seek к середине */ + int framesBeforeSeek = 0; + QTimer seekTimer; + seekTimer.setSingleShot(true); + QObject::connect(&seekTimer, &QTimer::timeout, [&]() { + framesBeforeSeek = frames; + eng.seekMs(eng.durationMs() / 2); + QTimer::singleShot(1000, &eng, [&]() { + framesAfterSeek = frames - framesBeforeSeek; + posAfterSeek = eng.positionMs(); + }); + }); + seekTimer.start(1000); + QEventLoop loop; QTimer timer; timer.setSingleShot(true); QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); - timer.start(5000); + timer.start(6000); loop.exec(); eng.close(); - qInfo("opened=%d (%dx%d dur=%lldms audio=%d) frames=%d finished=%d errors=%d lastPos=%lldms", - opened, width, height, (long long)dur, hasAudio ? 1 : 0, frames, finished, errors, (long long)lastPos); + qInfo("opened=%d (%dx%d dur=%lldms audio=%d) frames=%d finished=%d errors=%d lastPos=%lldms posAfterSeek=%lldms framesAfterSeek=%d", + opened, width, height, (long long)dur, hasAudio ? 1 : 0, frames, finished, errors, + (long long)lastPos, (long long)posAfterSeek, framesAfterSeek); int rc = 0; if (opened != 1) { qCritical("FAIL: opened != 1"); rc = 1; } if (frames <= 0) { qCritical("FAIL: no frames presented"); rc = 1; } if (errors > 0) { qCritical("FAIL: errors present"); rc = 1; } + if (posAfterSeek <= 0 || framesAfterSeek <= 0) { qCritical("FAIL: no frames/position after seek"); rc = 1; } qInfo(rc == 0 ? "PASS" : "FAIL"); return rc; }