🎬 第四章 Qt播放音视频及Multimedia多媒体模块
📄 一句话概括

音乐播放器 = 小播放器 + 播放列表 + 进度条 + 音量。 最麻烦的不是播放,是进度条:它既显示位置又接收拖动,会自己触发自己。

📝 本节和前面几节的关系

上一节的三步只够「播一个文件」。 这一节把三个必备件装上,其中进度条那条「防回环」的规矩,所有带滑块的播放器都躲不开。 另外还要处理一件事: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 秒实验

  1. 把 positionChanged 直接接到 slider->setValue 上,试试拖动时手一松会不会弹回去。
  2. 算一下:五秒的音频、800 像素的滑块,用户想要「跳到第 2.5 秒」,滑块应该停在第几像素?
  3. 判断一下:音量滑块该直接映射 0~100,还是走对数映射?为什么?

常见误区

  1. 把 positionChanged 直接连到 setValue。 用户拖动时会被播放进度盖回去,手一松就弹。 要加 dragging 标志。
  2. 边拖边 setPosition。 每次 seek 都要从关键帧解起,一秒触发几十次必然卡死。松手才 seek。
  3. 用 rand() % size() 做随机播放。 会连播同一首,用户觉得是 bug。用洗牌,一轮用完再重洗。
  4. 在 Qt 6 里找 QMediaPlaylist。 它被删了,自己维护 QList<QUrl> 加一个索引, 代码不到三十行。
自测三问(点开对答案) 1. 进度条为什么会「自己弹回去」? —— 因为拖动改变滑块值,而 `positionChanged` 又在不断把滑块值改成播放位置,两股数据流打架。 要在拖动期间屏蔽播放器这一股。 2. 为什么松手才 seek,而不是边拖边 seek? —— seek 要从最近的关键帧解到目标位置,成本高。边拖边 seek 一秒触发几十次,界面会卡死。 3. 音量滑块为什么要做对数映射? —— 人耳对响度的感知是对数的。 线性映射下,滑块在一半位置时听起来仍然接近满音量,感觉「前半段没变化」。

音乐播放器有了,视频播放器其实只多三件事:全屏、画面比例、还有那个「拖动要跳帧」的体验问题。 下一节收尾第三章的后半段。

相关笔记