暂未播放
0:00
0:00

Sonic Topography 改造全记录:从 Electron 桌面应用到纯静态网页

2929 字
15 分钟
Sonic Topography 改造全记录:从 Electron 桌面应用到纯静态网页

上次弄完博客里的音乐页面后才发现,那个页面也是从 Sonic Topography 获取灵感改造的,做完感觉比原版还要炫酷一些,那还说啥了,继续折腾。
Sonic Topography 原本是个 Electron 桌面应用,这次把它整个搬进浏览器。

演示地址: music.x1anyu.cn

一、从 Electron 砍到纯静态#

砍掉的三层#

动手前项目里有三块桌面专属的东西:desktop/(Electron 主进程、窗口、菜单)、server/(本地后端,干网易云/QQ 反代和 Cookie 转发的活)、local-server.mjs(开发态本地服务)。连 package.json 里的 electron 依赖和打包脚本一块删掉,项目立马轻了一截。

vite.config.ts 里原来挂了个 neteaseApiPlugin,开发态把网易云接口代理到本地后端。删掉它,vite 的 plugins 就剩 react()tailwindcss(),成了一次纯粹的前端构建,开发态不再有后端。

三个决策#

第一个是 base: './'。桌面端资源都在本地,无所谓;但纯网页常常被丢到子目录或对象存储的某个路径下,构建产物要是写死 /assets/xxx 这种绝对路径,一放非根目录就整页 404。把 vite 的 base 设成 './',产物全走相对路径,dist/ 扔哪都能跑——子目录、对象存储、EO Pages 都行。

第二个是状态。后端没了,歌单和设置没地方放,最顺手的去处就是浏览器 localStorage(musicApi.ts + uiStorage.ts,歌单 key 是 sonic-topography-playlists-v1)。好处是零后端、打开即用,代价是状态全绑在这台浏览器上——换设备、清缓存,歌单就没了。

第三个是音源,换成 Meting。网页端其实只走两路:Meting 接口(默认 tencent,就是 QQ 音乐)+ 本地文件上传。启动时默认加载一个 Meting 歌单(DEFAULT_METING_PLAYLIST_ID = '7991874132')并放第一首,免得进来是张空白页。

二、Meting 对接与封面跨域污染#

为什么是 Meting#

实际对接的是初叶 Meting(metowolf 的衍生版),不是 metowolf 原版,接口细节有点差别,下面写的都是实测过的。它响应统一带 Access-Control-Allow-Origin: *,配合 AudioEnginecrossOrigin="anonymous",音频能播也能拿去做频谱可视化,正好补上「没登录态也能播」这个缺口。

接口对接#

基址是 /api,不是根路径。初叶的 base 长 https://meting.yufish.cn/api 这样,解析路径是 /?server=&type=&id=。漏掉 /api 直接 404。前端 metingApi.ts 里的 METING_BASE 一定得带这个后缀。

返回字段是 title / author / url / pic / lrc。注意 url / pic / lrc 这三个不是真实资源的直链,而是接口端点,各自再 302 跳到真实资源。

  • type=lrc 返回的是纯文本歌词(不是 JSON)。
  • type=url 返回的是音频端点,302 跳到真实音频 CDN,同样带 Access-Control-Allow-Origin: *,所以音频可视化没问题。

地址解析有优先级。运行时可覆盖的 Meting 地址按这个顺序取:localStorage sonic-topography:meting-apiwindow.__SONIC_METING_API__public/meting-config.jsonbase → 环境变量 VITE_METING_API。部署时填个自建地址就能切走公共实例。默认搜索源设成 'meting'

封面跨域污染#

状态、构建、音源都顺了,下一个坑出在 3D 背景封面上。它时好时坏,特别容易误判。

pic 返回的是接口端点,真实图片要 302 跳到腾讯图片 CDN(y.gtimg.cn / y.qq.com,偶尔 qpic.cn)。这些 CDN 不返回 Access-Control-Allow-Origin。后果分两种:

  • 播放条封面用的是 <img>。浏览器加载 <img> 不读像素,跨域限制不拦它,所以播放条封面一直正常显示,这也是我一开始没察觉的原因。
  • 3D 背景封面用的是 THREE.TextureLoader,WebGL 贴图要读像素。CDN 没 CORS 头,纹理被标成跨域污染,加载失败,背景封面就不显示。

同一张图,一个地方好使一个地方坏。根子就在 CORS 污染:<img> 能看不能读,WebGL 既要看又要读。

修法是在交给 TextureLoader 之前,把已知缺 CORS 的图床主机先包一层图片代理:

