在博客中优雅地引入音乐播放器

播放器

控件

Hugo 把 data/music/generated.json 写入前端,入口位于 layouts/partials/footer/custom.html

1
2
3
4
5
6
<script>
    window.__ELAINA_MUSIC_PLAYLIST__ = {{ .Site.Data.music.generated | default (slice) | jsonify | safeJS }};
</script>
{{ with resources.Get "js/music-player.js" }}
<script src="{{ .RelPermalink }}" defer></script>
{{ end }}

播放器主体位于 assets/js/music-player.js。脚本创建一个原生音频对象,再把封面、曲名、歌手、进度条和控制按钮拼成固定在页面右下角的控件:

1
2
3
4
5
6
7
8
const PLAYBACK_MODES = ["list", "random", "repeat-one"];
const audio = new Audio();
audio.preload = "metadata";

const root = document.createElement("section");
root.id = "elaina-music-player";
root.className = "music-player";
document.body.append(root);

模式按钮会在列表循环、随机播放和单曲循环之间切换。歌单面板平时收起,点击封面或列表按钮后再展开,站内页面切换时播放器节点不会进入 PJAX 的替换区域。

悬浮播放器与展开后的歌单

{{ .Site.Data.music.generated | default (slice) | jsonify | safeJS }} 会把数据文件转成前端可用的数组。重新生成歌单后,不需要再到播放器脚本里逐首修改 nameartisturlcover

样式集中在 assets/scss/music-player.scss,再由 assets/scss/custom.scss 引入:

1
@import "music-player.scss";

明暗模式共用同一套结构,颜色则继续取 Stack 的卡片、正文和强调色变量,不会在暗色页面里突然出现一块白色控件。

歌单

歌单保存在 data/music/generated.json,由 scripts/sync_music.py 生成。

每首本地歌曲使用一个目录,例如音频放在 static/music/歌曲名/music.mp3,封面放在 static/music/歌曲名/cover.jpg

脚本会扫描 static/music,找到每个目录里的 music.mp3music.m4amusic.oggmusic.flacmusic.wav,再配上 cover.jpgcover.png 等封面,最后生成播放器读取的数组。

在站点根目录运行:

1
python scripts\sync_music.py local

本地目录与 Bilibili 收藏夹使用同一个脚本整理,生成的数据结构一致。

Bilibili 同步

Bilibili 收藏夹地址放在 data/music/sources.json

1
2
3
4
5
{
  "bilibili": {
    "favlist_url": "https://space.bilibili.com/<你的UID>/favlist?fid=<收藏夹ID>&ftype=create"
  }
}

运行同步命令:

1
python scripts\sync_music.py bilibili

脚本通过 Bilibili 收藏夹 API 读取视频列表,并从收藏夹 URL 中解析 fid

命令行也可以只传 media_id

1
python scripts\sync_music.py bilibili --media-id <收藏夹ID>

每个视频依次完成三项处理:

  • 读取视频详情,并按 BV 号建立本地目录。
  • 调用 yt-dlp 下载音频。Bilibili 音频通常保存为 music.m4a,不需要额外依赖 ffmpeg 转码。
  • 在音频目录中写入 info.json,保存播放器需要的元数据,再扫描 static/music 生成 data/music/generated.json

非公开收藏夹需要传入 Cookie:

1
python scripts\sync_music.py bilibili --cookie-file bilibili.cookie.txt

bilibili.cookie.txt 只保存在本地,不提交到仓库。

播放状态

整页刷新后,原生 Audio 也会重新创建,因此脚本用 localStorage 保存歌曲下标、播放时间、暂停状态、音量、面板展开状态和播放模式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const STORAGE_KEY = "elainaMusicPlayerState";

function saveState() {
    const state = {
        index: currentIndex,
        currentTime: Number.isFinite(audio.currentTime) ? audio.currentTime : 0,
        paused: audio.paused,
        volume: audio.volume,
        expanded: isExpanded,
        mode: playbackMode
    };
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}

初始化时读出保存的数据,并把播放进度暂存在 pendingSeek。音频元数据加载完成后才能取得总时长,loadedmetadata 事件负责恢复进度:

1
2
3
4
5
6
7
audio.addEventListener("loadedmetadata", () => {
    if (pendingSeek > 0 && Number.isFinite(audio.duration)) {
        audio.currentTime = Math.min(pendingSeek, Math.max(audio.duration - 0.25, 0));
    }
    pendingSeek = 0;
    updateProgress();
});

播放、暂停、切歌、调节音量和关闭页面时都会更新这份状态。localStorage 处理整页刷新,PJAX 处理站内跳转。

PJAX

页面切换

普通网页跳转时,浏览器会重新请求 HTML,重新解析 head 和 body,重新执行脚本。对于播放器来说,这意味着旧的 Audio 对象和控件都会消失,新页面只能重新创建一套。

