DongGu
播放器开发--音视频解码(3)

播放器开发--音视频解码(3)

PlayerController —— 流水线总控

前面两篇分别讲了 Demuxer(解封装)和 Decoder(解码),但它们还各自独立,需要一个角色把整条流水线串起来。PlayerController 就是这个”总控”——它负责:

  • 按正确顺序组装流水线各组件
  • 启动和停止所有工作线程
  • 对外提供 play / stop / pause / resume / seek 控制接口

类的结构一览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class PlayerController : public QObject {
Q_OBJECT

Q_PROPERTY(double duration READ getDuration NOTIFY durationChanged)
Q_PROPERTY(bool isPlaying READ isPlaying NOTIFY isPlayingChanged)
Q_PROPERTY(bool paused READ isPaused NOTIFY pausedChanged)

public:
Q_INVOKABLE bool play(const QString &filePath);
Q_INVOKABLE void stop();
Q_INVOKABLE void pause();
Q_INVOKABLE void resume();
Q_INVOKABLE void seek(double seconds);

Q_INVOKABLE FrameQueue* getVideoFrameQueue();
Q_INVOKABLE FrameQueue* getAudioFrameQueue();
Q_INVOKABLE double getCurrentTime();
Q_INVOKABLE void setVolume(int volume);
Q_INVOKABLE int getVolume() const;
Q_INVOKABLE Clock* getClockPtr() const;

signals:
void videoFrameReady();
void durationChanged();
void isPlayingChanged();
void pausedChanged();

private:
void demuxLoop();

// 核心组件
Demuxer _demuxer;
VideoDecoder _videoDecoder;
AudioDecoder _audioDecoder;
AudioOutput _audioOutput;

// 数据管道(四个队列,连接四个阶段)
PacketQueue _videoPacketQueue;
PacketQueue _audioPacketQueue;
FrameQueue _videoFrameQueue;
FrameQueue _audioFrameQueue;

// 线程与状态
std::thread _demuxThread;
std::atomic<bool> _isPlaying{false};
std::atomic<bool> _paused{false};
std::atomic<bool> _seeking{false};
std::atomic<bool> _seekInProgress{false};
std::atomic<bool> _hasAudio{false};
int _volume = 50;

// 时钟
std::shared_ptr<Clock> _clock;
};

这个类的职责很纯粹——它自己不做任何音视频处理,只负责”组装和调度”。所有复杂的逻辑都在 Demuxer、Decoder、AudioOutput 内部,PlayerController 只是把它们的接口配合起来调用。

阅读提示:Seek 和 Clock 相关逻辑将在后续文章中详细讲解,本文聚焦流水线的组装、启动和停止。


play() —— 组装并启动流水线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
bool PlayerController::play(const QString &filePath) {
stop(); // 先停掉当前播放(如果有的话)

_videoPacketQueue.reset();
_audioPacketQueue.reset();
_videoFrameQueue.reset();
_audioFrameQueue.reset();
_clock->setTime(0.0);

// ---- 1. 打开文件,获取流 index ----
std::string stdFilePath = filePath.toStdString();
if (!_demuxer.open(stdFilePath.data())) {
std::cerr << "PlayerController: Failed to open file." << std::endl;
return false;
}
int videoIdx = _demuxer.findVideoStream();
if (videoIdx < 0) {
std::cerr << "PlayerController: No video stream found." << std::endl;
return false;
}
int audioIdx = _demuxer.findAudioStream();

// ---- 2. 初始化解码器 ----
AVCodecParameters *videoParams = _demuxer.getVideoStreamCodecParameters();
if (!videoParams || !_videoDecoder.init(videoParams, _demuxer.getVideoStreamTimeBase())) {
std::cerr << "PlayerController: Video decoder init failed." << std::endl;
return false;
}

_hasAudio = false;
if (audioIdx >= 0) {
AVCodecParameters *audioParams = _demuxer.getAudioStreamCodecParameters();
if (audioParams && _audioDecoder.init(audioParams, _demuxer.getAudioStreamTimeBase())) {
_hasAudio = true;
} else {
std::cerr << "PlayerController: Audio decoder init failed, "
"playing video only." << std::endl;
}
}

// ---- 3. 启动解码线程 ----
_videoDecoder.setSeekFlag(&_seekInProgress);
_videoDecoder.start(_videoPacketQueue, _videoFrameQueue, [this]() {
QMetaObject::invokeMethod(this, [this]() { emit videoFrameReady(); },
Qt::QueuedConnection);
});
if (_hasAudio) {
_audioDecoder.setSeekFlag(&_seekInProgress);
_audioDecoder.start(_audioPacketQueue, _audioFrameQueue);
}

// ---- 4. 启动音频输出(先于 demux,确保消费者就位) ----
if (_hasAudio) {
AVCodecParameters *ap = _demuxer.getAudioStreamCodecParameters();
_audioOutput.setClock(_clock);
if (_audioOutput.init(ap->ch_layout, ap->sample_rate)) {
_audioOutput.start(_audioFrameQueue);
}
}

// ---- 5. 启动解复用线程(最后启动,作为数据源) ----
_isPlaying = true;
_demuxThread = std::thread(&PlayerController::demuxLoop, this);

emit durationChanged();
emit isPlayingChanged();
return true;
}