function resolveCoverUrl(pic?: string): string | undefined {
if (!pic) return undefined;
// 腾讯图床不返 CORS,WebGL 读像素会被污染 → 走图片代理补 CORS
const noCorsHosts = ['y.gtimg.cn', 'y.qq.com', 'qpic.cn'];
if (noCorsHosts.some(h => pic.includes(h))) {
const proxy = localStorage.getItem('sonic-topography:meting-pic-proxy')
|| 'https://proxy-api.x1anyu.cn/pic/?url=';
return proxy + encodeURIComponent(pic);
}
return pic;
}

自建的 edgeone /pic 图片代理(相当于 weserv 的角色,但跑在广州节点、同源可控)会返回 Access-Control-Allow-Origin: *,污染就解了。代理默认就是它,也能经 localStorage sonic-topography:meting-pic-proxy 改回 weserv 或任何自建端点。

三、移动端适配#

断点怎么定#

上游只有一条 CSS 断点 @media (max-width: 600px),压根没有 JS 侧的 isMobile。第一件事就是加一个 JS 媒体查询,在组件里分流布局:

const MOBILE_MQ = '(max-width: 768px), (pointer: coarse)';
const isMobile = typeof window !== 'undefined'
? window.matchMedia(MOBILE_MQ).matches
: false;

pointer: coarse 是关键——它盯的是「主输入设备是不是粗指针(手指)」,不是屏幕宽度。触屏笔记本、平板会命中,普通桌面显示器不会。这样切出来的 isMobile 在真机上才准,不会误伤那些逻辑分辨率卡在 768 附近、实际是鼠标 + 触屏混用的设备。

顺带把 CSS 断点从 600px 提到 768px,跟这条 JS 断点对齐。不然 JS 判 isMobile 和 CSS 改样式会在同一台设备上各判各的,出现「JS 当手机、CSS 当桌面」的错位。

思路分叉:isMobile 分支 vs 纯 CSS 覆盖#

确定哪些地方是手机布局、哪些是桌面布局之后,有两种做法:

  • A. CSS 覆盖:同一套 JSX,用 @media 把桌面样式改写成手机样式。
  • B. JSX 里 isMobile ? <手机布局> : <桌面布局> 直接分流。

我一开始想走 A,少写重复结构。撞到的第一个坎是 Tailwind v4 下,某些 transform 类用 CSS 覆盖不掉。具体说,桌面端播放条某个元素用了 translate-x-* / -translate-x-full 这类工具类,我在 @media (max-width: 768px) 里写 transform: none !important 想压平,结果没生效。不是 !important 没写,是 Tailwind v4 的生成规则在那个具体类上赢了层叠和内联优先级。

于是下决心走 B:凡是手机和桌面布局差到「要改 transform / 要换元素结构」的地方,直接 isMobile ? ... : ... 在 JSX 层分成两套。桌面那套的 transform 类根本不参与手机渲染,从根上避开覆盖之争。CSS 只留「同一个 DOM、只是间距字号不同」的微调,比如按钮 min-height: 46pxfont-size: 14px 这种纯数值覆盖。

说白了就是:结构或 transform 差异大就 JSX 分支,纯数值微调就 CSS 覆盖。两种混着用最稳,非要只用一种反而麻烦。

最坑的一处:hover 触发条吞掉所有 touch#

桌面端那个左侧菜单的触发,最早是挂在一个「hover 就展开」的整高触发条上(side-nav-trigger / side-nav-trigger-rightabsolute left-0 top-0 h-full + pointer-events-auto),靠 onMouseEnter 展开、onMouseLeave 收起。桌面鼠标 hover 没问题。

到了手机上,这条触发条是一整条 pointer-events-auto 覆盖层,把左缘的 touch 全吞了——点歌单按钮、滑菜单,全没反应,事件根本传不到下面真实的按钮上。

⚠️ 大坑:任何「悬停才出现/才触发」的交互,在手机上必须落地成一次明确的 tap,否则整片覆盖层会吞掉下面所有 touch 事件。

修法:移动端把触发条的 pointer-events 设成 none、给 onMouseEnter 加个 if (isMobile) return,hover 展开在手机上直接失效;真正打开菜单交给左上角齿轮按钮的 onClick 切换 isMobileSideNavOpen

手机端交互#

左侧菜单超长可滚动。菜单项一多,手机竖屏塞不下。原来 justify-center 居中,超长底部就被裁。改成 justify-start gap-6 overflow-y-auto,菜单比屏长就自己滚,不再丢项。

播放条按钮均分。手机播放条独立一行,8 个按钮(菜单 / 循环 / 上一首 / 播放 / 下一首 / 歌词 / 主题 / 音量)用 flex 均分,触摸目标够大。音量不是常驻滑块,是点一下弹个竖向滑块:showMobileVolume 状态 + 一个 writing-mode: vertical-lr; direction: rtl 的竖向 range.vol-vertical),松开再收起来。手机横屏本来就窄,常驻横向音量条太占地方,弹窗更顺手。