PJAX 拦截站内链接,通过 Ajax 请求新页面,只替换指定区域。浏览器不会整页刷新,位于替换区域外的播放器可以继续播放。

PJAX 代码位于 layouts/partials/footer/custom.html。库通过 Hugo Pipes 引入:

1
<script src="https://cdn.jsdelivr.net/npm/pjax/pjax.min.js"></script>

实例配置如下:

1
2
3
4
5
6
var pjax = new Pjax({
    selectors: [
        ".main-container", ".js-Pjax", ".Timer"
    ],
    cacheBust: false
})

selectors 指定 PJAX 切换页面时替换的 DOM。

.main-container 是 Stack 的主体布局容器,包含正文与左右侧栏,也是页面切换时的主要替换区域。

.js-Pjax 主要用于评论脚本相关区域。因为评论是外部脚本生成的,如果不把它纳入 PJAX 的更新范围,页面切换后评论区很容易残留旧状态。

.Timer 和运行时间区域有关。页面里使用的是 <div class="Timer">,因此选择器前面的点不能省略;写成 Timer 会被当成自定义标签名,切页后这部分便不会正确替换。

body class

Stack 主题的不同页面会给 body 设置不同 class,例如文章页和搜索页的 class 不完全相同。如果 PJAX 只替换 .main-container,而不更新 body.className,那么页面样式可能会串。

代码通过 handleResponse 同步新页面的 body.className

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
pjax._handleResponse = pjax.handleResponse;

pjax.handleResponse = function (responseText, request, href, options) {
    if (request.responseText.match("<html")) {
        if (responseText) {
            let newDom = new DOMParser().parseFromString(responseText, 'text/html');
            let bodyClass = newDom.body.className;
            document.body.setAttribute("class", bodyClass)
        }
        pjax._handleResponse(responseText, request, href, options);
    } else {
        // handle non-HTML response here
    }
}

新页面的 HTML 会先被解析成 DOM,读取其中的 body.className 并同步到当前页面,再交还给 PJAX 处理响应。

同步 body.className 后,搜索页、文章页与普通页面之间切换时仍会使用各自的页面样式。

顶部进度条

顶部进度条负责显示 PJAX 请求状态。

进度条脚本位于 assets/js/topbar.min.js,通过 Hugo Pipes 在 footer/custom.html 中引入:

1
2
3
{{ with resources.Get "js/topbar.min.js" }}
<script src={{ .Permalink }}></script>
{{ end }}

脚本监听 PJAX 事件:

1
2
3
4
5
6
7
document.addEventListener('pjax:send', () => {
    topbar.show();
})

document.addEventListener('pjax:complete', () => {
    topbar.hide();
})

进度条不计算真实加载百分比,只在请求开始时显示、完成时隐藏,用于反馈 PJAX 页面切换状态。

组件初始化

PJAX 只替换 HTML,不会触发 window.onloadDOMContentLoaded 与主题的一次性初始化逻辑。

pjax:complete 中统一处理这些组件:

 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
function cleanPjaxTimestamp(rawUrl) {
    const url = new URL(rawUrl, window.location.origin);
    url.searchParams.delete('t');

    if (url.hash.includes('?')) {
        const [anchor, query] = url.hash.split('?');
        const params = new URLSearchParams(query);
        params.delete('t');
        const remaining = params.toString();
        url.hash = remaining ? `${anchor}?${remaining}` : anchor;
    }

    return url.toString();
}

document.addEventListener('pjax:complete', () => {
    const cleanUrl = cleanPjaxTimestamp(window.location.href);
    if (cleanUrl !== window.location.href) {
        history.replaceState(null, '', cleanUrl);
    }

    renderKaTeX();
    window.Stack.init();
    topbar.hide();
})

cleanPjaxTimestamp() 删除普通查询参数以及误挂在锚点后的 ?t=...,避免文章内链接留下 /p/example/#section?t=...renderKaTeX() 渲染新文章中的公式,window.Stack.init() 重新绑定菜单、图片灯箱、平滑滚动、目录 scrollspy、搜索与代码块按钮,全部完成后再隐藏顶部进度条。

组件重载

KaTeX

KaTeX 的原始 partial 在 layouts/partials/article/components/math.html。里面会引入 KaTeX,并在页面加载时执行:

1
2
3
4
5
6
7
8
9
renderMathInElement(document.body, {
    delimiters: [
        { left: "$$", right: "$$", display: true },
        { left: "$", right: "$", display: false },
        { left: "\\(", right: "\\)", display: false },
        { left: "\\[", right: "\\]", display: true }
    ],
    ignoredClasses: ["gist"]
});

模板通过一个标记告诉 PJAX 当前页面是否需要公式渲染:

