🎬 第二章 VLC播放器及二次开发应用
📄 一句话概括

控制台版播放器一共四步:建实例、建媒体、建播放器、等结束。 代码只有二十行,但它把 LibVLC 三层对象的用法演示完整了。

📝 本节和前面几节的关系

上一节把 SDK 装好了,这一节写第一个能跑的程序。 控制台版是最小可运行版本:没有界面,画面开在 VLC 自己的窗口里。 先把这条链路跑通,再谈嵌入到别的界面里(后面三节)。

一、工程配置四处

在 VS 里新建一个控制台工程后,要改四个地方:

位置 填什么
C/C++ → 常规 → 附加包含目录 VLC 目录\include
链接器 → 常规 → 附加库目录 VLC 目录\lib
链接器 → 输入 → 附加依赖项 libvlc.lib
调试 → 环境 PATH=VLC 目录

第四行是最容易被漏的:不加这一句,调试时程序起不来(找不到 libvlc.dll), 但直接双击 exe 又可能能跑(因为系统 PATH 里有)。 这种「F5 不行、双击可以」的怪现象,八成就是这一行没配。

还有一个替代做法:把 libvlc.dll 与 libvlccore.dll 直接拷到工程输出目录, 这样不用改环境变量,但每次清理重建都要重拷一遍。 开发阶段建议用「调试环境变量」,交付时用「拷文件」。

二、最小可运行的代码

四步,一步不多:

#include <vlc/vlc.h>
#include <windows.h>

int main(int argc, char* argv[])
{
    libvlc_instance_t* inst;
    libvlc_media_t* m;
    libvlc_media_player_t* mp;

    inst = libvlc_new(0, NULL);          /* 1 建实例 */
    m    = libvlc_media_new_path(inst, "test.mp4");
    mp   = libvlc_media_player_new_from_media(m);

    libvlc_media_player_play(mp);        /* 2 开始播 */

    while (libvlc_media_player_is_playing(mp))
        Sleep(100);                      /* 3 等结束 */

    libvlc_media_player_release(mp);     /* 4 释放 */
    libvlc_media_release(m);
    libvlc_release(inst);
    return 0;
}

注意 new_from_media 之后没有释放 m 的语句——因为那个函数内部已经加了一次引用, 外面这份 m 你仍然要释放(示例里放在最后)。 很多示例代码省了这一句,那是泄漏,只是量小看不出来。

还有一个细节:控制台版没有窗口,所以画面会开在 VLC 自己创建的窗口里。 这也是为什么它「能跑」但不能「嵌进你的界面」——要嵌进去得用 set_hwnd,那是下一节的事。

三、为什么要 Sleep 轮询

play 是异步的,所以主线程不能立刻退出——退了程序就结束,播放器跟着死。 所以要有一个「等」的循环。

两种等法: 轮询 is_playing(示例里的写法):简单,但每 100 毫秒醒一次。 挂 EndReached 事件:精确,但要写回调与事件循环。

示例用轮询是因为它短,工程里应该用事件。 因为轮询有个明显的缺陷:「暂停」时 is_playing 也是 false,循环会提前退出。

所以正确的退出条件是「状态变成 Ended 或 Error」,而不是「不再 playing」。 libvlc_media_player_get_state 返回的枚举里有这几个值:NothingSpecial、Opening、Buffering、 Playing、Paused、Stopped、Ended、Error。

四、Python 等价物

沙箱里没有 VS,但有 Python 与 PyAV。 用 PyAV 写一遍同样的四步,可以在沙箱里真跑起来。

import av

c = av.open("test.mp4")        # 1 打开(相当于建实例+媒体)
for fr in c.decode(video=0):   # 2 解码(相当于播放)
    rgb = fr.reformat(format="rgb24")
    # 这里本该把 rgb 贴到窗口(相当于 VLC 的 vout)
c.close()                      # 3 释放

这个对照能看清一件事:LibVLC 省掉的,正是「贴到窗口」那一步之后的所有事——它自己建窗口、 自己按时间显示、自己同步声音。PyAV 只做到「拿到帧」,剩下全靠你。

所以「用 LibVLC 还是用 FFmpeg」这个问题,答案取决于「你愿意自己写多少」: 愿意写同步与渲染,就用 FFmpeg(体积小、可控);不愿意,就用 LibVLC(体积大、省事)。

五、踩坑清单

五个坑,按出现频率排:

现象 原因
程序一闪就退 没加等待循环,主线程直接结束
没有声音 音频输出模块没加载,或系统默认设备被改
画面是黑的 文件路径用了相对路径,工作目录不对
播到一半停了 文件播完,正常结束,不是 bug
退出时崩 释放顺序错,或事件没 detach

第三行值得单独说:VS 调试时的工作目录是「工程目录」, 而直接双击 exe 时的工作目录是「exe 所在目录」。所以同一个相对路径,F5 能找到、双击找不到。 这类问题一律用绝对路径,或者显式设工作目录。