手机端歌词默认 3D 环绕#

Sonic Topography 有三种歌词样式:songyancai(默认,2D 时间线)、dynamic-bounce(弹跳)、spatial-wall(3D 环绕,渲染在 Three.js 画布里)。

手机上其实更想要 3D 环绕那种沉浸感。我在 App.tsx 初始化 lyricsSettings 时做了个判断:

import { STORAGE_KEY as LYRICS_SETTINGS_STORAGE_KEY } from './lib/lyricsSettings';
// ...
const stored = readLyricsSettingsStorage();
const isMobileInit =
typeof window !== 'undefined' &&
window.matchMedia('(max-width: 768px), (pointer: coarse)').matches;
const initial =
isMobileInit && !localStorage.getItem(LYRICS_SETTINGS_STORAGE_KEY)
? { ...stored, style: 'spatial-wall' }
: stored;

要点:

  • 只在手机端、且用户从没自定义过歌词样式时,才默认改成 spatial-wall。桌面端沿用改后的全局默认 songyancai。有个容易忽略的点:上游全局默认其实是 spatial-wall,这轮我们把它翻成了 songyancai,再用这条 override 给手机端恢复 3D 环绕。所以「手机端 3D 环绕」严格说是「恢复」不是「新加」。
  • 只改运行时 state,不写 localStorage。用户一旦在设置里手动换过样式就会持久化,之后尊重他的选择;老用户也不会被强制改回。这是「默认」和「强制」的区别。
  • spatial-wall 是在 3D 画布里渲染的,2D 的 LyricsDisplaystyle === 'spatial-wall' 时直接 return null。所以手机端默认开启后,2D 歌词浮层让位给 3D 环绕歌词。

顺带提一个之前留的坑:.lyrics-main-container 那条移动端 CSS 写着「关闭 3D 倾斜避免溢出与变形」。它只作用于 2D 歌词容器的内联 transform,对 spatial-wall(走 3D 画布)完全没影响,所以这次默认开 3D 环绕不用动它。当初写它是为了防止 2D 歌词在窄屏被 transform 撑变形,跟 3D 环绕是两码事。

顺手修的四个小毛病#

重排过程中,几个一直没改好的细节也一起收掉了:

歌名别拼歌手。原来桌面歌名显示成 周杰伦 - 红尘客栈 这种「歌手 - 歌名」合并串。根子是 trackName 状态在加载歌曲时拼成了 ${artist} - ${name},桌面 meta 直接用了它。改法是桌面歌名改用 currentSong?.name(手机端早这么用了),歌手单独走下面那行 currentSong?.artisttrackName 这个组合串我没删,它还给 3D 标题和「无歌时的禁用判断」用,属于无害残留,没必要为了干净把依赖它的地方也改崩。

关掉歌名滚动。原来歌名是跑马灯(MarqueeTitle 组件,两份重复 span + CSS 动画)。桌面空间够,根本不用滚,反而晃眼。直接换成静态 <div className="truncate ...">,超长就省略号,不再动。

左上角齿轮贴边 + 移动端常显。上游品牌齿轮(brand-mark)定位在 top-[88px] left-[56px],而且没有那圈半透明圆——初稿里「原来套了一圈光晕」是我写错了,那圈 rounded-full border border-white/15 bg-black/30 其实是我们加在右菜单按钮的移动端分支上的,不是上游基线。真正改的是齿轮位置:移动端从 top-[88px] left-[56px] 移到 top-[10px] left-[10px] 贴左上角,可见条件从「仅 showLeftIcon」放宽成「移动端永远显示」((displaySettings.showLeftIcon || isMobile) && !(isMobile && isRightSidebarOpen))。要是你觉得右菜单按钮那圈圆角边框碍眼,它现在确实还在,可以一并去掉。

菜单点开关不了。播放条上的菜单按钮,原来 onClick 只干一件事:setIsRightSidebarOpen(true),只能开不能关,再点一次当然没反应。改成 toggle:开着就关,关着就开并切到歌单视图:

onClick={() => {
if (isRightSidebarOpen) setIsRightSidebarOpen(false);
else { setIsRightSidebarOpen(true); setMobileRightView('list'); }
}}

改完再点菜单按钮,开合就正常了。到这里,从桌面到手机,网页版总算能当个正经播放器用了。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
Sonic Topography 改造全记录:从 Electron 桌面应用到纯静态网页
https://x1anyu.cn/posts/13/
作者
羡鱼
发布于
2026-08-29
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
羡鱼
临渊空慕水中鱼, 不如携风自渡河.
分类
标签
最新动态
站点统计
文章
14
分类
7
标签
35
总字数
19,969
运行时长
0
最后活动
0 天前
文章目录