VitePress 自动替换视频为 ArtPlayer

浏览器自带的原生播放器相信大家都用过,总体体验不怎么样。为了提高用户播放体验,我们会引入第三方播放器,比如 DPlayerArtPlayer 等。由于前者一直没有维护,此处我们选型为 ArtPlayer,当然也可以换成其他播放器。

通过本文的方法你可以将 Markdown 中直接使用 <video> 元素插入的视频就可以自动替换成 ArtPlayer,这样可以在提高用户体验的同时保留原始 Markdown 文件对不同软件的兼容。

Markdown 插入视频

对于 VitePress 这种以 Markdown 文件驱动的站点,这里我们可以采用 HTML 的 <video> 元素

1
<video src="movie.mp4" controls width="100%"></video>

或者

1
2
3
4
<video controls width="100%">
  <source src="movie.webm" type="video/webm" />
  <source src="movie.mp4" type="video/mp4" />
</video>

这种通过 HTML 插入视频的方式最为方便,只要查看器支持渲染 Markdown 中的 HTML5 即可。尤其是我们一般写文章用的以浏览器内核(Chromium、Webkit)驱动的 Typora、VS Code、Obsidian 等,直接就可以预览。

还有一种方式是先写一个 Vue 组件,然后在 Markdown 中引用。但这种方式就有点麻烦了,因为通常你的编辑器中是不跑 Vue 的,自己搓的组件一般也无法被编辑器识别,兼容性也不好。

本文要解决的问题是如何给这个插入的视频获得更好的播放体验,因此需要将这个 <video> 元素替换成 ArtPlayer。

ArtPlayer 的加载(编写 Vue 组件)

先 NPM 安装一下:

1
pnpm add artplayer

由于 ArtPlayer 非 Web Component 组件,它的渲染原理是通过 JavaScript 代码在指定 DOM(<div>)中插入播放器的 HTML 元素。

也就是说,使用 ArtPlayer 必须从 JavaScript 创建。没有 <Artplayer> 这个元素。无法实现简单的替换。

我们肯定不想在每个 Markdown 中都写一遍 JavaScript。

好在,这里使用的是 VitePress,支持在 Markdown 中插入 Vue 组件。众所周知,Vue 组件就可以实现自定义元素的效果。这就好办了,我们可以“手搓”一个基本兼容 <video> 元素的 Vue 组件,然后全局引用不就行了?

然后给 ArtPlayer 的 Vue 组件写出来。

因为我们只需要读取视频播放地址和封面,其他信息不重要,因此可以少传入一些属性。

这里使用组合式 API(<script setup>),因为非专业前端选项式 API 看球不懂。 组合式 API 更符合函数式编程的直觉,但获取一些属性就需要调用一些 hooks。

由于我们上面提到的视频文件地址有两种方式进行定义,我们需要都进行兼容:

  1. 直接 <video src="..." />,这样和 Vue 组件 Props 传值 的方式类似,可以直接通过属性读取。
  2. <video><source src="aaa"><source src="bbb">...</video>,这种情况就比较难受,因为里面的那个 <source> 元素涉及到 插槽 Slots ,然后这种方式可以支持多线路。

Vue 使用的是 Virtual DOM 技术,最终生成的 div 是随机的,我们可以使用 Vue 的模板引用 refs 来获取到准确的 DOM,交给 ArtPlayer 进行渲染。

最终代码如下:

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
<!-- src/.vitepress/theme/components/ArtVideo.vue -->
<script setup lang="ts">
import Artplayer from "artplayer";
import { onMounted, onUnmounted, ref, useSlots } from "vue";

const props = defineProps({
  src: {
    type: String,
    required: false,
    default: "",
  },
  poster: {
    type: String,
    required: false,
    default: "",
  },
});

const container = ref<HTMLDivElement>();
let art: Artplayer;

const slots = useSlots();
const sources = ref<{ src: string; type?: string }[]>([]);

if (props.src) {
  sources.value.push({ src: props.src });
}

if (slots.default) {
  const slotContent = slots.default();
  const newSources = slotContent
    .filter((node) => node.type === "source" && node.props?.src)
    .map((node) => ({
      src: node.props!.src,
      type: node.props?.type || undefined,
    }));
  sources.value = [...sources.value, ...newSources];
}

onMounted(() => {
  if (container.value) {
    art = new Artplayer({
      container: container.value,
      url: sources.value[0].src,
      poster: props.poster,
      pip: true,
      setting: true,
      playbackRate: true,
      aspectRatio: true,
      fullscreen: true,
      fullscreenWeb: true,
      mutex: true,
      theme: getComputedStyle(container.value).getPropertyValue(
        "--vp-c-brand-3"
      ),
      quality:
        sources.value.length > 1
          ? sources.value.map((source, index) => ({
              html: source.type || `线路 ${index + 1}`,
              url: source.src,
              default: index === 0,
            }))
          : [],
    });
    art.on("ready", () => {
      art.autoHeight();
    });

    art.on("resize", () => {
      art.autoHeight();
    });
    console.log(art);
  }
});

onUnmounted(() => {
  art?.destroy();
});
</script>

<template>
  <div class="video-container">
    <div ref="container" class="artplayer-app"></div>
  </div>
</template>

<style>
.video-container {
  margin: 1rem 0;
  border-radius: 8px;
  overflow: hidden;
}

.artplayer-app {
  width: 100%;
  height: 400px;
  background: #000;
}
</style>

