用瀏覽器聽歌。搜尋、播放、收藏、下載,不用裝 App。
部署步驟 → DEPLOY.md | 現成的部署包 → Releases
一個跑在瀏覽器裡的音樂播放器。播放器與音源徹底分開:前端不認識任何音源,只透過 一層插件介面問「給我一個可播的 URL」,音源自己去處理搜尋、扇出、救援、簽名。
app 出廠不帶任何音源 —— 開啟後要到「設置」頁貼上網址安裝,才能搜尋與播放。 這是刻意的(見下)。
插件是一支 CommonJS 檔案,module.exports 出幾個方法就是一個音源;載入時跑在
new Function 的沙箱裡。這個 repo 不含任何音源 —— 音源由使用者自己提供。
沙箱刻意很小:只給 fetch、計時器、URL、btoa/atob 與 console,沒有 window、
沒有 document、沒有 localStorage,也不提供任何 npm 模組 —— 插件要什麼自己用
原生 fetch 去拿,需要跨域代抓時打 /api/proxy?url=<目標>。要求模組會直接拋錯並
說明原因,而不是回一個空物件讓插件在後面某處莫名炸掉。
對外是一個音源,底下並發扇出到兩個子音源後交錯合併、同名同歌手去重:
| 子音源 | 覆蓋範圍 |
|---|---|
netease |
簡體華語曲庫最完整,附歌詞與封面 |
joox |
港台繁體、粵語與 live 版本多 |
兩者都經一個公開 API 取得,由瀏覽器直連、不經本站後端。這樣上游的 IP 限流是 各使用者各自計算,而不是全站共用一個出口;音源也因此不依賴任何特定後端,同一支 插件貼到任何一份 whymusic 都能用。上游位址寫在音源檔裡,換一家 只要改那支檔案。
跨子源救援:單一子源取不到音源時,會用歌名+歌手到其餘子源找同一首歌。比對前 做繁簡歸一化,所以查「浮誇」也能命中簡體源的「浮夸」。
播不出來時換子源:音源給了 URL 但瀏覽器實際播不出來(CDN 對該地區回 403、 容器格式不支援…)時,前端會把該子源排除後請音源換一個再試同一首歌。這種失敗 只有播放端知道 —— 音源那邊只知道「解析成功」。
這個專案不隨附音源,產物與 repo 裡都沒有任何音源檔。 安裝方式只有一種: 到「設置」頁貼上你自己的音源網址。那個網址要能被瀏覽器抓到(同源、或對方送 CORS 標頭)。
安裝時會把程式碼整份存進瀏覽器的 localStorage,之後執行時不再向外抓 —— 換句話說 裝完就與那個網址無關了。代價是它不會自動更新:音源改版後要在「設置」頁重新貼一次。
改音源邏輯不必改前端程式碼。第三方插件同樣是貼網址安裝;外部網址由後端經
/api/proxy 代抓,你的瀏覽器不必連得到那個託管站。
前端不直接呼叫任何音源 API,全部經插件介面:
| 功能 | 插件方法 |
|---|---|
| 搜尋(歌曲) | search(query, page, type) |
| 推薦 | getRecommend(mode, limit) — 本專案擴充的方法 |
| 播放 / 下載 | getMediaSource(item) |
| 歌詞 / 封面 | getLyric / getMusicArtwork |
play() 裡沒有任何平台名稱的判斷 —— 它只問音源要一個可播的 URL 然後播。要不要
跨源救援、要不要簽名、音質怎麼選,全是插件的事,加新音源不必改前端。
因此沒裝音源時整個 app 沒有內容:推薦與搜尋都顯示「需要音源」。插件未實作某個
方法時回 null,UI 會明確說「此音源不支援」,而不是顯示空清單讓人以為壞了。
前端剩下的兩個 /api/ 呼叫都不是音源 API:/api/proxy(跨域代抓)與
沒有任何供應音源的端點 —— 音源不由本站提供。
曲目右邊的心心加入收藏,「收藏」頁列出全部並依序播放 —— 那是自己一首一首挑 出來的清單,順序有意義。
收藏存在瀏覽器本機(localStorage),所以綁裝置。要搬到別處有兩條路:
匯出 / 匯入歌單(「設置」→ 歌單)。匯出成 Markdown:
# WhyMusic 收藏
匯出時間:2026-08-19 12:49
共 2 首
1. 月亮代表我 — moon tang
2. 等一等 — The Hertz任何文字編輯器、筆記軟體、聊天視窗都打得開。檔尾另藏一段 HTML 註解裡的 JSON —— Markdown 算繪時看不見,但匯入本站時能精確還原(含 id 與子音源),不必逐首重新搜尋。
匯入也吃任何純文字清單(一行一首「歌名 - 歌手」),會逐首用音源搜尋比對,
找不到的會明確列出來。分隔符接受破折號、連字號、tab 與 by,編號和項目符號會自動
剝掉,上限 200 行。
換裝置同步(「設置」→ 換裝置)。產生一組 8 碼配對碼,另一台裝置輸入即可套用 目前安裝的音源,24 小時後失效。沒有帳號也不存任何個人資料。只同步音源,收藏請用 歌單匯出。
需要後端有儲存(CF 用 KV、自架用檔案系統),沒有就整區隱藏。
香港粵語流行榜,兩種排序:最新(榜單原順序)與熱門(依熱度,同熱度以發行 時間新者優先)。分類對應哪份榜單由音源決定並自報,前端不寫死。
播放器上的按鈕循環切換三種模式,選擇記在 localStorage:
| 圖示 | 模式 | 行為 |
|---|---|---|
| 🔁 | 自動續播(預設) | 清單依序;推薦頁隨機(千首榜單依序播會永遠繞在前幾首) |
| 🔂 | 單曲循環 | 用 audio 原生 loop,重播不必重新解析音源,沒有可聽出來的空隙 |
| ➡️ | 播完即停 | — |
自動續播撞到播不出來的歌會跳過它繼續,連續失敗 8 首才收手。使用者自己點的那首 失敗時仍會彈窗告知 —— 那是他明確選的,不該默默跳走。
播放器用兩個 audio 元素輪替:下一首在前台就先載進閒置的那一個,換歌時只對一個
已經載好的元素呼叫 play(),不動 src、不碰網路。在背景換 src 會讓音訊工作階段
失效,那正是「鎖屏播到一半就沒聲音」的成因。
同時註冊 MediaSession,鎖定畫面/通知欄/耳機按鈕都能控制。
平台差異(實測):
- Android 建議「加到主畫面」裝成應用。部分國產 ROM 只給已安裝的應用完整的鎖屏 媒體控制待遇;鎖屏看不到控制項時,先檢查該 ROM 的鎖屏通知顯示設定與瀏覽器的 後台活動權限 —— 那些是系統設定,網頁沒有 API 可以覆寫。
- iOS 建議直接用 Safari,不要用桌面應用。iOS 的獨立模式(standalone)對背景音訊 支援很差,鎖屏會播不下去。因此 iOS 上不會進入獨立模式,「加到主畫面」只會是一個 開 Safari 的捷徑。
MediaSession 需要 secure context,所以純 HTTP 的部署(例如沒配憑證的 VPS)不會有 鎖屏控制。
預設是蘋果平面風的深色 UI(大標題、分段控制、毛玻璃底欄、單一強調色)。
舊版的藍色漸層介面仍在 packages/web/src/ui/ClassicUI.tsx,沒有切換按鈕,
需要時可設 localStorage.setItem('musicfree-ui', 'classic') 切回去。
兩套 UI 共用同一份 useMusicApp() hook,換皮不必動任何音源或播放邏輯。
「設置」頁底部顯示前端與後端各自的建置戳記。兩者應一致 —— 不一致代表只部署了 一半(例如前端上去了但後端沒有),而不是快取問題。
| Cloudflare Pages | VPS / 自建 | |
|---|---|---|
| 費用 | 免費 | 一臺 VPS |
| 功能 | 完整 | 完整(純 HTTP 時沒有鎖屏控制) |
| 配對碼儲存 | KV binding | 檔案系統 |
| 上游快取 | 只在單一 isolate 內 | 全站共用 |
pnpm install
pnpm deploy:cf # 部署到 Cloudflare Pages不想裝任何工具的話,Releases 有現成的 zip,直接拖進 Cloudflare 儀表板就能部署。自己產一份:
pnpm build:zip # → dist-cf/musicweb-cf.zip詳細步驟見 DEPLOY.md。
pnpm install
pnpm dev # 前端 dev server(API 需另外跑自架後端)
pnpm build # 編譯前端
pnpm build:cf # 前端 + _worker.js(不含音源),戳記只算一次傳給兩邊
pnpm typecheck自架後端(同時服務前端與 API):
node packages/web/scripts/server.mjs # 預設 :8788musicweb/
│ └── whymusic.js
├── packages/
│ ├── core/ # 播放器 + 插件管理器(型別與沙箱)
│ └── web/
│ ├── src/
│ │ ├── musicApp.ts # 所有狀態與行為(兩套 UI 共用)
│ │ ├── App.tsx # 外殼:取狀態、決定套哪張皮
│ │ ├── main.tsx # 掛載 + service worker 註冊策略
│ │ ├── ui/AppleUI.tsx # 預設介面
│ │ ├── ui/ClassicUI.tsx # 舊介面(保留)
│ │ └── core/ # Player(雙元素)/ PluginManager / 插件沙箱
│ ├── shared/sync.js # 配對碼規則(兩個後端共用)
│ ├── worker/ # Cloudflare 版後端
│ │ ├── index.js # 路由(打包成 dist/_worker.js)
│ │ └── why.js # 音源邏輯(後端側,插件的備援路徑)
│ ├── scripts/server.mjs # 自架後端(Node,零外部依賴)
│ ├── public/ # logo / favicon / manifest / sw.js
│ └── wrangler.toml # Pages 設定與 KV binding
├── scripts/
│ ├── build-cf.mjs # 完整建置(戳記只算一次傳給前端與 worker)
│ ├── build-stamp.mjs # 建置戳記
│ ├── build-worker.mjs # 打包 worker → dist/_worker.js
│ └── build-cf-zip.mjs # 產出可拖拉上傳的 zip
├── .env.example
└── DEPLOY.md
| 層 | 技術 |
|---|---|
| 前端 | React 18 + TypeScript + Tailwind CSS |
| 核心 | 自製插件系統(PluginManager + 雙元素 Player + new Function 沙箱) |
| CF 後端 | Cloudflare Workers(單一 _worker.js,esbuild 打包)+ KV |
| 自架後端 | Node.js(零外部依賴,只用內建模組) |
MIT