启动顺序为什么重要?

play() 中各组件的启动顺序是从下游到上游

1
2
3
4
1. 初始化解码器           → 准备好解码环境
2. 启动解码线程(消费者) → 消费者就位,等待 PacketQueue 中有数据
3. 启动音频输出(消费者) → 消费者就位,等待 FrameQueue 中有数据
4. 启动解复用线程(生产者)→ 最后启动,开始往管道里推数据

如果反过来先启动解复用线程,demuxLoop 会立刻往 PacketQueue 塞数据,但此时解码线程还没启动,包会堆积在队列中。虽然不会导致错误(队列能容纳一定数据),但让消费者先就位、生产者后启动,逻辑上更清晰。

另外还有一个硬性约束:必须先 setClockstart AudioOutput。如果 AudioOutput 的消费线程跑起来时 _clock 还是 nullptr,会直接崩溃。

onFrameReady 回调与跨线程信号

视频解码线程中,每当一帧解码完成并推入 FrameQueue,需要通知 QML 端的 VideoSurface 刷新显示。但解码线程不能直接操作 UI 组件(Qt 的 GUI 操作必须在主线程),所以用 QMetaObject::invokeMethod + Qt::QueuedConnection 把信号发射调度到主线程:

1
2
3
4
_videoDecoder.start(_videoPacketQueue, _videoFrameQueue, [this]() {
QMetaObject::invokeMethod(this, [this]() { emit videoFrameReady(); },
Qt::QueuedConnection);
});

音频解码没有加这个回调,因为音频由 AudioOutput 的消费线程自行驱动,不需要通知 UI。


demuxLoop() —— 流水线的源头

这是整个播放循环的起点,运行在独立的解复用线程中。逻辑很直白:不断读包,按 stream_index 分发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
void PlayerController::demuxLoop() {
constexpr double kTailProtectionSeconds = 0.7;

int videoStreamIndex = _demuxer.getVideoStreamIndex();
int audioStreamIndex = _demuxer.getAudioStreamIndex();
if (videoStreamIndex < 0) {
std::cerr << "PlayerController: No video stream found during demuxing."
<< std::endl;
return;
}

while (_isPlaying.load()) {
AVPacketPtr pkt = _demuxer.readPacket();
if (!pkt) {
// ---- 文件读完,进入排空流程 ----
const double videoEndTime = _demuxer.getVideoStreamEndTime();
const double tailProtectStart = videoEndTime - kTailProtectionSeconds;
_videoFrameQueue.protectTailFrom(tailProtectStart);

_videoPacketQueue.push(AVPacketPtr());
if (audioStreamIndex >= 0) {
_audioPacketQueue.push(AVPacketPtr());
}

_isPlaying.store(false);
QMetaObject::invokeMethod(this, [this]() { emit isPlayingChanged(); },
Qt::QueuedConnection);
break;
}

// ---- 按 stream_index 分发 ----
if (pkt->stream_index == videoStreamIndex) {
if (!_videoPacketQueue.push(std::move(pkt))) {
if (_isPlaying.load()) {
std::cerr << "PlayerController: Failed to push packet to queue."
<< std::endl;
}
break;
}
} else if (pkt->stream_index == audioStreamIndex) {
if (!_audioPacketQueue.push(std::move(pkt))) {
if (_isPlaying.load()) {
std::cerr << "PlayerController: Failed to push audio "
"packet to queue." << std::endl;
}
break;
}
} else {
// 其他流(字幕、数据等),暂不处理,直接丢弃
continue;
}
}
}

