ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于QMediaPlayer的音视频播放器开发:解码器、状态机与工程实践

基于QMediaPlayer的音视频播放器开发:解码器、状态机与工程实践 简介这套源码是一份基于QMediaPlayer类开发的音视频播放器完整工程适合正在学习Qt框架的开发者、需要编写桌面播放器的程序员以及想快速搭建播放功能的二次开发者使用能够帮助读者理解播放器从界面搭建到底层调用音频视频接口的完整流程。压缩包内共收录七十四份文件整体体积为十五点八二兆字节主要包含八份C加加源程序、七份头文件、三份界面设计文档、一份样式表和一份工程配置另有四十九张界面图标素材以及一个用于视频解码的辅助程序文件组织合理方便直接编译运行。项目代码把播放控制、进度拖动、音量调节、视频画面显示等功能划分清晰界面采用动态图片按钮和自定义滑块展示了更多交互细节阅读源码可以掌握媒体播放器的模块划分技巧以及界面资源管理方式。截至目前已有超过一千七百人次学习下载既可以作为课程设计参考也可以作为音视频客户端开发的入门范例。1. 做一个基于 QMediaPlayer 的音视频播放器项目真正难的不是播放视频做一个基于 QMediaPlayer 的音视频播放器项目真正的工作量不在 QMediaPlayer 本身而在播放列表、进度条、解码器后端的差异上。很多标着“完整源码”的压缩包在本地能跑换个系统就黑屏问题几乎都出在媒体状态机与依赖后端上。这个 20210531 时间节点也很有代表性源码大概率基于 Qt 5.15QMediaPlaylist 还是主力 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 c11 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/seekPlaylistModel 负责把文件列表映射给 QListViewMainWindow 只负责把按钮信号接到 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遇到空格和中文就挂。标准做法是先转成本地文件 URLvoid 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 接受的是 QMediaContentQMediaContent 可以由 QUrl 构造QUrl::fromLocalFile 会自动对空格和 Unicode 字符做百分号编码。setCurrentIndex(0) 会把第一项设置为当前项但不自动调用 play()起播动作由用户点击播放按钮时触发。QMediaPlaylist 内部维护 currentIndex如果当前索引为 -1addMedia 后不会自动纠正所以上面的判断是必要的。播放模式对应的枚举值模式枚举值行为单曲一次CurrentItemOnce播放完当前项后停止单曲循环CurrentItemInLoop当前项播完立即重播顺序播放Sequential按列表顺序进入下一项末项播完停止列表循环Loop末项播完回到第一项继续3.3 进度条与音量条两个容易互相打架的联动进度条联动表面上是“positionChanged 来了就把滑块值更新成 position”实际上有拖动冲突。用户按住滑块时positionChanged 依然在触发会把滑块拉回播放头位置用户还没松手播放头又立刻跳到滑块值体验是滑块发抖。解决的方案是加一个鼠标状态标志在 sliderPressed 到 sliderReleased 之间屏蔽播放器的 position 刷新void MainWindow::initPlayerUI() { auto sliderPressed std::make_sharedbool(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_castint(pos)); } }); }这里用std::make_sharedbool而不是捕获局部引用是为了避免 connect 生命周期内局部变量失效如果你觉得 shared_ptr 不直观把标志位写成 MainWindow 的成员变量也可以。positionChanged 单位是毫秒QSlider 的最大值要设置成 duration() 的毫秒数才能让 setValue 直接等于播放位置。音量条更简单QSlider 范围 0 到 100valueChanged 后用 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于是播放器能打开文件、拿到时长画面却渲染不出来。平台默认后端常见失败WindowsWindows Media Foundation缺少 Media Feature Packmp4 黑屏LinuxGStreamer缺少 gstreamer1.0-libav不能解码 h264macOSAVFoundation部分音频格式不识别排查时先用命令行验证后端能力再决定是装包还是换格式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_h264Qt 的 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.constDataqint16(); 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 的电平数据接到你项目的波形图控件上再按需拆分后端格式列表这个播放器源码的可维护性就已经超过大多数示例工程了。本文还有配套的精品资源点击获取
返回列表