简介:这套源码是一份基于QMediaPlayer类开发的音视频播放器完整工程,适合正在学习Qt框架的开发者、需要编写桌面播放器的程序员以及想快速搭建播放功能的二次开发者使用,能够帮助读者理解播放器从界面搭建到底层调用音频视频接口的完整流程。压缩包内共收录七十四份文件,整体体积为十五点八二兆字节,主要包含八份C加加源程序、七份头文件、三份界面设计文档、一份样式表和一份工程配置,另有四十九张界面图标素材以及一个用于视频解码的辅助程序,文件组织合理,方便直接编译运行。项目代码把播放控制、进度拖动、音量调节、视频画面显示等功能划分清晰,界面采用动态图片按钮和自定义滑块,展示了更多交互细节,阅读源码可以掌握媒体播放器的模块划分技巧以及界面资源管理方式。截至目前,已有超过一千七百人次学习下载,既可以作为课程设计参考,也可以作为音视频客户端开发的入门范例。
1. 做一个基于 QMediaPlayer 的音视频播放器项目,真正难的不是播放视频
做一个基于 QMediaPlayer 的音视频播放器项目,真正的工作量不在 QMediaPlayer 本身,而在播放列表、进度条、解码器后端的差异上。很多标着“完整源码”的压缩包,在本地能跑,换个系统就黑屏,问题几乎都出在媒体状态机与依赖后端上。这个 20210531 时间节点也很有代表性:源码大概率基于 Qt 5.15,QMediaPlaylist 还是主力 API,放今天编译很容易踩到 Qt 6 的兼容问题。下面的梳理不是某个压缩包的使用说明书,而是我拿到这类播放器源码时会走的完整路径:先分清楚 QMediaPlayer 的职责边界,再搭一个能改能测的框架,把代码封装、解码器依赖、中文路径、状态机这些坑一次说透。适合做过基础 QWidget 程序、想靠播放器项目练手或做嵌入式 Qt 开发的同学。
2. 先看懂 QMediaPlayer 的职责边界:它只管解码控制,不负责界面渲染
2.1 播放器管线:QMediaPlayer、QMediaPlaylist、QVideoWidget 各管一段
QMediaPlayer 不是一个解码器库,它是一个播放会话控制器。播放 mp4 时真正干活的解码后端,在 Windows 上是 Windows Media Foundation,在 Linux 上是 GStreamer,在 macOS 上是 AVFoundation。QMediaPlayer 通过 Qt Multimedia 的抽象层转发控制指令,再回传 position、state、mediaStatus 这些信号。所以你写同一份代码,在嵌入式 Linux 板子和 Windows 桌面上表现可能完全不一样,差别不在代码,而在后端插件。
管线按职责分三段。QMediaPlayer 管“播放、暂停、跳转、音量”,QMediaPlaylist 管“有哪些媒体、下一首是谁”,QVideoWidget 只负责把解码后的画面画出来。音频没有可视化控件,默认直接送系统音频输出;如果要做音量电平表,得另接 QAudioProbe。这里最典型的误解是:以为给 QMediaPlayer 的 setMedia 传一个文件路径,它就自动包办格式解析和画面缩放。实际上 setMedia 只负责把 QUrl 交给后端,能不能播取决于后端认不认这个封装格式。
拿到播放器源码包时,我一般会先读工程文件而不是 main.cpp。工程文件里QT += multimedia multimediawidgets这两行的存在与否,直接决定了这个项目能不能编译。只做音频播放可以只加 multimedia,要出现 QVideoWidget 就必须有 multimediawidgets,否则报错信息只会告诉你是QVideoWidget: No such file or directory,新手容易误判成头文件路径问题。下面是 Qt 5 时代最常见的 .pro 写法:
QT += core gui multimedia multimediawidgets greaterThan(QT_MAJOR_VERSION, 4): QT += widgets CONFIG += c++11 TARGET = player TEMPLATE = app SOURCES += main.cpp \ playerwidget.cpp HEADERS += playerwidget.hmultimedia提供 QMediaPlayer 与 QMediaPlaylist 核心类,multimediawidgets提供 QVideoWidget、QGraphicsVideoItem 等显示组件;greaterThan(QT_MAJOR_VERSION, 4): QT += widgets是 Qt 5 兼容 Qt 4 时代的写法,纯 Qt 5 工程里写QT += widgets即可。如果你手里的源码是基于 Qt 5.15 写的,而你现在用 Qt 6 打开,QMediaPlaylist 的跨版本兼容是首先要确认的:Qt 6 早期调整过这个类的归属,最稳的做法是按源码标注的 Qt 版本建立构建套件,不要直接切换主版本。
2.2 两套状态信号:stateChanged 管开关,mediaStatusChanged 管数据流
QMediaPlayer 的信号很多,最容易被混淆的是 stateChanged 和 mediaStatusChanged。stateChanged 的值只有三个:StoppedState、PlayingState、PausedState,代表播放器自身启停状态。mediaStatusChanged 则与媒体数据流有关,包括 LoadingMedia、LoadedMedia、BufferingMedia、BufferedMedia、EndOfMedia、InvalidMedia、NoMedia 等。界面上的“播放/暂停”按钮应该跟 stateChanged 联动;播放到结尾的提示、缓冲中动画、以及“这个文件不能播”的错误提示,应该跟 mediaStatusChanged 联动。把两者混用,最常见的 bug 是点暂停后按钮文字正常变了,但进度条因为错误接收了 mediaStatusChanged 的刷新而继续走。
| 信号 | 触发含义 | 界面对应 |
|---|---|---|
| stateChanged | 播放器启停状态变化 | 播放/暂停按钮文字 |
| mediaStatusChanged | 媒体数据流状态变化 | 缓冲提示、播放结束提示 |
| positionChanged | 播放位置毫秒变化 | 进度条、当前时间标签 |
| durationChanged | 总时长变化 | 总时长标签 |
| volumeChanged | 音量变化 | 音量滑块回显 |
下面的连接方式是我在源码里会用到的基准写法:
connect(player, &QMediaPlayer::stateChanged, this, [this](QMediaPlayer::State state) { if (state == QMediaPlayer::PlayingState) { ui->playButton->setText(tr("暂停")); } else { ui->playButton->setText(tr("播放")); } }); connect(player, &QMediaPlayer::mediaStatusChanged, this, [this](QMediaPlayer::MediaStatus status) { if (status == QMediaPlayer::EndOfMedia) { ui->statusLabel->setText(tr("播放完成")); } else if (status == QMediaPlayer::InvalidMedia) { ui->statusLabel->setText(tr("媒体无效,请检查后端解码器")); } });mediaStatusChanged 的触发频率比 stateChanged 高很多,在缓冲、起播、码流切换时都会触发。不要在 status 为 BufferingMedia 时销毁播放器或重置播放列表,否则后端的异步线程还在工作,容易在退出时崩溃;比较稳的做法是只响应 EndOfMedia 和 InvalidMedia,其余状态交给日志。
2.3 后端插件不是 Qt 的一部分:查问题先看 playable 状态
QMediaPlayer 的 canPlay() 和 mediaStatus 组合起来,是排查“文件明明存在但播放器没反应”的第一落点。设置媒体后,立刻调用 canPlay() 可能因为后端尚未完成解析而返回 false,正确顺序是等待 mediaStatusChanged 变成 LoadedMedia 或 BufferedMedia 再拿结论。可以打印成下面这种便于 grep 的日志:
connect(player, &QMediaPlayer::mediaStatusChanged, player, [](QMediaPlayer::MediaStatus status) { qInfo().noquote() << QString("mediaStatus=%1").arg(int(status)); });isMetaDataAvailable 只是元数据是否可用的标志,判断是否能播还要结合 errorOccurred 信号。播放器源码里如果到处写 isAvailable(),那是 Qt 头文件自带的接口,跟实际媒体状态不是一回事,不要搞混。拿到源码后先把这一行日志加在 player 初始化处,比逐个断点调试快得多。
3. 基于 QMediaPlayer 搭一套可复用的播放器框架:从类划分到参数设置
3.1 三层结构:PlayerCore、PlaylistModel、MainWindow 各干各的
很多播放器“源码”只有两个文件:main.cpp 和 widget.cpp,所有逻辑堆在 MainWindow 里。能跑,但维护困难,而且换一个列表控件就要改 UI 层。我习惯把它拆成三层:PlayerCore 封装 QMediaPlayer 和 QMediaPlaylist,向上暴露 play/pause/stop/seek;PlaylistModel 负责把文件列表映射给 QListView;MainWindow 只负责把按钮信号接到 PlayerCore。这样以后要把 Qt Widgets 换成 Qt Quick,只需要换掉最外层。
PlayerCore 的头文件可以这样定:
#include <QObject> #include <QMediaPlayer> #include <QMediaPlaylist> class PlayerCore : public QObject { Q_OBJECT public: explicit PlayerCore(QObject *parent = nullptr); QMediaPlayer *player() const { return m_player; } QMediaPlaylist *playlist() const { return m_playlist; } void play(); void pause(); void stop(); void seek(qint64 position); void setVolume(int volume); void appendFiles(const QStringList &paths); private: QMediaPlayer *m_player; // 会话控制 QMediaPlaylist *m_playlist; // 列表状态 };m_player 与 m_playlist 都挂在 PlayerCore 的父子链上,不需要在析构函数里手动 delete。如果只保留 QMediaPlayer 而不建 QMediaPlaylist,也可以直接 setMedia 单文件,但会导致上一首/下一首逻辑堆死在界面层,所以这个封装对完整播放器项目是有必要的。
构造函数里要做两件关键事:把 playlist 挂到 player 上,并把播放模式设置好:
PlayerCore::PlayerCore(QObject *parent) : QObject(parent) { m_playlist = new QMediaPlaylist(this); m_player = new QMediaPlayer(this); m_player->setPlaylist(m_playlist); m_playlist->setPlaybackMode(QMediaPlaylist::Sequential); }setPlaylist 之后的播放不再依赖 setMedia,而是依赖 setCurrentIndex;每一条媒体成为 playlist 中的一个 item。Sequential 模式表示当前播放完自动进入下一首,最后一首结束后播放器进入 StoppedState。如果你希望和音乐播放器一样最终回到第一首继续循环,就改成 Loop。播放模式是运行时可以切换的属性,不存在“翻代码改枚举”的需求。
3.2 往播放列表添加文件:用 QUrl::fromLocalFile 而不是 QUrl
读本地音视频文件时,有两个入口:单个打开和批量拖入。最后都收敛到 appendFiles。最常见的坑是开发者把文件路径放在 QString 里直接构造 QUrl,遇到空格和中文就挂。标准做法是先转成本地文件 URL:
void PlayerCore::appendFiles(const QStringList &paths) { for (const QString &path : paths) { QUrl url = QUrl::fromLocalFile(path); m_playlist->addMedia(url); // 自动百分号编码 } if (m_playlist->currentIndex() < 0 && m_playlist->mediaCount() > 0) { m_playlist->setCurrentIndex(0); } }addMedia 接受的是 QMediaContent,QMediaContent 可以由 QUrl 构造;QUrl::fromLocalFile 会自动对空格和 Unicode 字符做百分号编码。setCurrentIndex(0) 会把第一项设置为当前项,但不自动调用 play(),起播动作由用户点击播放按钮时触发。QMediaPlaylist 内部维护 currentIndex,如果当前索引为 -1,addMedia 后不会自动纠正,所以上面的判断是必要的。
播放模式对应的枚举值:
| 模式 | 枚举值 | 行为 |
|---|---|---|
| 单曲一次 | CurrentItemOnce | 播放完当前项后停止 |
| 单曲循环 | CurrentItemInLoop | 当前项播完立即重播 |
| 顺序播放 | Sequential | 按列表顺序进入下一项,末项播完停止 |
| 列表循环 | Loop | 末项播完回到第一项继续 |
3.3 进度条与音量条:两个容易互相打架的联动
进度条联动表面上是“positionChanged 来了就把滑块值更新成 position”,实际上有拖动冲突。用户按住滑块时,positionChanged 依然在触发,会把滑块拉回播放头位置;用户还没松手,播放头又立刻跳到滑块值,体验是滑块发抖。解决的方案是加一个鼠标状态标志,在 sliderPressed 到 sliderReleased 之间屏蔽播放器的 position 刷新:
void MainWindow::initPlayerUI() { auto sliderPressed = std::make_shared<bool>(false); connect(ui->positionSlider, &QSlider::sliderPressed, this, [sliderPressed]() { *sliderPressed = true; }); connect(ui->positionSlider, &QSlider::sliderReleased, this, [sliderPressed, this]() { *sliderPressed = false; m_core->player()->setPosition(ui->positionSlider->value()); }); connect(m_core->player(), &QMediaPlayer::positionChanged, this, [sliderPressed, this](qint64 pos) { if (!*sliderPressed) { ui->positionSlider->setValue(static_cast<int>(pos)); } }); }这里用std::make_shared<bool>而不是捕获局部引用,是为了避免 connect 生命周期内局部变量失效;如果你觉得 shared_ptr 不直观,把标志位写成 MainWindow 的成员变量也可以。positionChanged 单位是毫秒,QSlider 的最大值要设置成 duration() 的毫秒数,才能让 setValue 直接等于播放位置。音量条更简单,QSlider 范围 0 到 100,valueChanged 后用 player->setVolume(value) 下发,QMediaPlayer 会把音量映射到系统混音器,不用自己再写增益曲线。
3.4 把时长格式化成 mm:ss 的时间标签
时长标签看起来是小事,但在源码里反复出现。QMediaPlayer 的 durationChanged 信号给的是毫秒,直接除以 1000 显示成秒数会显得很业余。这里给一个不依赖 locale 的格式化函数:
QString formatPlayTime(qint64 ms) { const qint64 totalSec = ms / 1000; const qint64 minutes = totalSec / 60; const qint64 seconds = totalSec % 60; return QString("%1:%2").arg(minutes, 2, 10, QChar('0')) .arg(seconds, 2, 10, QChar('0')); }arg(minutes, 2, 10, QChar('0'))的含义是:整数占两位、按十进制、不足补零。时长超过一小时时,这段代码会自然显示出 100:23 而不是 01:40:23,对大多数播放器足够。每次 durationChanged 时更新总时长标签,每次 positionChanged 时更新当前位置标签,两个标签放进同一个 QLabel 也可以。
4. 解码器、中文路径与媒体状态机:QMediaPlayer 播放器源码最容易翻车的三处
4.1 换台机器就不能播:先查后端而不是查代码
音视频播放器项目上线后最常见的售后问题是“在我电脑上能播,发给别人变成黑屏”。这是因为 QMediaPlayer 不内置编解码器,它能解码多少格式完全取决于运行处的系统后端。Linux 桌面用 GStreamer 时,MP4 的 H.264 解码依赖 gstreamer1.0-libav,而很多精简系统只装了 gstreamer1.0-plugins-base,于是播放器能打开文件、拿到时长,画面却渲染不出来。
| 平台 | 默认后端 | 常见失败 |
|---|---|---|
| Windows | Windows Media Foundation | 缺少 Media Feature Pack,mp4 黑屏 |
| Linux | GStreamer | 缺少 gstreamer1.0-libav,不能解码 h264 |
| macOS | AVFoundation | 部分音频格式不识别 |
排查时先用命令行验证后端能力,再决定是装包还是换格式:
gst-inspect-1.0 | grep -E "qtdemux|avdec_h264" sudo apt install gstreamer1.0-plugins-base \ gstreamer1.0-plugins-good gstreamer1.0-plugins-bad \ gstreamer1.0-plugins-ugly gstreamer1.0-libavgst-inspect-1.0 列出当前系统可用的插件;如果没有返回 avdec_h264,Qt 的 GStreamer 后端就只能找软件解码,性能会急剧下降。安装完插件不需要重编译程序,重启播放器即可生效。嵌入式板卡上若没有命令行工具,可以直接写一段小代码调用 gst_element_factory_find("avdec_h264") 来判断插件是否存在。
4.2 中文路径与空格:setMedia 的输入必须符合 URL 规范
Windows 和 mac 的路径都很容易带空格,中文文件夹更常见。如果用QMediaContent(QUrl("D:/我的音乐/周杰伦 晴天.mp3"))这样直接传字符串,QUrl 会比较宽容但不会百分号编码,后端解析时经常失败。正确的做法是只做 fromLocalFile 转换:
QString path = "D:/我的音乐/beautiful day.mp3"; player->setMedia(QUrl::fromLocalFile(path)); // 正确 player->setMedia(QUrl(path)); // 错误:空格可能被当成非法字符还有一个更隐蔽的坑:setMedia 之后马上调用 position() 会得到 0,但不代表文件失败;此时 mediaStatus 通常是 LoadingMedia,要等它变成 BufferedMedia 才能读实时 position。调试时建议把错误信号和状态信号都打出来:
connect(player, &QMediaPlayer::errorOccurred, this, [](QMediaPlayer::Error error, const QString &errorString) { qWarning() << "error" << int(error) << errorString; });errorOccurred 从 Qt 5.15 开始可用,如果你手里的源码还在用旧式connect(player, SIGNAL(error(QMediaPlayer::Error)), ...),升级到 Qt 5.15 后会出现信号匹配失败,换成上面的 new style 写法最省事。不要直接用 player->error() 去判断文件问题,因为部分后端在解析失败时不会立即设置 error,只把 mediaStatus 置为 InvalidMedia。
4.3 EndOfMedia 之后:下一首的触发时机与循环逻辑
在 QMediaPlaylist 模式下,Sequential 会自己切下一首,但很多播放器源码里会看到播放完最后一条后界面停住,进度条归零。原因是播放器进入 EndOfMedia 状态后,playlist 的 currentIndex 已经指向了末尾,此时如果不重置索引,播放器不播任何内容。
推荐的收尾逻辑是判断当前是不是最后一条,是则复位到 0 并停止;否则主动调到 next 再 play,避免依赖 QMediaPlaylist 内部状态:
connect(player, &QMediaPlayer::mediaStatusChanged, this, [this](QMediaPlayer::MediaStatus status) { if (status != QMediaPlayer::EndOfMedia) return; auto pl = m_core->playlist(); if (pl->currentIndex() == pl->mediaCount() - 1) { if (pl->playbackMode() == QMediaPlaylist::Loop) { pl->setCurrentIndex(0); m_core->player()->play(); } else { m_core->player()->stop(); } } else { pl->next(); m_core->player()->play(); } });这种写法把单曲循环的判断提前挡掉,Loop 和 Sequential 在“播完一首”这件事上表现相同,区别只在列表末尾的转向;如果你把能否循环的判断写错顺序,会出现“单曲循环时末尾又重复播了文件”这类难查的 bug。
4.4 渲染控件选择:QVideoWidget 与 QGraphicsVideoItem 在源码里的差别
源码里出现画面输出时有两种常见挂载方式。QVideoWidget 是最直接的选择,setVideoOutput(videoWidget) 一行就能跑,适合 Widgets 界面;如果项目需要把视频嵌入复杂场景,比如浮层、旋转、多画面布局,用 QGraphicsVideoItem 更合理:
QVideoWidget *videoWidget = new QVideoWidget(this); videoWidget->setAspectRatioMode(Qt::KeepAspectRatio); // 等比缩放 player->setVideoOutput(videoWidget);QVideoWidget 在 Windows 上偶发黑屏,多半是后端没有及时分配 DirectX 表面;常见做法是延迟到 mediaStatusChanged 的 BufferedMedia 后再调用 show(),而不是在构造时立刻 show。QGraphicsVideoItem 则需要显式设置 size 到 QSizeF,否则默认大小是 0 x 0,很容易出现“有声音没画面”的假 bug。
5. 进阶:把 QMediaPlayer 播放器从“能放”做到“好用”的三个小改动
5.1 断点续播:把播放位置和音量存进 QSettings
播放器项目做完基本播放后,用户第一个感知到的功能就是断点续播。用 QSettings 保存当前媒体的 URL、position、音量和播放模式。保存时机不能放在每次 positionChanged 里,建议做 5 秒节流:
void MainWindow::savePlaybackState() { QSettings settings("MyPlayer", "PlayerState"); settings.setValue("media", m_core->playlist()->currentMedia().canonicalUrl()); settings.setValue("position", m_core->player()->position()); settings.setValue("volume", m_core->player()->volume()); settings.setValue("playbackMode", int(m_core->playlist()->playbackMode())); } connect(m_core->player(), &QMediaPlayer::positionChanged, this, [this](qint64) { if (m_lastSaveTime.elapsed() > 5000) { savePlaybackState(); m_lastSaveTime.restart(); } });QSettings 直接写注册表和配置文件,不需要引入 JSON;如果你的播放器要支持“最近播放列表”,再把同一个结构体换成 QJsonObject 序列化。canonicalUrl() 比 request().url() 更稳定,它会去掉文件路径里的相对符号。
5.2 用 QAudioProbe 采集音频电平,给界面加一个迷你电平表
QMediaPlayer 本身不提供波形回调,但 QAudioProbe 可以挂到 player 上,从播放流中拿到未混音前的 PCM 数据。QAudioProbe 是采样器而不是过滤器,它不影响播放。在 Qt 5.15 中可以直接对 QMediaPlayer 设置:
QAudioProbe *probe = new QAudioProbe(this); probe->setSource(m_core->player()); connect(probe, &QAudioProbe::audioBufferProbed, this, [](const QAudioBuffer &buffer) { const qint16 *data = buffer.constData<qint16>(); qreal rms = 0; for (int i = 0; i < buffer.sampleCount(); ++i) { rms += qreal(data[i]) * data[i]; } rms = (buffer.sampleCount() > 0) ? std::sqrt(rms / buffer.sampleCount()) : 0; double normalized = qBound(0.0, rms / 32768.0, 1.0); // 把 normalized 交给电平表控件更新 });两个细节要注意:QAudioProbe 只在媒体解码后产生音频数据,纯视频或静音视频不会触发;不要在 audioBufferProbed 里直接更新复杂控件,因为回调频率可能到每秒十几次,最好把 normalized 值交给一个只接收 double 的信号,再让 UI 线程去刷新。
5.3 键盘快捷键与全屏切换:打动 review 代码的细节
播放器源码的质量,往往不在播放本身,而在快捷键和全屏处理。全屏切换不能用 resize 硬顶,要触发窗口状态变化:
void MainWindow::toggleFullscreen() { if (isFullScreen()) { showNormal(); ui->videoWidget->setFocus(); } else { showFullScreen(); } }空格键绑定播放/暂停,方向键控制音量和 seek。做快捷键时注意 QWidget 默认焦点会被按钮吃掉,需要在 MainWindow 的 keyPressEvent 里过滤,或者给窗口设置setFocusPolicy(Qt::StrongFocus)。把 QAudioProbe 的电平数据接到你项目的波形图控件上,再按需拆分后端格式列表,这个播放器源码的可维护性就已经超过大多数示例工程了。
本文还有配套的精品资源,点击获取