上面这个组件已经满足本人的使用需求。如果你想要传更多参数对播放器进行控制,也是可以的,反正多传的参数原生组件识别不了就是了。

然后在 Markdown 中试用一下:

1
2
3
4
5
<script setup>
import ArtVideo from ".vitepress/theme/components/ArtVideo"
</script>

<ArtVideo src="movie.mp4" controls width="100%"></ArtVideo>

应该是没有问题的。

引用 Vue 组件

我们不想在每个 Markdown 中都写 JavaScript 引入前面写的 ArtVideo 组件,对于 VitePress 可以全局引用。

https://vitepress.dev/zh/guide/custom-theme#theme-interface

错误示范:

1
2
3
4
5
6
7
8
9
10
// src/.vitepress/theme/index.ts
import Video from "./components/Video.vue";

export default {
  // ...
  enhanceApp({ app }) {
    // ...
    app.component("video", Video);
  },
};

然而没这么简单,当我们尝试全局引入,发现浏览器控制台报错:

1
[Vue warn]: Do not use built-in or reserved HTML elements as component id: video

看来我们不能全局引入名为 video 这种已经存在的元素的组件。因此,这里我们将其重命名为 ArtVideo

1
2
3
4
5
6
7
8
9
10
// src/.vitepress/theme/index.ts
import ArtVideo from "./components/ArtVideo.vue";

export default {
  // ...
  enhanceApp({ app }) {
    // ...
    app.component("ArtVideo", ArtVideo);
  },
};

现在我们在任意 Markdown 中插入 <ArtVideo>,可以发现视频能够正常使用 ArtPlayer 播放了。

由于我们直接通过引入名为 <video> 组件的梦想已经破灭,接下来我们需要让 VitePress 在渲染 Markdown 的时候将 <video> 替换成 <ArtVideo>

我们需要查找相应的 API 完成对 Markdown 内容的替换。假如实在找不到,最后的一招还有正则表达式替换。

VitePress 的 Markdown 渲染

VitePress 在站点配置中给了 Markdown 的渲染配置:

https://vitepress.dev/zh/reference/site-config#markdown

1
2
3
export default {
  markdown: {...}
}

VitePress 使用的是 Markdown-it 这个库作为解析器,将 Markdown 渲染成 HTML 作为 Vue 的 <template>。因此我们需要知道 Markdown-it 的渲染原理,无非就是将 Markdown 解析成抽象语法树(AST),然后再生成 HTML。

可以通过这个 DEMO 的 Debug 选项看到它生成的语法树结构:

https://markdown-it.github.io/

上图是错误示范,这里的 HTML 没有启用。我们要知道 VitePress 的 HTML 解析默认是启用的。

这里我们可以看出来了,它会分别解析开放标签和闭合标签,类型都是 html_inline

那么我们就去翻它的文档。

https://markdown-it.github.io/markdown-it/

结果发现一脸懵,一堆 API,怎么用都不知道。后面在它 GitHub 仓库主页发现还有文档:

https://github.com/markdown-it/markdown-it/blob/master/docs/examples/renderer_rules.md

根据这篇开发者文档,我们就可以轻松编写一个插件,控制这个解析的流程,完成对 HTML 元素的替换。

最后的代码如下:

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
// src/.vitepress/config.mts
import { defineConfig } from "vitepress";

// https://vitepress.dev/reference/site-config
export default defineConfig({
  // ...
  markdown: {
    config: (md) => {
      // https://github.com/markdown-it/markdown-it/blob/master/docs/examples/renderer_rules.md
      // 自定义插件:替换 video 标签
      md.use((md) => {
        // 1. 处理块中的 video 标签
        const defaultHtmlBlockRender =
          md.renderer.rules.html_block ||
          function (tokens, idx, options, env, self) {
            return self.renderToken(tokens, idx, options);
          };

        md.renderer.rules.html_block = function (
          tokens,
          idx,
          options,
          env,
          self
        ) {
          const content = tokens[idx].content;
          // console.debug(`处理 html_block #${idx}:`, content);
          if (content.includes("<video") || content.includes("</video>")) {
            console.debug(`发现 video 标签在 html_block #${idx}`, content);
            return content.replaceAll("video", "ArtVideo");
          }
          return defaultHtmlBlockRender(tokens, idx, options, env, self);
        };

        // 2. 处理内联的 video 标签
        const defaultHtmlInlineRender =
          md.renderer.rules.html_inline ||
          function (tokens, idx, options, env, self) {
            return self.renderToken(tokens, idx, options);
          };
        md.renderer.rules.html_inline = function (
          tokens,
          idx,
          options,
          env,
          self
        ) {
          const content = tokens[idx].content;
          // console.debug(`处理 html_inline #${idx}:`, content);
          if (content.includes("<video") || content.includes("</video>")) {
            console.debug(`发现 video 标签在 html_inline #${idx}`, content);
            return content.replaceAll("video", "ArtVideo");
          }
          return defaultHtmlInlineRender(tokens, idx, options, env, self);
        };
      });
    },
  },
});

现在 Markdown 中直接使用 <video> 元素插入的视频就可以自动替换成 ArtPlayer 了!

通过这个方法,也可以将其他 Markdown 中原生的 HTML 元素自动替换成 Vue 组件,比如 <audio> 替换成 Aplayer 等等。这样可以在提高用户体验的同时保留原始 Markdown 文件对不同软件的兼容。