音乐播放器 = 小播放器 + 播放列表 + 进度条 + 音量。 最麻烦的不是播放,是进度条:它既显示位置又接收拖动,会自己触发自己。
上一节的三步只够「播一个文件」。
这一节把三个必备件装上,其中进度条那条「防回环」的规矩,所有带滑块的播放器都躲不开。
另外还要处理一件事:Qt 6 把 QMediaPlaylist 删了。
一、播放列表:Qt 6 把它删了
Qt 5 里有一个现成的类 QMediaPlaylist:addMedia、setCurrentIndex、setPlaybackMode,
下一首、上一首、循环、随机全有。
Qt 6 把它整个删掉了。
理由跟后端有关:播放列表的操作语义要跟后端对齐,而各平台后端的列表能力差异太大,
Qt 索性退回「你自己管一个 QList<QUrl>」。
自己管其实不难,四条逻辑:
next():索引加一,到末尾按循环模式决定回零还是停。
prev():索引减一,到 0 时按模式决定。
mediaStatusChanged == EndOfMedia 时自动调 next()。
随机播放用一个「未播过的索引池」,不要用 rand() 直接取,否则会连播同一首。
「不要用 rand() 直接取」这条是实感问题:二十首歌里随机取,
一万次播放会有几十次「连着放了同一首」,用户就会觉得随机坏了。
洗牌(std::shuffle)才是对的:每轮把顺序打乱一次,循环用完再重洗。
二、进度条的双向同步与防回环
进度条跟播放器之间有两股数据流,方向相反:
播放器 → 滑块:positionChanged 来了,把滑块值改掉。
滑块 → 播放器:用户拖动,滑块值变了,要去 setPosition。
如果只写第一股,「用户拖动」就会被下一帧的 positionChanged 覆盖——手一松,滑块自己弹回去。
这就是「回环」,几乎所有第一次写播放器的人都会撞上。
正确做法是「拖动期间不听播放器」:
// 用户在拖的时候,不要用播放进度去覆盖它
connect(slider, &QSlider::sliderPressed, this, [=]{
dragging = true;
});
connect(slider, &QSlider::sliderReleased, this, [=]{
dragging = false;
player->setPosition(slider->value()); // 松手才 seek
});
connect(player, &QMediaPlayer::positionChanged, this, [=](qint64 p){
if (!dragging) slider->setValue(int(p));
});
为什么是「松手才 seek」而不是「边拖边 seek」: 因为 seek 很贵。每次 seek 都要从关键帧解到目标位置, 边拖边 seek 就是每秒触发几十次,界面直接卡死。 Demo 里 D26 量的就是这个成本。
还有个折中做法:拖动时用一个 label 显示「目标时间」,松手才真正 seek。 这就是所有成熟播放器的行为——你拖的时候画面不动,松手才跳。
三、音量、静音与「记住上次」
音量有三个坑,一个比一个隐蔽:
| 坑 | 表现 | 正确做法 |
|---|---|---|
| 范围不一样 | Qt 5 是 0~100 整数,Qt 6 是 0~1 浮点 | 按版本分别处理 |
| 线性滑块听起来不对 | 前段变化大、后段几乎没感觉 | 用对数映射 |
| 重启后回到默认 | 用户每次都要重调 | 用 QSettings 存 |
第二个坑要展开说:人耳对音量的感觉是对数的。滑块推到一半,线性音量是 50%, 听起来却像「只小了一点点」,因为一半的能量在人耳听来离满音量只差几个分贝。
Qt 给了一个转换函数:QAudio::convertVolume(v, from, to),
滑块用 QtAudio::LogarithmicVolumeScale,播放器用 LinearVolumeScale。
这样滑块的「一半位置」听起来才是一半响度。
第三个坑是体验问题:QSettings 存音量、上次播放的曲目、上次的播放位置,
三行代码换来「像个正经软件」。
这一步没有技术含量,但用户感知最强。
四、动手 Demo:音乐播放器的进度条,多久刷一次才合适
这段造了一段五秒的音频,然后按四档刷新间隔算「一秒刷几次」和「滑块动一像素要多久」。 那张四行表就是答案:100 到 250 毫秒这一档。
dur = s.duration * s.time_base # 5.02 秒
# 滑块宽度按 800 像素算,整条时长铺满
px_per_ms = 800.0 / (dur * 1000.0)
for gap in (16.7, 100.0, 250.0, 1000.0):
times = 1000.0 / gap # 一秒刷几次
step = gap * px_per_ms # 一次跳过多少像素
print("%.1f 毫秒 %.1f 次 %.0f 像素" % (gap, times, step))
实跑输出:
--------------------------------------------------------------------
音乐播放器的进度条,多久刷一次才合适
--------------------------------------------------------------------
**进度条每帧刷太浪费,一秒刷一次又太顿。**
**这个间隔可以算,不用猜。**
音频流:**48000 Hz、stereo、时长 5.02 秒**。
**进度条刷新的三条约束**:
刷新间隔 一秒刷几次 滑块动一像素要多久
-----------------------------------------------------
16.7 毫秒 59.9 次 0 毫秒
100.0 毫秒 10.0 次 1 毫秒
250.0 毫秒 4.0 次 2 毫秒
1000.0 毫秒 1.0 次 6 毫秒
**结论是「100 到 250 毫秒」这一档**:
60 Hz 刷(16.7 毫秒):滑块要动,人眼也看不出差别,纯浪费。
**100 毫秒刷:一秒十次,拖动感顺滑,开销可忽略。**
**1 秒刷:一秒跳一大格,看着像卡住。**
**还有两个细节必须处理**:
**用户正在拖滑块时,不要用播放进度去覆盖它**——否则手一松就被拽回去。
做法是「拖动期间暂停更新,松手后 seek」。
**`sliderMoved` 与 `sliderReleased` 是两个信号**,前者用于实时预览,后者才真正 seek。
**混用会导致拖动时疯狂 seek,卡死。**
「滑块动一像素要多久」这一列是这么来的:五秒的时长铺在 800 像素上, 一毫秒对应 0.16 像素。所以 16.7 毫秒的间隔, 每次只走 2.7 像素——比一个像素宽,但人眼分不出。
四档的结论: 16.7 毫秒:一秒六十次,滑块每次动不到三像素,纯浪费。 100 毫秒:一秒十次,动十六像素,看着是「连续滑」,这是最优点。 250 毫秒:一秒四次,动四十像素,开始能看出格子。 1000 毫秒:一秒一次,动一百六十像素,像卡住了。
注意这个结论跟「数据源」是配套的:上一节说过 positionChanged 本身就是百毫秒级,
所以 100 毫秒刷新恰好跟数据源对齐,不早不晚。
刷得再快,拿到的还是同一个数。
第二条「拖动期间不要覆盖」在这张表里也有影子:如果按 16.7 毫秒刷,
用户刚要拖到某个位置,下一帧就被播放进度盖回去,
手还没松、滑块已经在跳了。间隔越长这个问题越轻,但根治只能靠 dragging 标志。
30 秒实验
- 把
positionChanged直接接到slider->setValue上,试试拖动时手一松会不会弹回去。 - 算一下:五秒的音频、800 像素的滑块,用户想要「跳到第 2.5 秒」,滑块应该停在第几像素?
- 判断一下:音量滑块该直接映射 0~100,还是走对数映射?为什么?
常见误区
- 把
positionChanged直接连到setValue。 用户拖动时会被播放进度盖回去,手一松就弹。 要加dragging标志。 - 边拖边
setPosition。 每次 seek 都要从关键帧解起,一秒触发几十次必然卡死。松手才 seek。 - 用
rand() % size()做随机播放。 会连播同一首,用户觉得是 bug。用洗牌,一轮用完再重洗。 - 在 Qt 6 里找
QMediaPlaylist。 它被删了,自己维护QList<QUrl>加一个索引, 代码不到三十行。
自测三问(点开对答案)
1. 进度条为什么会「自己弹回去」? —— 因为拖动改变滑块值,而 `positionChanged` 又在不断把滑块值改成播放位置,两股数据流打架。 要在拖动期间屏蔽播放器这一股。 2. 为什么松手才 seek,而不是边拖边 seek? —— seek 要从最近的关键帧解到目标位置,成本高。边拖边 seek 一秒触发几十次,界面会卡死。 3. 音量滑块为什么要做对数映射? —— 人耳对响度的感知是对数的。 线性映射下,滑块在一半位置时听起来仍然接近满音量,感觉「前半段没变化」。音乐播放器有了,视频播放器其实只多三件事:全屏、画面比例、还有那个「拖动要跳帧」的体验问题。 下一节收尾第三章的后半段。
- ☐ (本节完,下一节 04.04 Qt实现视频播放器)