
为什么要重构
博客左下角原本的悬浮音乐播放器是「唱片 + 点击弹出大面板」的结构。功能是够用的,但两个形态之间的视觉关联很弱——唱片在左下角,面板飘在上方,看起来像两个不相干的组件。
参考 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 层,播放逻辑零侵入。
分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
换条路线
从其他文章中稳定抽取
最后更新于 ,距今已过 1 天
部分内容可能已过时