1
<div class="math-katex"></div>

footer/custom.html 中的 renderKaTeX 负责执行渲染:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
async function renderKaTeX() {
    let katex = document.querySelector(".math-katex");
    if (!katex) {
        return;
    }

    while (typeof renderMathInElement !== 'function') {
        await delay(500);
    }

    renderMathInElement(document.body, {
        delimiters: [
            { left: "$$", right: "$$", display: true },
            { left: "$", right: "$", display: false },
            { left: "\\(", right: "\\)", display: false },
            { left: "\\[", right: "\\]", display: true }
        ],
        ignoredClasses: ["gist"]
    });
}

while 等待 KaTeX 脚本加载完成,避免 renderMathInElement 尚未定义时直接调用。

搜索

搜索逻辑位于 assets/ts/search.tsx,初始化函数可以由 Stack.init() 重复调用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
function searchInit() {
    let search = document.querySelector('.search-result');
    if (search) {
        const searchForm = document.querySelector('.search-form') as HTMLFormElement,
            searchInput = searchForm.querySelector('input') as HTMLInputElement,
            searchResultList = document.querySelector('.search-result--list') as HTMLDivElement,
            searchResultTitle = document.querySelector('.search-result--title') as HTMLHeadingElement;

        new Search({
            form: searchForm,
            input: searchInput,
            list: searchResultList,
            resultTitle: searchResultTitle,
            resultTitleTemplate: window.searchResultTitleTemplate
        });
    }
}

export {
    searchInit
}

assets/ts/main.ts 导入并调用该函数:

1
2
3
4
5
6
7
8
9
import { searchInit } from "ts/search";

let Stack = {
    init: () => {
        menu();
        searchInit();
        new StackColorScheme(document.getElementById('dark-mode-toggle'));
    }
}

每次执行 window.Stack.init() 都会重新检查搜索框。普通文章页没有 .search-result,函数会直接返回。

评论

Giscus 依赖外部脚本 https://giscus.app/client.js,PJAX 局部替换页面时需要重新注入。

layouts/partials/comments/include.html 提供页面标记:

1
2
3
4
{{ if .Site.Params.comments.enabled }}
    {{ partial (printf "comments/provider/%s" .Site.Params.comments.provider) . }}
    <div class="comment"></div>
{{ end }}

footer/custom.html 跳过搜索页,再检测 .comment

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function shouldSkipGiscus() {
    return document.body.classList.contains("template-search");
}

if (shouldSkipGiscus()) {
    return;
}

let comment = document.querySelector(".comment");
if (!comment) {
    return;
}

搜索页不注入评论区。

存在评论容器时,脚本根据当前明暗模式创建 Giscus:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function getGiscusTheme() {
    return document.documentElement.dataset.scheme === "dark"
        ? '{{- default `dark_dimmed` .darkTheme -}}'
        : '{{- default `light` .lightTheme -}}';
}

let script = document.createElement('script');
script.setAttribute('src', 'https://giscus.app/client.js');
script.setAttribute('data-repo', '{{- .repo -}}');
script.setAttribute('data-repo-id', '{{- .repoID -}}');
script.setAttribute('data-category', '{{- .category -}}');
script.setAttribute('data-category-id', '{{- .categoryID -}}');
script.setAttribute('data-theme', getGiscusTheme());
script.async = true;

初次加载与 PJAX 完成后都执行:

1
2
3
4
5
injectGiscusScript();

document.addEventListener('pjax:complete', function () {
    injectGiscusScript();
});

切换文章后,评论区会加载当前页面对应的 Giscus discussion。

页脚统计

页面切换时 footer 不一定完整刷新,因此 updateRunningDays() 在初次加载和 pjax:complete 后都会执行:

1
2
3
4
5
updateRunningDays();

document.addEventListener('pjax:complete', function () {
    updateRunningDays();
});

访问量统计用的是 Vercount,脚本是:

1
<script defer src="https://cn.vercount.one/js"></script>

文章信息区的浏览量标签在 layouts/partials/article/components/details.html 中:

1
2
3
4
5
6
<div id="viewCount">
    {{ partial "helper/icon" "eye" }}
    <time class="article-time--reading">
        <span id="vercount_value_page_pv">loading... </span>    </time>
</div>

showHideView() 会在非文章页隐藏浏览量,并在 PJAX 完成后重新检查当前页面类型。

初始化约束

代码块在第一次处理后写入 data-enhanced="true",播放器通过固定 ID 和 window.ElainaMusicPlayer 避免重复创建。所有由 Stack.init() 重载的组件都需要类似的幂等标记,否则多次切页后会出现重复按钮或重复事件。

topbarPjaxrenderMathInElementwindow.Stack 必须在调用前完成加载。相关脚本集中放在 footer,需要异步加载的函数会等待依赖出现。