第一行也很常见:很多人写完四步就 return 0, 结果窗口闪一下就没了——因为主线程退出后,整个进程被销毁,播放器跟着死。

六、动手 Demo:最小播放器的循环,其实是一个时钟

这段用真实视频读出每帧的时间戳,算「每帧该等多久」。 它对应控制台版里那个「等」的循环——只不过这里等得更精确。

for fr in c.decode(video=0):
    if fr.pts is None:
        continue
    sec = float(fr.pts * tb)      # 换算成秒
    ms = sec * 1000
    gap = 0.0 if last is None else ms - last
    last = ms

实跑输出:

--------------------------------------------------------------------
最小播放器的循环,其实是一个时钟
--------------------------------------------------------------------
**所有播放器的主循环都能写成一句话:算「这一帧该在什么时候出现」,然后等或不等。**
**LibVLC 把这句话包好了,但它内部做的还是这件事。**

  视频流:**320×240、30 fps、时间基 1/15360**。

**按时间戳算出来的「每帧该等多久」**:

  帧号     时间戳     应显示时刻 毫秒     距上一帧 毫秒
  ---------------------------------------------------------
  0        0          0.0                 0.0
  1        512        33.3                33.3
  2        1024       66.7                33.3
  3        1536       100.0               33.3
  4        2048       133.3               33.3
  5        2560       166.7               33.3
  6        3072       200.0               33.3
  7        3584       233.3               33.3

**注意这一列全是 33.3,一毫秒都不差。**
  **因为这个片子的时间基是 1/15360,而 30 fps 一帧正好 512 个刻度**,能整除。
  **换成 29.97 或 23.976 就没这么巧了**——

  帧率       每帧刻度     只能取整到     一千帧累计错
  -------------------------------------------------------
  30.000     512.00       512            0.0 毫秒
  29.970     512.51       513            31.7 毫秒
  25.000     614.40       614            26.0 毫秒
  23.976     640.64       641            23.4 毫秒

**29.97 fps 每帧要 512.51 个刻度,而时间戳只能是整数**——**一千帧(约 33 秒)就累计错三十多毫秒。**
  **这就是为什么播放器不能「每帧固定睡 33 毫秒」**——**误差会累积,几分钟后音画就错开一大截。**

**正确做法是「按绝对时间对齐」**:每一帧都拿「它的时间戳」减「当前时钟」,
**得到的差值决定等多久或丢多少,而不是累加每帧的固定时长。**
  这条规则在第九章会再出现一次,那时候它叫「主时钟」。

**LibVLC 把这个时钟藏起来了**,所以写二次开发代码时几乎不用管它。
  **但一旦要做「多路画面同步」「外部时钟驱动」,就必须知道它在哪儿。**

表格里每帧间隔都是 33.3 毫秒,一毫秒不差——因为这个片子的时间基 1/15360 与 30 fps 正好整除。

换成 29.97 fps 就没这么巧了:每帧要 512.51 个刻度,而时间戳只能是整数, 一千帧(约 33 秒)就累计错三十多毫秒。 这就是「每帧固定睡 33 毫秒」这种做法必然出问题的原因。

正确做法是「按绝对时间对齐」:每帧都拿「它的时间戳」减「当前时钟」, 差值决定等多久或丢多少,而不是累加固定时长。 这条规则在第九章会再出现一次,那时候它叫「主时钟」。

30 秒实验

  1. 按上面四步写一遍控制台程序,用 F5 与双击各跑一次,比较工作目录的差别。
  2. 把 is_playing 换成 get_state,看暂停时循环还会不会退出。
  3. 把 new_from_media 换成 new + set_media,比较两种写法的释放语句。

常见误区

  1. 写完四步就 return。 主线程退出,进程销毁,播放器跟着死。必须有等待循环。
  2. 用 is_playing 当退出条件。 暂停时它也是 false,循环会提前退出。应该判 Ended 与 Error。
  3. 用相对路径。 F5 与双击的工作目录不同,同一句代码在两种运行方式下结果不同。
自测三问(点开对答案) 1. 控制台版为什么要有一个 Sleep 循环? —— 因为 play 是异步的,主线程一退出进程就销毁,播放器跟着死。循环让进程活到播放结束。 2. 为什么用 `is_playing` 当退出条件不严谨? —— 暂停时它也是 false。正确的判据是状态变成 Ended 或 Error。 3. PyAV 版与 LibVLC 版差在哪一步之后? —— 拿到帧之后。LibVLC 自己建窗口、自己同步、自己显示;PyAV 只到「拿到帧」,后面全靠你写。

控制台版能跑了,但它开的是 VLC 自己的窗口。下一节把它嵌进 MFC 的对话框里——靠的是一个窗口句柄。

相关笔记