核心逻辑拆解

主循环while (_isPlaying) 不断调用 _demuxer.readPacket()。这个调用会阻塞直到读到一帧数据,或者被 I/O 中断唤醒(stop/seek 时)。

分发路由:拿到 packet 后比较 pkt->stream_index

匹配 操作
== videoStreamIndex 推入 _videoPacketQueue,视频解码线程会从这里取
== audioStreamIndex 推入 _audioPacketQueue,音频解码线程会从这里取
其他 continue,直接丢弃(不关心的流类型,如字幕)

push 是阻塞的——如果 PacketQueue 满了(说明解码端处理不过来),demuxLoop 会挂起等待,形成自然的反压。队列空出来之后自动恢复读取。

文件读完的处理readPacket() 返回空指针时,说明文件已到结尾,需要进行三步排空操作:

  1. **protectTailFrom(videoEndTime - 0.7s)**:设置视频帧队列的尾部保护区,告诉渲染端最后 0.7 秒的帧不能丢弃(保证播放到结尾时画面完整,不会因为同步机制跳帧)
  2. 推入空 packet:向视频和音频 PacketQueue 各塞入一个空指针。解码线程收到空指针后会进入 draining 模式,将内部缓冲的帧全部吐出来
  3. _isPlaying = false 并通知 UI:播放结束,更新状态

提示:尾部保护和 draining 机制的细节将在 A/V 同步 文章中展开。


stop() —— 安全关闭流水线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void PlayerController::stop() {
_isPlaying.store(false);

// 1. 先 abort 所有队列,唤醒阻塞的 pop/push
_videoPacketQueue.abort();
_audioPacketQueue.abort();
_videoFrameQueue.abort();
_audioFrameQueue.abort();

// 2. 停止消费者线程
_audioOutput.stop();
_audioDecoder.stop();
_videoDecoder.stop();

// 3. 中断解复用线程的阻塞 I/O,等待退出
_demuxer.interrupt();
if (_demuxThread.joinable()) {
_demuxThread.join();
}
_demuxer.clearInterrupt();

// 4. 清空残留数据
_videoPacketQueue.clear();
_audioPacketQueue.clear();
_videoFrameQueue.clear();
_audioFrameQueue.clear();

if (_paused.load()) {
_paused.store(false);
emit pausedChanged();
}
emit isPlayingChanged();
}

为什么要按这个顺序?

stop() 要做的事可以概括为:先断输入,再杀消费者,最后清场

  1. abort 队列:如果解码线程正卡在 pop() 上等数据,直接 join 会死锁。先 abort() 让所有阻塞的 pop/push 立即返回,线程才能正常退出循环
  2. 停止消费者:解码器和音频输出各自 stop() 内部会置 _running = falsejoin 自己的线程
  3. 中断 demuxerdemuxLoop 可能正卡在 av_read_frame() 上等磁盘 I/O。调用 _demuxer.interrupt() 通过中断回调让它返回,然后 join 解复用线程
  4. 清空队列:线程都退出后再 clear(),安全释放所有残留的 AVPacket 和 AVFrame

提示pause() / resume() 的实现很简单——暂停/恢复时钟和音频输出即可,解码和渲染线程会自然地随时钟暂停而停止消费。


小结

PlayerController 本身逻辑不多,它的价值在于把前两篇讲到的 Demuxer 和 Decoder 组装成一个可工作的流水线。读者可以把 play() 理解为一篇”安装说明书”:

1
stop → reset 队列 → open 文件 → init 解码器 → start 消费者线程 → start 生产者线程

到此为止,从文件读取到解码出 YUV/PCM 数据的完整链路就跑通了。下一篇将进入 A/V 同步,讲 Clock 如何协调音频和视频的播放节奏。


这次的插图来自画师DityPretty
图片地址:https://www.pixiv.net/artworks/112850569

本项目源码请看:https://github.com/DongGuZhengHuaJi/VideoPlayer

本文作者:DongGu
本文链接:https://donggu.xyz/2026/05/10/音视频学习/音视频解码-总控篇/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可