简介:这是一份基于Qt 5.14.1与Qt Creator 4.11.1开发的视频播放器完整工程,定位在解决“快速搭建带截图的播放器”需求,面向具备C++基础的Qt入门者、高校学生或需要参考桌面端播放器交互逻辑的开发者;软件设置默认打开方式后可双击直接播放,界面包含打开、暂停、进度条、播放时间及窗口控制按钮,支持拖拽移动、边缘缩放,最小尺寸100×100像素,交互细节较完整。资源包为RAR压缩格式,共28个文件、约33.91MB,主要包含4个cpp源码与3个头文件、2个qrc资源描述文件,以及14张png和jpg图标素材,另有pro工程文件、txt使用说明和K-Lite解码器安装程序,结构清晰,便于按文件类型快速定位;当前已有324人学习该资源,具备一定参考价值。通过阅读源码和运行示例,可学习QMediaPlayer等多媒体模块的接入方式、自定义窗口事件处理、快捷键控制(如Ctrl+S截图、Shift+>快进20毫秒、D/→前进1秒)以及播放状态同步和界面更新思路,这些功能实现均可直接迁移到个人项目中,适合作为课程设计、毕业设计或工具类软件二次开发的基础参考。
1. Qt视频播放器要做截图,先要越过三道坎
视频播放器是 Qt 最常见的练手项目,但“能放”和“能截图交付”是两种程序。前者把 QVideoWidget 塞进窗口,接好play()就能跑;后者要拿到未压缩的像素帧,还要处理视频格式、线程切换、控件渲染这三块平时看不见的复杂度。截图的坑通常不在按钮上,而在视频帧到底由谁持有、以什么像素格式到达、以及保存时是否占用了错误的线程。这篇博客按我日常做桌面工具时的常见路径,从选型开始梳理一套可移植的 Qt 播放器方案:用 Qt Multimedia 做播放主链路,用 QVideoSink 或 QAbstractVideoSurface 做截帧,必要时走 OpenGL 读回,最后落到打包、自定义控件和验证。适合要交付桌面端视频工具、嵌入式 Linux 播放器,或给团队做内部测试软件的工程师;刚入门的人跟着顺序走也能跑通。
2. Qt视频播放器先定技术路线:QMediaPlayer、libmpv、QtAV 还是 FFmpeg 自绘
在写一行代码之前,要把播放后端选清楚。Qt 的视频播放并没有唯一答案,不同路线对截图能力的影响非常大。常见的选择有四条,我一般先列一张表把边界划出来,再根据字幕、音轨、硬解码、截图方式做取舍。
| 路线 | 解码能力 | 硬件解码 | 截图实现难度 | 集成成本 | 典型场景 |
|---|---|---|---|---|---|
| Qt Multimedia(QMediaPlayer) | 依赖系统后端,常见 H.264/MP4 没问题 | 平台相关 | 低,Qt6 有现成帧回调 | 低 | 桌面工具、快捷交付 |
| libmpv + Qt Widgets | mpv 全功能,几乎全格式 | 内置 | 中,需要拿 mpv 渲染回调 | 中 | 字幕/音轨复杂的产品 |
| QtAV | FFmpeg 能力 | 部分 | 自带截图类 | 中 | 老项目延续维护 |
| FFmpeg 自绘 | 完全自己控制 | 自己初始化解码器 | 低到中,解码后直接拿 AVFrame | 高 | 嵌入式、低延迟定制播放器 |
2.1 四条路线的选型依据与截图差异
QMediaPlayer 是官方路径,开发效率最高,项目中如果没有特殊字幕格式、不需要--vf这类 ffmpeg 滤镜,优先选它。Qt 5.15 里它通过 QAbstractVideoSurface 暴露视频帧,截图要自己写一个 surface 子类;Qt 6 则暴露QVideoSink,信号videoFrameChanged直接给 QVideoFrame,截图变成一件很自然的事。所以“选 Qt Multimedia 等于放弃截图”是不对的,只是 Qt5 时期多写一个回调类而已。
libmpv 适合需要 mpv 复杂行为的产品,比如播放列表、OBD 字幕样式、倍速精确控制。截图要走的路径相对绕:要么用 mpv 的screenshot-raw命令拿座标图,要么通过 mpv 渲染回调在 OpenGL 纹理里读回像素。这两种方式都要求你理解 mpv 的异步命令模型,工程量比 QMediaPlayer 高一个量级。
FFmpeg 自绘则常见于嵌入式场景。解码线程解出 AVFrame,转成 QImage 后通过信号发到 UI 线程显示。这种方案截图最直接,因为帧原本就在自己手里,但音频同步、倍速、seek 都要自己实现,不建议为了一个截图功能把整个播放器带进这个复杂度。
2.2 Qt5 与 Qt6 的截图接口差异必须先弄清
如果你要做截图功能,第一件事是确认目标 Qt 版本。Qt 5.15 的QMediaPlayer::setVideoOutput可以接受 QAbstractVideoSurface,但必须自己实现present()回调;Qt 6 推荐用QMediaPlayer::videoSink()拿到 QVideoSink 对象,连接它的videoFrameChanged信号。两套代码互不通用,跨版本不要直接复制粘贴。
一句话判断:工程从零开始且没有历史包袱,直接用 Qt 6;嵌入式或工控环境还锁在 Qt 5.15,就按 Qt5 的 surface 方案重写截帧部分。后面第 4 章会给两套最小实现。
2.3 最小 CMake 工程:Multimedia 组件怎么挂
路径确定后,先搭一个能编译的空工程。CMake 版本建议 3.16 以上,Windows 上用 Qt 官方预编译包时要注意编译器和 Qt 包一致,否则后面会出现无法解析的外部符号。
cmake_minimum_required(VERSION 3.16) project(QtSnapPlayer VERSION 1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(Qt6 COMPONENTS Widgets Multimedia MultimediaWidgets REQUIRED) add_executable(QtSnapPlayer main.cpp MainWindow.cpp CaptureSurface.h ) target_link_libraries(QtSnapPlayer PRIVATE Qt6::Widgets Qt6::Multimedia Qt6::MultimediaWidgets )这里MultimediaWidgets装的是 QVideoWidget,纯 Widgets 应用必须带它;如果你最后走自定义 OpenGL 渲染,不连 QVideoWidget 也要保留 MultimediaWidgets,否则 QVideoWidget 头文件解析会失败。Qt5 用户把Qt6改成Qt5,组件名不变。cmake 输出目录也要指定,方便后面 windeployqt 打发布包:
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)这条参数决定 exe 和 DLL 的落点,发布脚本直接用这个目录最省事。
3. Qt播放器主体实现:状态槽、线程边界与进度控制
骨架定了之后,核心类是MainWindow,它持有播放器、音频输出、视频显示控件和截图状态。不要在主窗口里直接 new 一个 QVideoWidget 就完事,先把播放器、音频输出和视频输出的生命周期挂清楚,否则切歌切视频时会遇到对象已被销毁的崩溃。
3.1 QMediaPlayer 与 QAudioOutput、QVideoWidget 的挂接
一个最简播放器类骨架如下:
class MainWindow : public QMainWindow { Q_OBJECT public: explicit MainWindow(QWidget *parent = nullptr); private slots: void openFile(); void snapshoot(); void updatePosition(qint64 pos); void updateDuration(qint64 duration); private: QMediaPlayer *m_player; QAudioOutput *m_audio; QVideoWidget *m_videoWidget; QSlider *m_seekSlider; bool m_capturePending = false; }; MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), m_player(new QMediaPlayer(this)), m_audio(new QAudioOutput(this)), m_videoWidget(new QVideoWidget(this)) { m_player->setAudioOutput(m_audio); m_player->setVideoOutput(m_videoWidget); setCentralWidget(m_videoWidget); connect(m_player, &QMediaPlayer::positionChanged, this, &MainWindow::updatePosition); connect(m_player, &QMediaPlayer::durationChanged, this, &MainWindow::updateDuration); }关键点是setAudioOutput和setVideoOutput要在任何play()调用之前执行。QMediaPlayer 在 Qt6 中把音频输出单独拆成 QAudioOutput,音量接口也从播放器搬到了音频输出对象上。如果不创建 QAudioOutput,程序通常不报错,但无声,这是新手最容易忽略的一步。
3.2 进度条与时间标签:positionChanged 的联动参数
进度条要用durationChanged信号来设定范围,不能写死为 100。视频时长可能从几十秒到两小时,滑动条 range 与时长对齐后,positionChanged才能直接映射真实位置。
void MainWindow::updateDuration(qint64 duration) { m_seekSlider->setRange(0, static_cast<int>(duration)); m_seekSlider->setEnabled(duration > 0); } void MainWindow::updatePosition(qint64 pos) { if (!m_seekSlider->isSliderDown()) { m_seekSlider->setValue(static_cast<int>(pos)); } m_timeLabel->setText(QString("%1 / %2") .arg(formatTime(pos)) .arg(formatTime(m_player->duration()))); } QString MainWindow::formatTime(qint64 ms) { int secs = static_cast<int>(ms / 1000); return QString("%1:%2").arg(secs / 60, 2, 10, QLatin1Char(' ')) .arg(secs % 60, 2, 10, QLatin1Char('0')); }isSliderDown()判断用户是否正在拖动,避免播放器的位置信号把滑块拽回去。如果不判断,拖拽总是一次次被 signal 重置,表现就是“拖不动”。倍速播放时不建议直接把setPlaybackRate和滑块绑死,建议只影响播放节奏,不影响定位逻辑。
3.3 线程边界:QMediaPlayer 不需要 QThread,FFmpeg 方案才需要
很多人会问:播放视频这么重,要不要放到后台 QThread?这是 Qt 播放器项目里的经典误区。QMediaPlayer 本身已经是多线程架构,解码在底层后端线程完成,通过信号跨线程通知 UI。你强行再用 QThread 包一层,反而把线程模型复杂化,容易造成槽函数在错误线程里执行。
真正需要自建线程的场景是 FFmpeg 自绘播放器。解码线程持续读取 AVFrame,取到帧后先转 QImage,再用QMetaObject::invokeMethod或队列连接投递到 UI 线程刷新控件。画到 QWidget 的操作必须留在 GUI 线程,否则会随机崩溃。需要跨线程共享的数据用QMutex保护,不要在线程里直接调用QVideoWidget的成员函数。如果视频源是本地文件而不是 RTSP 流,开启QMediaPlayer::LowLatency参数反而会导致某些文件 seek 变慢,默认配置更稳。
4. Qt视频播放器截图功能:QVideoSink、QAbstractVideoSurface 与 OpenGL 读回
截图是整个项目真正的分水岭。播放器把视频画面渲染到屏幕上之后,再想从界面层抓图是最不可控的做法;正确思路是在解码帧到达渲染之前截住它。按 Qt 版本和渲染方式,我给三条可行路径。
4.1 Qt6 截图首选:QVideoSink 只拿一帧
Qt6 中QMediaPlayer::videoSink()返回的是 QVideoSink 指针,可以连接它的videoFrameChanged信号。每一帧更新时这个信号都会触发,所以必须用标志位过滤,只在按下截图按钮时保存一次。
void MainWindow::snapshoot() { m_capturePending = true; } void MainWindow::initSnapshot() { QVideoSink *sink = m_player->videoSink(); connect(sink, &QVideoSink::videoFrameChanged, this, &MainWindow::onVideoFrameChanged); } void MainWindow::onVideoFrameChanged(const QVideoFrame &frame) { if (!m_capturePending) return; m_capturePending = false; QImage image = frame.toImage(); if (image.isNull()) return; image.save(m_snapPath, "PNG"); }videoFrameChanged的参数是QVideoFrame,toImage()会做一次像素格式转换。性能要求高时不做转换,直接保存QVideoFrame是不行的,图片格式需要 QImage。m_capturePending用普通 bool 就够,因为信号和槽默认在同一线程,不会产生竞争;如果你把播放器放到非 GUI 线程,则要换成 QAtomicInt。
4.2 Qt5 截图:实现 QAbstractVideoSurface 的 present 回调
Qt 5.15 没有一个干净的QVideoSink可用,常见做法是实现一个 QAbstractVideoSurface 的子类,覆盖supportedPixelFormats和present。播放器调用setVideoOutput(surface)后,每一帧会送进present()。
class CaptureSurface : public QAbstractVideoSurface { Q_OBJECT public: CaptureSurface(QObject *parent = nullptr) : QAbstractVideoSurface(parent) {} QList<QVideoFrame::PixelFormat> supportedPixelFormats( QAbstractVideoBuffer::HandleType type) const override { Q_UNUSED(type); return {QVideoFrame::Format_RGB32, QVideoFrame::Format_ARGB32}; } bool present(const QVideoFrame &frame) override { if (m_pendingLoad) { m_lastImage = frame.image(); // 直接从视频帧拷贝像素 m_pendingLoad = false; emit frameReady(m_lastImage); } return true; } bool start(const QVideoSurfaceFormat &format) override { return QAbstractVideoSurface::start(format); } void stop() override { QAbstractVideoSurface::stop(); m_pendingLoad = false; } private: bool m_pendingLoad = false; QImage m_lastImage; };这里有个常见坑:QAbstractVideoSurface::present()是在底层视频线程回调的,不要在回调里直接写 UI。把截图按钮设置为m_pendingLoad = true,在后台线程把frame.image()拷贝到一个普通 QImage 后,再通过信号发到主线程保存,可以避免画面撕裂和主线程卡顿。frame.image()对硬件解码的 NV12 纹理帧经常拿到空图,行为表现为截图纯黑。解决方案是在setVideoOutput之前设置播放器使用软件解码,或者强制 surface 只接受 RGB32 格式,Linux 下还可以禁用 VAAPI 后端。
4.3 自定义渲染时读回画面:glReadPixels 参数说明
如果你走 FFmpeg 自绘或通过 QOpenGLWidget 直接渲染视频纹理,截图的路径就变成了 OpenGL 读回。核心函数是glReadPixels,它从当前绑定的帧缓冲中读像素,使用不当会得到镜像和错位图像。
GLint viewport[4]; glGetIntegerv(GL_VIEWPORT, viewport); int w = viewport[2]; int h = viewport[3]; QImage image(w, h, QImage::Format_RGB32); glPixelStorei(GL_PACK_ALIGNMENT, 1); glReadPixels(0, 0, w, h, GL_BGRA, GL_UNSIGNED_BYTE, image.bits()); // OpenGL 原点在左下,QImage 原点在左上,需要垂直翻转 QImage flipped = image.mirrored(false, true);glReadPixels的format参数用GL_BGRA而不是GL_RGBA,因为 QImage 在内存中是小端存储的 ARGB32,与 BGRA 字节序天然对齐。GL_PACK_ALIGNMENT设为 1,避免每一行像素按 4 字节对齐补齐造成图像错位。读取操作要放在绘制之后、交换缓冲之前,即paintGL末尾或帧缓冲对象仍绑定时。若在 QOpenGLFramebufferObject 渲染,则可直接绑定 FBO 后再读。
4.4 截图的保存:路径、格式和事件时序
无论是哪条取帧路径,保存逻辑都建议统一封装到一个函数。推荐使用带时间戳的文件名,避免连续点击按钮互相覆盖;PNG 格式无损失,JPG 才需要quality参数,取值范围 0-100,95 以下能看到明显压缩马赛克。
void MainWindow::saveImage(const QImage &image) { QString dir = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation) + QStringLiteral("/QtSnapPlayer"); QDir().mkpath(dir); QString path = dir + QStringLiteral("/%1.png") .arg(QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss_zzz")); image.save(path, "PNG"); }截图按钮最好在用户按下后立即调用saveImage,而不是把所有画面上一次。生成时间戳放在函数内,连续两次截图得到的文件名不会冲突。测试中发现图片保存后文件大小为 0,通常不是保存逻辑错误,而是 QImage 的 bytesPerLine 与 width*bpp 不一致,最常见来源就是 GL 读回时没有按行对齐处理。
5. 收尾三件事:自定义进度条、发布排错与截图自检
播放器和截图功能都跑通后,离交付还差几步:进度条要能看缓冲,发布包要干净,截图要能被自动化验证。这三件事看起来零碎,却是工程化落地时最少不了的。
5.1 自定义进度条:在 QSlider 里画出缓冲区和已播放两段
自带 QSlider 只能显示当前位置,看不出缓冲进度。做法是继承 QSlider,重写paintEvent,先画缓冲区和播放区,再调用基类绘制滑块。关键参数是itemTexts、background、groove三个矩形区域,缓冲区颜色用一个半透明 QColor 叠加。
class BufferSlider : public QSlider { Q_OBJECT public: void setBufferProgress(int value) { m_buffer = value; update(); } protected: void paintEvent(QPaintEvent *event) override { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); QRectF bg = QRectF(0, height()/2 - 2, width(), 4); p.fillRect(bg, QColor(0, 0, 0, 60)); int val = maximum() > 0 ? value() * width() / maximum() : 0; p.fillRect(bg.adjusted(0, 0, -(width() - val), 0), QColor(0, 120, 255)); QSlider::paintEvent(event); } };m_buffer在 Qt5 里可以通过QMediaPlayer::bufferStatus()获取 0-100 数值,Qt6 的 bufferProgressChanged 提供同样语义,注意把 buffer 和 value 映射到相同长度单位,否则两个矩形会错位。
5.2 发布时最易犯的版本混用:cannot mix incompatible qt library
发布的常见做法是windeployqt自动收集 DLL。但环境变量 PATH 里若有另一个 Qt 版本,部署工具可能把 5.15.2 的 Qt5Core.dll 和另一个版本的 Qt5Widgets.dll 混到同一个发布目录,程序启动时 stderr 出现“cannot mix incompatible Qt library (5.15.3) with this library (5.15.2)”,运行过程中窗口建立失败。排查步骤是先在干净的 cmd 里清空 PATH,只保留系统目录和 Qt 对应编译器目录,再执行部署:
C:\Qt\5.15.2\msvc2019_64\bin\windeployqt.exe --release --no-translations build\bin\QtSnapPlayer.exe部署完成后检查发布目录中 Qt5Core.dll 与 Qt5Widgets.dll 的资源版本号必须一致。Linux 端类似,用 linuxdeployqt 或者 AppImage 工具时也要保证库来源单一。
5.3 用 QTest 验证截图真实落盘
截图功能不要靠人眼确认,直接写成自动化测试。最小自检代码如下:
void TestCapture::snapshotFileWritten() { QString snapPath = "./test_snapshot.png"; auto player = std::make_unique<QMediaPlayer>(); // 播放一段固定视频,等待进入 PlayingState 后触发截图 QSignalSpy spy(player.get(), &QMediaPlayer::playingChanged); QVERIFY(spy.wait(3000)); // 执行截图挂载,生成文件 QVERIFY(QFile::exists(snapPath)); QImage img(snapPath); QVERIFY(!img.isNull()); QVERIFY(img.width() > 0 && img.height() > 0); }QVERIFY会在条件为假时中断测试,并把当前文件和内存打印出来,定位黑图问题比单纯人工播放有效得多。把QImage::isNull()和文件大小同时写入构建日志,图像退化问题在回归测试里就能早暴露,不用等用户反馈。
本文还有配套的精品资源,点击获取