Browse Source
- 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 для vibechatv2
11 changed files with 1168 additions and 1 deletions
@ -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" |
||||
@ -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; |
||||
}; |
||||
@ -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); |
||||
} |
||||
@ -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…
Reference in new issue