1236 字
6 分钟
给博客左下角的音乐播放器做三态变形:唱片 / 歌词胶囊 / 完整控件

为什么要重构#

博客左下角原本的悬浮音乐播放器是「唱片 + 点击弹出大面板」的结构。功能是够用的,但两个形态之间的视觉关联很弱——唱片在左下角,面板飘在上方,看起来像两个不相干的组件。

参考 Firefly 主题的做法后,我把它重构成了三态一体的 dock:三种形态共享同一棵 DOM 树,唱片本身就是展开形态的一部分,只是位置和周边表面随形态过渡。

三态形态机#

type DockShape = "disc" | "pill" | "bar";
形态内容什么时候出现
disc只有一张可拖动的唱片未播放的静置状态
pill唱片 + 歌词胶囊播放中、鼠标不在控件上
bar信息层 + 工具栏 + 唱片骑缝鼠标悬停,或点击唱片

形态转移规则(照搬参考实现的约定):

  • 未播放:disc ⇄ bar,点击唱片切换,点击控件外收回唱片;
  • 播放中:静置形态由歌词开关决定(开 → pill,关 → disc);
  • 鼠标进入控件任意组件 → 无条件展开 bar;离开 160ms 后收回静置形态;
  • 暂停时 pill 收回 bar
/** 播放中静置形态:歌词开关打开为胶囊,关闭为唱片 */
function dockRestingShape(): DockShape {
return dockLyricsOn ? "pill" : "disc";
}
// 播放状态变化时同步形态
$effect(() => {
if (!playing) {
if (dockShape === "pill") dockShape = "bar";
} else if (dockShape !== "bar" && dockShape !== dockRestingShape()) {
dockShape = dockHover ? "bar" : dockRestingShape();
}
});

bar 态的分层表面#

展开形态不是一张大卡片,而是三层独立表面叠出来的:

┌──────────────────────┐
│ 列表 | 歌词(Tab) │ ← 面板:dock 上方独立一层
│ 封面缩略图 + 当前项高亮 │
├──────────────────────┤
│ 标题 │ ← 信息层(两侧内缩的卡片)
│ 艺术家 0:52 ─●─ 4:07│
│ (唱片) ⏮ ▶ ⏭ 🔊 │ ← 工具栏胶囊,唱片骑在接缝上
└──────────────────────┘

展开/收起的编排是关键:信息层用 grid-template-rows: 0fr → 1fr 过渡高度,工具栏从 scale(0.72) 淡入,面板延迟 120ms 再上浮——三层错峰出现,比整体缩放高级得多。

唱片在三态间的位移也全靠 CSS 过渡:disc 态贴容器左下,pill 态垂直居中进胶囊,bar 态用 bottom: calc(var(--fp-bar-h) - var(--fp-disc) / 2) 让中心正好骑在信息层与工具栏的接缝上。

拖动顺滑:两个实战坑#

坑一:响应式更新导致拖动跟手#

最初每次 pointermove 都更新 Svelte 的 $state,结果拖动明显滞后——每次状态更新都走一遍组件 diff 再写回 style,指针已经走出去几十像素了。

解法是拖动中直接写 DOM,抬起时才提交状态

function onDiscPointerMove(event: PointerEvent): void {
if (!discDragging || event.pointerId !== discPointerId) return;
const dx = event.clientX - discDragStart.px;
const dy = event.clientY - dragStart.py;
if (!discMoved && Math.abs(dx) < 4 && Math.abs(dy) < 4) return;
discMoved = true;
// 拖动中直接写 DOM transform 绕过响应式渲染保证跟手
const next = clampDiscPos(discDragStart.x + dx, discDragStart.y + dy);
if (discEl) {
discEl.style.transform = `translate(${next.x}px, ${next.y}px)`;
}
discPos = next;
}

配合 will-change: transform,实测逐帧采样最大滞后 0px。

坑二:封面图片的原生拖拽打断指针事件#

展开态拖动时前一步正常、之后事件全部消失。用事件监听排查,抓到了这条链:

dragstart → pointercancel → lostpointercapture

唱片的封面是 <img>,浏览器对它启动原生图片拖拽时会取消正在进行的指针拖动。修复三连:

<div ondragstart={(e) => e.preventDefault()} ...>
<img draggable="false" ... />
&__disc img
-webkit-user-drag: none
user-select: none

歌词开关的互斥联动#

控件上的歌词按钮控制的是控件自身的胶囊歌词,和屏幕上的悬浮歌词条是互斥的——开一个自动关另一个,双向联动且都持久化到 localStorage:

function toggleDockLyrics(): void {
dockLyricsOn = !dockLyricsOn;
localStorage.setItem(DOCK_LYRICS_KEY, dockLyricsOn ? "true" : "false");
setFloatingOn(!dockLyricsOn); // 互斥:开此关彼
}

胶囊里的文本优先显示当前歌词行,没有活动行时回退到「曲名 - 歌手」。

收尾#

重构完成后:收起是一张会转的唱片,播放中变成「唱片 + 歌词胶囊」,鼠标靠近展开完整控件,移开自动收回;位置可拖动且跨会话记忆;点击外部整体折叠。所有形态共享同一个播放引擎,互斥联动全部落在 UI 层,播放逻辑零侵入。

给博客左下角的音乐播放器做三态变形:唱片 / 歌词胶囊 / 完整控件
https://blog.fqzlr.top/posts/tech/music-dock-three-states/
作者
fqzlr
发布于
2026-09-05
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取