music, extracted from the platform into its own repository
Everything the plugin is, moved out of officerdev/platform on 2026-08-15 — 41 files, unchanged from the tree they left. manifest.ts identity, one permission, ffmpeg/ffprobe declared api/ the sidecar proxy; the prefix comes from mountPrefix() sidecar/ the whole /api/music contract — indexing, streaming, per-user state db/ music_favorites, _playlists, _playlist_items, _now_playing web/ panels, layout, and the player: engine, bar, lyrics, favourites cliamp/ the second playback path, parked — not working, kept deliberately widgets/ the dashboard widget, parked — plugins cannot contribute widgets assets/ icon.png, the dock tile scripts/ the reindex CLI PLUGIN.md is the design record: what moved, what stayed, what broke, and why. MUSIC_API.md is the contract the phone and tablet apps speak, and the reason the sidecar's HTTP shape is not free to change. ── It does not build here, and that is the point ── The platform resolves `hooks/useClient`, `officerdev`, `officerdb/db` and `@@/*` through the workspace links in its own node_modules. Measured from this directory, outside the platform checkout, every one of them fails to resolve — 7 imports in the backend, ~29 in the frontend. So this repository is the source of truth, not yet a buildable unit. Making it one means the host API becoming something a plugin can depend on rather than something it reaches into. That is the next problem, and having the code here is what makes it unavoidable rather than theoretical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
+363
@@ -0,0 +1,363 @@
|
|||||||
|
# Music API (`/api/music/*`)
|
||||||
|
|
||||||
|
Platform API the mobile app uses to **stream music** and **sync a server-built library index**, so the
|
||||||
|
app no longer pre-downloads whole tracks or walks/ID3-parses the library on-device.
|
||||||
|
|
||||||
|
- **Source of truth for the code:** `plugins/music/sidecar/index.ts` (the `officer-music` sidecar owns
|
||||||
|
all of this; the platform `/api/music/*` route is a transparent auth-ing proxy).
|
||||||
|
- **Music root:** `~/Music` on the server. All `path` values are **home-relative** (e.g.
|
||||||
|
`Music/Albums/AC-DC/[1980] Back in Black/01 Hells Bells.mp3`), identical to `/api/file-browser/raw`.
|
||||||
|
- **`<rel>`:** an album folder path **relative to the `Music` root** (e.g. `Albums/AC-DC/[1980] Back in Black`).
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
Every endpoint is behind the standard user auth. Two ways to pass the JWT:
|
||||||
|
|
||||||
|
- **Header:** `Authorization: Bearer <jwt>` (normal fetches).
|
||||||
|
- **Query:** `?token=<jwt>` — for media elements / native players that can't set headers (audio, images).
|
||||||
|
|
||||||
|
`401` = no token · `403` = invalid/expired token.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Playback — stream a track
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/music/stream?path=<home-relative>&token=<jwt>
|
||||||
|
```
|
||||||
|
|
||||||
|
Byte-range streaming so the player can **seek without downloading the whole file**.
|
||||||
|
|
||||||
|
| Case | Status | Headers |
|
||||||
|
| --------------------- | ------ | --------------------------------------------------------------------------------------------- |
|
||||||
|
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` |
|
||||||
|
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
|
||||||
|
|
||||||
|
- **`X-Audio-Duration`**: track duration in **seconds** (ffprobe-derived). Read this to set the player's
|
||||||
|
duration up front — it's the fix for AVPlayer reporting an _indefinite_ duration on progressively-streamed
|
||||||
|
VBR MP3s. No need to scan the file.
|
||||||
|
- Errors: `400` invalid/missing path · `404` not found · `416` bad range.
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -H "Authorization: Bearer $JWT" -H "Range: bytes=0-1023" \
|
||||||
|
"$BASE/api/music/stream?path=Music/Albums/AC-DC/[1980]%20Back%20in%20Black/01%20Hells%20Bells.mp3" -D -
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Library index — the synced cache
|
||||||
|
|
||||||
|
The server maintains a cache tree that **mirrors the library**, one entry per album folder. The app syncs
|
||||||
|
this instead of walking + ID3-parsing the library itself.
|
||||||
|
|
||||||
|
Each album has a **version stamp `v`** (hash of the album's source files' names/sizes/mtimes + its cover).
|
||||||
|
`v` changes **iff the album's content changed** → it's the whole basis of the diff: _unchanged `v` ⇒ skip_.
|
||||||
|
|
||||||
|
### 2.1 Manifest — one call, whole library
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/music/manifest
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"generatedAt": 1785034701973, // ms; when the index was last built
|
||||||
|
"albums": {
|
||||||
|
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
|
||||||
|
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
|
||||||
|
"Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 },
|
||||||
|
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true },
|
||||||
|
// …
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`404` if the index has never been built (see §3). Entries with **`tracks: 0`** are container folders (e.g. an
|
||||||
|
**artist** folder). An entry with **`disco: true`** is an artist folder that has a discography — fetch its
|
||||||
|
grouping via `/discography` (§2.4). **`videos: N`** (optional) counts video files (concerts, clips) that live
|
||||||
|
directly in that artist/album folder — their per-file metadata is in that folder's `meta.json` (§2.2). A folder
|
||||||
|
may have any mix of `tracks`, `videos`, and `disco`.
|
||||||
|
|
||||||
|
### 2.2 Album metadata
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/music/meta?path=<rel>
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Match: <v>` returns `304`.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"path": "Albums/AC-DC/[1980] Back in Black",
|
||||||
|
"cover": "cover.jpg", // present only if a cover exists
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"file": "01 Hells Bells.mp3", // filename within the album folder
|
||||||
|
"title": "Hells Bells",
|
||||||
|
"artist": "AC/DC",
|
||||||
|
"albumArtist": "AC/DC",
|
||||||
|
"album": "Back in Black",
|
||||||
|
"track": "1",
|
||||||
|
"year": "1980",
|
||||||
|
"durationSec": 312,
|
||||||
|
"lyrics": "lrc", // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
|
||||||
|
},
|
||||||
|
// …
|
||||||
|
],
|
||||||
|
"videos": [
|
||||||
|
// present only for folders that contain video files
|
||||||
|
{
|
||||||
|
"file": "1989 - Seattle.mp4", // filename within the folder
|
||||||
|
"title": "Live Shit: Seattle", // from the container title tag, if any
|
||||||
|
"durationSec": 8130,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"poster": "posters/1989 - Seattle.mp4.jpg", // present when a poster was generated (see §2.3.1)
|
||||||
|
},
|
||||||
|
// …
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All track/video fields except `file` are optional (absent when the tag/stream info is missing). `videos` is
|
||||||
|
omitted entirely when the folder has none.
|
||||||
|
To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byte-range; works for `.mp4`).
|
||||||
|
|
||||||
|
### 2.3 Cover
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/music/cover?path=<rel>
|
||||||
|
```
|
||||||
|
|
||||||
|
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||||
|
Only meaningful when the manifest entry has `"cover": true`.
|
||||||
|
|
||||||
|
#### 2.3.1 Video poster
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/music/poster?path=<rel>&file=<video filename>
|
||||||
|
```
|
||||||
|
|
||||||
|
A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. `file` is
|
||||||
|
the video's filename within `<rel>` (URL-encode it). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404`
|
||||||
|
when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field.
|
||||||
|
|
||||||
|
#### 2.3.2 Lyrics
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/music/lyrics?path=<rel>&file=<track filename>
|
||||||
|
```
|
||||||
|
|
||||||
|
Plain-text body of the track's lyrics; the `X-Lyrics-Format` header is `lrc` (synced, `[mm:ss.xx]`-timestamped)
|
||||||
|
or `txt` (plain). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404` when the track has no lyrics. Only
|
||||||
|
request it when that track's `meta.tracks[]` entry has a `lyrics` field (`"lrc"`/`"txt"`).
|
||||||
|
|
||||||
|
Sources, in precedence order (indexed at build time): an external **`<track basename>.lrc`** > external
|
||||||
|
**`<track basename>.txt`** > **embedded** lyrics in the audio tags (`lyrics` / `lyrics-<lang>` /
|
||||||
|
`unsyncedlyrics`). A `.txt` (or embedded) whose text actually contains `[mm:ss]` lines is served as `lrc`.
|
||||||
|
|
||||||
|
### 2.4 Discography (artist album grouping)
|
||||||
|
|
||||||
|
For artist folders (manifest entry with `"disco": true`), this returns a map of **album folder → release
|
||||||
|
type**, so the player can split an artist's album list into sections (Studio, Live, Compilation, Single, EP…).
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
|
||||||
|
```
|
||||||
|
|
||||||
|
Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"artist": "Anthrax",
|
||||||
|
"albums": {
|
||||||
|
"[1984] Fistful Of Metal": "Studio",
|
||||||
|
"[1985] Armed And Dangerous": "EP",
|
||||||
|
"[1994] The Island Years": "Live",
|
||||||
|
"[1991] Attack Of The Killer B's": "Compilation",
|
||||||
|
// …
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Keys are **album folder names** (`[year] title`) — they map 1:1 to the artist's album folders, i.e. the
|
||||||
|
last path segment of that album's manifest `<rel>`. Group the artist's albums by looking each up here.
|
||||||
|
- **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`,
|
||||||
|
`DJ-Mix`, `Demo`, `Mixtape`, `Bootleg`, `Other` (unknown values pass through as-is). The player defines
|
||||||
|
section order.
|
||||||
|
- An album folder **not present** here has no classification → put it in an "Other"/uncategorized section.
|
||||||
|
- Source of truth is each artist's `_discography.md` (author-maintained); this JSON is derived from it and
|
||||||
|
re-generated whenever that file changes (its `v` bumps independently of the albums' `meta`/`cover`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Building / refreshing the index
|
||||||
|
|
||||||
|
The index is built **on demand** (nothing is pre-built or scheduled). A build is **incremental** — albums
|
||||||
|
whose `v` is unchanged are skipped — and it **prunes** albums removed from the library.
|
||||||
|
|
||||||
|
### 3.1 Trigger
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/music/reindex → returns IndexStatus (running: true)
|
||||||
|
GET /api/music/reindex/status → IndexStatus snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
`IndexStatus`:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"running": true,
|
||||||
|
"startedAt": 1785034701973,
|
||||||
|
"finishedAt": null,
|
||||||
|
"foldersScanned": 45,
|
||||||
|
"albumsBuilt": 12,
|
||||||
|
"albumsSkipped": 3,
|
||||||
|
"tracksIndexed": 320,
|
||||||
|
"videosIndexed": 4,
|
||||||
|
"coversSaved": 12,
|
||||||
|
"postersSaved": 4,
|
||||||
|
"lyricsIndexed": 45,
|
||||||
|
"discographies": 3,
|
||||||
|
"currentPath": "Albums/AC-DC/[1980] Back in Black",
|
||||||
|
"error": null,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Live progress — SSE
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/music/reindex/stream
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Triggers a build if none is running.** Pass `?trigger=0` to **watch only** (subscribe without starting one).
|
||||||
|
- Emits `event: progress` (an `IndexStatus`) throttled to ~200 ms, then a single `event: done` (an
|
||||||
|
`IndexReport`) and **closes** the stream.
|
||||||
|
|
||||||
|
```
|
||||||
|
event: progress
|
||||||
|
data: {"running":true,"foldersScanned":45,"tracksIndexed":320,"albumsBuilt":12,"albumsSkipped":3,"coversSaved":12,"currentPath":"Albums/AC-DC/[1980] Back in Black", …}
|
||||||
|
|
||||||
|
event: done
|
||||||
|
data: {"albums":15,"built":12,"skipped":3,"foldersScanned":45,"tracksIndexed":320,"coversSaved":12,"elapsedSec":37.2,"error":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
`IndexReport` (the `done` payload):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"albums": 15,
|
||||||
|
"built": 12,
|
||||||
|
"skipped": 3,
|
||||||
|
"foldersScanned": 45,
|
||||||
|
"tracksIndexed": 320,
|
||||||
|
"coversSaved": 12,
|
||||||
|
"discographies": 3,
|
||||||
|
"elapsedSec": 37.2,
|
||||||
|
"error": null,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Recommended resync algorithm (app side)
|
||||||
|
|
||||||
|
Keep the last `manifest.albums` you synced. On resync:
|
||||||
|
|
||||||
|
1. `GET /api/music/manifest`.
|
||||||
|
2. For each `<rel>` in the new manifest:
|
||||||
|
- **new**, or **`v` differs** from your stored copy → fetch `GET /meta?path=<rel>` (+ `GET /cover?path=<rel>`
|
||||||
|
if `cover:true`, + `GET /discography?path=<rel>` if `disco:true`); store them under your local `<rel>/`.
|
||||||
|
- **`v` unchanged** → **skip** (no download).
|
||||||
|
3. For each `<rel>` you have locally that's **absent** from the new manifest → delete it.
|
||||||
|
4. Save the new manifest as your baseline.
|
||||||
|
|
||||||
|
Optionally trigger a fresh server build first via `GET /reindex/stream` (and show progress from its
|
||||||
|
`progress`/`done` events) so the manifest reflects the latest library before you diff.
|
||||||
|
|
||||||
|
Result: a resync after adding one album = 1 manifest fetch + that one album's `meta` + `cover`. Nothing else moves.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-user state — Favorites & Currently-playing
|
||||||
|
|
||||||
|
Unlike everything above (library data served by the sidecar), these are **per-user** and served by the
|
||||||
|
platform straight from Postgres — same `/api/music` prefix and same auth. Keys are opaque paths the app
|
||||||
|
supplies; the server never interprets them:
|
||||||
|
|
||||||
|
| kind | key |
|
||||||
|
| -------- | --------------------------------------------------------------------- |
|
||||||
|
| `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) |
|
||||||
|
| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` |
|
||||||
|
| `artist` | music-rel — `Albums/AC-DC` |
|
||||||
|
|
||||||
|
### Favorites
|
||||||
|
|
||||||
|
- **`GET /api/music/favorites`** → grouped keys, newest first:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tracks": ["Music/…/01 Hells Bells.mp3"],
|
||||||
|
"albums": ["Albums/AC-DC/[1980] Back in Black"],
|
||||||
|
"artists": ["Albums/AC-DC"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **`POST /api/music/favorites`** `{ "kind": "track|album|artist", "key": "…" }` → `{ ok: true }`. Idempotent
|
||||||
|
(a repeat add is a no-op).
|
||||||
|
- **`DELETE /api/music/favorites?kind=<kind>&key=<key>`** → `{ ok: true }` (no-op if not set). Key passed as a
|
||||||
|
query param (URL-encode it).
|
||||||
|
- `400 { error: "kind and key required" }` on a bad/missing kind or empty key.
|
||||||
|
|
||||||
|
### Currently-playing (resume)
|
||||||
|
|
||||||
|
One snapshot per user — persist while playing (throttled) and on pause / track-change / close; read it on
|
||||||
|
launch to offer "resume".
|
||||||
|
|
||||||
|
- **`GET /api/music/now-playing`** → the snapshot or `null`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"homePath": "Music/…/01 Hells Bells.mp3",
|
||||||
|
"dir": "Music/Albums/AC-DC/[1980] Back in Black",
|
||||||
|
"title": "Hells Bells",
|
||||||
|
"artist": "AC/DC",
|
||||||
|
"album": "Back in Black",
|
||||||
|
"durationSec": 312.5,
|
||||||
|
"positionSec": 140,
|
||||||
|
"updatedAt": "2026-07-27T11:27:54.441Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`dir` is the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track).
|
||||||
|
- **`PUT /api/music/now-playing`** `{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }`
|
||||||
|
→ `{ ok: true }` (upsert). Omitted fields default to `""`/`0`.
|
||||||
|
- **`DELETE /api/music/now-playing`** → `{ ok: true }` (clear, e.g. on stop).
|
||||||
|
- `400 { error: "homePath required" }` if `homePath` is missing/empty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Playlists
|
||||||
|
|
||||||
|
Server-side playlists, scoped to the calling user. Items are track **keys** — the same
|
||||||
|
`<albumRel>/<file>` strings favorites uses — so a playlist survives a reindex as long as the file stays
|
||||||
|
put. `404` throughout means "not yours or not there"; the two are deliberately indistinguishable.
|
||||||
|
|
||||||
|
| method | path | body | returns |
|
||||||
|
| -------- | -------------------------------- | -------------- | ---------------------------------------------------------------- |
|
||||||
|
| `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first |
|
||||||
|
| `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken |
|
||||||
|
| `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` |
|
||||||
|
| `PATCH` | `/api/music/playlists/:id` | `{ name }` | rename; `409` if taken |
|
||||||
|
| `DELETE` | `/api/music/playlists/:id` | — | deletes it, items cascade |
|
||||||
|
| `POST` | `/api/music/playlists/:id/items` | `{ keys: [] }` | append → `{ count }` |
|
||||||
|
| `PUT` | `/api/music/playlists/:id/items` | `{ keys: [] }` | replace the whole list → `{ count }` |
|
||||||
|
|
||||||
|
`PUT` is how you reorder or remove: send the list you want, in order. There is no per-item delete.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed.
|
||||||
|
- **Durations are exact** (ffprobe) in both `X-Audio-Duration` and `meta.json`'s `durationSec` (seconds).
|
||||||
|
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed _offline
|
||||||
|
audio files_ is a separate, later feature.)
|
||||||
|
- **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable.
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# Music — the second plugin
|
||||||
|
|
||||||
|
**Status: extracted 2026-08-15.** Written after the fact rather than during, because unlike offscale this
|
||||||
|
one had no design questions left open — the runbook (`plugins/EXTRACTING-A-PLUGIN.md`) had already decided
|
||||||
|
everything except one call. This records what moved, what did not, and the two bugs the extraction found.
|
||||||
|
|
||||||
|
Read `plugins/offscale/PLUGIN.md` first. It is the design document for the plugin system; this is a
|
||||||
|
worked second case, and it is interesting mainly for being the messy one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What music is
|
||||||
|
|
||||||
|
The `/music` screen, the library index, and the phone and tablet apps that stream from it. The contract
|
||||||
|
those apps speak is `MUSIC_API.md`, next to this file — it is the reason the sidecar's HTTP shape is not
|
||||||
|
free to change.
|
||||||
|
|
||||||
|
```
|
||||||
|
manifest.ts identity, one permission
|
||||||
|
api/router.ts re-exports the platform's proxy — see below
|
||||||
|
sidecar/index.ts the whole /api/music contract (503 lines)
|
||||||
|
sidecar/indexer.ts the library walker → cache tree + manifest (1079 lines)
|
||||||
|
sidecar/stream-audio.ts 206 / Content-Range / 416, and X-Audio-Duration
|
||||||
|
sidecar/nightly-reindex.ts 3am full rebuild, staged and swapped
|
||||||
|
db/ music_favorites, _playlists, _playlist_items, _now_playing
|
||||||
|
web/ two panels and a layout; the shell renders the Workspace
|
||||||
|
scripts/ the reindex CLI, which talks to the sidecar port directly
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The three things that stayed, and why
|
||||||
|
|
||||||
|
Offscale left nothing behind. Music leaves three, and calling them seams rather than loose ends only
|
||||||
|
means each one is written down with what would close it.
|
||||||
|
|
||||||
|
### 1. cliamp — parked in the plugin, not left in the platform
|
||||||
|
|
||||||
|
`cliamp` and `cliamp-audio` are a _second_ playback path: the `cliamp` TUI run on the server, with its
|
||||||
|
terminal and its PulseAudio null sink piped to the browser. Out of scope by the owner's decision.
|
||||||
|
|
||||||
|
**All of it now lives in `./cliamp/`** — moved 2026-08-15, in two passes on the same day:
|
||||||
|
|
||||||
|
```
|
||||||
|
sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, cliamp-ws.test.ts → cliamp/
|
||||||
|
api/cliamp/relay.ts → cliamp/relay.ts
|
||||||
|
apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx → cliamp/
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/servers/sidecar/music/`, `src/servers/api/cliamp/` and `src/servers/api/music/` are **gone**, and
|
||||||
|
`server.tsx` has no cliamp import, provider name, handler entry or route left.
|
||||||
|
|
||||||
|
Two things went with it that were live rather than inert:
|
||||||
|
|
||||||
|
- **The file browser's `Play` action.** A context-menu item on any audio file or folder set `?play=`,
|
||||||
|
which rendered a cliamp terminal panel pointed at `/api/cliamp/ws` — a route that upgraded into a
|
||||||
|
`handlers` entry that was commented out, so `handlers[provider]!.open(ws)` asserted non-null on
|
||||||
|
`undefined`. **Using that menu item crashed the socket handler.** The action, its layout, its panel and
|
||||||
|
its two menu entries are removed; the components are parked here.
|
||||||
|
- **The two socket routes.** They now 404. Verified live.
|
||||||
|
|
||||||
|
That closed the totality drift as a side effect: `server.tsx`'s route table and its `handlers` map agree
|
||||||
|
again, which they had not since 2026-08-13. `registry.test.ts` keeps an assertion on it.
|
||||||
|
|
||||||
|
**What it takes to bring cliamp back:** a plugin owning a websocket. `server.reload({ routes })` is proven
|
||||||
|
and never called. A platform gap, not a music one.
|
||||||
|
|
||||||
|
### 2. ~~`src/servers/api/music/router.ts`~~ — deleted, and the reason it existed was nothing
|
||||||
|
|
||||||
|
The proxy was constructed in PLATFORM code that knew the string `'music'`, and this plugin's
|
||||||
|
`api/router.ts` merely re-exported it. The stated reason: `api/cliamp/relay.ts` imported
|
||||||
|
`getMusicServerWsUrl` from it, so it could not move.
|
||||||
|
|
||||||
|
That reason was three layers of nothing:
|
||||||
|
|
||||||
|
- `server.tsx:20` imported the relay's two exports — **used only on commented-out lines**
|
||||||
|
- so the relay's functions were never invoked, and its call to `getMusicServerWsUrl` never ran
|
||||||
|
- and the file's other export, `getMusicServerUrl`, had **no consumers at all**
|
||||||
|
|
||||||
|
A dead import held a music-named file in the platform. The proxy is now built in
|
||||||
|
`plugins/music/api/router.ts`; the relay takes its URL from there.
|
||||||
|
|
||||||
|
**And the prefix is derived rather than written.** It was the literal `'/api/music'`, which the proxy uses
|
||||||
|
to strip characters off the path. That is correct only because `mountPrefix` returns `/music` for a
|
||||||
|
first-party publisher — the same plugin published by anyone else mounts at `/api/p/<publisher>/music` and
|
||||||
|
would have forwarded `/alice/music/stream` to a sidecar expecting `/stream`. A latent bug only third
|
||||||
|
parties would ever hit, and a quiet violation of the rule that `mountPrefix` is the one function allowed
|
||||||
|
to know about provenance. It now calls `mountPrefix`.
|
||||||
|
|
||||||
|
`[open]` `appName` is still a literal there, because a plugin's router cannot see its own directory name —
|
||||||
|
the platform imports the module and reads `router`, so there is nowhere to inject it. The fix is
|
||||||
|
`api/router.ts` exporting a factory the installer calls with the plugin's own identity.
|
||||||
|
|
||||||
|
### 3. ~~The player~~ — moved, and the reasoning that kept it was removed rather than refuted
|
||||||
|
|
||||||
|
The first version of this document said the player stayed in the platform and called the decision
|
||||||
|
settled by a hard constraint:
|
||||||
|
|
||||||
|
> `useMusicPlayer` and `PlayerTrack` are imported from `officerdev` by `widgets/MusicPlayer/`, the
|
||||||
|
> dashboard widget — and the platform cannot import from a plugin. So the player state stays whatever is
|
||||||
|
> decided about the UI.
|
||||||
|
|
||||||
|
True at the time. The owner then moved the widget into the plugin, and the constraint evaporated: the
|
||||||
|
complete remaining platform dependency became one line, `DashboardLayout.tsx:66`.
|
||||||
|
|
||||||
|
So the whole of `officerdev/src/MusicPlayer/` now lives in `web/` — engine, state, bar, favourites,
|
||||||
|
lyrics toggle and the library vocabulary. **`src/` contains no music code at all.**
|
||||||
|
|
||||||
|
**Where the engine is mounted, and why it is not the shell.** `MusicPlayerHost` renders inside
|
||||||
|
`MusicDetail`, at the foot of the library view. That reads odd until you notice what it already did:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
if (pathname.startsWith('/music')) return null; // the panel draws its own MusicMiniBar
|
||||||
|
```
|
||||||
|
|
||||||
|
On `/music` the host has always rendered nothing and existed purely to own the `GaplessEngine`. Mounted
|
||||||
|
in the panel it does exactly that, and the bar code stays intact for whenever there is a slot to put it in.
|
||||||
|
|
||||||
|
**`[phase 2]` Leaving `/music` unmounts the host, which stops playback.** Deliberately deferred rather
|
||||||
|
than solved: making audio outlive the route needs either a shell slot a plugin can contribute to — which
|
||||||
|
reopens "there is no way to export a component" — or the engine hoisted to module scope so a panel
|
||||||
|
attaches and detaches from a singleton. The second keeps the rule and loses only the off-route transport
|
||||||
|
controls, and is the better idea, but it is a rewrite of the host's lifecycle rather than a move.
|
||||||
|
|
||||||
|
Nothing breaks in the meantime, and that was the bar: `player-time`'s `seekPlayer` is optional-chained
|
||||||
|
so a call with no host registered is a no-op, `registerPlayerSeek` clears only its own registration, the
|
||||||
|
host's cleanup destroys the engine and nulls its ref, and the queue lives in global state — so returning
|
||||||
|
to `/music` remounts the host and reloads it.
|
||||||
|
|
||||||
|
## Two bugs, neither visible from reading
|
||||||
|
|
||||||
|
**The app-store catalogue still listed music, and that would have blanked the screen.**
|
||||||
|
`permissionAvailability()` derives from `sidecar_installs`, and a _plugin_ never gets a row there — its
|
||||||
|
install state is `plugin_installs`. So `music` would have been permanently `unavailable`, which puts
|
||||||
|
`/music` into `deniedRoutes`: dock tile withheld, screen blank, on a server where the plugin was
|
||||||
|
installed, enabled and healthy.
|
||||||
|
|
||||||
|
This is the **headscale bug, exactly** — and it is documented six lines above where the music entry sat,
|
||||||
|
in the same file. Found by reading that note rather than by hitting it again, which is the only reason
|
||||||
|
it cost minutes instead of an evening. Entry removed.
|
||||||
|
|
||||||
|
**`[test] root = "./src"`, so moving `lyrics.test.ts` into `plugins/` stopped running it silently.** The
|
||||||
|
count fell by nine and the suite still read green-ish. A test that quietly stops running is worse than
|
||||||
|
one that fails, and _every_ future extraction would have taken its tests out of the suite the same way.
|
||||||
|
Root is now the repo. Positional filters cannot fix this — `bun test plugins` matches paths under root,
|
||||||
|
so it finds `src/servers/plugins/` and not `plugins/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
One permission, `music`, and the key is deliberately unchanged from the registry entry it replaces — so
|
||||||
|
every existing `role_permissions` grant keeps meaning what it meant, and `can('music')` keeps resolving
|
||||||
|
for the overlay. Renaming it would have been a silent data change.
|
||||||
|
|
||||||
|
The old entry carried `personal: ['/favorites', '/now-playing', '/playlists', '/queue']`. A manifest has
|
||||||
|
no `personal` field and should not grow one: that is the per-user visibility model, which is the plugin's
|
||||||
|
own job and explicitly not this extraction's work. They ride across on `readOnlyWrites` instead, because
|
||||||
|
`isRequestAllowedAtLevel` **concatenates the two lists** — one mechanism under two names. A read grant
|
||||||
|
therefore permits exactly the four paths it permitted yesterday, and no field was added.
|
||||||
|
|
||||||
|
`/queue` is in that list because it was. No such route exists, in the sidecar or anywhere else.
|
||||||
|
|
||||||
|
`[open]` What a member's grant _means_ is unfinished, and music is where the richer model was always
|
||||||
|
going to be designed (`plugins/offscale/PLUGIN.md` says so). It is genuinely non-uniform here in a way
|
||||||
|
offscale's is not: favourites, playlists and now-playing are already per-caller — the sidecar scopes
|
||||||
|
every one by the `X-Officer-User` header the proxy injects — while the library is one shared index for
|
||||||
|
the household. So "whose row is this" already has a real answer on one side and not the other. That is a
|
||||||
|
change inside `db/queries.ts`, not a flag on the manifest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Host dependencies — the field music created
|
||||||
|
|
||||||
|
`ffmpeg` and `ffprobe`. Offscale needed nothing, so until music there was no reason to build this and no
|
||||||
|
way to say it; the first draft of this document said "there is no field for a host binary" and left it at
|
||||||
|
that. That was the wrong answer, because of HOW music fails without them.
|
||||||
|
|
||||||
|
It does not fail. `ffprobe` missing means the indexer catches the spawn error and returns a track carrying
|
||||||
|
its filename and nothing else — no title, artist, album, duration or embedded lyrics — then walks the
|
||||||
|
whole library, writes a complete cache tree and reports success. Five swallowed catches in
|
||||||
|
`indexer.ts` and `stream-audio.ts`, no log, no counter. The only tell is `coversSaved: 0` in a report
|
||||||
|
nobody reads. A refusal wearing the costume of a normal result.
|
||||||
|
|
||||||
|
So `osDependencies` is a manifest field now (`servers/plugins/manifest.ts`, `servers/plugins/os-deps.ts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
osDependencies: [
|
||||||
|
{ binary: 'ffprobe', reason: '…', packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' } },
|
||||||
|
{ binary: 'ffmpeg', reason: '…', packages: { … } },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are declared even though one package provides both, because the platform probes BINARIES and these
|
||||||
|
two fail differently — and the owner should be told which one they are missing. The installer dedupes to
|
||||||
|
a single `ffmpeg` before anything reaches a command line.
|
||||||
|
|
||||||
|
The shape is `scripts/setup-old/setup.sh`'s, not invented: probe the binary, map to a package name per
|
||||||
|
manager. Probing the binary is what makes "built-in on this OS" free — if it is on PATH the package map
|
||||||
|
is never consulted. Per-manager names rather than canonical-with-overrides because `packages.sh` already
|
||||||
|
recorded why that indirection was rejected.
|
||||||
|
|
||||||
|
**Verified end to end on 2026-08-15.** Both binaries were absent on this machine all evening. The plugins
|
||||||
|
page showed `ffprobe missing — ffmpeg` and `ffmpeg missing — ffmpeg` with the exact root command it would
|
||||||
|
run; installing streamed `dependencies: installing ffmpeg with apt` → `dependencies: ffprobe, ffmpeg now
|
||||||
|
on PATH`, and `X-Audio-Duration: 7.026939` appeared on a stream response for the first time. The refusal
|
||||||
|
path was exercised separately against a temporary probe dependency: HTTP 400, `steps: []`, and the reason
|
||||||
|
named — nothing had happened, so there was nothing to undo.
|
||||||
|
|
||||||
|
`~/Music` still does not exist, so there is no library to index.
|
||||||
|
|
||||||
|
`cliamp`, `parec`, `pulseaudio` and `pactl` stayed behind with cliamp. The sidecar logs
|
||||||
|
`pulseaudio not installed, skipping audio setup` and carries on, which is the right shape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verified on the live server, 2026-08-15
|
||||||
|
|
||||||
|
The runbook's table, run against `platform.officer.dev` rather than reasoned about.
|
||||||
|
|
||||||
|
| Step | Result |
|
||||||
|
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| install | streamed 5 steps; schema applied in 2082ms; `officer-music` online; `/example, /music, /offscale` mounted |
|
||||||
|
| the API | `/api/music/manifest` 200, `/api/music/favorites` returns per-user JSON |
|
||||||
|
| range requests | full 200 + `Accept-Ranges`; `bytes=100-199` → **206**, correct `Content-Range`, exactly 100 bytes; unsatisfiable → **416**; `../../etc/passwd` → **400** |
|
||||||
|
| the screen | route generated in `Plugins.gen.tsx`, panels in the built bundle, `PluginScreen` wraps `WorkspaceView`. Structural — not eyeballed in a browser |
|
||||||
|
| dock | tile present in `/api/user/permissions`; `/music` in `routes`, not in `deniedRoutes` |
|
||||||
|
| permissions page | `music` listed among the grantable |
|
||||||
|
| disable | route 404s, sidecar `stopped`, **rows survive** |
|
||||||
|
| enable | 200 again, sidecar online, favourites still there |
|
||||||
|
| uninstall | route 404s, **absent from pm2**, ecosystem entry removed, **rows survive** |
|
||||||
|
| `bun db:push` while uninstalled | **`No changes detected`**, rows survive |
|
||||||
|
| install again | byte-identical steps, and a **restore** — the seeded favourite and playlist came back |
|
||||||
|
| `pm2 restart officer` | boots clean, all three plugins mount, music answers 200 |
|
||||||
|
|
||||||
|
Seeded rows and the audio fixture were removed afterwards; `~/Music` was deleted again, since it did not
|
||||||
|
exist before.
|
||||||
|
|
||||||
|
**Music is left INSTALLED and enabled.** It had been switched off since 2026-08-13, so this restores it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Still open
|
||||||
|
|
||||||
|
### The library browser reads the filesystem, not this plugin — and that is a PERMISSION dependency
|
||||||
|
|
||||||
|
Found 2026-08-15, after the extraction landed, by reading the code rather than by anything failing.
|
||||||
|
|
||||||
|
`MusicBrowser.tsx` lists folders with `GET /file-browser/ls`, not through the music sidecar
|
||||||
|
(`MusicBrowser.tsx:63,81`). `/file-browser` belongs to the **`files`** permission, and `files` is
|
||||||
|
**`confined`** — so:
|
||||||
|
|
||||||
|
- a member granted `music` but not `files` gets a working player, working favourites, and an **empty
|
||||||
|
library**, because every listing 403s;
|
||||||
|
- and `files` is not a grant that can simply be handed over. `authorize.ts` drops a confined grant for an
|
||||||
|
account with no `osUser`, so it means nothing without a per-user Linux account.
|
||||||
|
|
||||||
|
This is the first **cross-plugin permission dependency** in the system, and it is a different animal from
|
||||||
|
the one offscale has. Offscale's `ConsoleView` → `TerminalView` is a CODE dependency: it resolves at build
|
||||||
|
time, and the worst case is a plugin that will not compile. This one resolves at request time, per
|
||||||
|
account, and its failure mode is a screen that renders perfectly and shows nothing.
|
||||||
|
|
||||||
|
Three possible shapes, none chosen:
|
||||||
|
|
||||||
|
1. **The sidecar lists.** Music already walks the library for its index — `GET /music/ls` would put the
|
||||||
|
listing behind the `music` permission where it belongs, and the plugin stops needing `files` at all.
|
||||||
|
Most self-contained, and the most work.
|
||||||
|
2. **The manifest declares a permission dependency**, and the platform refuses the grant or warns. Honest,
|
||||||
|
but it makes one plugin's grant conditional on another permission, which is new machinery.
|
||||||
|
3. **Leave it and document it** — a member needs `files` too. Cheapest, and it quietly ties a music grant
|
||||||
|
to a Linux account, which is a much bigger commitment than the owner is agreeing to on that page.
|
||||||
|
|
||||||
|
(1) is probably right, and it is the same shape as offscale's rule that the sidecar absorbs everything.
|
||||||
|
Not tonight's call.
|
||||||
|
|
||||||
|
- **`hasPersonalWrites` reads `c.personal` only**, so the permissions API reports `false` for a plugin
|
||||||
|
that declares the same thing through `readOnlyWrites`. Nothing renders the field, so it is dead on the
|
||||||
|
wire — noted rather than fixed.
|
||||||
|
- **Two dock sources.** The app store keeps its own catalogue while the plugin system builds tiles from
|
||||||
|
manifests, and the self endpoint concatenates both. One when the store is rebuilt on the plugin system.
|
||||||
|
- **`src/servers/sidecar/protocol.ts` still declares `music:server`** per sidecar. Generalising the union
|
||||||
|
to `` `${string}:server` `` is the better fix and is pending for the whole protocol.
|
||||||
|
- **The cliamp sockets are claimed by no permission**, and are served. Now pinned by a test in
|
||||||
|
`registry.test.ts` rather than left to be rediscovered — closing it is the totality work.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
||||||
|
import { mountPrefix } from '@@/plugins/manifest';
|
||||||
|
import { manifest } from '../manifest';
|
||||||
|
|
||||||
|
// /api/music/* — auth, then forward to officer-music. No routes of its own and no music knowledge here:
|
||||||
|
// the whole contract lives in ../sidecar/index.ts, which is where the routes actually are.
|
||||||
|
//
|
||||||
|
// ── This used to live in the platform, and that was the bug ──
|
||||||
|
//
|
||||||
|
// Until 2026-08-15 the proxy was constructed in `src/servers/api/music/router.ts` — PLATFORM code that
|
||||||
|
// knew the string 'music' — and this file merely re-exported it. The justification was that
|
||||||
|
// `api/cliamp/relay.ts` imported `getMusicServerWsUrl` from it, so it could not move.
|
||||||
|
//
|
||||||
|
// That justification was three layers of nothing. The relay's functions were only reachable through
|
||||||
|
// `handlers` entries in server.tsx that were commented out, and its own import there was unused. A dead
|
||||||
|
// import held a music-named file in the platform, and the second export on it (`getMusicServerUrl`) had
|
||||||
|
// no callers at all. The relay now lives in ../cliamp/ and takes its URL from here.
|
||||||
|
//
|
||||||
|
// ── The prefix is DERIVED, not written ──
|
||||||
|
//
|
||||||
|
// It was the literal '/api/music', and that is wrong in a way that only shows up for someone else's
|
||||||
|
// plugin. The proxy strips `prefix.length` characters to build the sidecar path, so a hardcoded
|
||||||
|
// '/api/music' (10 chars) is correct only because `mountPrefix` happens to return `/music` for a
|
||||||
|
// first-party publisher. The same plugin published by anyone else mounts at `/api/p/<publisher>/music`
|
||||||
|
// and would forward `/alice/music/stream` to a sidecar expecting `/stream`.
|
||||||
|
//
|
||||||
|
// `mountPrefix` is the ONE function allowed to know about provenance, so the prefix comes from it. A
|
||||||
|
// literal here is that rule being broken quietly, which is exactly how first-party and third-party
|
||||||
|
// become two systems with only one of them tested.
|
||||||
|
//
|
||||||
|
// `appName` is passed as a literal because this file cannot see its own directory name. That is a real
|
||||||
|
// gap — the platform imports `router.ts` and reads `router`, so there is nowhere to inject it — and the
|
||||||
|
// day a plugin's router needs its own identity for anything else, `api/router.ts` should export a
|
||||||
|
// factory the installer calls instead. Recorded rather than worked around.
|
||||||
|
const proxy = createSidecarProxy({
|
||||||
|
name: 'music',
|
||||||
|
prefix: `/api${mountPrefix({ appName: 'music', manifest })}`,
|
||||||
|
// A from-scratch reindex holds the connection open for minutes with no bytes flowing; the default 60s
|
||||||
|
// idle drop would kill it. Applied to the whole prefix — the proxy must not know which routes are slow.
|
||||||
|
timeoutSeconds: 1800,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const router = proxy.router;
|
||||||
|
|
||||||
|
/** The sidecar as a `ws://` base. Used by ../cliamp/relay.ts, and by nothing else. */
|
||||||
|
export const getMusicServerWsUrl = proxy.getWsUrl;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
@@ -0,0 +1,185 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Volume2, VolumeX } from 'lucide-react';
|
||||||
|
|
||||||
|
type AudioStreamPlayerProps = {
|
||||||
|
wsUrl: string;
|
||||||
|
onError?: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAMPLE_RATE = 44100;
|
||||||
|
const CHANNELS = 2;
|
||||||
|
|
||||||
|
const buildWsUrl = (wsPath: string) => {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
|
||||||
|
const separator = wsPath.includes('?') ? '&' : '?';
|
||||||
|
return `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WORKLET_CODE = `
|
||||||
|
class PCMProcessor extends AudioWorkletProcessor {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.buffer = new Float32Array(0);
|
||||||
|
this.port.onmessage = (e) => {
|
||||||
|
const incoming = e.data;
|
||||||
|
const merged = new Float32Array(this.buffer.length + incoming.length);
|
||||||
|
merged.set(this.buffer);
|
||||||
|
merged.set(incoming, this.buffer.length);
|
||||||
|
this.buffer = merged;
|
||||||
|
const max = ${SAMPLE_RATE * CHANNELS * 2};
|
||||||
|
if (this.buffer.length > max) {
|
||||||
|
this.buffer = this.buffer.slice(this.buffer.length - max);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
process(inputs, outputs) {
|
||||||
|
const output = outputs[0];
|
||||||
|
if (!output || output.length === 0) return true;
|
||||||
|
const channels = output.length;
|
||||||
|
const frameSize = output[0].length;
|
||||||
|
const samplesNeeded = frameSize * channels;
|
||||||
|
if (this.buffer.length >= samplesNeeded) {
|
||||||
|
for (let i = 0; i < frameSize; i++) {
|
||||||
|
for (let ch = 0; ch < channels; ch++) {
|
||||||
|
output[ch][i] = this.buffer[i * channels + ch];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.buffer = this.buffer.slice(samplesNeeded);
|
||||||
|
} else {
|
||||||
|
for (let ch = 0; ch < channels; ch++) {
|
||||||
|
output[ch].fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registerProcessor('pcm-processor', PCMProcessor);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const workletBlobUrl = URL.createObjectURL(new Blob([WORKLET_CODE], { type: 'application/javascript' }));
|
||||||
|
|
||||||
|
export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) => {
|
||||||
|
const [muted, setMuted] = useState(false);
|
||||||
|
const [started, setStarted] = useState(false);
|
||||||
|
const ctxRef = useRef<AudioContext | null>(null);
|
||||||
|
const nodeRef = useRef<AudioWorkletNode | null>(null);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const gainRef = useRef<GainNode | null>(null);
|
||||||
|
|
||||||
|
// The effect below runs once per `wsUrl` and registers listeners that outlive every render after it, so a
|
||||||
|
// named `onError` dependency would either tear the stream down on each render or freeze the first render's
|
||||||
|
// callback. A ref is the third option: one stream, current callback.
|
||||||
|
const onErrorRef = useRef(onError);
|
||||||
|
onErrorRef.current = onError;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
let audioCtx: AudioContext | null = null;
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||||
|
ctxRef.current = audioCtx;
|
||||||
|
|
||||||
|
await audioCtx.audioWorklet.addModule(workletBlobUrl);
|
||||||
|
if (disposed) {
|
||||||
|
audioCtx.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor', {
|
||||||
|
outputChannelCount: [CHANNELS],
|
||||||
|
});
|
||||||
|
nodeRef.current = workletNode;
|
||||||
|
|
||||||
|
const gainNode = audioCtx.createGain();
|
||||||
|
gainRef.current = gainNode;
|
||||||
|
workletNode.connect(gainNode);
|
||||||
|
gainNode.connect(audioCtx.destination);
|
||||||
|
|
||||||
|
const ws = new WebSocket(buildWsUrl(wsUrl));
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.addEventListener('open', () => {
|
||||||
|
if (!disposed) setStarted(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('message', (ev) => {
|
||||||
|
if (disposed || !(ev.data instanceof ArrayBuffer)) return;
|
||||||
|
|
||||||
|
// Resume context if suspended (autoplay policy — will unlock on user gesture)
|
||||||
|
if (audioCtx && audioCtx.state === 'suspended') {
|
||||||
|
audioCtx.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
const int16 = new Int16Array(ev.data);
|
||||||
|
const float32 = new Float32Array(int16.length);
|
||||||
|
for (let i = 0; i < int16.length; i++) {
|
||||||
|
float32[i] = int16[i]! / 32768;
|
||||||
|
}
|
||||||
|
|
||||||
|
workletNode.port.postMessage(float32);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('error', () => {
|
||||||
|
if (!disposed) onErrorRef.current?.('Audio stream connection failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('close', () => {
|
||||||
|
if (!disposed) setStarted(false);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (!disposed) {
|
||||||
|
onErrorRef.current?.(err instanceof Error ? err.message : 'Audio playback failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
try {
|
||||||
|
wsRef.current?.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
wsRef.current = null;
|
||||||
|
try {
|
||||||
|
nodeRef.current?.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
nodeRef.current = null;
|
||||||
|
try {
|
||||||
|
audioCtx?.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
ctxRef.current = null;
|
||||||
|
gainRef.current = null;
|
||||||
|
};
|
||||||
|
}, [wsUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gainRef.current) {
|
||||||
|
gainRef.current.gain.value = muted ? 0 : 1;
|
||||||
|
}
|
||||||
|
}, [muted]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setMuted((m) => !m)}
|
||||||
|
className="p-1.5 rounded hover:bg-duck-dark/10 transition-colors cursor-pointer"
|
||||||
|
title={muted ? 'Unmute' : 'Mute'}
|
||||||
|
>
|
||||||
|
{muted ? (
|
||||||
|
<VolumeX className={`h-4 w-4 ${started ? 'text-red-500' : 'text-duck-dark/40'}`} />
|
||||||
|
) : (
|
||||||
|
<Volume2 className={`h-4 w-4 ${started ? 'text-duck-teal' : 'text-duck-dark/40'}`} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
import { Music } from 'lucide-react';
|
||||||
|
import { TerminalView } from 'officerdev';
|
||||||
|
import { AudioStreamPlayer } from './AudioStreamPlayer';
|
||||||
|
|
||||||
|
export const CliampPanelHeader = () => {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const playPath = searchParams.get('play') ?? '';
|
||||||
|
const fileName = playPath.split('/').pop() ?? 'cliamp';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Music className="h-4 w-4 shrink-0 opacity-60" />
|
||||||
|
<span className="text-xs font-medium truncate flex-1">{fileName}</span>
|
||||||
|
<AudioStreamPlayer wsUrl="/api/cliamp/audio/ws" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CliampPanelBody = () => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const playPath = searchParams.get('play') ?? '';
|
||||||
|
|
||||||
|
const wsPath = `/api/cliamp/ws?files=${encodeURIComponent(playPath)}`;
|
||||||
|
|
||||||
|
const handleExit = useCallback(() => {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
next.delete('play');
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
|
return <TerminalView className="h-full w-full" wsPath={wsPath} onExit={handleExit} autoFocus />;
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
pcm.!default {
|
||||||
|
type pulse
|
||||||
|
fallback "sysdefault"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctl.!default {
|
||||||
|
type pulse
|
||||||
|
fallback "sysdefault"
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { cliampUpgradeData, musicWebsocket } from './cliamp-ws';
|
||||||
|
|
||||||
|
// The player socket refuses a path before it spawns anything, so these two cases exercise the whole
|
||||||
|
// server → handler → frame path without starting cliamp. Anything that would actually play needs a real
|
||||||
|
// file and a real audio sink, so it is not tested here.
|
||||||
|
|
||||||
|
function serveOnce() {
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
fetch(req, srv) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const data = cliampUpgradeData(url.pathname, url.searchParams);
|
||||||
|
if (data && srv.upgrade(req, { data })) return undefined as unknown as Response;
|
||||||
|
return new Response('nope', { status: 400 });
|
||||||
|
},
|
||||||
|
websocket: musicWebsocket,
|
||||||
|
});
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstFrame(url: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
const timer = setTimeout(() => reject(new Error('no frame')), 3000);
|
||||||
|
ws.addEventListener('message', (ev) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
ws.close();
|
||||||
|
resolve(String(ev.data));
|
||||||
|
});
|
||||||
|
ws.addEventListener('error', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error('socket error'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('cliamp player socket', () => {
|
||||||
|
it('rejects a path that escapes the owner home', async () => {
|
||||||
|
const server = serveOnce();
|
||||||
|
try {
|
||||||
|
const frame = await firstFrame(`ws://127.0.0.1:${server.port}/cliamp/ws?files=../../etc/passwd`);
|
||||||
|
expect(JSON.parse(frame)).toEqual({ type: 'output', data: '\r\n[Error] Invalid file path.\r\n' });
|
||||||
|
} finally {
|
||||||
|
server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a missing files param instead of spawning', async () => {
|
||||||
|
const server = serveOnce();
|
||||||
|
try {
|
||||||
|
const frame = await firstFrame(`ws://127.0.0.1:${server.port}/cliamp/ws`);
|
||||||
|
expect(JSON.parse(frame)).toEqual({ type: 'output', data: '\r\n[Error] No files specified.\r\n' });
|
||||||
|
} finally {
|
||||||
|
server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes only the two cliamp paths', () => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
expect(cliampUpgradeData('/cliamp/ws', q)).toEqual({ kind: 'player', files: '' });
|
||||||
|
expect(cliampUpgradeData('/cliamp/audio/ws', q)).toEqual({ kind: 'capture' });
|
||||||
|
expect(cliampUpgradeData('/stream', q)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import type { ServerWebSocket } from 'bun';
|
||||||
|
import { spawn, type Subprocess } from 'bun';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join, normalize, resolve, sep } from 'node:path';
|
||||||
|
import { VIRTUAL_SINK } from './pulse-audio';
|
||||||
|
|
||||||
|
// Local playback, both halves of it, owned by the process that owns the audio pipeline:
|
||||||
|
//
|
||||||
|
// /cliamp/ws — runs the `cliamp` TUI player against a file and pipes its terminal both ways
|
||||||
|
// /cliamp/audio/ws — captures what the sink hears and streams it to the browser as raw PCM
|
||||||
|
//
|
||||||
|
// Officer relays these two sockets and nothing else: it authenticates the browser and forwards frames.
|
||||||
|
// Every fact below — where the binary is, what a legal path is, which sink to play into, the ALSA config,
|
||||||
|
// the capture format — is pipeline knowledge and stays here. The frame shapes are the browser's contract
|
||||||
|
// ({type:'output'|'exit'} / {type:'input'} as JSON text, PCM as binary), so they are unchanged by the move.
|
||||||
|
//
|
||||||
|
// Both sockets are loopback-only, like the rest of this server: officer is the only client.
|
||||||
|
|
||||||
|
const ASOUNDRC_PATH = join(import.meta.dir, 'asoundrc');
|
||||||
|
|
||||||
|
// Single super user, so the owner's home is the root every path is resolved against — same convention as
|
||||||
|
// stream-audio.ts and the indexer.
|
||||||
|
const ROOT_DIR = homedir();
|
||||||
|
|
||||||
|
// parec's output format IS the contract with the browser's AudioWorklet: signed 16-bit LE, 44.1kHz, stereo.
|
||||||
|
const CAPTURE_ARGS = ['--format=s16le', '--rate=44100', '--channels=2', '-d', `${VIRTUAL_SINK}.monitor`];
|
||||||
|
|
||||||
|
export type MusicWSData = { kind: 'player'; files: string } | { kind: 'capture' };
|
||||||
|
|
||||||
|
type Session = {
|
||||||
|
proc: Subprocess;
|
||||||
|
closed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessions = new Map<ServerWebSocket<MusicWSData>, Session>();
|
||||||
|
|
||||||
|
const sendOutput = (ws: ServerWebSocket<MusicWSData>, data: string) => {
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({ type: 'output', data }));
|
||||||
|
} catch {
|
||||||
|
/* ws already closed */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendExit = (ws: ServerWebSocket<MusicWSData>) => {
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({ type: 'exit' }));
|
||||||
|
} catch {
|
||||||
|
/* ws already closed */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Home-relative or leading-slash paths both mean "under the owner's home"; anything that escapes it after
|
||||||
|
// normalisation is rejected. The trailing separator matters: without it a sibling directory whose name
|
||||||
|
// merely starts with the home path would pass.
|
||||||
|
const resolveInHome = (file: string): string | null => {
|
||||||
|
const abs = normalize(resolve(ROOT_DIR, file.startsWith('/') ? `.${file}` : file));
|
||||||
|
return abs === ROOT_DIR || abs.startsWith(ROOT_DIR + sep) ? abs : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findCliamp = (): string | null => {
|
||||||
|
const which = Bun.which('cliamp');
|
||||||
|
if (which) return which;
|
||||||
|
const candidates = [
|
||||||
|
process.env.GOPATH ? `${process.env.GOPATH}/bin/cliamp` : null,
|
||||||
|
`${ROOT_DIR}/.local/go-path/bin/cliamp`,
|
||||||
|
`${ROOT_DIR}/go/bin/cliamp`,
|
||||||
|
];
|
||||||
|
for (const bin of candidates) {
|
||||||
|
if (!bin) continue;
|
||||||
|
try {
|
||||||
|
const stat = Bun.spawnSync({ cmd: ['test', '-x', bin], stdout: 'ignore', stderr: 'ignore' });
|
||||||
|
if (stat.exitCode === 0) return bin;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shellEscape = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
|
||||||
|
|
||||||
|
// Pump a byte stream into the socket until it ends; `frame` decides how it lands on the wire.
|
||||||
|
function pump(
|
||||||
|
ws: ServerWebSocket<MusicWSData>,
|
||||||
|
session: Session,
|
||||||
|
stream: ReadableStream<Uint8Array>,
|
||||||
|
frame: (ws: ServerWebSocket<MusicWSData>, chunk: Uint8Array) => void,
|
||||||
|
onEnd?: () => void,
|
||||||
|
): void {
|
||||||
|
const reader = stream.getReader();
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
while (!session.closed) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (value && !session.closed) frame(ws, value);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* stream ended */
|
||||||
|
} finally {
|
||||||
|
onEnd?.();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPlayer(ws: ServerWebSocket<MusicWSData>, files: string): void {
|
||||||
|
if (!files) return sendOutput(ws, '\r\n[Error] No files specified.\r\n');
|
||||||
|
|
||||||
|
const cliampPath = findCliamp();
|
||||||
|
if (!cliampPath) return sendOutput(ws, '\r\n[Error] cliamp not found on host.\r\n');
|
||||||
|
|
||||||
|
const target = resolveInHome(files);
|
||||||
|
if (!target) return sendOutput(ws, '\r\n[Error] Invalid file path.\r\n');
|
||||||
|
|
||||||
|
// `script` fakes a PTY for cliamp, which avoids a node-pty native dependency here.
|
||||||
|
const cliampCmd = `${shellEscape(cliampPath)} ${shellEscape(target)}`;
|
||||||
|
console.log(`[music] cliamp spawning: ${cliampCmd}`);
|
||||||
|
let proc: Subprocess<'pipe', 'pipe', 'pipe'>;
|
||||||
|
try {
|
||||||
|
proc = spawn({
|
||||||
|
cmd: ['script', '-qfc', cliampCmd, '/dev/null'],
|
||||||
|
stdin: 'pipe',
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
cwd: ROOT_DIR,
|
||||||
|
env: { ...process.env, TERM: 'xterm-256color', PULSE_SINK: VIRTUAL_SINK, ALSA_CONFIG_PATH: ASOUNDRC_PATH },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return sendOutput(ws, `\r\n[Error] ${err instanceof Error ? err.message : 'Failed to start cliamp'}\r\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session: Session = { proc, closed: false };
|
||||||
|
sessions.set(ws, session);
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const asText = (sock: ServerWebSocket<MusicWSData>, chunk: Uint8Array) => sendOutput(sock, decoder.decode(chunk));
|
||||||
|
const end = () => {
|
||||||
|
if (session.closed) return;
|
||||||
|
session.closed = true;
|
||||||
|
sendExit(ws);
|
||||||
|
};
|
||||||
|
pump(ws, session, proc.stdout, asText, end);
|
||||||
|
pump(ws, session, proc.stderr, asText); // cliamp writes some output there
|
||||||
|
|
||||||
|
void proc.exited.then((code) => {
|
||||||
|
console.log(`[music] cliamp exited code=${code}`);
|
||||||
|
end();
|
||||||
|
sessions.delete(ws);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCapture(ws: ServerWebSocket<MusicWSData>): void {
|
||||||
|
const parecPath = Bun.which('parec');
|
||||||
|
if (!parecPath) {
|
||||||
|
ws.close(4000, 'parec not found on host');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let proc: Subprocess<'ignore', 'pipe', 'ignore'>;
|
||||||
|
try {
|
||||||
|
proc = spawn({ cmd: [parecPath, ...CAPTURE_ARGS], stdin: 'ignore', stdout: 'pipe', stderr: 'ignore' });
|
||||||
|
} catch {
|
||||||
|
ws.close(4000, 'Failed to start audio capture');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session: Session = { proc, closed: false };
|
||||||
|
sessions.set(ws, session);
|
||||||
|
console.log('[music] parec started, streaming PCM to the relay');
|
||||||
|
|
||||||
|
pump(
|
||||||
|
ws,
|
||||||
|
session,
|
||||||
|
proc.stdout,
|
||||||
|
(sock, chunk) => {
|
||||||
|
try {
|
||||||
|
sock.sendBinary(chunk);
|
||||||
|
} catch {
|
||||||
|
session.closed = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (session.closed) return;
|
||||||
|
session.closed = true;
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
/* already closed */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const musicWebsocket = {
|
||||||
|
open(ws: ServerWebSocket<MusicWSData>) {
|
||||||
|
if (ws.data.kind === 'player') openPlayer(ws, ws.data.files);
|
||||||
|
else openCapture(ws);
|
||||||
|
},
|
||||||
|
|
||||||
|
message(ws: ServerWebSocket<MusicWSData>, raw: string | Buffer) {
|
||||||
|
const session = sessions.get(ws);
|
||||||
|
if (!session || session.closed || ws.data.kind !== 'player') return; // capture is one-way
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
|
||||||
|
if (msg.type === 'input' && msg.data) (session.proc as Subprocess<'pipe'>).stdin.write(msg.data);
|
||||||
|
} catch {
|
||||||
|
/* not a frame we understand */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
close(ws: ServerWebSocket<MusicWSData>) {
|
||||||
|
const session = sessions.get(ws);
|
||||||
|
if (!session) return;
|
||||||
|
session.closed = true;
|
||||||
|
try {
|
||||||
|
session.proc.kill();
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
sessions.delete(ws);
|
||||||
|
},
|
||||||
|
|
||||||
|
drain() {},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Upgrade one of the two cliamp sockets, or return null if this request is not for them. */
|
||||||
|
export function cliampUpgradeData(pathname: string, search: URLSearchParams): MusicWSData | null {
|
||||||
|
if (pathname === '/cliamp/ws') return { kind: 'player', files: search.get('files') ?? '' };
|
||||||
|
if (pathname === '/cliamp/audio/ws') return { kind: 'capture' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Host audio plumbing for local playback: a PulseAudio daemon and a null sink named `virtual_out`.
|
||||||
|
// cliamp plays *into* that sink (PULSE_SINK) and the capture side reads `virtual_out.monitor`, so the
|
||||||
|
// sink has to exist before either of them starts — which is why this runs at sidecar startup rather
|
||||||
|
// than on first play. Both steps are idempotent and both failures are non-fatal: a host without
|
||||||
|
// pulseaudio simply has no browser playback, and everything else the music sidecar does still works.
|
||||||
|
|
||||||
|
export const VIRTUAL_SINK = 'virtual_out';
|
||||||
|
|
||||||
|
export function ensurePulseAudio(): void {
|
||||||
|
const pulseaudio = Bun.which('pulseaudio');
|
||||||
|
const pactl = Bun.which('pactl');
|
||||||
|
if (!pulseaudio || !pactl) {
|
||||||
|
console.log('[music] pulseaudio not installed, skipping audio setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = Bun.spawnSync({ cmd: [pulseaudio, '--check'], stdout: 'ignore', stderr: 'ignore' });
|
||||||
|
if (check.exitCode !== 0) {
|
||||||
|
const start = Bun.spawnSync({ cmd: [pulseaudio, '--start', '-D'], stdout: 'ignore', stderr: 'ignore' });
|
||||||
|
if (start.exitCode !== 0) {
|
||||||
|
console.error('[music] failed to start pulseaudio');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('[music] pulseaudio started');
|
||||||
|
} else {
|
||||||
|
console.log('[music] pulseaudio already running');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sinks = Bun.spawnSync({ cmd: [pactl, 'list', 'short', 'sinks'], stdout: 'pipe', stderr: 'ignore' });
|
||||||
|
if (sinks.stdout.toString().includes(VIRTUAL_SINK)) {
|
||||||
|
console.log(`[music] ${VIRTUAL_SINK} sink already exists`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = Bun.spawnSync({
|
||||||
|
cmd: [
|
||||||
|
pactl,
|
||||||
|
'load-module',
|
||||||
|
'module-null-sink',
|
||||||
|
`sink_name=${VIRTUAL_SINK}`,
|
||||||
|
'sink_properties=device.description=Virtual_Output',
|
||||||
|
],
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
});
|
||||||
|
if (load.exitCode !== 0) console.error('[music] failed to load null sink:', load.stderr.toString().trim());
|
||||||
|
else console.log(`[music] ${VIRTUAL_SINK} null sink loaded`);
|
||||||
|
}
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
import type { ServerWebSocket } from 'bun';
|
||||||
|
import { getMusicServerWsUrl } from '../api/router';
|
||||||
|
|
||||||
|
// Platform side of the two cliamp sockets. Both used to spawn processes here — the `cliamp` player and a
|
||||||
|
// `parec` capture — which put the whole local-audio pipeline inside the thin proxy. They now live in the
|
||||||
|
// music PLUGIN (`plugins/music/cliamp/cliamp-ws.ts`), and this is what is left of them: authenticate the browser
|
||||||
|
// (done before the upgrade, in server.tsx), then pass frames through in both directions without reading
|
||||||
|
// them. Text or binary, no inspection — same dumb-pipe shape as the vault notifications relay.
|
||||||
|
|
||||||
|
export type CliampWSData = {
|
||||||
|
provider: 'cliamp' | 'cliamp-audio';
|
||||||
|
search?: string; // the browser's query string, forwarded minus the platform token
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpstreamState = {
|
||||||
|
ws: WebSocket | null;
|
||||||
|
queue: (string | Uint8Array<ArrayBuffer>)[];
|
||||||
|
ready: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bun hands frames over as `string | Buffer`; a Buffer is a Uint8Array at runtime, so forward as-is
|
||||||
|
// rather than copying every PCM chunk.
|
||||||
|
const asPayload = (raw: string | Buffer): string | Uint8Array<ArrayBuffer> =>
|
||||||
|
typeof raw === 'string' ? raw : (raw as Uint8Array<ArrayBuffer>);
|
||||||
|
|
||||||
|
// The sidecar has no use for the platform JWT and should not see it.
|
||||||
|
const forwardedQuery = (search: string | undefined): string => {
|
||||||
|
const params = new URLSearchParams(search ?? '');
|
||||||
|
params.delete('token');
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs ? `?${qs}` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
function createCliampRelay(path: string) {
|
||||||
|
const upstreams = new Map<ServerWebSocket<CliampWSData>, UpstreamState>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
open(ws: ServerWebSocket<CliampWSData>) {
|
||||||
|
const base = getMusicServerWsUrl();
|
||||||
|
if (!base) {
|
||||||
|
try {
|
||||||
|
ws.close(1011, 'Music sidecar not available');
|
||||||
|
} catch {
|
||||||
|
/* already closed */
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: UpstreamState = { ws: null, queue: [], ready: false };
|
||||||
|
upstreams.set(ws, state);
|
||||||
|
|
||||||
|
const upstream = new WebSocket(`${base}${path}${forwardedQuery(ws.data.search)}`);
|
||||||
|
upstream.binaryType = 'arraybuffer';
|
||||||
|
state.ws = upstream;
|
||||||
|
|
||||||
|
upstream.addEventListener('open', () => {
|
||||||
|
state.ready = true;
|
||||||
|
for (const m of state.queue) upstream.send(m);
|
||||||
|
state.queue.length = 0;
|
||||||
|
});
|
||||||
|
upstream.addEventListener('message', (ev) => {
|
||||||
|
try {
|
||||||
|
ws.send(ev.data as string | ArrayBuffer);
|
||||||
|
} catch {
|
||||||
|
/* client gone */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
upstream.addEventListener('close', (ev) => {
|
||||||
|
upstreams.delete(ws);
|
||||||
|
try {
|
||||||
|
ws.close(ev.code || 1000, ev.reason || '');
|
||||||
|
} catch {
|
||||||
|
/* already closed */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
upstream.addEventListener('error', () => {
|
||||||
|
upstreams.delete(ws);
|
||||||
|
try {
|
||||||
|
ws.close(1011, 'upstream error');
|
||||||
|
} catch {
|
||||||
|
/* already closed */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
message(ws: ServerWebSocket<CliampWSData>, raw: string | Buffer) {
|
||||||
|
const state = upstreams.get(ws);
|
||||||
|
if (!state) return;
|
||||||
|
const payload = asPayload(raw);
|
||||||
|
if (state.ready && state.ws) state.ws.send(payload);
|
||||||
|
else state.queue.push(payload); // buffer until the upstream socket opens
|
||||||
|
},
|
||||||
|
|
||||||
|
close(ws: ServerWebSocket<CliampWSData>) {
|
||||||
|
const state = upstreams.get(ws);
|
||||||
|
if (!state) return;
|
||||||
|
try {
|
||||||
|
state.ws?.close();
|
||||||
|
} catch {
|
||||||
|
/* already closed */
|
||||||
|
}
|
||||||
|
upstreams.delete(ws);
|
||||||
|
},
|
||||||
|
|
||||||
|
drain() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cliampWebsocket = createCliampRelay('/cliamp/ws');
|
||||||
|
export const cliampAudioWebsocket = createCliampRelay('/cliamp/audio/ws');
|
||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||||
|
import { db } from 'officerdb/db';
|
||||||
|
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
|
||||||
|
|
||||||
|
export type FavoriteKind = 'track' | 'album' | 'artist';
|
||||||
|
export type GroupedFavorites = { tracks: string[]; albums: string[]; artists: string[] };
|
||||||
|
|
||||||
|
/** All of a user's favorites, grouped by kind, newest first within each group. */
|
||||||
|
export async function getMusicFavorites(userId: number): Promise<GroupedFavorites> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ kind: musicFavorites.kind, key: musicFavorites.key })
|
||||||
|
.from(musicFavorites)
|
||||||
|
.where(eq(musicFavorites.userId, userId))
|
||||||
|
.orderBy(desc(musicFavorites.createdAt));
|
||||||
|
const grouped: GroupedFavorites = { tracks: [], albums: [], artists: [] };
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.kind === 'track') grouped.tracks.push(r.key);
|
||||||
|
else if (r.kind === 'album') grouped.albums.push(r.key);
|
||||||
|
else if (r.kind === 'artist') grouped.artists.push(r.key);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a favorite (idempotent — a repeat add is a no-op via the unique constraint). */
|
||||||
|
export async function addMusicFavorite(userId: number, kind: FavoriteKind, key: string): Promise<void> {
|
||||||
|
await db.insert(musicFavorites).values({ userId, kind, key }).onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a favorite (no-op if it wasn't set). */
|
||||||
|
export async function removeMusicFavorite(userId: number, kind: FavoriteKind, key: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.delete(musicFavorites)
|
||||||
|
.where(and(eq(musicFavorites.userId, userId), eq(musicFavorites.kind, kind), eq(musicFavorites.key, key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NowPlaying = {
|
||||||
|
homePath: string;
|
||||||
|
dir: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
durationSec: number;
|
||||||
|
positionSec: number;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NowPlayingInput = {
|
||||||
|
homePath: string;
|
||||||
|
dir?: string;
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
album?: string;
|
||||||
|
durationSec?: number;
|
||||||
|
positionSec?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** This (user, device)'s last "currently playing" snapshot, or null if none. */
|
||||||
|
export async function getNowPlaying(userId: number, device: string): Promise<NowPlaying | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
homePath: musicNowPlaying.homePath,
|
||||||
|
dir: musicNowPlaying.dir,
|
||||||
|
title: musicNowPlaying.title,
|
||||||
|
artist: musicNowPlaying.artist,
|
||||||
|
album: musicNowPlaying.album,
|
||||||
|
durationSec: musicNowPlaying.durationSec,
|
||||||
|
positionSec: musicNowPlaying.positionSec,
|
||||||
|
updatedAt: musicNowPlaying.updatedAt,
|
||||||
|
})
|
||||||
|
.from(musicNowPlaying)
|
||||||
|
.where(and(eq(musicNowPlaying.userId, userId), eq(musicNowPlaying.device, device)));
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upsert this (user, device)'s "currently playing" snapshot (one row per user+device). */
|
||||||
|
export async function setNowPlaying(userId: number, device: string, np: NowPlayingInput): Promise<void> {
|
||||||
|
const values = {
|
||||||
|
userId,
|
||||||
|
device,
|
||||||
|
homePath: np.homePath,
|
||||||
|
dir: np.dir ?? '',
|
||||||
|
title: np.title ?? '',
|
||||||
|
artist: np.artist ?? '',
|
||||||
|
album: np.album ?? '',
|
||||||
|
durationSec: np.durationSec ?? 0,
|
||||||
|
positionSec: np.positionSec ?? 0,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
await db
|
||||||
|
.insert(musicNowPlaying)
|
||||||
|
.values(values)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [musicNowPlaying.userId, musicNowPlaying.device],
|
||||||
|
set: {
|
||||||
|
homePath: values.homePath,
|
||||||
|
dir: values.dir,
|
||||||
|
title: values.title,
|
||||||
|
artist: values.artist,
|
||||||
|
album: values.album,
|
||||||
|
durationSec: values.durationSec,
|
||||||
|
positionSec: values.positionSec,
|
||||||
|
updatedAt: values.updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear this (user, device)'s "currently playing" (on close/stop). */
|
||||||
|
export async function clearNowPlaying(userId: number, device: string): Promise<void> {
|
||||||
|
await db.delete(musicNowPlaying).where(and(eq(musicNowPlaying.userId, userId), eq(musicNowPlaying.device, device)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Named playlists ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type PlaylistSummary = { id: number; name: string; count: number; createdAt: Date; updatedAt: Date };
|
||||||
|
export type Playlist = { id: number; name: string; items: string[]; createdAt: Date; updatedAt: Date };
|
||||||
|
|
||||||
|
/** All of a user's playlists with item counts, most-recently-updated first. */
|
||||||
|
export async function getPlaylists(userId: number): Promise<PlaylistSummary[]> {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: musicPlaylists.id,
|
||||||
|
name: musicPlaylists.name,
|
||||||
|
count: sql<number>`count(${musicPlaylistItems.id})::int`,
|
||||||
|
createdAt: musicPlaylists.createdAt,
|
||||||
|
updatedAt: musicPlaylists.updatedAt,
|
||||||
|
})
|
||||||
|
.from(musicPlaylists)
|
||||||
|
.leftJoin(musicPlaylistItems, eq(musicPlaylistItems.playlistId, musicPlaylists.id))
|
||||||
|
.where(eq(musicPlaylists.userId, userId))
|
||||||
|
.groupBy(musicPlaylists.id)
|
||||||
|
.orderBy(desc(musicPlaylists.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One playlist with its ordered item keys, or null if it doesn't exist or isn't this user's. */
|
||||||
|
export async function getPlaylist(userId: number, id: number): Promise<Playlist | null> {
|
||||||
|
const [header] = await db
|
||||||
|
.select({
|
||||||
|
id: musicPlaylists.id,
|
||||||
|
name: musicPlaylists.name,
|
||||||
|
createdAt: musicPlaylists.createdAt,
|
||||||
|
updatedAt: musicPlaylists.updatedAt,
|
||||||
|
})
|
||||||
|
.from(musicPlaylists)
|
||||||
|
.where(and(eq(musicPlaylists.id, id), eq(musicPlaylists.userId, userId)));
|
||||||
|
if (!header) return null;
|
||||||
|
const rows = await db
|
||||||
|
.select({ key: musicPlaylistItems.key })
|
||||||
|
.from(musicPlaylistItems)
|
||||||
|
.where(eq(musicPlaylistItems.playlistId, id))
|
||||||
|
.orderBy(asc(musicPlaylistItems.position));
|
||||||
|
return { ...header, items: rows.map((r) => r.key) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a named playlist. Returns the new row, or null if the name is already taken for this user. */
|
||||||
|
export async function createPlaylist(userId: number, name: string): Promise<PlaylistSummary | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(musicPlaylists)
|
||||||
|
.values({ userId, name })
|
||||||
|
.onConflictDoNothing({ target: [musicPlaylists.userId, musicPlaylists.name] })
|
||||||
|
.returning({
|
||||||
|
id: musicPlaylists.id,
|
||||||
|
name: musicPlaylists.name,
|
||||||
|
createdAt: musicPlaylists.createdAt,
|
||||||
|
updatedAt: musicPlaylists.updatedAt,
|
||||||
|
});
|
||||||
|
return row ? { ...row, count: 0 } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rename a playlist. Returns false if it isn't this user's, or the new name collides. */
|
||||||
|
export async function renamePlaylist(userId: number, id: number, name: string): Promise<boolean> {
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: musicPlaylists.id })
|
||||||
|
.from(musicPlaylists)
|
||||||
|
.where(and(eq(musicPlaylists.userId, userId), eq(musicPlaylists.name, name)));
|
||||||
|
if (existing.some((r) => r.id !== id)) return false; // name taken by a different playlist
|
||||||
|
const updated = await db
|
||||||
|
.update(musicPlaylists)
|
||||||
|
.set({ name, updatedAt: new Date() })
|
||||||
|
.where(and(eq(musicPlaylists.id, id), eq(musicPlaylists.userId, userId)))
|
||||||
|
.returning({ id: musicPlaylists.id });
|
||||||
|
return updated.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a playlist (items cascade). Returns false if it wasn't this user's. */
|
||||||
|
export async function deletePlaylist(userId: number, id: number): Promise<boolean> {
|
||||||
|
const deleted = await db
|
||||||
|
.delete(musicPlaylists)
|
||||||
|
.where(and(eq(musicPlaylists.id, id), eq(musicPlaylists.userId, userId)))
|
||||||
|
.returning({ id: musicPlaylists.id });
|
||||||
|
return deleted.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify a playlist belongs to the user; returns its id or null. */
|
||||||
|
async function ownedPlaylist(userId: number, id: number): Promise<number | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ id: musicPlaylists.id })
|
||||||
|
.from(musicPlaylists)
|
||||||
|
.where(and(eq(musicPlaylists.id, id), eq(musicPlaylists.userId, userId)));
|
||||||
|
return row?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append keys to the end of a playlist, preserving order. Returns the new count, or null if not the user's. */
|
||||||
|
export async function addPlaylistItems(userId: number, id: number, keys: string[]): Promise<number | null> {
|
||||||
|
if ((await ownedPlaylist(userId, id)) === null) return null;
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
const [{ next } = { next: 0 }] = await tx
|
||||||
|
.select({ next: sql<number>`coalesce(max(${musicPlaylistItems.position}) + 1, 0)::int` })
|
||||||
|
.from(musicPlaylistItems)
|
||||||
|
.where(eq(musicPlaylistItems.playlistId, id));
|
||||||
|
if (keys.length) {
|
||||||
|
await tx.insert(musicPlaylistItems).values(keys.map((key, i) => ({ playlistId: id, key, position: next + i })));
|
||||||
|
}
|
||||||
|
await tx.update(musicPlaylists).set({ updatedAt: new Date() }).where(eq(musicPlaylists.id, id));
|
||||||
|
const [{ total } = { total: 0 }] = await tx
|
||||||
|
.select({ total: sql<number>`count(*)::int` })
|
||||||
|
.from(musicPlaylistItems)
|
||||||
|
.where(eq(musicPlaylistItems.playlistId, id));
|
||||||
|
return total;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace a playlist's entire ordered item list (covers reorder + remove). Returns the new count, or null. */
|
||||||
|
export async function setPlaylistItems(userId: number, id: number, keys: string[]): Promise<number | null> {
|
||||||
|
if ((await ownedPlaylist(userId, id)) === null) return null;
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
await tx.delete(musicPlaylistItems).where(eq(musicPlaylistItems.playlistId, id));
|
||||||
|
if (keys.length) {
|
||||||
|
await tx.insert(musicPlaylistItems).values(keys.map((key, i) => ({ playlistId: id, key, position: i })));
|
||||||
|
}
|
||||||
|
await tx.update(musicPlaylists).set({ updatedAt: new Date() }).where(eq(musicPlaylists.id, id));
|
||||||
|
return keys.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
import { users } from 'officerdb/auth/schema';
|
||||||
|
|
||||||
|
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
|
||||||
|
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
|
||||||
|
// album → music-rel "Albums/AC-DC/[1980] Back in Black"
|
||||||
|
// artist → music-rel "Albums/AC-DC"
|
||||||
|
export const musicFavorites = pgTable(
|
||||||
|
'music_favorites',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
kind: text('kind').notNull(), // 'track' | 'album' | 'artist'
|
||||||
|
key: text('key').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('uq_music_favorites_user_kind_key').on(t.userId, t.kind, t.key),
|
||||||
|
index('idx_music_favorites_user_kind').on(t.userId, t.kind),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-user named playlists (header) + their ordered items (below). Like favorites, an item `key` is the
|
||||||
|
// opaque track homePath "Music/<rel>/<file>" the app supplies — the server never interprets it. A playlist
|
||||||
|
// name is unique per user; items are position-ordered and MAY repeat (a track can appear twice).
|
||||||
|
export const musicPlaylists = pgTable(
|
||||||
|
'music_playlists',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex('uq_music_playlists_user_name').on(t.userId, t.name)],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ordered track entries of a playlist. `position` is 0-based; deletes cascade from the playlist.
|
||||||
|
export const musicPlaylistItems = pgTable(
|
||||||
|
'music_playlist_items',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
playlistId: integer('playlist_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => musicPlaylists.id, { onDelete: 'cascade' }),
|
||||||
|
key: text('key').notNull(), // track homePath "Music/<rel>/<file>"
|
||||||
|
position: integer('position').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [index('idx_music_playlist_items_playlist').on(t.playlistId, t.position)],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-(user, device) "currently playing" for resume-across-launch: the current track + playback
|
||||||
|
// position, plus a light metadata snapshot so the resume card renders before the library index has
|
||||||
|
// synced on a fresh device. `dir` is the folder to rebuild the album queue from ('' for a cross-album
|
||||||
|
// queue). `device` is an opaque client tag ('' = default/phone, 'web' = the browser) so each client
|
||||||
|
// keeps its OWN resume state instead of stomping a shared one. One row per (user, device) — upserted;
|
||||||
|
// the app writes it throttled while playing and on pause/track-change/close.
|
||||||
|
export const musicNowPlaying = pgTable(
|
||||||
|
'music_now_playing',
|
||||||
|
{
|
||||||
|
userId: integer('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
device: text('device').notNull().default(''),
|
||||||
|
homePath: text('home_path').notNull(),
|
||||||
|
dir: text('dir').notNull().default(''),
|
||||||
|
title: text('title').notNull().default(''),
|
||||||
|
artist: text('artist').notNull().default(''),
|
||||||
|
album: text('album').notNull().default(''),
|
||||||
|
durationSec: real('duration_sec').notNull().default(0),
|
||||||
|
positionSec: real('position_sec').notNull().default(0),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ name: 'pk_music_now_playing', columns: [t.userId, t.device] })],
|
||||||
|
);
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
import type { PluginManifest } from '@@/plugins/manifest';
|
||||||
|
|
||||||
|
// Music — the library, the player, and the phone and tablet apps that stream from it.
|
||||||
|
//
|
||||||
|
// The second plugin extracted from the platform, on 2026-08-15. Bigger than offscale and, unlike it, not
|
||||||
|
// a clean cut. It took two passes: the first left three pieces in the platform, and the second moved two
|
||||||
|
// of them here after the owner read the code and asked why the platform still had files named for music.
|
||||||
|
// He was right — one of the three "seams" turned out to be dead code holding the door open.
|
||||||
|
//
|
||||||
|
// api/router.ts the sidecar proxy, built here — thin, and it must never grow music knowledge
|
||||||
|
// cliamp/ the second playback path, parked
|
||||||
|
// widgets/ the dashboard widget, parked
|
||||||
|
// assets/icon.png the dock tile, published to /plugins/music/ on install
|
||||||
|
// sidecar/ the whole /api/music contract: indexing, streaming, per-user state
|
||||||
|
// db/ music_favorites, _playlists, _playlist_items, _now_playing
|
||||||
|
// web/ the library panels; the shell renders the Workspace
|
||||||
|
//
|
||||||
|
// ── What stayed in the platform, and why ── (nothing. All three moved here.)
|
||||||
|
//
|
||||||
|
// 1. ~~cliamp~~ — MOVED HERE, all of it, into `./cliamp/`. The sidecar halves, the relay, the file
|
||||||
|
// browser's panel and its `Play` action. `src/servers/sidecar/music/`, `src/servers/api/cliamp/` and
|
||||||
|
// `src/servers/api/music/` no longer exist, and `server.tsx` has no cliamp anything. It is PARKED, not
|
||||||
|
// working: bringing it back needs a plugin to own a websocket, which is a platform gap.
|
||||||
|
//
|
||||||
|
// 2. ~~The dashboard widget~~ — MOVED HERE, to `./widgets/`, and unregistered from WidgetRegistry.
|
||||||
|
// Parked: plugins cannot contribute widgets and that mechanism is not built.
|
||||||
|
//
|
||||||
|
// 3. ~~The global player overlay~~ — MOVED HERE, to `./web/`. It stayed while the widget pinned
|
||||||
|
// `useMusicPlayer` in `officerdev`; once the widget left, the only platform dependency was one line
|
||||||
|
// in DashboardLayout. `MusicPlayerHost` now mounts inside the MusicDetail panel, where it owns the
|
||||||
|
// audio engine and renders nothing — which is what it already did on /music.
|
||||||
|
//
|
||||||
|
// `[phase 2]` Leaving /music stops playback. Giving audio a life outside the route needs a shell slot
|
||||||
|
// a plugin can contribute to, or the engine hoisted to module scope. Deferred deliberately; nothing
|
||||||
|
// breaks meanwhile.
|
||||||
|
//
|
||||||
|
// ── Host dependencies ──
|
||||||
|
//
|
||||||
|
// Music is the plugin that made `osDependencies` exist. Offscale was self-sufficient, so until this one
|
||||||
|
// there was nothing to declare and no reason to build the field — see ./PLUGIN.md.
|
||||||
|
export const manifest: PluginManifest = {
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '1.0.0',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
|
||||||
|
label: 'Music',
|
||||||
|
summary: 'The music library — browse, play, favourites and playlists',
|
||||||
|
// No `icon` field: this plugin ships `assets/icon.png` and the file wins. A lucide name could only
|
||||||
|
// ever pick from the 106 glyphs the platform happens to bundle, which is a ceiling a plugin from a
|
||||||
|
// marketplace cannot see coming — and this one's artwork is a voxel duck in headphones, not a glyph.
|
||||||
|
color: '#22c55e',
|
||||||
|
|
||||||
|
// One permission gating the whole surface, grantable per role at read or write like every other.
|
||||||
|
//
|
||||||
|
// The key is `music` and that is not incidental: it is the key the platform's own registry used until
|
||||||
|
// this extraction, so every existing `role_permissions` grant keeps meaning what it meant, and the
|
||||||
|
// overlay's `can('music')` keeps resolving. Renaming it would have been a silent data change.
|
||||||
|
//
|
||||||
|
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. Favourites,
|
||||||
|
// playlists and now-playing are already per-caller — the sidecar scopes every one of them by the
|
||||||
|
// `X-Officer-User` header the proxy injects — while the library itself is one shared index for the
|
||||||
|
// household. So "whose row is this" already has a real, non-uniform answer, which is why the platform's
|
||||||
|
// side of it is a uniform read/write and nothing more. Designing the rest belongs in these queries.
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'music',
|
||||||
|
label: 'Music',
|
||||||
|
description: 'The music library, playback, and your own favourites and playlists',
|
||||||
|
// These are the `personal` paths from the registry entry this replaces, carried across verbatim.
|
||||||
|
//
|
||||||
|
// They are not read-only — they are genuine writes to the CALLER'S own data, which is what made
|
||||||
|
// them safe at read level. The manifest deliberately has no `personal` field, and adding one would
|
||||||
|
// be designing the per-user visibility model that is explicitly not this extraction's work. It
|
||||||
|
// costs nothing to go without: `isRequestAllowedAtLevel` concatenates `personal` and
|
||||||
|
// `readOnlyWrites` into a single allow-list, so the two are the same mechanism under two names and
|
||||||
|
// a read grant permits exactly the same four paths it permitted yesterday.
|
||||||
|
//
|
||||||
|
// `/queue` is here because it was there. No such route exists, in the sidecar or anywhere else.
|
||||||
|
readOnlyWrites: ['/favorites', '/now-playing', '/playlists', '/queue'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Both come from one package everywhere, which is luck rather than a rule — hence a name per manager
|
||||||
|
// rather than one canonical name. `packages.sh` records why that indirection was rejected.
|
||||||
|
//
|
||||||
|
// They are declared SEPARATELY even so, because the platform probes binaries and these two fail
|
||||||
|
// differently. Losing `ffprobe` is the quiet one: the indexer catches the spawn error and returns a
|
||||||
|
// track carrying its filename and nothing else — no title, artist, album, duration or embedded
|
||||||
|
// lyrics — then reports success. Losing `ffmpeg` costs cover art and video poster frames, which is at
|
||||||
|
// least visible. Naming both means the owner is told which of the two they are missing.
|
||||||
|
osDependencies: [
|
||||||
|
{
|
||||||
|
binary: 'ffprobe',
|
||||||
|
reason: 'Reads tags, duration and embedded lyrics. Without it every track indexes as a bare filename.',
|
||||||
|
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binary: 'ffmpeg',
|
||||||
|
reason: 'Compresses cover art for phones and grabs poster frames from videos.',
|
||||||
|
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Trigger the music library index on the officer-music sidecar and follow its progress live,
|
||||||
|
* ending with a summary report. Run from the platform repo: bun scripts/reindex-music.ts
|
||||||
|
*
|
||||||
|
* It reads the sidecar's port from DATA_PATH/music/.server (written by the sidecar on startup) and
|
||||||
|
* consumes its /reindex/stream SSE endpoint — the same stream the app subscribes to.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||||
|
const PORT_FILE = join(DATA_PATH, 'music', '.server');
|
||||||
|
|
||||||
|
type Progress = {
|
||||||
|
running: boolean;
|
||||||
|
foldersScanned: number;
|
||||||
|
albumsBuilt: number;
|
||||||
|
albumsSkipped: number;
|
||||||
|
tracksIndexed: number;
|
||||||
|
coversSaved: number;
|
||||||
|
currentPath: string;
|
||||||
|
};
|
||||||
|
type Report = {
|
||||||
|
albums: number;
|
||||||
|
built: number;
|
||||||
|
skipped: number;
|
||||||
|
foldersScanned: number;
|
||||||
|
tracksIndexed: number;
|
||||||
|
coversSaved: number;
|
||||||
|
discographies: number;
|
||||||
|
elapsedSec: number;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readPort(): number {
|
||||||
|
try {
|
||||||
|
const p = parseInt(readFileSync(PORT_FILE, 'utf8').trim(), 10);
|
||||||
|
if (Number.isInteger(p) && p > 0) return p;
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
console.error(`✗ Could not read the sidecar port from ${PORT_FILE}.`);
|
||||||
|
console.error(' Is officer-music running? pm2 restart officer-music');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTTY = Boolean(process.stdout.isTTY);
|
||||||
|
const cols = () => (process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100);
|
||||||
|
|
||||||
|
function printProgress(p: Progress): void {
|
||||||
|
const line =
|
||||||
|
`♪ indexing… folders ${p.foldersScanned} · tracks ${p.tracksIndexed} · ` +
|
||||||
|
`built ${p.albumsBuilt} · skipped ${p.albumsSkipped} · covers ${p.coversSaved}` +
|
||||||
|
(p.currentPath ? ` · ${p.currentPath}` : '');
|
||||||
|
if (isTTY) {
|
||||||
|
const clipped = line.length > cols() - 1 ? line.slice(0, cols() - 2) + '…' : line;
|
||||||
|
process.stdout.write('\r\x1b[2K' + clipped);
|
||||||
|
} else {
|
||||||
|
process.stdout.write(line + '\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printReport(r: Report): void {
|
||||||
|
if (isTTY) process.stdout.write('\r\x1b[2K');
|
||||||
|
const l = (k: string, v: string | number) => console.log(` ${k.padEnd(9)} ${v}`);
|
||||||
|
console.log('\n─── Music index complete ───');
|
||||||
|
l('Albums:', `${r.albums} (${r.built} built, ${r.skipped} unchanged)`);
|
||||||
|
l('Tracks:', `${r.tracksIndexed} indexed`);
|
||||||
|
l('Covers:', `${r.coversSaved} compressed`);
|
||||||
|
l('Discogs:', `${r.discographies} artist${r.discographies === 1 ? '' : 's'}`);
|
||||||
|
l('Folders:', `${r.foldersScanned} scanned`);
|
||||||
|
l('Elapsed:', `${r.elapsedSec}s`);
|
||||||
|
if (r.error) l('Error:', r.error);
|
||||||
|
console.log('────────────────────────────\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const port = readPort();
|
||||||
|
const url = `http://127.0.0.1:${port}/reindex/stream`;
|
||||||
|
console.log(`Triggering music index via ${url}\n`);
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`✗ Could not reach the sidecar at 127.0.0.1:${port}: ${String(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
console.error(`✗ Sidecar returned ${res.status}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
let sep: number;
|
||||||
|
while ((sep = buf.indexOf('\n\n')) >= 0) {
|
||||||
|
const frame = buf.slice(0, sep);
|
||||||
|
buf = buf.slice(sep + 2);
|
||||||
|
let event = 'message';
|
||||||
|
let data = '';
|
||||||
|
for (const line of frame.split('\n')) {
|
||||||
|
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||||
|
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
||||||
|
}
|
||||||
|
if (!data) continue;
|
||||||
|
if (event === 'progress') printProgress(JSON.parse(data) as Progress);
|
||||||
|
else if (event === 'done') printReport(JSON.parse(data) as Report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join, basename } from 'node:path';
|
||||||
|
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||||
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||||
|
import { streamAudioFile } from './stream-audio';
|
||||||
|
import { cliampUpgradeData, musicWebsocket } from '../cliamp/cliamp-ws';
|
||||||
|
import { ensurePulseAudio } from '../cliamp/pulse-audio';
|
||||||
|
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
|
||||||
|
import {
|
||||||
|
reindexNow,
|
||||||
|
reindexFull,
|
||||||
|
ensureCacheSetup,
|
||||||
|
MUSIC_ROOT,
|
||||||
|
getIndexStatus,
|
||||||
|
getManifest,
|
||||||
|
albumVersion,
|
||||||
|
metaFilePath,
|
||||||
|
coverFilePath,
|
||||||
|
discographyFilePath,
|
||||||
|
posterFilePath,
|
||||||
|
lyricsFilePath,
|
||||||
|
onIndexProgress,
|
||||||
|
buildReport,
|
||||||
|
} from './indexer';
|
||||||
|
import {
|
||||||
|
getMusicFavorites,
|
||||||
|
addMusicFavorite,
|
||||||
|
removeMusicFavorite,
|
||||||
|
getNowPlaying,
|
||||||
|
setNowPlaying,
|
||||||
|
clearNowPlaying,
|
||||||
|
getPlaylists,
|
||||||
|
getPlaylist,
|
||||||
|
createPlaylist,
|
||||||
|
renamePlaylist,
|
||||||
|
deletePlaylist,
|
||||||
|
addPlaylistItems,
|
||||||
|
setPlaylistItems,
|
||||||
|
type FavoriteKind,
|
||||||
|
} from '../db/queries';
|
||||||
|
import { DATA_PATH } from '@@/data-path';
|
||||||
|
import { API_URL } from '@@/officer-url.mjs';
|
||||||
|
|
||||||
|
// ── Per-user state validation ──
|
||||||
|
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
|
||||||
|
// loopback-only so we trust it). Favorite/playlist `key`s are opaque paths we never interpret.
|
||||||
|
const userIdOf = (req: Request): number | null => {
|
||||||
|
const n = Number(req.headers.get('x-officer-user'));
|
||||||
|
return Number.isInteger(n) && n > 0 ? n : null;
|
||||||
|
};
|
||||||
|
const FAVORITE_KINDS = new Set<FavoriteKind>(['track', 'album', 'artist']);
|
||||||
|
const isKind = (k: unknown): k is FavoriteKind => typeof k === 'string' && FAVORITE_KINDS.has(k as FavoriteKind);
|
||||||
|
const PLAYLIST_NAME_MAX = 200;
|
||||||
|
const cleanName = (v: unknown): string | null => {
|
||||||
|
if (typeof v !== 'string') return null;
|
||||||
|
const n = v.trim();
|
||||||
|
return n && n.length <= PLAYLIST_NAME_MAX ? n : null;
|
||||||
|
};
|
||||||
|
const asKeys = (v: unknown): string[] | null =>
|
||||||
|
Array.isArray(v) && v.every((k) => typeof k === 'string' && k) ? (v as string[]) : null;
|
||||||
|
|
||||||
|
// The music sidecar (officer-music). Same philosophy as the other officer-* sidecars: a singleton
|
||||||
|
// process that registers with the API server. It OWNS an audio-streaming HTTP server (all path
|
||||||
|
// resolution + streaming + ffprobe duration happens here); the platform API is just a thin proxy that
|
||||||
|
// authenticates and forwards to us. The server listens on a random loopback port, reported to the API
|
||||||
|
// on connect so it can route `/api/music/*` here.
|
||||||
|
//
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// HTTP CONTRACT — the full `/api/music/*` surface (this fetch handler is the source of truth; the
|
||||||
|
// platform side is an opaque catch-all proxy). All routes are reached as `/api/music/<name>`, authed
|
||||||
|
// upstream by userMiddleware (Bearer header or `?token=` for media). Data shapes are the exported
|
||||||
|
// `IndexStatus` / `IndexReport` / `IndexMeta` types in indexer.ts.
|
||||||
|
//
|
||||||
|
// GET /stream?path=<home-relative> audio with Range→206 (Content-Range/Length/Accept-Ranges)
|
||||||
|
// + `X-Audio-Duration` (seconds, ffprobe). 400/404/416.
|
||||||
|
// GET /manifest pure read of the last completed index (NO build triggered) —
|
||||||
|
// { version, generatedAt, albums: { "<rel>": { v, cover, tracks, videos?, disco? } } }
|
||||||
|
// GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
|
||||||
|
// GET /cover?path=<rel> compressed cover.jpg. ETag: <v>; If-None-Match → 304.
|
||||||
|
// GET /poster?path=<rel>&file=<video> compressed video poster (frame grab). ETag: <v>; 304. 404 if none.
|
||||||
|
// GET /lyrics?path=<rel>&file=<track> track lyrics text (X-Lyrics-Format: lrc|txt). ETag: <v>; 304. 404 if none.
|
||||||
|
// GET /image?path=<rel>&file=<img> loose folder image bytes (image/*, the ORIGINAL). ETag: <v>; 304. 404 if none.
|
||||||
|
// GET /discography?path=<artist rel> artist discography.json (only where manifest entry has
|
||||||
|
// disco:true) = { artist, albums: { "<[year] album folder>":
|
||||||
|
// "<Type>" } }, Type ∈ Studio/Live/Compilation/Single/EP/…
|
||||||
|
// ETag: <v>; If-None-Match → 304. Source: each artist folder's
|
||||||
|
// _discography.md (normalized; the md itself is never modified).
|
||||||
|
// POST /reindex[?full=1] run the build to COMPLETION, then return the final IndexStatus.
|
||||||
|
// default = incremental (skips unchanged); ?full=1 = full staged
|
||||||
|
// rebuild + atomic swap (backfill a meta-format change).
|
||||||
|
// GET /reindex/status IndexStatus snapshot.
|
||||||
|
// GET /reindex/stream SSE. Triggers a build if idle (`?trigger=0` = watch-only).
|
||||||
|
// `event: progress` (IndexStatus) throttled ~200ms, then one
|
||||||
|
// `event: done` (IndexReport) and the stream closes.
|
||||||
|
// GET /health "ok".
|
||||||
|
//
|
||||||
|
// ── Per-user state (USER data in Postgres, not library data). User id in X-Officer-User, injected by
|
||||||
|
// the platform proxy after auth; `key`s are opaque paths (track homePath / album|artist rel). ──
|
||||||
|
// GET /favorites { tracks[], albums[], artists[] } (keys, newest first).
|
||||||
|
// POST /favorites { kind, key } add (idempotent). kind ∈ track|album|artist.
|
||||||
|
// DELETE /favorites?kind=&key= remove.
|
||||||
|
// GET /now-playing[?device=] last snapshot for that device, or null. device '' = default/phone, 'web' = browser.
|
||||||
|
// PUT /now-playing[?device=] { homePath, dir?, title?, artist?, album?, durationSec?, positionSec? } upsert.
|
||||||
|
// DELETE /now-playing[?device=] clear that device's snapshot.
|
||||||
|
// GET /playlists [{ id, name, count, createdAt, updatedAt }] (recent first).
|
||||||
|
// POST /playlists { name } create → 201 row; 409 if name taken.
|
||||||
|
// GET /playlists/:id { id, name, items:[keys], … }; 404 if not the user's.
|
||||||
|
// PATCH /playlists/:id { name } rename; 404 / 409.
|
||||||
|
// DELETE /playlists/:id delete (items cascade); 404.
|
||||||
|
// POST /playlists/:id/items { keys[] } append → { count }; 404.
|
||||||
|
// PUT /playlists/:id/items { keys[] } replace whole list (reorder/remove) → { count }; 404.
|
||||||
|
//
|
||||||
|
// `<rel>` = album folder path relative to the Music root (e.g. "Albums/AC-DC/[1980] Back in Black").
|
||||||
|
// `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── Audio-streaming HTTP server ──
|
||||||
|
|
||||||
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
|
function getFreePort(): number {
|
||||||
|
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||||
|
const port = probe.port;
|
||||||
|
probe.stop(true);
|
||||||
|
if (port == null) throw new Error('failed to acquire a free port');
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = getFreePort();
|
||||||
|
|
||||||
|
// Ensure the cache is a symlink-to-slot before serving/building, so full reindexes can swap atomically.
|
||||||
|
await ensureCacheSetup();
|
||||||
|
|
||||||
|
// Nightly full reindex at 3am (staged + atomic swap).
|
||||||
|
startNightlyReindex();
|
||||||
|
|
||||||
|
// No filesystem watcher on ~/Music. Bun's recursive fs.watch costs one inotify watch per ENTRY, files
|
||||||
|
// included — ~92k for this library against a 65536 ceiling — so it could never establish, and the
|
||||||
|
// ENOSPC came back asynchronously as an unhandled 'error' event that killed this whole sidecar 17k
|
||||||
|
// times over. It also drained the per-UID watch pool, starving every other watcher on the machine.
|
||||||
|
// Reindexing is triggered instead: the ↻ button in the music browser (POST /reindex, incremental) and
|
||||||
|
// the nightly full rebuild above. `reindexFolder` in the indexer is retained and currently unused — it
|
||||||
|
// is the targeted hook for whatever writes to ~/Music (slskd, transmission, download-media) to declare
|
||||||
|
// the one folder it just wrote, which is the cheap version of what the watcher was guessing at.
|
||||||
|
|
||||||
|
// PulseAudio daemon + the `virtual_out` null sink both cliamp halves depend on. Officer used to do this at
|
||||||
|
// its own boot, which meant every restart of a process with no audio responsibilities re-checked the sink.
|
||||||
|
ensurePulseAudio();
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port,
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
// Bun caps the server-level idleTimeout at 255s. Keep it there as the baseline; the build/stream
|
||||||
|
// endpoints (which can idle for a whole from-scratch rebuild) extend it per-request via server.timeout.
|
||||||
|
idleTimeout: 255,
|
||||||
|
async fetch(req, server) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
// The two cliamp sockets. Officer has already authenticated the browser and is relaying frames; the
|
||||||
|
// player and the capture themselves live here (cliamp-ws.ts).
|
||||||
|
const wsData = cliampUpgradeData(url.pathname, url.searchParams);
|
||||||
|
if (wsData) {
|
||||||
|
if (server.upgrade(req, { data: wsData })) return undefined as unknown as Response;
|
||||||
|
return new Response('Expected a WebSocket upgrade', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// A from-scratch reindex can take many minutes with no bytes flowing on the triggering request.
|
||||||
|
// Give the build endpoints a 30-min idle timeout so they aren't dropped (/manifest is a pure read now).
|
||||||
|
if (url.pathname === '/reindex' || url.pathname === '/reindex/stream') {
|
||||||
|
server.timeout(req, 1800);
|
||||||
|
}
|
||||||
|
const json = (data: unknown, init?: ResponseInit) =>
|
||||||
|
new Response(JSON.stringify(data), {
|
||||||
|
...init,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||||
|
});
|
||||||
|
// Version-stamped artifacts (cover/meta/…) and the manifest carry an ETag(=v) but must be REVALIDATED,
|
||||||
|
// not served blind from the browser cache — otherwise a changed cover keeps showing the old image at
|
||||||
|
// the same URL. `no-cache` = cache but always revalidate; the ETag/If-None-Match then makes it a cheap
|
||||||
|
// 304 when nothing changed. (The platform proxy forwards If-None-Match so this works end-to-end.)
|
||||||
|
const NO_CACHE = { 'Cache-Control': 'no-cache' } as const;
|
||||||
|
|
||||||
|
if (url.pathname === '/health') return new Response('ok');
|
||||||
|
|
||||||
|
// ── Streaming ──
|
||||||
|
if (url.pathname === '/stream') {
|
||||||
|
const path = url.searchParams.get('path');
|
||||||
|
if (!path) return new Response('path is required', { status: 400 });
|
||||||
|
return streamAudioFile(path, req.headers.get('range'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Index build ──
|
||||||
|
if (url.pathname === '/reindex') {
|
||||||
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
||||||
|
// Run to completion, THEN respond — so the caller's manifest read right after is fresh. Both join an
|
||||||
|
// in-flight build rather than starting a second.
|
||||||
|
// default : incremental — near-instant, skips unchanged albums by version stamp.
|
||||||
|
// ?full=1 : full from-scratch rebuild into a fresh slot, swapped in atomically (staged + safe) —
|
||||||
|
// use to backfill a meta-format change (e.g. a new track field) across the WHOLE library.
|
||||||
|
const full = url.searchParams.get('full') === '1' || url.searchParams.get('full') === 'true';
|
||||||
|
const result = await (full ? reindexFull() : reindexNow());
|
||||||
|
return json(result);
|
||||||
|
}
|
||||||
|
if (url.pathname === '/reindex/status') return json(getIndexStatus());
|
||||||
|
|
||||||
|
// SSE progress stream (for the app + the CLI). Triggers a build if idle (unless ?trigger=0), then
|
||||||
|
// streams `progress` events until the build finishes, ending with a `done` event carrying the report.
|
||||||
|
if (url.pathname === '/reindex/stream') {
|
||||||
|
const trigger = url.searchParams.get('trigger') !== '0';
|
||||||
|
if (trigger) void reindexNow();
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
let closed = false;
|
||||||
|
let unsub = () => {};
|
||||||
|
const send = (event: string, data: unknown) => {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
||||||
|
} catch {
|
||||||
|
/* stream closed */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const finish = (s: ReturnType<typeof getIndexStatus>) => {
|
||||||
|
send('done', buildReport(s));
|
||||||
|
unsub();
|
||||||
|
closed = true;
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
/* already closed */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
send('progress', getIndexStatus());
|
||||||
|
const cur = getIndexStatus();
|
||||||
|
if (!cur.running) {
|
||||||
|
finish(cur); // nothing running → emit the last report and close
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unsub = onIndexProgress((s) => {
|
||||||
|
send('progress', s);
|
||||||
|
if (!s.running && s.finishedAt) finish(s);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync surface ──
|
||||||
|
if (url.pathname === '/manifest') {
|
||||||
|
// Pure read — returns the last completed index. It does NOT trigger a build (that could kick off a
|
||||||
|
// long/full rebuild on a plain app refresh); use POST /reindex explicitly to pick up disk changes.
|
||||||
|
return json(await getManifest(), { headers: NO_CACHE });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video poster (a compressed frame grab). Keyed by the video's folder rel + its filename.
|
||||||
|
if (url.pathname === '/poster') {
|
||||||
|
const rel = url.searchParams.get('path');
|
||||||
|
const file = url.searchParams.get('file');
|
||||||
|
if (rel === null || !file) return new Response('path and file are required', { status: 400 });
|
||||||
|
const posterPath = posterFilePath(rel, file);
|
||||||
|
if (!posterPath) return new Response('Invalid path', { status: 400 });
|
||||||
|
if (!(await Bun.file(posterPath).exists())) return new Response('Not found', { status: 404 });
|
||||||
|
const v = await albumVersion(rel);
|
||||||
|
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304, headers: NO_CACHE });
|
||||||
|
return new Response(Bun.file(posterPath), {
|
||||||
|
headers: { 'Content-Type': 'image/jpeg', ...NO_CACHE, ...(v ? { ETag: v } : {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track lyrics (external .lrc/.txt sidecar or embedded, cached during indexing). Try synced first.
|
||||||
|
if (url.pathname === '/lyrics') {
|
||||||
|
const rel = url.searchParams.get('path');
|
||||||
|
const file = url.searchParams.get('file');
|
||||||
|
if (rel === null || !file) return new Response('path and file are required', { status: 400 });
|
||||||
|
for (const fmt of ['lrc', 'txt'] as const) {
|
||||||
|
const p = lyricsFilePath(rel, file, fmt);
|
||||||
|
if (p && (await Bun.file(p).exists())) {
|
||||||
|
const v = await albumVersion(rel);
|
||||||
|
if (v && req.headers.get('if-none-match') === v)
|
||||||
|
return new Response(null, { status: 304, headers: NO_CACHE });
|
||||||
|
return new Response(Bun.file(p), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'X-Lyrics-Format': fmt,
|
||||||
|
...NO_CACHE,
|
||||||
|
...(v ? { ETag: v } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folder image (band photo / booklet scan) — served as the ORIGINAL file from the library folder
|
||||||
|
// (no cached artifact). `path` = music-relative folder, `file` = image name (basename'd for safety).
|
||||||
|
if (url.pathname === '/image') {
|
||||||
|
const rel = url.searchParams.get('path') ?? '';
|
||||||
|
const file = basename(url.searchParams.get('file') ?? '');
|
||||||
|
if (!file) return new Response('file is required', { status: 400 });
|
||||||
|
const abs = join(MUSIC_ROOT, rel, file);
|
||||||
|
if (abs !== MUSIC_ROOT && !abs.startsWith(MUSIC_ROOT + '/')) return new Response('Invalid path', { status: 400 });
|
||||||
|
if (!(await Bun.file(abs).exists())) return new Response('Not found', { status: 404 });
|
||||||
|
const v = await albumVersion(rel);
|
||||||
|
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304, headers: NO_CACHE });
|
||||||
|
const ext = file.slice(file.lastIndexOf('.') + 1).toLowerCase();
|
||||||
|
const type =
|
||||||
|
ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : ext === 'gif' ? 'image/gif' : 'image/jpeg';
|
||||||
|
return new Response(Bun.file(abs), { headers: { 'Content-Type': type, ...NO_CACHE, ...(v ? { ETag: v } : {}) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/meta' || url.pathname === '/cover' || url.pathname === '/discography') {
|
||||||
|
const rel = url.searchParams.get('path');
|
||||||
|
if (rel === null) return new Response('path is required', { status: 400 });
|
||||||
|
const spec = {
|
||||||
|
'/meta': { file: metaFilePath(rel), type: 'application/json' },
|
||||||
|
'/cover': { file: coverFilePath(rel), type: 'image/jpeg' },
|
||||||
|
'/discography': { file: discographyFilePath(rel), type: 'application/json' },
|
||||||
|
}[url.pathname]!;
|
||||||
|
if (!spec.file) return new Response('Invalid path', { status: 400 });
|
||||||
|
if (!(await Bun.file(spec.file).exists())) return new Response('Not found', { status: 404 });
|
||||||
|
|
||||||
|
const v = await albumVersion(rel);
|
||||||
|
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304, headers: NO_CACHE });
|
||||||
|
return new Response(Bun.file(spec.file), {
|
||||||
|
headers: { 'Content-Type': spec.type, ...NO_CACHE, ...(v ? { ETag: v } : {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-user state (favorites / now-playing / playlists) ──────────────────────────────────────
|
||||||
|
// USER data backed by Postgres (NOT library data). The user id comes from X-Officer-User (see above).
|
||||||
|
const P = url.pathname;
|
||||||
|
if (P === '/favorites' || P === '/now-playing' || P === '/playlists' || P.startsWith('/playlists/')) {
|
||||||
|
const uid = userIdOf(req);
|
||||||
|
if (uid === null) return json({ error: 'unauthenticated' }, { status: 401 });
|
||||||
|
const m = req.method;
|
||||||
|
|
||||||
|
if (P === '/favorites') {
|
||||||
|
if (m === 'GET') return json(await getMusicFavorites(uid));
|
||||||
|
if (m === 'POST') {
|
||||||
|
const { kind, key } = (await req.json().catch(() => ({}))) as { kind?: unknown; key?: unknown };
|
||||||
|
if (!isKind(kind) || typeof key !== 'string' || !key)
|
||||||
|
return json({ error: 'kind and key required' }, { status: 400 });
|
||||||
|
await addMusicFavorite(uid, kind, key);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
if (m === 'DELETE') {
|
||||||
|
const kind = url.searchParams.get('kind');
|
||||||
|
const key = url.searchParams.get('key');
|
||||||
|
if (!isKind(kind) || !key) return json({ error: 'kind and key required' }, { status: 400 });
|
||||||
|
await removeMusicFavorite(uid, kind, key);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
return new Response('Method not allowed', { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (P === '/now-playing') {
|
||||||
|
// Per-client resume state: `?device=` tags the caller ('' = default/phone, 'web' = the browser)
|
||||||
|
// so each keeps its own now-playing instead of sharing one row. Missing → '' (back-compat).
|
||||||
|
const device = url.searchParams.get('device') ?? '';
|
||||||
|
if (m === 'GET') return json(await getNowPlaying(uid, device));
|
||||||
|
if (m === 'PUT') {
|
||||||
|
const b = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
|
if (typeof b.homePath !== 'string' || !b.homePath)
|
||||||
|
return json({ error: 'homePath required' }, { status: 400 });
|
||||||
|
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
|
||||||
|
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
|
||||||
|
await setNowPlaying(uid, device, {
|
||||||
|
homePath: b.homePath,
|
||||||
|
dir: str(b.dir),
|
||||||
|
title: str(b.title),
|
||||||
|
artist: str(b.artist),
|
||||||
|
album: str(b.album),
|
||||||
|
durationSec: num(b.durationSec),
|
||||||
|
positionSec: num(b.positionSec),
|
||||||
|
});
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
if (m === 'DELETE') {
|
||||||
|
await clearNowPlaying(uid, device);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
return new Response('Method not allowed', { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (P === '/playlists') {
|
||||||
|
if (m === 'GET') return json(await getPlaylists(uid));
|
||||||
|
if (m === 'POST') {
|
||||||
|
const { name } = (await req.json().catch(() => ({}))) as { name?: unknown };
|
||||||
|
const n = cleanName(name);
|
||||||
|
if (!n) return json({ error: 'name required (1-200 chars)' }, { status: 400 });
|
||||||
|
const row = await createPlaylist(uid, n);
|
||||||
|
return row
|
||||||
|
? json(row, { status: 201 })
|
||||||
|
: json({ error: 'a playlist with that name already exists' }, { status: 409 });
|
||||||
|
}
|
||||||
|
return new Response('Method not allowed', { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// /playlists/:id and /playlists/:id/items
|
||||||
|
const match = P.match(/^\/playlists\/(\d+)(\/items)?$/);
|
||||||
|
if (!match) return json({ error: 'not found' }, { status: 404 });
|
||||||
|
const id = Number(match[1]);
|
||||||
|
|
||||||
|
if (match[2]) {
|
||||||
|
// /playlists/:id/items — POST append, PUT replace (reorder/remove)
|
||||||
|
if (m !== 'POST' && m !== 'PUT') return new Response('Method not allowed', { status: 405 });
|
||||||
|
const { keys } = (await req.json().catch(() => ({}))) as { keys?: unknown };
|
||||||
|
const ks = asKeys(keys);
|
||||||
|
if (!ks) return json({ error: 'keys[] required' }, { status: 400 });
|
||||||
|
const count = await (m === 'POST' ? addPlaylistItems(uid, id, ks) : setPlaylistItems(uid, id, ks));
|
||||||
|
return count === null ? json({ error: 'not found' }, { status: 404 }) : json({ count });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m === 'GET') {
|
||||||
|
const pl = await getPlaylist(uid, id);
|
||||||
|
return pl ? json(pl) : json({ error: 'not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (m === 'PATCH') {
|
||||||
|
const { name } = (await req.json().catch(() => ({}))) as { name?: unknown };
|
||||||
|
const n = cleanName(name);
|
||||||
|
if (!n) return json({ error: 'name required (1-200 chars)' }, { status: 400 });
|
||||||
|
if (!(await getPlaylist(uid, id))) return json({ error: 'not found' }, { status: 404 });
|
||||||
|
const ok = await renamePlaylist(uid, id, n);
|
||||||
|
return ok ? json({ ok: true }) : json({ error: 'a playlist with that name already exists' }, { status: 409 });
|
||||||
|
}
|
||||||
|
if (m === 'DELETE') {
|
||||||
|
const ok = await deletePlaylist(uid, id);
|
||||||
|
return ok ? json({ ok: true }) : json({ error: 'not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
return new Response('Method not allowed', { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
},
|
||||||
|
websocket: musicWebsocket,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[music] audio server listening on http://127.0.0.1:${port}`);
|
||||||
|
|
||||||
|
// Write the port to a well-known file so local tooling (scripts/reindex-music.ts) can find the server.
|
||||||
|
try {
|
||||||
|
mkdirSync(join(DATA_PATH, 'music'), { recursive: true });
|
||||||
|
writeFileSync(join(DATA_PATH, 'music', '.server'), String(port));
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Command handlers ──
|
||||||
|
|
||||||
|
type ReplyFn = (msg: SidecarEvent) => void;
|
||||||
|
|
||||||
|
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||||
|
switch (cmd.type) {
|
||||||
|
case 'ping':
|
||||||
|
reply({ type: 'pong', id: cmd.id });
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
reply({
|
||||||
|
type: 'error',
|
||||||
|
id: (cmd as SidecarCommand).id,
|
||||||
|
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connect to API server ──
|
||||||
|
|
||||||
|
const connection = createSidecarConnector({
|
||||||
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
|
name: 'music',
|
||||||
|
handles: ['music'],
|
||||||
|
onCommand(cmd, reply) {
|
||||||
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
|
},
|
||||||
|
onConnected() {
|
||||||
|
// Tell the API where our audio server is listening, so it can proxy /api/music/* here.
|
||||||
|
connection.send({ type: 'music:server', port });
|
||||||
|
console.log(`[music] reported audio server port ${port} to API`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Graceful shutdown ──
|
||||||
|
|
||||||
|
function shutdown(signal: string) {
|
||||||
|
console.log(`[music] ${signal} received, shutting down...`);
|
||||||
|
stopNightlyReindex();
|
||||||
|
try {
|
||||||
|
server.stop(true);
|
||||||
|
} catch {
|
||||||
|
/* already stopped */
|
||||||
|
}
|
||||||
|
connection.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
+1079
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
|||||||
|
import { reindexFull } from './indexer';
|
||||||
|
|
||||||
|
// Nightly full-from-scratch reindex at 3am (server-local time). Uses reindexFull, so it builds into a
|
||||||
|
// fresh slot and atomically swaps it in only on success — the live index is never disrupted mid-build.
|
||||||
|
// Self-scheduling (a fresh setTimeout each night) rather than setInterval, so it always fires at 3am
|
||||||
|
// regardless of drift.
|
||||||
|
|
||||||
|
const REINDEX_HOUR = 3;
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function msUntilNextHour(hour: number): number {
|
||||||
|
const now = new Date();
|
||||||
|
const next = new Date(now);
|
||||||
|
next.setHours(hour, 0, 0, 0);
|
||||||
|
if (next <= now) next.setDate(next.getDate() + 1);
|
||||||
|
return next.getTime() - now.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startNightlyReindex(): void {
|
||||||
|
const schedule = () => {
|
||||||
|
const ms = msUntilNextHour(REINDEX_HOUR);
|
||||||
|
const at = new Date(Date.now() + ms);
|
||||||
|
console.log(
|
||||||
|
`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`,
|
||||||
|
);
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
console.log('[music] nightly full reindex starting');
|
||||||
|
try {
|
||||||
|
await reindexFull();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[music] nightly full reindex error:', err instanceof Error ? err.message : err);
|
||||||
|
}
|
||||||
|
schedule(); // reschedule for the following night
|
||||||
|
}, ms);
|
||||||
|
};
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopNightlyReindex(): void {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { stat } from 'node:fs/promises';
|
||||||
|
import { resolve, sep } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
// All processing lives here (the platform is just a proxy). Files live under the owner's home — single
|
||||||
|
// super user, so HOME_DIR. Paths from the app are home-relative (e.g. "Music/Artist/Album/track.mp3"),
|
||||||
|
// exactly like file-browser /raw.
|
||||||
|
const ROOT_DIR = homedir();
|
||||||
|
|
||||||
|
const CONTENT_TYPES: Record<string, string> = {
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
m4a: 'audio/mp4',
|
||||||
|
mp4: 'audio/mp4',
|
||||||
|
aac: 'audio/aac',
|
||||||
|
flac: 'audio/flac',
|
||||||
|
wav: 'audio/wav',
|
||||||
|
ogg: 'audio/ogg',
|
||||||
|
opus: 'audio/opus',
|
||||||
|
wma: 'audio/x-ms-wma',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Probe duration once per file (keyed by absolute path + mtime) — the player makes many range requests
|
||||||
|
// per track, and we don't want to shell out to ffprobe on each one.
|
||||||
|
const durationCache = new Map<string, number>();
|
||||||
|
|
||||||
|
async function probeDuration(absPath: string, mtimeMs: number): Promise<number | undefined> {
|
||||||
|
const key = `${absPath}:${mtimeMs}`;
|
||||||
|
const cached = durationCache.get(key);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
'ffprobe',
|
||||||
|
'-v',
|
||||||
|
'error',
|
||||||
|
'-show_entries',
|
||||||
|
'format=duration',
|
||||||
|
'-of',
|
||||||
|
'default=noprint_wrappers=1:nokey=1',
|
||||||
|
absPath,
|
||||||
|
],
|
||||||
|
{ stdout: 'pipe', stderr: 'ignore' },
|
||||||
|
);
|
||||||
|
const out = (await new Response(proc.stdout).text()).trim();
|
||||||
|
await proc.exited;
|
||||||
|
const d = parseFloat(out);
|
||||||
|
if (Number.isFinite(d) && d > 0) {
|
||||||
|
durationCache.set(key, d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ffprobe missing or failed — no duration header */
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a home-relative path within ROOT_DIR; null if it escapes (traversal). */
|
||||||
|
function resolveWithinRoot(relPath: string): string | null {
|
||||||
|
const clean = relPath.replace(/^\/+/, '');
|
||||||
|
const abs = resolve(ROOT_DIR, clean);
|
||||||
|
if (abs !== ROOT_DIR && !abs.startsWith(ROOT_DIR + sep)) return null;
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serve an audio file with byte-range support + an X-Audio-Duration header (ffprobe-derived). */
|
||||||
|
export async function streamAudioFile(relPath: string, rangeHeader: string | null): Promise<Response> {
|
||||||
|
const absPath = resolveWithinRoot(relPath);
|
||||||
|
if (!absPath) return new Response('Invalid path', { status: 400 });
|
||||||
|
|
||||||
|
let s;
|
||||||
|
try {
|
||||||
|
s = await stat(absPath);
|
||||||
|
} catch {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
if (!s.isFile()) return new Response('Not a file', { status: 404 });
|
||||||
|
|
||||||
|
const total = s.size;
|
||||||
|
const ext = absPath.slice(absPath.lastIndexOf('.') + 1).toLowerCase();
|
||||||
|
const contentType = CONTENT_TYPES[ext] ?? 'application/octet-stream';
|
||||||
|
const duration = await probeDuration(absPath, s.mtimeMs);
|
||||||
|
const file = Bun.file(absPath);
|
||||||
|
|
||||||
|
const baseHeaders: Record<string, string> = {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
...(duration ? { 'X-Audio-Duration': String(duration) } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (rangeHeader) {
|
||||||
|
const m = rangeHeader.match(/bytes=(\d*)-(\d*)/);
|
||||||
|
if (m) {
|
||||||
|
const start = m[1] ? parseInt(m[1], 10) : 0;
|
||||||
|
const end = m[2] ? parseInt(m[2], 10) : total - 1;
|
||||||
|
if (Number.isNaN(start) || start < 0 || end >= total || start > end) {
|
||||||
|
return new Response('Invalid range', { status: 416, headers: { 'Content-Range': `bytes */${total}` } });
|
||||||
|
}
|
||||||
|
return new Response(file.slice(start, end + 1), {
|
||||||
|
status: 206,
|
||||||
|
headers: {
|
||||||
|
...baseHeaders,
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${total}`,
|
||||||
|
'Content-Length': String(end - start + 1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(file, { status: 200, headers: { ...baseHeaders, 'Content-Length': String(total) } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
|
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
|
||||||
|
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
||||||
|
import { MusicHeart } from './MusicHeart';
|
||||||
|
import { useMusicFavorites } from './useMusicFavorites';
|
||||||
|
import {
|
||||||
|
MUSIC_FAV_CHANNEL,
|
||||||
|
coverUrl,
|
||||||
|
musicPath,
|
||||||
|
parseAlbumName,
|
||||||
|
sortTracks,
|
||||||
|
toRel,
|
||||||
|
type AlbumMeta,
|
||||||
|
type FavoriteKind,
|
||||||
|
} from './shared';
|
||||||
|
|
||||||
|
// The user's favorited artists / albums / tracks, grouped — shown in the right panel. Keys follow the
|
||||||
|
// favorites convention: album/artist are music-relative ("Albums/…"), tracks are home paths
|
||||||
|
// ("Music/…/file"). An album/artist row is a link into the library; a track row plays, so it stays a
|
||||||
|
// button — it mutates rather than navigates, even though it also moves the library to the album.
|
||||||
|
export const FavoritesView = () => {
|
||||||
|
const { get, token } = useClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const player = useMusicPlayer();
|
||||||
|
const { favorites } = useMusicFavorites();
|
||||||
|
const [, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, false);
|
||||||
|
|
||||||
|
const playTrack = async (homePath: string) => {
|
||||||
|
const cut = homePath.lastIndexOf('/');
|
||||||
|
const albumHome = homePath.slice(0, cut);
|
||||||
|
const file = homePath.slice(cut + 1);
|
||||||
|
const albumRel = toRel(albumHome);
|
||||||
|
try {
|
||||||
|
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
|
||||||
|
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({
|
||||||
|
albumRel,
|
||||||
|
file: t.file,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist,
|
||||||
|
}));
|
||||||
|
player.playQueue(
|
||||||
|
q,
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
q.findIndex((t) => t.file === file),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
player.playQueue([{ albumRel, file }], 0);
|
||||||
|
}
|
||||||
|
navigate(musicPath(albumRel));
|
||||||
|
setFavOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const empty = !favorites.artists.length && !favorites.albums.length && !favorites.tracks.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto p-4 md:p-6">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<Heart size={20} className="fill-red-500 text-red-500" />
|
||||||
|
<h1 className="flex-1 text-2xl font-bold text-foreground">Favorites</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFavOpen(false)}
|
||||||
|
className="cursor-pointer rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{empty ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
|
||||||
|
<Heart size={44} className="text-muted-foreground/30" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No favorites yet. Click the heart on any artist, album or track.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Section title="Artists" count={favorites.artists.length}>
|
||||||
|
{favorites.artists.map((key) => {
|
||||||
|
const segs = key.split('/');
|
||||||
|
return (
|
||||||
|
<FavRow
|
||||||
|
key={key}
|
||||||
|
kind="artist"
|
||||||
|
favKey={key}
|
||||||
|
cover={coverUrl(key, token)}
|
||||||
|
fallback={<User size={18} className="text-muted-foreground" />}
|
||||||
|
title={segs[segs.length - 1] ?? key}
|
||||||
|
to={musicPath(key)}
|
||||||
|
onClick={() => setFavOpen(false)}
|
||||||
|
chevron
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Albums" count={favorites.albums.length}>
|
||||||
|
{favorites.albums.map((key) => {
|
||||||
|
const segs = key.split('/');
|
||||||
|
const { title, year } = parseAlbumName(segs[segs.length - 1] ?? key);
|
||||||
|
const artist = segs.length >= 3 ? segs[1] : '';
|
||||||
|
return (
|
||||||
|
<FavRow
|
||||||
|
key={key}
|
||||||
|
kind="album"
|
||||||
|
favKey={key}
|
||||||
|
cover={coverUrl(key, token)}
|
||||||
|
fallback={<Disc3 size={18} className="text-muted-foreground" />}
|
||||||
|
title={title}
|
||||||
|
subtitle={[artist, year].filter(Boolean).join(' · ')}
|
||||||
|
to={musicPath(key)}
|
||||||
|
onClick={() => setFavOpen(false)}
|
||||||
|
chevron
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Tracks" count={favorites.tracks.length}>
|
||||||
|
{favorites.tracks.map((key) => {
|
||||||
|
const segs = key.split('/');
|
||||||
|
const base = segs[segs.length - 1] ?? key;
|
||||||
|
const albumRel = toRel(key.slice(0, Math.max(0, key.lastIndexOf('/'))));
|
||||||
|
const album = segs.length >= 2 ? parseAlbumName(segs[segs.length - 2]!).title : '';
|
||||||
|
return (
|
||||||
|
<FavRow
|
||||||
|
key={key}
|
||||||
|
kind="track"
|
||||||
|
favKey={key}
|
||||||
|
cover={coverUrl(albumRel, token)}
|
||||||
|
fallback={<Music size={18} className="text-muted-foreground" />}
|
||||||
|
title={base.replace(/\.[^/.]+$/, '')}
|
||||||
|
subtitle={album}
|
||||||
|
onClick={() => playTrack(key)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Section = ({ title, count, children }: { title: string; count: number; children: ReactNode }) =>
|
||||||
|
count ? (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-1 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{title} <span className="text-muted-foreground/60">{count}</span>
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col">{children}</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
// `to` makes the row an anchor (an album or artist, which is a place); without it the row is a button
|
||||||
|
// (a track, which plays). The heart stays a sibling either way — it must not be inside either one.
|
||||||
|
const FavRow = ({
|
||||||
|
kind,
|
||||||
|
favKey,
|
||||||
|
cover,
|
||||||
|
fallback,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
to,
|
||||||
|
onClick,
|
||||||
|
chevron,
|
||||||
|
}: {
|
||||||
|
kind: FavoriteKind;
|
||||||
|
favKey: string;
|
||||||
|
cover: string;
|
||||||
|
fallback: ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
to?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
chevron?: boolean;
|
||||||
|
}) => {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||||
|
{cover && !failed ? (
|
||||||
|
<img src={cover} alt="" className="h-full w-full object-cover" onError={() => setFailed(true)} />
|
||||||
|
) : (
|
||||||
|
fallback
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm text-foreground">{title}</span>
|
||||||
|
{subtitle ? <span className="block truncate text-xs text-muted-foreground">{subtitle}</span> : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const cls = 'flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left';
|
||||||
|
return (
|
||||||
|
<div className="group flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted">
|
||||||
|
{to ? (
|
||||||
|
<Link to={to} onClick={onClick} className={cls}>
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={onClick} className={cls}>
|
||||||
|
{inner}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<MusicHeart kind={kind} favKey={favKey} size={16} className="shrink-0" />
|
||||||
|
{chevron ? <ChevronRight size={16} className="shrink-0 text-muted-foreground" /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { Loader2, Music4 } from 'lucide-react';
|
||||||
|
import type { LyricLine } from './lyrics';
|
||||||
|
import { seekPlayer } from './player-time';
|
||||||
|
import { useActiveLyricIndex } from './useLyrics';
|
||||||
|
|
||||||
|
type LyricsPaneProps = {
|
||||||
|
lines: LyricLine[] | null;
|
||||||
|
synced: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lyrics sheet. Synced (.lrc) lyrics centre, highlight the current line, auto-scroll and seek on
|
||||||
|
* click; plain (.txt) lyrics left-align and scroll by hand only.
|
||||||
|
*
|
||||||
|
* It takes no position prop: it subscribes to the player clock itself and only re-renders when the
|
||||||
|
* highlight moves, so the sixty-frames-a-second feed never reaches the DOM.
|
||||||
|
*/
|
||||||
|
export const LyricsPane = ({ lines, synced, loading }: LyricsPaneProps) => {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lineRefs = useRef<(HTMLParagraphElement | null)[]>([]);
|
||||||
|
const activeIndex = useActiveLyricIndex(lines, synced);
|
||||||
|
|
||||||
|
// Keep the active line ~40% down the panel. scrollTop rather than scrollIntoView, which would also
|
||||||
|
// scroll every ancestor and drag the whole workspace.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!synced || activeIndex < 0) return;
|
||||||
|
const box = scrollRef.current;
|
||||||
|
const el = lineRefs.current[activeIndex];
|
||||||
|
if (!box || !el) return;
|
||||||
|
box.scrollTo({ top: Math.max(0, el.offsetTop - box.clientHeight * 0.4), behavior: 'smooth' });
|
||||||
|
}, [activeIndex, synced]);
|
||||||
|
|
||||||
|
const rendered = useMemo(() => {
|
||||||
|
if (!lines) return null;
|
||||||
|
return lines.map((line, i) => {
|
||||||
|
const active = synced && i === activeIndex;
|
||||||
|
const seekable = synced && line.timeSec != null;
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
key={i}
|
||||||
|
ref={(el) => {
|
||||||
|
lineRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
onClick={seekable ? () => seekPlayer(line.timeSec!) : undefined}
|
||||||
|
className={[
|
||||||
|
'py-1 text-[15px] font-semibold leading-7 transition-colors duration-200',
|
||||||
|
synced ? 'text-center' : 'text-left text-foreground/85',
|
||||||
|
// Only the colour changes on the active line — no weight or size change, so nothing reflows
|
||||||
|
// and the sheet does not jitter as the highlight moves.
|
||||||
|
active ? 'text-foreground' : synced ? 'text-muted-foreground' : '',
|
||||||
|
seekable ? 'cursor-pointer hover:text-foreground/80' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
|
{line.text || (synced ? '♪' : ' ')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [lines, synced, activeIndex]);
|
||||||
|
|
||||||
|
const empty = !loading && (!lines || !lines.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={scrollRef} className="h-full overflow-y-auto px-6 pt-3">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{empty && (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
<Music4 size={28} className="opacity-40" />
|
||||||
|
<p className="text-sm">No lyrics for this track.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && <div className="mx-auto max-w-2xl">{rendered}</div>}
|
||||||
|
{/* Tail so the last lines can still scroll up to the 40% mark. */}
|
||||||
|
{!loading && synced && <div style={{ height: '55%' }} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { MicVocal } from 'lucide-react';
|
||||||
|
import { LyricsPane } from './LyricsPane';
|
||||||
|
import { useLyrics } from './useLyrics';
|
||||||
|
import { useLyricsOpen } from './useLyricsOpen';
|
||||||
|
import { useMusicPlayer } from './useMusicPlayer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The right-hand half of the /music detail panel when lyrics are on. It follows the PLAYING track, not
|
||||||
|
* the browsed album — which is why it reads the player rather than taking props from the album view.
|
||||||
|
*/
|
||||||
|
export const LyricsPanel = () => {
|
||||||
|
const { token } = useClient();
|
||||||
|
const { current } = useMusicPlayer();
|
||||||
|
const [, toggleLyrics] = useLyricsOpen();
|
||||||
|
const lyrics = useLyrics(current?.albumRel ?? '', current?.file ?? '', true, token);
|
||||||
|
|
||||||
|
// `dark` is not decoration: the theme tokens are CSS variables scoped to a `.dark` ancestor, so marking
|
||||||
|
// this subtree re-points foreground/muted-foreground/border at their dark values. Without it a light
|
||||||
|
// theme would paint near-black text on the black sheet.
|
||||||
|
return (
|
||||||
|
<div className="dark flex h-full flex-col bg-black">
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2">
|
||||||
|
<MicVocal size={15} className="shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">{current?.title ?? current?.file ?? 'Lyrics'}</p>
|
||||||
|
{current?.artist && <p className="truncate text-xs text-muted-foreground">{current.artist}</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleLyrics}
|
||||||
|
title="Hide lyrics"
|
||||||
|
className="shrink-0 cursor-pointer text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
{current ? (
|
||||||
|
<LyricsPane lines={lyrics.lines} synced={lyrics.synced} loading={lyrics.loading} />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
|
||||||
|
Play something to see its lyrics.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { useState, useEffect, type ReactNode } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
|
import { Library, Music2, ChevronLeft, Folder, Search, X, RefreshCw, Heart } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
MUSIC_ROOT,
|
||||||
|
MUSIC_FAV_CHANNEL,
|
||||||
|
MUSIC_RESYNC_CHANNEL,
|
||||||
|
coverUrl,
|
||||||
|
fuzzyMatch,
|
||||||
|
musicParentPath,
|
||||||
|
musicPath,
|
||||||
|
toRel,
|
||||||
|
useMusicCwd,
|
||||||
|
type LsResult,
|
||||||
|
type Manifest,
|
||||||
|
type ManifestAlbum,
|
||||||
|
} from './shared';
|
||||||
|
|
||||||
|
// A row's leading thumbnail: the folder's indexed cover (its folder.jpg/cover.jpg, server-compressed),
|
||||||
|
// falling back to an icon when it has none or the image fails to load.
|
||||||
|
const RowThumb = ({ src, fallback }: { src: string | null; fallback: ReactNode }) => {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
useEffect(() => setFailed(false), [src]);
|
||||||
|
return (
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||||
|
{src && !failed ? (
|
||||||
|
<img src={src} alt="" className="h-full w-full object-cover" onError={() => setFailed(true)} />
|
||||||
|
) : (
|
||||||
|
fallback
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hidden files/folders (dotfiles like .claude, .git) never belong in the library listing.
|
||||||
|
const visibleDirs = (r: LsResult) =>
|
||||||
|
r.entries
|
||||||
|
.filter((e) => e.type === 'directory' && !e.name.startsWith('.'))
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
// Left panel of the /music workspace — a single-column drill-down LIST navigator (libraries →
|
||||||
|
// artists → albums as list items; never a grid). Every row is a link to `/music?path=…`; MusicDetail
|
||||||
|
// (right panel) reads the same param and renders the rich detail (covers/grids/tracklist).
|
||||||
|
export const MusicBrowser = () => {
|
||||||
|
const { get, post, token } = useClient();
|
||||||
|
const cwd = useMusicCwd();
|
||||||
|
const [favOpen, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, false);
|
||||||
|
const [resync, setResync] = usePanelChannel<number>(MUSIC_RESYNC_CHANNEL, 0);
|
||||||
|
const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({});
|
||||||
|
const [libraries, setLibraries] = useState<string[]>([]);
|
||||||
|
const [folders, setFolders] = useState<string[]>([]);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [reindexing, setReindexing] = useState(false);
|
||||||
|
|
||||||
|
// Refetch manifest + libraries on mount and whenever a reindex bumps the resync nonce.
|
||||||
|
useEffect(() => {
|
||||||
|
get<Manifest>('/music/manifest')
|
||||||
|
.then((m) => setManifest(m.albums))
|
||||||
|
.catch(() => setManifest({}));
|
||||||
|
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
|
||||||
|
.then((r) => setLibraries(visibleDirs(r)))
|
||||||
|
.catch(() => setLibraries([]));
|
||||||
|
}, [resync]);
|
||||||
|
|
||||||
|
// The container folder whose children we list = the current folder, or its parent when the current
|
||||||
|
// path is an album leaf (so its siblings stay listed while the right shows the tracklist).
|
||||||
|
const rel = toRel(cwd);
|
||||||
|
const isAlbum = (manifest[rel]?.tracks ?? 0) > 0;
|
||||||
|
const navFolder = !cwd ? null : isAlbum ? cwd.split('/').slice(0, -1).join('/') : cwd;
|
||||||
|
const selected = cwd ? cwd.split('/').pop() : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!navFolder) {
|
||||||
|
setFolders([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(navFolder)}`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) setFolders(visibleDirs(r));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [navFolder, resync]);
|
||||||
|
|
||||||
|
// Start each folder unfiltered.
|
||||||
|
useEffect(() => setQuery(''), [navFolder]);
|
||||||
|
|
||||||
|
// Trigger a server-side library rebuild, then bump the resync nonce so BOTH panels refetch their
|
||||||
|
// manifest / listings / meta (a fresh Date.now() value guarantees the effects re-run).
|
||||||
|
const reindex = async () => {
|
||||||
|
if (reindexing) return;
|
||||||
|
setReindexing(true);
|
||||||
|
try {
|
||||||
|
await post('/music/reindex');
|
||||||
|
setResync(Date.now());
|
||||||
|
} finally {
|
||||||
|
setReindexing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const crumbs = navFolder ? navFolder.slice(MUSIC_ROOT.length + 1).split('/') : [];
|
||||||
|
const coverFor = (childRel: string) => (manifest[childRel]?.cover ? coverUrl(childRel, token) : null);
|
||||||
|
const shownLibraries = libraries.filter((l) => fuzzyMatch(query, l));
|
||||||
|
const shownFolders = folders.filter((f) => fuzzyMatch(query, f));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-y-auto p-3">
|
||||||
|
<div className="flex items-center gap-1 pb-2">
|
||||||
|
{/* Favorites is a view of this panel, not a location, so it stays a channel — but going home
|
||||||
|
has to close it explicitly: the route doesn't change when you are already at the root. */}
|
||||||
|
<Link
|
||||||
|
to="/music"
|
||||||
|
onClick={() => setFavOpen(false)}
|
||||||
|
className="flex flex-1 cursor-pointer items-center gap-2 px-2 text-left text-foreground"
|
||||||
|
>
|
||||||
|
<Music2 size={20} className="text-primary" />
|
||||||
|
<span className="text-lg font-semibold">Music</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFavOpen(!favOpen)}
|
||||||
|
title="Favorites"
|
||||||
|
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
size={18}
|
||||||
|
className={favOpen ? 'fill-red-500 text-red-500' : 'text-muted-foreground hover:text-foreground'}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 flex items-center gap-1.5 px-1">
|
||||||
|
<div className="flex h-8 min-w-0 flex-1 items-center gap-1.5 rounded-md border border-border bg-background px-2">
|
||||||
|
<Search size={13} className="shrink-0 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Filter…"
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuery('')}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reindex}
|
||||||
|
disabled={reindexing}
|
||||||
|
title="Reindex library"
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={reindexing ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!navFolder ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 px-2 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
<Library size={13} /> Libraries
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{shownLibraries.map((lib) => (
|
||||||
|
<Link
|
||||||
|
key={lib}
|
||||||
|
to={musicPath(lib)}
|
||||||
|
className="flex items-center gap-3 truncate rounded-md px-2 py-2 text-left text-base text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<RowThumb src={coverFor(lib)} fallback={<Library size={18} className="text-muted-foreground" />} />
|
||||||
|
<span className="truncate">{lib}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{!shownLibraries.length && (
|
||||||
|
<span className="px-2 text-base text-muted-foreground">{query ? 'No matches' : 'No libraries'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to={musicParentPath(toRel(navFolder))}
|
||||||
|
className="mb-1 flex items-center gap-1 truncate px-2 py-1 text-left text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={13} className="shrink-0" />
|
||||||
|
<span className="truncate">{crumbs.join(' / ')}</span>
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{shownFolders.map((f) => (
|
||||||
|
<Link
|
||||||
|
key={f}
|
||||||
|
to={musicPath(toRel(`${navFolder}/${f}`))}
|
||||||
|
className={`flex items-center gap-3 truncate rounded-md px-2 py-2 text-left text-base ${
|
||||||
|
selected === f
|
||||||
|
? 'bg-muted text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<RowThumb
|
||||||
|
src={coverFor(toRel(`${navFolder}/${f}`))}
|
||||||
|
fallback={<Folder size={18} className="text-muted-foreground" />}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{f}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{!shownFolders.length && (
|
||||||
|
<span className="px-2 py-2 text-base text-muted-foreground">
|
||||||
|
{query ? 'No matches' : 'No subfolders'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
|
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
|
||||||
|
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||||
|
import { WorkspaceLayout } from 'officerdev';
|
||||||
|
import { MusicHeart } from './MusicHeart';
|
||||||
|
import { FavoritesView } from './FavoritesView';
|
||||||
|
import { useMusicPlayer } from './useMusicPlayer';
|
||||||
|
import type { PlayerTrack } from './useMusicPlayer';
|
||||||
|
import { LyricsPanel } from './LyricsPanel';
|
||||||
|
import { MusicMiniBar } from './MusicMiniBar';
|
||||||
|
import { MusicPlayerHost } from './MusicPlayerHost';
|
||||||
|
import { useLyricsOpen } from './useLyricsOpen';
|
||||||
|
import {
|
||||||
|
MUSIC_ROOT,
|
||||||
|
MUSIC_FAV_CHANNEL,
|
||||||
|
MUSIC_RESYNC_CHANNEL,
|
||||||
|
TYPE_ORDER,
|
||||||
|
coverUrl,
|
||||||
|
fmtDuration,
|
||||||
|
isAudio,
|
||||||
|
musicParentPath,
|
||||||
|
musicPath,
|
||||||
|
sortTracks,
|
||||||
|
toRel,
|
||||||
|
trackHomePath,
|
||||||
|
useMusicCwd,
|
||||||
|
type AlbumMeta,
|
||||||
|
type Discography,
|
||||||
|
type LsResult,
|
||||||
|
type Manifest,
|
||||||
|
type ManifestAlbum,
|
||||||
|
type Track,
|
||||||
|
} from './shared';
|
||||||
|
|
||||||
|
// Turning the lyrics on splits THIS panel in two rather than opening a panel of its own: the workspace
|
||||||
|
// system is a layout engine as well as a shell, so a nested WorkspaceLayout with a fixed layout and
|
||||||
|
// components keyed by panel id gets a resizable split with no persistence and no registry entries.
|
||||||
|
const LYRICS_LAYOUT: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'music-detail-split',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'music-detail-list', appType: null }, size: 62 },
|
||||||
|
{ node: { type: 'panel', id: 'music-detail-lyrics', appType: null }, size: 38 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// The library view is rendered by MusicDetail itself and passed down through context, so toggling the
|
||||||
|
// split moves the same element rather than mounting a second copy — the fetched album, and every request
|
||||||
|
// that produced it, survives the toggle.
|
||||||
|
const LibraryViewContext = createContext<ReactNode>(null);
|
||||||
|
const LibraryViewPanel = () => <>{useContext(LibraryViewContext)}</>;
|
||||||
|
|
||||||
|
const LYRICS_PANELS: PanelComponents = {
|
||||||
|
'music-detail-list': LibraryViewPanel,
|
||||||
|
'music-detail-lyrics': LyricsPanel,
|
||||||
|
};
|
||||||
|
|
||||||
|
const keepLayout = () => {};
|
||||||
|
|
||||||
|
// Right panel of the /music workspace — renders the content of `/music?path=…`: an album (tracklist),
|
||||||
|
// an artist (album cards grouped by discography type), or a folder grid. Drilling in is a link, so it
|
||||||
|
// changes the address; playback goes through the app-wide player.
|
||||||
|
export const MusicDetail = () => {
|
||||||
|
const { token, get } = useClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const player = useMusicPlayer();
|
||||||
|
const cwd = useMusicCwd();
|
||||||
|
const [favOpen, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, false);
|
||||||
|
const [resync] = usePanelChannel<number>(MUSIC_RESYNC_CHANNEL, 0);
|
||||||
|
const [lyricsOpen, toggleLyrics] = useLyricsOpen();
|
||||||
|
|
||||||
|
const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({});
|
||||||
|
const [libraries, setLibraries] = useState<string[]>([]);
|
||||||
|
const [folders, setFolders] = useState<string[]>([]);
|
||||||
|
const [album, setAlbum] = useState<Track[] | null>(null);
|
||||||
|
const [disco, setDisco] = useState<Discography | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// On first mount with no location yet, open the currently-playing album — so a reload/return lands on
|
||||||
|
// the track you were listening to (the player itself restores via the saved now-playing snapshot).
|
||||||
|
// Once only, so it never yanks you back after you navigate away (e.g. up to the library root).
|
||||||
|
const autoNavRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoNavRef.current) return;
|
||||||
|
if (cwd) {
|
||||||
|
autoNavRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (player.current) {
|
||||||
|
autoNavRef.current = true;
|
||||||
|
// `replace`: landing on /music and being moved to the playing album is one arrival, not two, so
|
||||||
|
// Back should leave the screen rather than undo a jump the user never asked for.
|
||||||
|
navigate(musicPath(player.current.albumRel), { replace: true });
|
||||||
|
}
|
||||||
|
}, [player.current, cwd, navigate]);
|
||||||
|
|
||||||
|
// Navigating anywhere (left panel or from within Favorites) closes the Favorites view.
|
||||||
|
useEffect(() => {
|
||||||
|
setFavOpen(false);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [cwd]);
|
||||||
|
|
||||||
|
// Refetch manifest + libraries on mount and whenever a reindex bumps the resync nonce. A fresh manifest
|
||||||
|
// object identity also re-runs the [cwd, manifest] listing effect below, refreshing the folder grid /
|
||||||
|
// album meta for whatever is currently open — so the right panel updates in place, no nav required.
|
||||||
|
useEffect(() => {
|
||||||
|
get<Manifest>('/music/manifest')
|
||||||
|
.then((m) => setManifest(m.albums))
|
||||||
|
.catch(() => setManifest({}));
|
||||||
|
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
|
||||||
|
.then((r) =>
|
||||||
|
setLibraries(
|
||||||
|
r.entries
|
||||||
|
.filter((e) => e.type === 'directory' && !e.name.startsWith('.'))
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch(() => setLibraries([]));
|
||||||
|
}, [resync]);
|
||||||
|
|
||||||
|
const rel = toRel(cwd);
|
||||||
|
const childRel = (name: string) => (rel ? `${rel}/${name}` : name);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cwd) {
|
||||||
|
setFolders([]);
|
||||||
|
setAlbum(null);
|
||||||
|
setDisco(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setAlbum(null);
|
||||||
|
setDisco(null);
|
||||||
|
setFolders([]);
|
||||||
|
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`)
|
||||||
|
.then(async (r) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setFolders(
|
||||||
|
r.entries
|
||||||
|
.filter((e) => e.type === 'directory' && !e.name.startsWith('.'))
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort(),
|
||||||
|
);
|
||||||
|
const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name);
|
||||||
|
if (audio.length) {
|
||||||
|
try {
|
||||||
|
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(rel)}`);
|
||||||
|
if (!cancelled) setAlbum(sortTracks(meta.tracks));
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setAlbum(audio.sort().map((f) => ({ file: f })));
|
||||||
|
}
|
||||||
|
} else if (manifest[rel]?.disco) {
|
||||||
|
try {
|
||||||
|
const d = await get<Discography>(`/music/discography?path=${encodeURIComponent(rel)}`);
|
||||||
|
if (!cancelled) setDisco(d);
|
||||||
|
} catch {
|
||||||
|
/* plain grid */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [cwd, manifest]);
|
||||||
|
|
||||||
|
const playAlbum = async (albumRel: string, startIndex = 0) => {
|
||||||
|
try {
|
||||||
|
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
|
||||||
|
const queue: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({
|
||||||
|
albumRel,
|
||||||
|
file: t.file,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist,
|
||||||
|
}));
|
||||||
|
player.playQueue(queue, startIndex);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const playCurrent = (i: number) => {
|
||||||
|
if (!album) return;
|
||||||
|
const queue: PlayerTrack[] = album.map((t) => ({ albumRel: rel, file: t.file, title: t.title, artist: t.artist }));
|
||||||
|
player.playQueue(queue, i);
|
||||||
|
};
|
||||||
|
const isCurrent = (albumRel: string, file: string) =>
|
||||||
|
player.current?.albumRel === albumRel && player.current?.file === file;
|
||||||
|
|
||||||
|
// The album's play button is the dock's play button when the dock is already on this album: same
|
||||||
|
// useGlobal state, so it shows pause while it plays and resumes where it stopped. It only starts the
|
||||||
|
// album from the top when something else (or nothing) is loaded.
|
||||||
|
const albumLoaded = !!album && player.current?.albumRel === rel;
|
||||||
|
const albumPlaying = albumLoaded && player.playing;
|
||||||
|
const toggleAlbum = () => (albumLoaded ? player.toggle() : playCurrent(0));
|
||||||
|
|
||||||
|
const crumbs = rel ? rel.split('/') : [];
|
||||||
|
|
||||||
|
// `r` is already the child's rel, so it is both the cover key and the link target — a library root and
|
||||||
|
// a nested album need no different treatment. Play and heart are siblings of the anchor, never inside it.
|
||||||
|
const Card = ({ r, name, playable }: { r: string; name: string; playable: boolean }) => (
|
||||||
|
<div className="group relative">
|
||||||
|
<Link
|
||||||
|
to={musicPath(r)}
|
||||||
|
className="flex w-full flex-col gap-2 rounded-lg bg-card/60 p-3 text-left transition-colors hover:bg-card"
|
||||||
|
>
|
||||||
|
<div className="aspect-square w-full overflow-hidden rounded-md bg-muted">
|
||||||
|
<img
|
||||||
|
src={coverUrl(r, token)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="truncate text-sm font-medium text-foreground">{name}</span>
|
||||||
|
</Link>
|
||||||
|
{playable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => playAlbum(r, 0)}
|
||||||
|
className="absolute bottom-14 right-4 flex h-10 w-10 translate-y-2 items-center justify-center rounded-full bg-primary text-primary-foreground opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100 hover:scale-105"
|
||||||
|
>
|
||||||
|
<Play size={18} className="ml-0.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{playable && (
|
||||||
|
<MusicHeart
|
||||||
|
kind="album"
|
||||||
|
favKey={r}
|
||||||
|
size={18}
|
||||||
|
hoverReveal
|
||||||
|
className="absolute right-2 top-2 rounded-full bg-black/40 p-1.5 text-white"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = favOpen ? (
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<FavoritesView />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">
|
||||||
|
{cwd && (
|
||||||
|
<Link
|
||||||
|
to={musicParentPath(rel)}
|
||||||
|
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
<span className="truncate">{crumbs.length ? crumbs.join(' / ') : cwd.split('/')[1]}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && <p className="text-sm text-muted-foreground">Loading…</p>}
|
||||||
|
|
||||||
|
{/* Home — the library roots carry their own folder art (the manifest indexes them like any other
|
||||||
|
folder), so they get the same cards as everything else rather than a wall of flat tiles. */}
|
||||||
|
{!cwd && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
{libraries.map((lib) => (
|
||||||
|
<Card key={lib} r={lib} name={lib} playable={(manifest[lib]?.tracks ?? 0) > 0} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Album */}
|
||||||
|
{album && (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="flex items-end gap-5">
|
||||||
|
<div className="h-40 w-40 shrink-0 overflow-hidden rounded-lg bg-muted shadow-lg">
|
||||||
|
<img
|
||||||
|
src={coverUrl(rel, token)}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Album</p>
|
||||||
|
<h1 className="truncate text-3xl font-bold text-foreground">{crumbs[crumbs.length - 1]}</h1>
|
||||||
|
<p className="mt-1 truncate text-sm text-muted-foreground">
|
||||||
|
{crumbs[crumbs.length - 2] ?? ''} · {album.length} songs
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAlbum}
|
||||||
|
title={albumPlaying ? 'Pause' : 'Play'}
|
||||||
|
className="flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground hover:scale-105"
|
||||||
|
>
|
||||||
|
{albumPlaying ? <Pause size={18} /> : <Play size={18} className="ml-0.5" />}
|
||||||
|
</button>
|
||||||
|
<MusicHeart kind="album" favKey={rel} size={24} className="p-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleLyrics}
|
||||||
|
title={lyricsOpen ? 'Hide lyrics' : 'Show lyrics'}
|
||||||
|
aria-pressed={lyricsOpen}
|
||||||
|
className={`cursor-pointer p-1 hover:text-foreground ${
|
||||||
|
lyricsOpen ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MicVocal size={22} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{album.map((t, i) => {
|
||||||
|
const cur = isCurrent(rel, t.file);
|
||||||
|
const artist = t.artist ?? t.albumArtist ?? '';
|
||||||
|
const dur = fmtDuration(t.durationSec);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={t.file}
|
||||||
|
className={`group flex items-center gap-3 rounded px-3 py-2 ${
|
||||||
|
cur ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => playCurrent(i)}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||||
|
>
|
||||||
|
<span className="flex w-5 shrink-0 justify-end">
|
||||||
|
{cur ? (
|
||||||
|
<Volume2 size={15} className="text-primary" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm tabular-nums text-muted-foreground">{i + 1}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className={`block truncate text-sm ${cur ? 'font-medium' : ''}`}>{t.title ?? t.file}</span>
|
||||||
|
{artist && <span className="block truncate text-xs text-muted-foreground">{artist}</span>}
|
||||||
|
</span>
|
||||||
|
{dur && <span className="shrink-0 text-xs tabular-nums text-muted-foreground">{dur}</span>}
|
||||||
|
</button>
|
||||||
|
<MusicHeart
|
||||||
|
kind="track"
|
||||||
|
favKey={trackHomePath(rel, t.file)}
|
||||||
|
size={16}
|
||||||
|
hoverReveal
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Artist — discography sections */}
|
||||||
|
{disco && !album && (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">{disco.artist}</h1>
|
||||||
|
<MusicHeart kind="artist" favKey={rel} size={22} className="p-1" />
|
||||||
|
</div>
|
||||||
|
{TYPE_ORDER.filter((type) => folders.some((f) => (disco.albums[f] ?? 'Other') === type)).map((type) => (
|
||||||
|
<section key={type}>
|
||||||
|
<h2 className="mb-2 text-lg font-semibold text-foreground">
|
||||||
|
{type === 'Studio' ? 'Studio Albums' : type}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
{folders
|
||||||
|
.filter((f) => (disco.albums[f] ?? 'Other') === type)
|
||||||
|
.map((f) => (
|
||||||
|
<Card key={f} r={childRel(f)} name={f} playable={(manifest[childRel(f)]?.tracks ?? 0) > 0} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Grid — a library root, or an artist folder (crumbs>=2) whose albums aren't grouped by a
|
||||||
|
discography. Show an artist header + heart on the latter. */}
|
||||||
|
{!album && !disco && cwd && !loading && (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{crumbs.length >= 2 && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">{crumbs[crumbs.length - 1]}</h1>
|
||||||
|
<MusicHeart kind="artist" favKey={rel} size={22} className="p-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
{folders.map((f) => (
|
||||||
|
<Card key={f} r={childRel(f)} name={f} playable={(manifest[childRel(f)]?.tracks ?? 0) > 0} />
|
||||||
|
))}
|
||||||
|
{!folders.length && <p className="text-sm text-muted-foreground">Empty</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The scrubber sits at the foot of this panel instead of the app-wide dock, which hides itself on
|
||||||
|
// /music: the album view already carries the transport, so all the dock added here was a second row.
|
||||||
|
const libraryView = (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{content}
|
||||||
|
<MusicMiniBar />
|
||||||
|
{/* The audio engine, mounted HERE rather than by the shell.
|
||||||
|
It moved out of DashboardLayout on 2026-08-15 when the player became the plugin's. It renders
|
||||||
|
nothing while the route is /music — the mini bar above is the transport — so this is purely
|
||||||
|
"something owns the GaplessEngine while the screen is open".
|
||||||
|
[phase 2] Leaving /music unmounts it, which stops playback. Making audio outlive the route
|
||||||
|
needs either a shell slot a plugin can contribute to, or the engine hoisted to module scope;
|
||||||
|
that decision is deliberately deferred. Nothing breaks in the meantime: player-time's
|
||||||
|
registrations are optional-chained and the queue lives in global state, so returning to /music
|
||||||
|
remounts the host and reloads it. */}
|
||||||
|
<MusicPlayerHost />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lyricsOpen) return libraryView;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LibraryViewContext.Provider value={libraryView}>
|
||||||
|
<WorkspaceLayout layout={LYRICS_LAYOUT} onLayoutChange={keepLayout} components={LYRICS_PANELS} noHeader />
|
||||||
|
</LibraryViewContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Heart } from 'lucide-react';
|
||||||
|
import type { FavoriteKind } from './shared';
|
||||||
|
import { useMusicFavorites } from './useMusicFavorites';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A heart toggle for a favoritable thing (track / album / artist). Reads and writes the shared
|
||||||
|
* favorites cache, so every heart for the same key stays in sync and flips optimistically. Stops click
|
||||||
|
* propagation so it works inside clickable rows/cards. Renders nothing without a key.
|
||||||
|
*/
|
||||||
|
export const MusicHeart = ({
|
||||||
|
kind,
|
||||||
|
favKey,
|
||||||
|
size = 18,
|
||||||
|
className = '',
|
||||||
|
hoverReveal = false,
|
||||||
|
}: {
|
||||||
|
kind: FavoriteKind;
|
||||||
|
favKey: string;
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
/** When set, a NOT-favorited heart is hidden until the enclosing `group` is hovered/focused; a
|
||||||
|
* favorited (filled) heart always stays visible. Keeps dense lists uncluttered. */
|
||||||
|
hoverReveal?: boolean;
|
||||||
|
}) => {
|
||||||
|
const { isFavorite, toggle } = useMusicFavorites();
|
||||||
|
if (!favKey) return null;
|
||||||
|
const on = isFavorite(kind, favKey);
|
||||||
|
const reveal = hoverReveal && !on ? 'opacity-0 group-hover:opacity-100 focus:opacity-100' : '';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggle(kind, favKey);
|
||||||
|
}}
|
||||||
|
title={on ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
|
aria-label={on ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
|
className={`flex cursor-pointer items-center justify-center transition-colors ${reveal} ${className}`}
|
||||||
|
>
|
||||||
|
<Heart size={size} className={on ? 'fill-red-500 text-red-500' : 'text-muted-foreground hover:text-foreground'} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { MicVocal, Pause, Play } from 'lucide-react';
|
||||||
|
import { SeekBar } from 'officerdev';
|
||||||
|
import { coverUrl, fmtClock } from './shared';
|
||||||
|
import { seekPlayer } from './player-time';
|
||||||
|
import { useLyricsOpen } from './useLyricsOpen';
|
||||||
|
import { useMusicPlayer } from './useMusicPlayer';
|
||||||
|
import { usePlayerClock } from './usePlayerClock';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player, reduced to what the /music screen does not already show. The album view has the transport
|
||||||
|
* and the tracklist, so this is the scrubber — plus play/pause and the lyrics toggle, which are the two
|
||||||
|
* controls you can still want while browsing an album that ISN'T the one playing.
|
||||||
|
*
|
||||||
|
* It sits inside the detail panel, which is why the full dock hides on /music: two bars would be one bar
|
||||||
|
* too many, and the dock's own row costs the workspace its height on every screen.
|
||||||
|
*/
|
||||||
|
export const MusicMiniBar = () => {
|
||||||
|
const { token } = useClient();
|
||||||
|
const { current, playing, toggle } = useMusicPlayer();
|
||||||
|
const [lyricsOpen, toggleLyrics] = useLyricsOpen();
|
||||||
|
const { position, duration } = usePlayerClock();
|
||||||
|
const barRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
|
const onSeekDown = (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const seekAt = (clientX: number) => {
|
||||||
|
const bar = barRef.current;
|
||||||
|
if (!bar || !duration) return;
|
||||||
|
const rect = bar.getBoundingClientRect();
|
||||||
|
seekPlayer(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * duration);
|
||||||
|
};
|
||||||
|
ev.preventDefault();
|
||||||
|
seekAt(ev.clientX);
|
||||||
|
const onMove = (moveEv: MouseEvent) => seekAt(moveEv.clientX);
|
||||||
|
const onUp = () => {
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex shrink-0 items-center gap-3 border-t border-border bg-card/60 px-4 py-2">
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||||
|
<img
|
||||||
|
src={coverUrl(current.albumRel, token)}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onError={(ev) => {
|
||||||
|
(ev.currentTarget as HTMLImageElement).style.visibility = 'hidden';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
title={playing ? 'Pause' : 'Play'}
|
||||||
|
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground hover:opacity-90 active:scale-95"
|
||||||
|
>
|
||||||
|
{playing ? <Pause size={15} /> : <Play size={15} className="ml-0.5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="hidden w-48 min-w-0 shrink-0 sm:block">
|
||||||
|
<p className="truncate text-xs font-medium text-foreground">{current.title ?? current.file}</p>
|
||||||
|
{current.artist && <p className="truncate text-[11px] text-muted-foreground">{current.artist}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="w-10 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||||
|
{fmtClock(position)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<SeekBar
|
||||||
|
barRef={barRef}
|
||||||
|
onSeekDown={onSeekDown}
|
||||||
|
pct={duration ? (position / duration) * 100 : 0}
|
||||||
|
trackClass="bg-muted"
|
||||||
|
fillClass="bg-primary"
|
||||||
|
thumbClass="border-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-10 shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||||
|
{fmtClock(duration)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleLyrics}
|
||||||
|
title={lyricsOpen ? 'Hide lyrics' : 'Show lyrics'}
|
||||||
|
aria-pressed={lyricsOpen}
|
||||||
|
className={`shrink-0 cursor-pointer p-1 hover:text-foreground ${
|
||||||
|
lyricsOpen ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MicVocal size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { usePermissions } from 'hooks/usePermissions';
|
||||||
|
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
|
||||||
|
import { SeekBar } from 'officerdev';
|
||||||
|
import { MusicHeart } from './MusicHeart';
|
||||||
|
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from './shared';
|
||||||
|
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
||||||
|
import { GaplessEngine, type EngineTrack } from './gapless-engine';
|
||||||
|
import { publishPlayerTime, registerPlayerSeek } from './player-time';
|
||||||
|
import { useLyricsOpen } from './useLyricsOpen';
|
||||||
|
|
||||||
|
// Mounted once in the persistent DashboardLayout (outside <Routes>), so it owns the single audio engine
|
||||||
|
// and the site-wide play dock — playback survives navigation between routes.
|
||||||
|
//
|
||||||
|
// Audio is Web Audio (gapless-engine), NOT an <audio> element: it schedules each track to start at the
|
||||||
|
// exact sample the previous one ends, so a continuous mix plays with zero gap on auto-advance. React
|
||||||
|
// holds the queue/index (shared via useMusicPlayer); this host reconciles it with the engine — user
|
||||||
|
// actions (new album, jump, prev/next) command the engine, and the engine's own natural advance mirrors
|
||||||
|
// back into the index without restarting playback.
|
||||||
|
|
||||||
|
const MUSIC_API = '/api/music';
|
||||||
|
|
||||||
|
export const MusicPlayerHost = () => {
|
||||||
|
const { token, get, put, delete: del } = useClient();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const canUseMusic = can('music');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const { current, index, queue, playing, toggle, next, prev, setPlaying, syncIndex, close, loadQueue } =
|
||||||
|
useMusicPlayer();
|
||||||
|
|
||||||
|
const engineRef = useRef<GaplessEngine | null>(null);
|
||||||
|
const [position, setPosition] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [volume, setVolume] = useState(() => {
|
||||||
|
const v = parseFloat(localStorage.getItem('music.volume') ?? '1');
|
||||||
|
return Number.isFinite(v) ? v : 1;
|
||||||
|
});
|
||||||
|
const [muted, setMuted] = useState(false);
|
||||||
|
const [lyricsOpen, toggleLyrics] = useLyricsOpen();
|
||||||
|
|
||||||
|
const trackKey = current ? `${current.albumRel}/${current.file}` : '';
|
||||||
|
|
||||||
|
// Latest position/duration in refs so persist reads fresh values without re-subscribing. seekToRef holds
|
||||||
|
// a pending restore offset; isRestoringRef suppresses persist during the restore load so it doesn't
|
||||||
|
// clobber the saved position with 0; restoredRef makes restore run once; engineIndexRef is the index the
|
||||||
|
// engine is actually on, used to tell an engine-driven advance apart from a user jump.
|
||||||
|
const positionRef = useRef(0);
|
||||||
|
positionRef.current = position;
|
||||||
|
const durationRef = useRef(0);
|
||||||
|
durationRef.current = duration;
|
||||||
|
const restoredRef = useRef(false);
|
||||||
|
const isRestoringRef = useRef(false);
|
||||||
|
const seekToRef = useRef<number | null>(null);
|
||||||
|
const engineIndexRef = useRef(0);
|
||||||
|
// The engine is created once, so its callbacks would capture first-render closures. useMusicPlayer's
|
||||||
|
// functional setters (syncIndex/setPlaying) read the state captured at THAT render (the initial EMPTY
|
||||||
|
// queue) — calling them from a stale closure wipes the queue. Route them through refs kept current.
|
||||||
|
const syncIndexRef = useRef(syncIndex);
|
||||||
|
syncIndexRef.current = syncIndex;
|
||||||
|
const setPlayingRef = useRef(setPlaying);
|
||||||
|
setPlayingRef.current = setPlaying;
|
||||||
|
const closeRef = useRef(close);
|
||||||
|
closeRef.current = close;
|
||||||
|
|
||||||
|
const withToken = (u: string) => (token ? `${u}${u.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : u);
|
||||||
|
const streamUrl = (t: PlayerTrack) =>
|
||||||
|
withToken(`${MUSIC_API}/stream?path=${encodeURIComponent(`Music/${t.albumRel}/${t.file}`)}`);
|
||||||
|
const coverUrl = (rel: string) => withToken(`${MUSIC_API}/cover?path=${encodeURIComponent(rel)}`);
|
||||||
|
const toEngineTrack = (t: PlayerTrack): EngineTrack => ({ key: `${t.albumRel}/${t.file}`, url: streamUrl(t) });
|
||||||
|
|
||||||
|
const persist = () => {
|
||||||
|
if (!current) return;
|
||||||
|
put('/music/now-playing?device=web', {
|
||||||
|
homePath: trackHomePath(current.albumRel, current.file),
|
||||||
|
dir: `Music/${current.albumRel}`,
|
||||||
|
title: current.title ?? '',
|
||||||
|
artist: current.artist ?? '',
|
||||||
|
album: current.albumRel.split('/').pop() ?? '',
|
||||||
|
durationSec: Math.round(durationRef.current) || 0,
|
||||||
|
positionSec: Math.round(positionRef.current) || 0,
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Engine lifecycle (mounted once) ──
|
||||||
|
useEffect(() => {
|
||||||
|
const engine = new GaplessEngine({
|
||||||
|
onTime: (pos, dur) => {
|
||||||
|
positionRef.current = pos;
|
||||||
|
durationRef.current = dur;
|
||||||
|
setPosition(pos);
|
||||||
|
setDuration(dur);
|
||||||
|
publishPlayerTime(pos, dur); // the lyrics pane and the /music scrubber live in another tree
|
||||||
|
isRestoringRef.current = false; // saved position has been applied — safe to persist again
|
||||||
|
},
|
||||||
|
onIndex: (i) => {
|
||||||
|
engineIndexRef.current = i; // engine advanced on its own → mirror to UI without restarting
|
||||||
|
syncIndexRef.current(i);
|
||||||
|
},
|
||||||
|
// Queue finished on its own → clear it so the in-flow dock releases its space (no idle bar lingering
|
||||||
|
// after playback). The saved snapshot is left intact, so a reload still resumes where you left off.
|
||||||
|
onEndOfQueue: () => closeRef.current(),
|
||||||
|
onLoadingChange: setLoading,
|
||||||
|
});
|
||||||
|
engineRef.current = engine;
|
||||||
|
engine.setVolume(muted ? 0 : volume);
|
||||||
|
// Satisfy the browser autoplay policy ONCE, on the first user gesture — after that, sticky activation
|
||||||
|
// lets engine.play() resume the context on its own. It MUST be once-only: a persistent listener would
|
||||||
|
// resume the context on every click, overriding a deliberate pause (pause = ctx.suspend()).
|
||||||
|
const unlock = () => engine.unlock();
|
||||||
|
document.addEventListener('pointerdown', unlock, { once: true });
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', unlock);
|
||||||
|
engine.destroy();
|
||||||
|
engineRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Restore the saved "currently playing" on first load — paused, at its position — so a reload/return
|
||||||
|
// lands back on the track. Skipped when a queue already exists (an in-app nav kept player state).
|
||||||
|
//
|
||||||
|
// Also skipped without the `music` permission. This host is mounted by the shell for every account, so it
|
||||||
|
// used to reach for `/music/now-playing` on a member's very first paint and 403.
|
||||||
|
useEffect(() => {
|
||||||
|
if (restoredRef.current) return;
|
||||||
|
if (!canUseMusic) return;
|
||||||
|
restoredRef.current = true;
|
||||||
|
if (queue.length) return;
|
||||||
|
(async () => {
|
||||||
|
const snap = await get<NowPlaying | null>('/music/now-playing?device=web').catch(() => null);
|
||||||
|
if (!snap?.homePath) return;
|
||||||
|
const albumRel = (snap.dir || snap.homePath.split('/').slice(0, -1).join('/')).replace(/^Music\//, '');
|
||||||
|
const file = snap.homePath.split('/').pop() ?? '';
|
||||||
|
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`).catch(() => null);
|
||||||
|
if (!meta) return;
|
||||||
|
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({
|
||||||
|
albumRel,
|
||||||
|
file: t.file,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist,
|
||||||
|
}));
|
||||||
|
const idx = Math.max(
|
||||||
|
0,
|
||||||
|
q.findIndex((t) => t.file === file),
|
||||||
|
);
|
||||||
|
isRestoringRef.current = true;
|
||||||
|
seekToRef.current = snap.positionSec > 0 ? snap.positionSec : null;
|
||||||
|
loadQueue(q, idx);
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A NEW queue (playQueue/loadQueue set a fresh array) → (re)load the engine at that index. A pending
|
||||||
|
// restore offset starts it paused at position; otherwise autoplay follows the queue's `playing` flag.
|
||||||
|
useEffect(() => {
|
||||||
|
const engine = engineRef.current;
|
||||||
|
if (!engine) return;
|
||||||
|
engineIndexRef.current = index;
|
||||||
|
const seekTo = seekToRef.current ?? 0;
|
||||||
|
seekToRef.current = null;
|
||||||
|
engine.load(queue.map(toEngineTrack), index, playing, seekTo);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [queue]);
|
||||||
|
|
||||||
|
// Index changed on the SAME queue: a user jump / prev-next (engine advance already matches, so it no-ops).
|
||||||
|
useEffect(() => {
|
||||||
|
const engine = engineRef.current;
|
||||||
|
if (!engine || !queue.length) return;
|
||||||
|
if (index === engineIndexRef.current) return;
|
||||||
|
engineIndexRef.current = index;
|
||||||
|
engine.skipTo(index);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [index]);
|
||||||
|
|
||||||
|
// Play/pause.
|
||||||
|
useEffect(() => {
|
||||||
|
const engine = engineRef.current;
|
||||||
|
if (!engine || !current) return;
|
||||||
|
if (playing) engine.play();
|
||||||
|
else engine.pause();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [playing]);
|
||||||
|
|
||||||
|
// Volume / mute.
|
||||||
|
useEffect(() => {
|
||||||
|
engineRef.current?.setVolume(muted ? 0 : volume);
|
||||||
|
}, [volume, muted]);
|
||||||
|
|
||||||
|
// Persist on play/pause + track change (not during the restore load).
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRestoringRef.current) return;
|
||||||
|
persist();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [trackKey, playing]);
|
||||||
|
|
||||||
|
// Heartbeat while playing, so the saved position keeps up.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing || !current) return;
|
||||||
|
const id = setInterval(persist, 10000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [playing, trackKey]);
|
||||||
|
|
||||||
|
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = parseFloat(e.target.value);
|
||||||
|
setVolume(v);
|
||||||
|
setMuted(v === 0);
|
||||||
|
localStorage.setItem('music.volume', String(v));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seek is the engine's, and the engine is this component's — so the lyrics panel, which lives in the
|
||||||
|
// /music workspace rather than under the dock, reaches it through this registration.
|
||||||
|
const seekTo = useCallback((sec: number) => {
|
||||||
|
setPosition(sec);
|
||||||
|
publishPlayerTime(sec, durationRef.current);
|
||||||
|
engineRef.current?.seek(sec);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => registerPlayerSeek(seekTo), [seekTo]);
|
||||||
|
|
||||||
|
// Scrubber → engine.seek (Web Audio has no <audio>.currentTime, so drive it directly).
|
||||||
|
const barRef = useRef<HTMLDivElement>(null);
|
||||||
|
const onSeekDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const seekAt = (clientX: number) => {
|
||||||
|
const bar = barRef.current;
|
||||||
|
if (!bar || !duration) return;
|
||||||
|
const rect = bar.getBoundingClientRect();
|
||||||
|
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||||
|
seekTo(pct * duration);
|
||||||
|
};
|
||||||
|
e.preventDefault();
|
||||||
|
seekAt(e.clientX);
|
||||||
|
const onMove = (ev: MouseEvent) => seekAt(ev.clientX);
|
||||||
|
const onUp = () => {
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitle = current?.artist ?? current?.albumRel.split('/').slice(-2, -1)[0] ?? '';
|
||||||
|
const pct = duration ? (position / duration) * 100 : 0;
|
||||||
|
|
||||||
|
// Closing the dock also clears the saved "currently playing" snapshot, so it doesn't get restored on
|
||||||
|
// the next load. (Merely close()-ing the local queue would leave the server snapshot to bring it back.)
|
||||||
|
const handleClose = () => {
|
||||||
|
del('/music/now-playing?device=web').catch(() => {});
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
// The dock's microphone opens the lyrics panel inside the /music workspace, so it navigates there
|
||||||
|
// rather than growing a sheet of its own — the dock keeps its height on every screen.
|
||||||
|
const showLyrics = () => {
|
||||||
|
if (!lyricsOpen) navigate('/music');
|
||||||
|
toggleLyrics();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
|
// On /music the library screen draws its own MusicMiniBar inside the panel, and the album view already
|
||||||
|
// has the transport — so the dock would be a second bar taking a full row off the workspace. The host
|
||||||
|
// stays MOUNTED (it owns the engine); only its bar is withheld.
|
||||||
|
if (pathname.startsWith('/music')) return null;
|
||||||
|
|
||||||
|
// In-flow bottom bar (NOT position:fixed) — it reserves its own height so the content above shrinks to
|
||||||
|
// fit and the nav dock naturally sits above it, no overlap hacks needed.
|
||||||
|
return (
|
||||||
|
<div className="flex shrink-0 items-center gap-3 border-t border-border bg-card/95 px-4 py-2 backdrop-blur">
|
||||||
|
{/* cover + info — a real link to the playing album, so it cmd-clicks like anything else */}
|
||||||
|
<Link
|
||||||
|
to={musicPath(current.albumRel)}
|
||||||
|
title="Show in library"
|
||||||
|
className="flex min-w-0 cursor-pointer items-center gap-3 rounded text-left transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||||
|
<img
|
||||||
|
src={coverUrl(current.albumRel)}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="hidden w-44 shrink-0 sm:block">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">{current.title ?? current.file}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* transport */}
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={prev}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="cursor-pointer p-1.5 text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<SkipBack size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground hover:opacity-90 active:scale-95"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : playing ? (
|
||||||
|
<Pause size={18} />
|
||||||
|
) : (
|
||||||
|
<Play size={18} className="ml-0.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={next}
|
||||||
|
disabled={index >= queue.length - 1}
|
||||||
|
className="cursor-pointer p-1.5 text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<SkipForward size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* scrubber + times */}
|
||||||
|
<span className="hidden w-10 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground md:block">
|
||||||
|
{fmtClock(position)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<SeekBar
|
||||||
|
barRef={barRef}
|
||||||
|
onSeekDown={onSeekDown}
|
||||||
|
pct={pct}
|
||||||
|
trackClass="bg-muted"
|
||||||
|
fillClass="bg-primary"
|
||||||
|
thumbClass="border-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="hidden w-10 shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground md:block">
|
||||||
|
{fmtClock(duration)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* volume */}
|
||||||
|
<div className="hidden shrink-0 items-center gap-1.5 md:flex">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMuted((m) => !m)}
|
||||||
|
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{muted || volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={muted ? 0 : volume}
|
||||||
|
onChange={changeVolume}
|
||||||
|
className="h-1 w-16 cursor-pointer accent-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={showLyrics}
|
||||||
|
title={lyricsOpen ? 'Hide lyrics' : 'Show lyrics'}
|
||||||
|
aria-pressed={lyricsOpen}
|
||||||
|
className={`shrink-0 cursor-pointer p-1.5 hover:text-foreground ${lyricsOpen ? 'text-primary' : 'text-muted-foreground'}`}
|
||||||
|
>
|
||||||
|
<MicVocal size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<MusicHeart
|
||||||
|
kind="track"
|
||||||
|
favKey={trackHomePath(current.albumRel, current.file)}
|
||||||
|
size={18}
|
||||||
|
className="shrink-0 p-1.5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="shrink-0 cursor-pointer p-1.5 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { GaplessEngine, type EngineTrack } from './gapless-engine';
|
||||||
|
|
||||||
|
// These tests exist because the failure they cover is inaudible to the code and obvious to the ear: two
|
||||||
|
// sources playing the same track at once. Nothing throws, no state looks wrong, `cur` points at a real
|
||||||
|
// node that is really playing — there is simply a second one nobody is holding. The only way to catch it
|
||||||
|
// is to count the nodes that were started.
|
||||||
|
|
||||||
|
type StartCall = { when: number; offset: number };
|
||||||
|
|
||||||
|
class FakeSource {
|
||||||
|
buffer: AudioBuffer | null = null;
|
||||||
|
onended: (() => void) | null = null;
|
||||||
|
started: StartCall | null = null;
|
||||||
|
stopped = false;
|
||||||
|
disconnected = false;
|
||||||
|
constructor(private ctx: FakeContext) {}
|
||||||
|
connect(): void {}
|
||||||
|
disconnect(): void {
|
||||||
|
this.disconnected = true;
|
||||||
|
}
|
||||||
|
start(when = 0, offset = 0): void {
|
||||||
|
if (this.started) throw new Error('InvalidStateError: already started');
|
||||||
|
this.started = { when, offset };
|
||||||
|
this.ctx.started.push(this);
|
||||||
|
}
|
||||||
|
stop(): void {
|
||||||
|
if (!this.started) throw new Error('InvalidStateError: not started');
|
||||||
|
this.stopped = true;
|
||||||
|
}
|
||||||
|
/** What the browser does at the end of the buffer (or at stop()) — the engine's advance trigger. */
|
||||||
|
end(): void {
|
||||||
|
this.onended?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeContext {
|
||||||
|
currentTime = 0;
|
||||||
|
state: 'running' | 'suspended' | 'closed' = 'running';
|
||||||
|
destination = {};
|
||||||
|
started: FakeSource[] = [];
|
||||||
|
createGain() {
|
||||||
|
return { gain: { value: 1 }, connect: () => {} };
|
||||||
|
}
|
||||||
|
createBufferSource() {
|
||||||
|
return new FakeSource(this) as unknown as AudioBufferSourceNode & FakeSource;
|
||||||
|
}
|
||||||
|
async resume(): Promise<void> {
|
||||||
|
this.state = 'running';
|
||||||
|
}
|
||||||
|
async suspend(): Promise<void> {
|
||||||
|
this.state = 'suspended';
|
||||||
|
}
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.state = 'closed';
|
||||||
|
}
|
||||||
|
async decodeAudioData(): Promise<AudioBuffer> {
|
||||||
|
// Decoding is the slow step the bug hides inside — a macrotask is enough to model "not instant".
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
return { duration: 100 } as AudioBuffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sources producing sound RIGHT NOW. The `when <= currentTime` clause is not a detail: a gapless engine
|
||||||
|
* always has the next track already started, scheduled at the current track's end. Counting those as
|
||||||
|
* audible would make the healthy state look like the bug.
|
||||||
|
*/
|
||||||
|
const audible = (ctx: FakeContext) =>
|
||||||
|
ctx.started.filter((s) => !s.stopped && s.started !== null && s.started.when <= ctx.currentTime);
|
||||||
|
|
||||||
|
let ctx: FakeContext;
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalCtor = (globalThis as Record<string, unknown>).AudioContext;
|
||||||
|
|
||||||
|
const QUEUE: EngineTrack[] = [
|
||||||
|
{ key: 'a', url: '/stream/a' },
|
||||||
|
{ key: 'b', url: '/stream/b' },
|
||||||
|
{ key: 'c', url: '/stream/c' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Let every pending decode + its continuation run.
|
||||||
|
const settle = async () => {
|
||||||
|
for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0));
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ctx = new FakeContext();
|
||||||
|
(globalThis as Record<string, unknown>).AudioContext = function () {
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
globalThis.fetch = (async () => ({
|
||||||
|
ok: true,
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(8),
|
||||||
|
})) as unknown as typeof fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
(globalThis as Record<string, unknown>).AudioContext = originalCtor;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GaplessEngine', () => {
|
||||||
|
test('load(autoplay) followed by play() starts the track exactly once', async () => {
|
||||||
|
// The host's queue effect and playing effect both fire in the same commit when a queue starts from a
|
||||||
|
// paused player. This is that commit, and it used to produce two sources of the same track.
|
||||||
|
const engine = new GaplessEngine({});
|
||||||
|
engine.load(QUEUE, 0, true);
|
||||||
|
engine.play();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(audible(ctx)).toHaveLength(1);
|
||||||
|
engine.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeated play() during the decode does not stack sources', async () => {
|
||||||
|
const engine = new GaplessEngine({});
|
||||||
|
engine.load(QUEUE, 0, true);
|
||||||
|
engine.play();
|
||||||
|
engine.play();
|
||||||
|
engine.play();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(audible(ctx)).toHaveLength(1);
|
||||||
|
engine.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pause then play while still decoding does not stack sources', async () => {
|
||||||
|
// The impatient-user path: nothing is audible yet because the file is still downloading, so the play
|
||||||
|
// button gets hit again.
|
||||||
|
const engine = new GaplessEngine({});
|
||||||
|
engine.load(QUEUE, 0, true);
|
||||||
|
engine.pause();
|
||||||
|
engine.play();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(audible(ctx)).toHaveLength(1);
|
||||||
|
engine.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale source ending does not advance the queue', async () => {
|
||||||
|
// Defence in depth: even if a source outlives its bookkeeping, only `cur` may move the index.
|
||||||
|
const seen: number[] = [];
|
||||||
|
const engine = new GaplessEngine({ onIndex: (i) => seen.push(i) });
|
||||||
|
engine.load(QUEUE, 0, true);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const first = audible(ctx)[0]!;
|
||||||
|
engine.skipTo(2); // bumps the generation and stops `first`
|
||||||
|
await settle();
|
||||||
|
first.end(); // the browser still delivers its onended
|
||||||
|
|
||||||
|
expect(seen).toEqual([]); // skipTo is a user action; only a NATURAL boundary reports an index
|
||||||
|
engine.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching queues leaves nothing from the old one sounding', async () => {
|
||||||
|
const engine = new GaplessEngine({});
|
||||||
|
engine.load(QUEUE, 0, true);
|
||||||
|
await settle();
|
||||||
|
engine.load([{ key: 'z', url: '/stream/z' }], 0, true);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(audible(ctx)).toHaveLength(1);
|
||||||
|
expect(audible(ctx)[0]!.started).not.toBeNull();
|
||||||
|
engine.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a natural boundary advances the index exactly once', async () => {
|
||||||
|
const seen: number[] = [];
|
||||||
|
const engine = new GaplessEngine({ onIndex: (i) => seen.push(i) });
|
||||||
|
engine.load(QUEUE, 0, true);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const cur = audible(ctx)[0]!;
|
||||||
|
cur.end();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(seen).toEqual([1]);
|
||||||
|
engine.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroy() silences every source it started', async () => {
|
||||||
|
const engine = new GaplessEngine({});
|
||||||
|
engine.load(QUEUE, 0, true);
|
||||||
|
await settle();
|
||||||
|
engine.destroy();
|
||||||
|
|
||||||
|
expect(audible(ctx)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
// Sample-accurate gapless playback engine (Web Audio).
|
||||||
|
//
|
||||||
|
// The HTML5 <audio> element cannot play tracks back-to-back without a gap: advancing swaps the element's
|
||||||
|
// `src`, which re-fetches + re-buffers the next file. For a continuous DJ mix (already silence-trimmed at
|
||||||
|
// the edges) that gap is audible. This engine instead decodes each track to an AudioBuffer and schedules
|
||||||
|
// the NEXT track's AudioBufferSourceNode to `start()` at the precise AudioContext time the current track
|
||||||
|
// ends — so the seam is sample-accurate, zero gap.
|
||||||
|
//
|
||||||
|
// Cost of that guarantee: it must download + decode the WHOLE next file to PCM ahead of time (a ~10-min
|
||||||
|
// track ≈ 200 MB in memory), so we cache only a few decoded buffers. Startup / manual-skip therefore has
|
||||||
|
// a decode latency (surfaced via onLoadingChange); auto-advance is pre-decoded so it's instant.
|
||||||
|
|
||||||
|
export type EngineTrack = { key: string; url: string };
|
||||||
|
|
||||||
|
export type EngineCallbacks = {
|
||||||
|
/** The engine advanced to a new track on its OWN (natural boundary) — mirror it into UI state. */
|
||||||
|
onIndex?: (index: number) => void;
|
||||||
|
/** Current position + duration (seconds). Fires ~per animation frame while playing, and on load/seek. */
|
||||||
|
onTime?: (positionSec: number, durationSec: number) => void;
|
||||||
|
/** Playback stopped because the queue ran out (host should reflect paused state). */
|
||||||
|
onEndOfQueue?: () => void;
|
||||||
|
/** True while the CURRENT track is being fetched/decoded (startup / skip / seek). */
|
||||||
|
onLoadingChange?: (loading: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CACHE_MAX = 3; // decoded buffers kept (current + next + a little headroom); bounds memory
|
||||||
|
|
||||||
|
export class GaplessEngine {
|
||||||
|
private ctx: AudioContext | null = null;
|
||||||
|
private gain: GainNode | null = null;
|
||||||
|
private volume = 1;
|
||||||
|
|
||||||
|
private buffers = new Map<string, AudioBuffer>(); // url → decoded PCM (insertion-ordered LRU)
|
||||||
|
private inflight = new Map<string, Promise<AudioBuffer | null>>();
|
||||||
|
|
||||||
|
private queue: EngineTrack[] = [];
|
||||||
|
private index = 0;
|
||||||
|
|
||||||
|
private cur: AudioBufferSourceNode | null = null;
|
||||||
|
private nxt: AudioBufferSourceNode | null = null;
|
||||||
|
// EVERY source node this engine has started and that has not yet ended. `cur`/`nxt` are the two it is
|
||||||
|
// reasoning about; this is the set it is responsible for silencing. They diverged once — a duplicate
|
||||||
|
// begin() left a source playing that nothing held a reference to, so nothing could ever stop it — and
|
||||||
|
// an orphan in Web Audio is unstoppable and inaudible to the code. Add here, stop from here.
|
||||||
|
private live = new Set<AudioBufferSourceNode>();
|
||||||
|
private curBaseTime = 0; // ctx time that corresponds to position 0 of the current track
|
||||||
|
private curDuration = 0;
|
||||||
|
private nextStartAt = 0; // ctx time the scheduled `nxt` will begin (= current track's end)
|
||||||
|
private pendingOffset = 0; // where the current track should (re)start from — seek/resume offset
|
||||||
|
private started = false; // has the current track's source actually been started?
|
||||||
|
private playing = false;
|
||||||
|
private gen = 0; // bumped on any disruptive change; stale async/onended callbacks check it and bail
|
||||||
|
// The generation a begin() is currently in flight for, or -1. `gen` alone cannot express this: it marks
|
||||||
|
// a change, and two begin() calls for the SAME generation are exactly the case that must be refused.
|
||||||
|
private beginGen = -1;
|
||||||
|
private raf = 0;
|
||||||
|
|
||||||
|
private cb: EngineCallbacks;
|
||||||
|
|
||||||
|
constructor(cb: EngineCallbacks) {
|
||||||
|
this.cb = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCtx(): AudioContext {
|
||||||
|
if (!this.ctx) {
|
||||||
|
this.ctx = new AudioContext();
|
||||||
|
this.gain = this.ctx.createGain();
|
||||||
|
this.gain.gain.value = this.volume;
|
||||||
|
this.gain.connect(this.ctx.destination);
|
||||||
|
}
|
||||||
|
return this.ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resume the context from a user gesture (autoplay policy). Safe to call repeatedly. */
|
||||||
|
unlock(): void {
|
||||||
|
const ctx = this.ensureCtx();
|
||||||
|
if (ctx.state === 'suspended') void ctx.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(v: number): void {
|
||||||
|
this.volume = v;
|
||||||
|
if (this.gain) this.gain.gain.value = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decoding (fetch full file → PCM), deduped + LRU-capped ──
|
||||||
|
private decode(url: string): Promise<AudioBuffer | null> {
|
||||||
|
const cached = this.buffers.get(url);
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
|
const existing = this.inflight.get(url);
|
||||||
|
if (existing) return existing;
|
||||||
|
const ctx = this.ensureCtx();
|
||||||
|
const p = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const bytes = await res.arrayBuffer();
|
||||||
|
const decoded = await ctx.decodeAudioData(bytes);
|
||||||
|
this.buffers.set(url, decoded);
|
||||||
|
this.evict();
|
||||||
|
return decoded;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
this.inflight.delete(url);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
this.inflight.set(url, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private evict(): void {
|
||||||
|
while (this.buffers.size > CACHE_MAX) {
|
||||||
|
const keep = new Set([this.queue[this.index]?.url, this.queue[this.index + 1]?.url]);
|
||||||
|
let removed = false;
|
||||||
|
for (const k of this.buffers.keys()) {
|
||||||
|
if (!keep.has(k)) {
|
||||||
|
this.buffers.delete(k);
|
||||||
|
removed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!removed) break; // everything left is current/next — stop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public control surface ──
|
||||||
|
|
||||||
|
/** Load a queue and either start it (autoplay) or prepare it paused at `seekTo`. */
|
||||||
|
load(queue: EngineTrack[], index: number, autoplay: boolean, seekTo = 0): void {
|
||||||
|
this.gen++;
|
||||||
|
this.stopSources();
|
||||||
|
this.queue = queue;
|
||||||
|
this.index = queue.length ? Math.max(0, Math.min(index, queue.length - 1)) : 0;
|
||||||
|
this.playing = autoplay && queue.length > 0;
|
||||||
|
this.started = false;
|
||||||
|
this.pendingOffset = Math.max(0, seekTo);
|
||||||
|
this.curDuration = 0;
|
||||||
|
this.curBaseTime = 0;
|
||||||
|
this.startTicker();
|
||||||
|
if (!queue.length) return;
|
||||||
|
if (autoplay) void this.begin(this.gen);
|
||||||
|
else void this.prepare(this.gen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manual jump to an arbitrary index (prev / next button / track click). A small decode hitch is fine. */
|
||||||
|
skipTo(index: number): void {
|
||||||
|
this.gen++;
|
||||||
|
this.stopSources();
|
||||||
|
this.index = Math.max(0, Math.min(index, this.queue.length - 1));
|
||||||
|
this.pendingOffset = 0;
|
||||||
|
this.started = false;
|
||||||
|
this.curDuration = 0;
|
||||||
|
if (this.playing) void this.begin(this.gen);
|
||||||
|
else void this.prepare(this.gen);
|
||||||
|
}
|
||||||
|
|
||||||
|
seek(sec: number): void {
|
||||||
|
if (!this.queue[this.index]) return;
|
||||||
|
this.gen++;
|
||||||
|
this.stopSources();
|
||||||
|
this.pendingOffset = Math.max(0, this.curDuration ? Math.min(sec, this.curDuration) : sec);
|
||||||
|
this.started = false;
|
||||||
|
if (this.playing) {
|
||||||
|
void this.begin(this.gen);
|
||||||
|
} else {
|
||||||
|
this.cb.onTime?.(this.pendingOffset, this.curDuration);
|
||||||
|
void this.prepare(this.gen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
play(): void {
|
||||||
|
this.playing = true;
|
||||||
|
const ctx = this.ensureCtx();
|
||||||
|
if (!this.started) void this.begin(this.gen);
|
||||||
|
else if (ctx.state === 'suspended') void ctx.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(): void {
|
||||||
|
this.playing = false;
|
||||||
|
if (this.ctx && this.ctx.state === 'running') void this.ctx.suspend();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.gen++;
|
||||||
|
if (this.raf) cancelAnimationFrame(this.raf);
|
||||||
|
this.raf = 0;
|
||||||
|
this.stopSources();
|
||||||
|
this.buffers.clear();
|
||||||
|
this.inflight.clear();
|
||||||
|
if (this.ctx) {
|
||||||
|
void this.ctx.close();
|
||||||
|
this.ctx = null;
|
||||||
|
this.gain = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internals ──
|
||||||
|
|
||||||
|
/** Decode the current track without starting it — paused/prepared (restore, or seek while paused). */
|
||||||
|
private async prepare(gen: number): Promise<void> {
|
||||||
|
const t = this.queue[this.index];
|
||||||
|
if (!t) return;
|
||||||
|
this.cb.onLoadingChange?.(true);
|
||||||
|
const buf = await this.decode(t.url);
|
||||||
|
this.cb.onLoadingChange?.(false);
|
||||||
|
if (gen !== this.gen || !buf) return;
|
||||||
|
this.curDuration = buf.duration;
|
||||||
|
this.cb.onTime?.(Math.min(this.pendingOffset, buf.duration), buf.duration);
|
||||||
|
void this.preloadNext(gen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the current track at `pendingOffset` (decoding first if needed).
|
||||||
|
*
|
||||||
|
* Re-entrant calls for the same generation are REFUSED, and that guard is the whole reason this method
|
||||||
|
* is not simply idempotent-by-gen. `started` only becomes true at the very end, after a fetch and a
|
||||||
|
* full decode — seconds, for a long track. Anything that calls begin() in that window sees
|
||||||
|
* `started === false` and starts a second, parallel decode of the same track, and both finish and both
|
||||||
|
* call startBuffer(): two sources of the same audio playing at once, only one of them in `cur`.
|
||||||
|
*
|
||||||
|
* That was not a rare race. Starting a queue from a paused player fired it every single time: the host
|
||||||
|
* commits a new queue and `playing: true` together, its queue effect calls load(autoplay) → begin, and
|
||||||
|
* its playing effect then calls play() → begin again, same generation. The audible result compounds —
|
||||||
|
* the untracked twin keeps its own onended, so at the track boundary advance() runs twice, the index
|
||||||
|
* jumps two tracks and a second source is promoted while the first is still sounding.
|
||||||
|
*/
|
||||||
|
private async begin(gen: number): Promise<void> {
|
||||||
|
if (this.beginGen === gen) return;
|
||||||
|
this.beginGen = gen;
|
||||||
|
try {
|
||||||
|
const ctx = this.ensureCtx();
|
||||||
|
if (ctx.state === 'suspended') await ctx.resume();
|
||||||
|
if (gen !== this.gen) return;
|
||||||
|
const t = this.queue[this.index];
|
||||||
|
if (!t) return;
|
||||||
|
this.cb.onLoadingChange?.(true);
|
||||||
|
const buf = await this.decode(t.url);
|
||||||
|
this.cb.onLoadingChange?.(false);
|
||||||
|
if (gen !== this.gen || !buf) return;
|
||||||
|
this.startBuffer(buf, this.pendingOffset, gen);
|
||||||
|
void this.preloadNext(gen);
|
||||||
|
} finally {
|
||||||
|
// Released even on the bail paths: a decode that fails must not leave this generation permanently
|
||||||
|
// unable to start, or a failed track would wedge the player until something bumped `gen`.
|
||||||
|
if (this.beginGen === gen) this.beginGen = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startBuffer(buf: AudioBuffer, offset: number, gen: number): void {
|
||||||
|
const ctx = this.ensureCtx();
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = buf;
|
||||||
|
src.connect(this.gain!);
|
||||||
|
const startAt = ctx.currentTime;
|
||||||
|
const off = Math.min(Math.max(0, offset), buf.duration);
|
||||||
|
src.start(startAt, off);
|
||||||
|
this.live.add(src);
|
||||||
|
src.onended = () => this.onSourceEnded(src, gen);
|
||||||
|
this.cur = src;
|
||||||
|
this.curDuration = buf.duration;
|
||||||
|
this.curBaseTime = startAt - off; // position = ctx.currentTime - curBaseTime
|
||||||
|
this.started = true;
|
||||||
|
this.playing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode index+1 and schedule it to begin exactly when the current track ends. */
|
||||||
|
private async preloadNext(gen: number): Promise<void> {
|
||||||
|
if (this.nxt) {
|
||||||
|
const stale = this.nxt;
|
||||||
|
this.live.delete(stale);
|
||||||
|
try {
|
||||||
|
stale.onended = null;
|
||||||
|
stale.stop();
|
||||||
|
} catch {
|
||||||
|
/* not started */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
stale.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* already disconnected */
|
||||||
|
}
|
||||||
|
this.nxt = null;
|
||||||
|
}
|
||||||
|
const forIndex = this.index;
|
||||||
|
const nextTrack = this.queue[forIndex + 1];
|
||||||
|
if (!nextTrack || !this.started) return;
|
||||||
|
const buf = await this.decode(nextTrack.url);
|
||||||
|
if (gen !== this.gen || this.index !== forIndex || !buf) return;
|
||||||
|
const ctx = this.ensureCtx();
|
||||||
|
const boundary = this.curBaseTime + this.curDuration;
|
||||||
|
if (boundary <= ctx.currentTime) return; // already past — advance() will start it fresh
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = buf;
|
||||||
|
src.connect(this.gain!);
|
||||||
|
src.start(boundary, 0);
|
||||||
|
this.live.add(src);
|
||||||
|
src.onended = () => this.onSourceEnded(src, gen);
|
||||||
|
this.nxt = src;
|
||||||
|
this.nextStartAt = boundary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A source finished. Only the one the engine considers CURRENT may drive the queue forward.
|
||||||
|
*
|
||||||
|
* Without the identity check, any source that outlives its bookkeeping still advances the queue when it
|
||||||
|
* ends — so one stray node does not just play unwanted audio, it desynchronises the index for
|
||||||
|
* everything after it. `cur` is the single source of truth for "what is playing"; ending anything else
|
||||||
|
* is bookkeeping, not an event.
|
||||||
|
*/
|
||||||
|
private onSourceEnded(src: AudioBufferSourceNode, gen: number): void {
|
||||||
|
this.live.delete(src);
|
||||||
|
if (gen !== this.gen || this.cur !== src) return;
|
||||||
|
this.advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired at a track boundary (its source ended): move to the next track. */
|
||||||
|
private advance(): void {
|
||||||
|
if (this.index + 1 >= this.queue.length) {
|
||||||
|
this.playing = false;
|
||||||
|
this.started = false;
|
||||||
|
this.cur = null;
|
||||||
|
this.cb.onEndOfQueue?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.index++;
|
||||||
|
const promoted = this.nxt;
|
||||||
|
this.nxt = null;
|
||||||
|
if (promoted?.buffer) {
|
||||||
|
// The next source was scheduled to start at the exact boundary — it's already playing seamlessly.
|
||||||
|
this.cur = promoted;
|
||||||
|
this.curDuration = promoted.buffer.duration;
|
||||||
|
this.curBaseTime = this.nextStartAt;
|
||||||
|
this.started = true;
|
||||||
|
this.cb.onIndex?.(this.index);
|
||||||
|
void this.preloadNext(this.gen);
|
||||||
|
} else {
|
||||||
|
// Next wasn't decoded in time (rare) — start it now, accepting a tiny gap this once.
|
||||||
|
this.cb.onIndex?.(this.index);
|
||||||
|
this.pendingOffset = 0;
|
||||||
|
this.started = false;
|
||||||
|
void this.begin(this.gen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Silence everything this engine has started. Iterates `live`, not just cur/nxt — see its declaration. */
|
||||||
|
private stopSources(): void {
|
||||||
|
for (const s of this.live) {
|
||||||
|
try {
|
||||||
|
s.onended = null;
|
||||||
|
s.stop();
|
||||||
|
} catch {
|
||||||
|
/* not started */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
s.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* already disconnected */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.live.clear();
|
||||||
|
this.cur = null;
|
||||||
|
this.nxt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startTicker(): void {
|
||||||
|
if (this.raf) return;
|
||||||
|
const tick = () => {
|
||||||
|
this.raf = requestAnimationFrame(tick);
|
||||||
|
if (!this.ctx || !this.started || !this.playing) return;
|
||||||
|
const pos = this.ctx.currentTime - this.curBaseTime;
|
||||||
|
this.cb.onTime?.(Math.max(0, Math.min(pos, this.curDuration)), this.curDuration);
|
||||||
|
};
|
||||||
|
this.raf = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
|
||||||
|
export const defaultLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'music-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'music-browser', appType: 'music-browser' }, size: 26 },
|
||||||
|
{ node: { type: 'panel', id: 'music-detail', appType: 'music-detail' }, size: 74 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { parseLyrics, activeLineIndex } from './lyrics';
|
||||||
|
|
||||||
|
describe('parseLyrics', () => {
|
||||||
|
test('plain text is not synced and keeps every line, blanks included', () => {
|
||||||
|
const { synced, lines } = parseLyrics('first\n\n second \n');
|
||||||
|
expect(synced).toBe(false);
|
||||||
|
expect(lines.map((l) => l.text)).toEqual(['first', '', 'second', '']);
|
||||||
|
expect(lines.every((l) => l.timeSec === undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lrc timestamps parse to seconds, with hundredths', () => {
|
||||||
|
const { synced, lines } = parseLyrics('[00:12.50]hello\n[01:03]world');
|
||||||
|
expect(synced).toBe(true);
|
||||||
|
expect(lines).toEqual([
|
||||||
|
{ timeSec: 12.5, text: 'hello' },
|
||||||
|
{ timeSec: 63, text: 'world' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a single-digit fraction is tenths, not thousandths', () => {
|
||||||
|
expect(parseLyrics('[00:01.5]x').lines[0]?.timeSec).toBe(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('metadata tags are dropped', () => {
|
||||||
|
const { lines } = parseLyrics('[ar:Artist]\n[ti:Title]\n[00:01.00]real');
|
||||||
|
expect(lines).toEqual([{ timeSec: 1, text: 'real' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('several stamps on one line become several lines, sorted by time', () => {
|
||||||
|
const { lines } = parseLyrics('[02:00.00][00:30.00]chorus\n[01:00.00]verse');
|
||||||
|
expect(lines).toEqual([
|
||||||
|
{ timeSec: 30, text: 'chorus' },
|
||||||
|
{ timeSec: 60, text: 'verse' },
|
||||||
|
{ timeSec: 120, text: 'chorus' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an untimed line inside a synced file survives, but blanks do not', () => {
|
||||||
|
const { lines } = parseLyrics('[00:01.00]a\n\nspoken\n');
|
||||||
|
expect(lines.map((l) => l.text)).toEqual(['spoken', 'a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty timed line is kept — it is a musical rest', () => {
|
||||||
|
expect(parseLyrics('[00:10.00]').lines).toEqual([{ timeSec: 10, text: '' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('activeLineIndex', () => {
|
||||||
|
const lines = [
|
||||||
|
{ timeSec: 10, text: 'a' },
|
||||||
|
{ timeSec: 20, text: 'b' },
|
||||||
|
{ timeSec: 30, text: 'c' },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('-1 before the first line', () => {
|
||||||
|
expect(activeLineIndex(lines, 0)).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 0.2s lookahead highlights fractionally early', () => {
|
||||||
|
expect(activeLineIndex(lines, 9.7)).toBe(-1);
|
||||||
|
expect(activeLineIndex(lines, 9.9)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('holds the last line past the end', () => {
|
||||||
|
expect(activeLineIndex(lines, 25)).toBe(1);
|
||||||
|
expect(activeLineIndex(lines, 9999)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('untimed lines never become active', () => {
|
||||||
|
expect(activeLineIndex([{ text: 'x' }, { timeSec: 5, text: 'y' }], 60)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Parse lyrics text into displayable lines. `.lrc` carries `[mm:ss.xx]` timestamps (possibly several per
|
||||||
|
* line, e.g. repeated choruses) and metadata tags ([ar:], [ti:], …) which are dropped. `.txt` is plain.
|
||||||
|
* A synced result is sorted by time so the active-line lookup is a simple scan.
|
||||||
|
*
|
||||||
|
* Ported from the mobile app (packages/core/src/services/lyrics.ts) — same file format, same server,
|
||||||
|
* so the two must agree on what a line is.
|
||||||
|
*/
|
||||||
|
export type LyricLine = { timeSec?: number; text: string };
|
||||||
|
export type ParsedLyrics = { synced: boolean; lines: LyricLine[] };
|
||||||
|
|
||||||
|
const TIME_RE = /\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]/g;
|
||||||
|
const META_RE = /^\[(ar|ti|al|by|offset|length|re|ve|au|la|id):/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lines are treated as synced whenever the text actually contains `[mm:ss]` timestamps — the server's
|
||||||
|
* `X-Lyrics-Format` header is not trusted (it need not survive a proxy, and embedded lyrics carrying
|
||||||
|
* timestamps should sync regardless of which file they came from). No timestamps → plain text.
|
||||||
|
*/
|
||||||
|
export function parseLyrics(text: string): ParsedLyrics {
|
||||||
|
if (!/\[\d{1,2}:\d{2}/.test(text)) {
|
||||||
|
return { synced: false, lines: text.split(/\r?\n/).map((t) => ({ text: t.trim() })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: LyricLine[] = [];
|
||||||
|
for (const rawLine of text.split(/\r?\n/)) {
|
||||||
|
if (META_RE.test(rawLine.trim())) continue;
|
||||||
|
const stamps: number[] = [];
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
TIME_RE.lastIndex = 0;
|
||||||
|
while ((m = TIME_RE.exec(rawLine)) !== null) {
|
||||||
|
const min = Number(m[1]);
|
||||||
|
const sec = Number(m[2]);
|
||||||
|
// "[00:12.3]" is three tenths, not three milliseconds — pad right before reading as thousandths.
|
||||||
|
const frac = m[3] ? Number(`${m[3]}00`.slice(0, 3)) / 1000 : 0;
|
||||||
|
stamps.push(min * 60 + sec + frac);
|
||||||
|
}
|
||||||
|
const lyric = rawLine.replace(TIME_RE, '').trim();
|
||||||
|
if (!stamps.length) {
|
||||||
|
if (lyric) out.push({ text: lyric }); // a plain line inside an otherwise-synced file
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const t of stamps) out.push({ timeSec: t, text: lyric });
|
||||||
|
}
|
||||||
|
|
||||||
|
const synced = out.some((l) => l.timeSec != null);
|
||||||
|
if (synced) out.sort((a, b) => (a.timeSec ?? 0) - (b.timeSec ?? 0));
|
||||||
|
return { synced, lines: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the active line for a playback position (synced only); -1 before the first line. The 0.2s
|
||||||
|
* lookahead lands the highlight fractionally early, which reads as on-time — arriving late reads as lag.
|
||||||
|
*/
|
||||||
|
export function activeLineIndex(lines: LyricLine[], positionSec: number): number {
|
||||||
|
let idx = -1;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const t = lines[i]?.timeSec;
|
||||||
|
if (t == null) continue;
|
||||||
|
if (t <= positionSec + 0.2) idx = i;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { AppRegistryMeta } from 'officerdev';
|
||||||
|
import { Music, ListMusic } from 'lucide-react';
|
||||||
|
import { MusicBrowser } from './MusicBrowser';
|
||||||
|
import { MusicDetail } from './MusicDetail';
|
||||||
|
|
||||||
|
// The panels this plugin contributes. The shell renders `WorkspaceView` around them, arranged by
|
||||||
|
// `layout.ts` — a plugin never renders the screen.
|
||||||
|
//
|
||||||
|
// The two do not coordinate with each other: both read `?path=` off the URL, which is why there is no
|
||||||
|
// channel between them and why a library location is linkable and cmd-clickable. `MusicDetail` opens a
|
||||||
|
// NESTED workspace of its own for the lyrics split, which is a layout inside a panel rather than a second
|
||||||
|
// screen.
|
||||||
|
//
|
||||||
|
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
|
||||||
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
|
{ key: 'music-browser', name: 'Library', icon: ListMusic, component: MusicBrowser, availableOnPanel: false },
|
||||||
|
{ key: 'music-detail', name: 'Music', icon: Music, component: MusicDetail, availableOnPanel: false },
|
||||||
|
];
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Playback position, published outside React.
|
||||||
|
*
|
||||||
|
* The lyrics pane no longer lives in the player's subtree — it renders in a panel of the /music
|
||||||
|
* workspace — so the position has to cross the tree. It cannot cross as state: the engine reports a new
|
||||||
|
* position every animation frame, and a shared state channel would re-render every consumer 60× a
|
||||||
|
* second. Instead the host pushes into this module and subscribers decide for themselves what is worth
|
||||||
|
* a render (the lyrics pane only re-renders when the ACTIVE LINE changes, roughly once a line).
|
||||||
|
*
|
||||||
|
* Seek travels the other way for the same reason: the engine is the host's, but a click on a lyric line
|
||||||
|
* has to reach it.
|
||||||
|
*/
|
||||||
|
let position = 0;
|
||||||
|
let duration = 0;
|
||||||
|
const subscribers = new Set<(sec: number, dur: number) => void>();
|
||||||
|
let seekFn: ((sec: number) => void) | null = null;
|
||||||
|
|
||||||
|
export const publishPlayerTime = (sec: number, dur: number): void => {
|
||||||
|
position = sec;
|
||||||
|
duration = dur;
|
||||||
|
for (const fn of subscribers) fn(sec, dur);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Latest values, for a subscriber that mounts mid-track. */
|
||||||
|
export const getPlayerTime = (): number => position;
|
||||||
|
export const getPlayerDuration = (): number => duration;
|
||||||
|
|
||||||
|
export const subscribePlayerTime = (fn: (sec: number, dur: number) => void): (() => void) => {
|
||||||
|
subscribers.add(fn);
|
||||||
|
return () => {
|
||||||
|
subscribers.delete(fn);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const registerPlayerSeek = (fn: (sec: number) => void): (() => void) => {
|
||||||
|
seekFn = fn;
|
||||||
|
return () => {
|
||||||
|
if (seekFn === fn) seekFn = null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seekPlayer = (sec: number): void => seekFn?.(sec);
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
// Shared types/helpers for the /music workspace panels (MusicBrowser + MusicDetail), which coordinate
|
||||||
|
// via the `?path=` search param and play through the app-wide useMusicPlayer.
|
||||||
|
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
export const MUSIC_ROOT = 'Music';
|
||||||
|
export const MUSIC_FAV_CHANNEL = 'music:favorites';
|
||||||
|
// Bumped (to a fresh nonce) when a library reindex finishes, so BOTH panels re-run their manifest /
|
||||||
|
// listing / meta fetches — otherwise only the panel that triggered the reindex refreshes.
|
||||||
|
export const MUSIC_RESYNC_CHANNEL = 'music:resync';
|
||||||
|
|
||||||
|
// Album folders are named "[year] Album Name" → display as "Album Name" + year.
|
||||||
|
const ALBUM_NAME_RE = /^\[(\d{4})\]\s*(.+)$/;
|
||||||
|
export const parseAlbumName = (name: string): { title: string; year?: string } => {
|
||||||
|
const m = ALBUM_NAME_RE.exec(name.trim());
|
||||||
|
return m ? { title: m[2]!.trim(), year: m[1] } : { title: name };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DirEntry = { name: string; type: 'directory' | 'file'; size: number; modifiedAt: number };
|
||||||
|
export type LsResult = { entries: DirEntry[] };
|
||||||
|
export type ManifestAlbum = { v: string; cover: boolean; tracks: number; disco?: boolean };
|
||||||
|
export type Manifest = { albums: Record<string, ManifestAlbum> };
|
||||||
|
export type Track = {
|
||||||
|
file: string;
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
albumArtist?: string;
|
||||||
|
track?: string;
|
||||||
|
durationSec?: number;
|
||||||
|
};
|
||||||
|
export type AlbumMeta = { path: string; cover?: string; tracks: Track[] };
|
||||||
|
|
||||||
|
/** Seconds → "m:ss" (or "h:mm:ss" once past an hour); '' when unknown. */
|
||||||
|
export const fmtDuration = (sec?: number): string => {
|
||||||
|
if (!sec || sec <= 0) return '';
|
||||||
|
const s = Math.round(sec);
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const ss = String(s % 60).padStart(2, '0');
|
||||||
|
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Seconds → "m:ss" for a running clock: unknown reads as 0:00, never blank, so it doesn't jitter. */
|
||||||
|
export const fmtClock = (sec: number): string =>
|
||||||
|
Number.isFinite(sec) && sec >= 0
|
||||||
|
? `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, '0')}`
|
||||||
|
: '0:00';
|
||||||
|
|
||||||
|
/** Case-insensitive subsequence fuzzy match: every char of `query` appears in order within `text`. */
|
||||||
|
export const fuzzyMatch = (query: string, text: string): boolean => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
const t = text.toLowerCase();
|
||||||
|
let qi = 0;
|
||||||
|
for (let ti = 0; ti < t.length && qi < q.length; ti++) if (t[ti] === q[qi]!) qi++;
|
||||||
|
return qi === q.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Parse a track-number tag ("7", "07", "7/14") to a number, or null when absent/unparseable. */
|
||||||
|
const trackNo = (t: Track): number | null => {
|
||||||
|
const raw = t.track?.split('/')[0]?.trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const n = parseInt(raw, 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical album track order: by the `track` NUMBER, falling back to the tag title only for tracks
|
||||||
|
* that have no number (numbered tracks always precede unnumbered ones; filename breaks a final tie).
|
||||||
|
* meta.json is in ffprobe/readdir order (arbitrary), so every consumer must sort with this.
|
||||||
|
*/
|
||||||
|
export const sortTracks = <T extends Track>(tracks: T[]): T[] => {
|
||||||
|
const key = (t: Track) => (t.title || t.file).toLowerCase();
|
||||||
|
return [...tracks].sort((a, b) => {
|
||||||
|
const na = trackNo(a);
|
||||||
|
const nb = trackNo(b);
|
||||||
|
if (na !== null && nb !== null) return na - nb || key(a).localeCompare(key(b));
|
||||||
|
if (na !== null) return -1;
|
||||||
|
if (nb !== null) return 1;
|
||||||
|
return key(a).localeCompare(key(b));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export type Discography = { artist: string; albums: Record<string, string> };
|
||||||
|
|
||||||
|
export type FavoriteKind = 'track' | 'album' | 'artist';
|
||||||
|
export type GroupedFavorites = { tracks: string[]; albums: string[]; artists: string[] };
|
||||||
|
|
||||||
|
/** Per-user "currently playing" snapshot (GET/PUT /api/music/now-playing). */
|
||||||
|
export type NowPlaying = {
|
||||||
|
homePath: string;
|
||||||
|
dir: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
durationSec: number;
|
||||||
|
positionSec: number;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** homePath ("Music/<rel>/<file>") for a track — its favorite key + /stream path. */
|
||||||
|
export const trackHomePath = (rel: string, file: string) => `${MUSIC_ROOT}/${rel}/${file}`;
|
||||||
|
|
||||||
|
const AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma', 'mp4']);
|
||||||
|
export const isAudio = (n: string) => {
|
||||||
|
const d = n.lastIndexOf('.');
|
||||||
|
return d >= 0 && AUDIO_EXT.has(n.slice(d + 1).toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Section order for an artist's discography.
|
||||||
|
export const TYPE_ORDER = [
|
||||||
|
'Studio',
|
||||||
|
'Live',
|
||||||
|
'Compilation',
|
||||||
|
'EP',
|
||||||
|
'Single',
|
||||||
|
'Soundtrack',
|
||||||
|
'Remix',
|
||||||
|
'DJ-Mix',
|
||||||
|
'Demo',
|
||||||
|
'Mixtape',
|
||||||
|
'Bootleg',
|
||||||
|
'Other',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const coverUrl = (rel: string, token: string | null) =>
|
||||||
|
`/api/music/cover?path=${encodeURIComponent(rel)}${token ? `&token=${encodeURIComponent(token)}` : ''}`;
|
||||||
|
|
||||||
|
/** Path (home-relative) → rel (relative to the Music root). */
|
||||||
|
export const toRel = (cwd: string | null) => (cwd ? cwd.slice(MUSIC_ROOT.length + 1) : '');
|
||||||
|
|
||||||
|
// Where you are in the library is `/music?path=<rel>`, not a `music:cwd` channel. A query param rather
|
||||||
|
// than `/music/*` because the location is one of several things this screen holds (the lyrics split and
|
||||||
|
// the favorites view are the others), and because a splat would have to be the last segment of the
|
||||||
|
// route — the same reason /chat spells its group that way. `rel === ''` is the library root, which is
|
||||||
|
// the bare /music and a real state, so there is no redirect guard.
|
||||||
|
export const MUSIC_PATH_PARAM = 'path';
|
||||||
|
|
||||||
|
/** Link target for a library location. `rel` is relative to the Music root; '' is the root itself. */
|
||||||
|
export const musicPath = (rel: string) => (rel ? `/music?${MUSIC_PATH_PARAM}=${encodeURIComponent(rel)}` : '/music');
|
||||||
|
|
||||||
|
/** Link target for the parent of `rel` — '' (the root) is its own parent, which is where "up" stops. */
|
||||||
|
export const musicParentPath = (rel: string) => musicPath(rel.split('/').slice(0, -1).join('/'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The open library folder as a home-relative path ("Music/…"), or null at the root — the vocabulary the
|
||||||
|
* panels already speak, so reading the URL costs them nothing. Each panel calls this itself; they never
|
||||||
|
* tell each other where they are.
|
||||||
|
*/
|
||||||
|
export const useMusicCwd = (): string | null => {
|
||||||
|
const rel = useSearchParams()[0].get(MUSIC_PATH_PARAM)?.trim() ?? '';
|
||||||
|
return rel ? `${MUSIC_ROOT}/${rel}` : null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { LyricLine } from './lyrics';
|
||||||
|
import { activeLineIndex, parseLyrics } from './lyrics';
|
||||||
|
import { getPlayerTime, subscribePlayerTime } from './player-time';
|
||||||
|
|
||||||
|
export type UseLyrics = {
|
||||||
|
loading: boolean;
|
||||||
|
/** null while loading, and when the track has none. */
|
||||||
|
lines: LyricLine[] | null;
|
||||||
|
synced: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch + parse the current track's lyrics. Gated on `enabled` so nothing is requested until the pane
|
||||||
|
* is actually open — the dock lives on every screen and most listening happens with it closed.
|
||||||
|
*
|
||||||
|
* Deliberately NOT gated on an index "has lyrics" flag the way the mobile app does: the web player's
|
||||||
|
* queue carries only what it needs to stream, and a 404 for a track without lyrics is cheaper than
|
||||||
|
* threading that flag through every producer of a queue.
|
||||||
|
*
|
||||||
|
* Auth goes in the query string rather than a header, matching how this component already builds its
|
||||||
|
* /stream and /cover URLs.
|
||||||
|
*/
|
||||||
|
export const useLyrics = (albumRel: string, file: string, enabled: boolean, token: string | null): UseLyrics => {
|
||||||
|
const [state, setState] = useState<UseLyrics>({ loading: false, lines: null, synced: false });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !albumRel || !file) {
|
||||||
|
setState({ loading: false, lines: null, synced: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url =
|
||||||
|
`/api/music/lyrics?path=${encodeURIComponent(albumRel)}&file=${encodeURIComponent(file)}` +
|
||||||
|
(token ? `&token=${encodeURIComponent(token)}` : '');
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
setState({ loading: true, lines: null, synced: false });
|
||||||
|
fetch(url, { signal: ctrl.signal })
|
||||||
|
.then(async (res) => {
|
||||||
|
// 404 is the ordinary "this track has no lyrics" answer, not an error worth surfacing.
|
||||||
|
if (!res.ok) return setState({ loading: false, lines: null, synced: false });
|
||||||
|
const { synced, lines } = parseLyrics(await res.text());
|
||||||
|
setState({ loading: false, lines, synced });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!ctrl.signal.aborted) setState({ loading: false, lines: null, synced: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => ctrl.abort();
|
||||||
|
}, [albumRel, file, enabled, token]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the line to highlight, driven by the engine's position feed.
|
||||||
|
*
|
||||||
|
* The feed ticks every animation frame; this re-renders only when the index actually moves — React bails
|
||||||
|
* out of an identical setState — so a synced sheet repaints about once a line instead of sixty times a
|
||||||
|
* second, even though it lives nowhere near the component that owns the clock.
|
||||||
|
*/
|
||||||
|
export const useActiveLyricIndex = (lines: LyricLine[] | null, synced: boolean): number => {
|
||||||
|
const [index, setIndex] = useState(-1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!synced || !lines) {
|
||||||
|
setIndex(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIndex(activeLineIndex(lines, getPlayerTime()));
|
||||||
|
return subscribePlayerTime((sec) => {
|
||||||
|
const next = activeLineIndex(lines, sec);
|
||||||
|
setIndex((prev) => (prev === next ? prev : next));
|
||||||
|
});
|
||||||
|
}, [lines, synced]);
|
||||||
|
|
||||||
|
return index;
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
|
|
||||||
|
export const MUSIC_LYRICS_CHANNEL = 'music:lyrics';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'music.lyrics';
|
||||||
|
// Read once, at import: the play dock re-renders every animation frame, and this is its initial value.
|
||||||
|
const initialOpen = localStorage.getItem(STORAGE_KEY) === '1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the lyrics panel is open. Shared by the two microphone buttons — the one in the play dock and
|
||||||
|
* the one on the album header — which are the same switch shown twice, so it lives in a channel rather
|
||||||
|
* than in either component. Seeded from localStorage so the choice survives a reload.
|
||||||
|
*/
|
||||||
|
export const useLyricsOpen = () => {
|
||||||
|
const [open, setOpen] = usePanelChannel<boolean>(MUSIC_LYRICS_CHANNEL, initialOpen);
|
||||||
|
|
||||||
|
// Deliberately not a functional update: useGlobal applies those to the render-time snapshot, and `open`
|
||||||
|
// is that snapshot anyway.
|
||||||
|
const toggleLyrics = () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, open ? '0' : '1');
|
||||||
|
setOpen(!open);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [open, toggleLyrics] as const;
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import type { FavoriteKind, GroupedFavorites } from './shared';
|
||||||
|
|
||||||
|
const KEY = ['music', 'favorites'] as const;
|
||||||
|
const EMPTY: GroupedFavorites = { tracks: [], albums: [], artists: [] };
|
||||||
|
const groupOf = (kind: FavoriteKind): keyof GroupedFavorites =>
|
||||||
|
kind === 'track' ? 'tracks' : kind === 'album' ? 'albums' : 'artists';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's music favorites (tracks / albums / artists) for the /music workspace, backed by the
|
||||||
|
* platform's `/api/music/favorites`. One shared react-query cache, so every heart reflects the same
|
||||||
|
* state; toggling is optimistic (flips instantly, rolls back on failure).
|
||||||
|
*/
|
||||||
|
export function useMusicFavorites() {
|
||||||
|
const { get, post, delete: del } = useClient();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: KEY,
|
||||||
|
queryFn: () => get<GroupedFavorites>('/music/favorites'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: ({ on, kind, key }: { on: boolean; kind: FavoriteKind; key: string }) =>
|
||||||
|
on
|
||||||
|
? post('/music/favorites', { kind, key })
|
||||||
|
: del(`/music/favorites?kind=${encodeURIComponent(kind)}&key=${encodeURIComponent(key)}`),
|
||||||
|
onMutate: async ({ on, kind, key }) => {
|
||||||
|
await qc.cancelQueries({ queryKey: KEY });
|
||||||
|
const prev = qc.getQueryData<GroupedFavorites>(KEY) ?? EMPTY;
|
||||||
|
const g = groupOf(kind);
|
||||||
|
qc.setQueryData<GroupedFavorites>(KEY, {
|
||||||
|
...prev,
|
||||||
|
[g]: on ? [key, ...prev[g].filter((k) => k !== key)] : prev[g].filter((k) => k !== key),
|
||||||
|
});
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onError: (_e, _v, ctx) => {
|
||||||
|
if (ctx?.prev) qc.setQueryData(KEY, ctx.prev);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFavorite = (kind: FavoriteKind, key: string) => (data ?? EMPTY)[groupOf(kind)].includes(key);
|
||||||
|
const toggle = (kind: FavoriteKind, key: string) => {
|
||||||
|
if (!key) return;
|
||||||
|
mutation.mutate({ on: !isFavorite(kind, key), kind, key });
|
||||||
|
};
|
||||||
|
|
||||||
|
return { favorites: data ?? EMPTY, isFavorite, toggle };
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
|
||||||
|
// App-wide music player state (react-query-backed via useGlobal, so it's shared across the whole app and
|
||||||
|
// survives route changes). The audio element itself lives in MusicPlayerHost (mounted once in the
|
||||||
|
// persistent DashboardLayout); this hook is the control surface any component uses to drive it.
|
||||||
|
|
||||||
|
export type PlayerTrack = {
|
||||||
|
albumRel: string; // album path relative to the Music root (for stream + cover URLs)
|
||||||
|
file: string; // track filename within the album folder
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MusicPlayerState = {
|
||||||
|
queue: PlayerTrack[];
|
||||||
|
index: number;
|
||||||
|
playing: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL: MusicPlayerState = { queue: [], index: 0, playing: false };
|
||||||
|
|
||||||
|
export function useMusicPlayer() {
|
||||||
|
const [state, setState] = useGlobal<MusicPlayerState>('MUSIC_PLAYER', INITIAL);
|
||||||
|
|
||||||
|
const playQueue = (queue: PlayerTrack[], index = 0) =>
|
||||||
|
setState({ queue, index: Math.max(0, Math.min(index, Math.max(0, queue.length - 1))), playing: true });
|
||||||
|
// Like playQueue but paused — for restoring a saved "currently playing" on load without auto-playing
|
||||||
|
// (browsers block autoplay on reload anyway; the user resumes with a click).
|
||||||
|
const loadQueue = (queue: PlayerTrack[], index = 0) =>
|
||||||
|
setState({ queue, index: Math.max(0, Math.min(index, Math.max(0, queue.length - 1))), playing: false });
|
||||||
|
const toggle = () => setState((s) => ({ ...s, playing: !s.playing }));
|
||||||
|
const setPlaying = (playing: boolean) => setState((s) => ({ ...s, playing }));
|
||||||
|
// Mirror an engine-driven natural advance into the index WITHOUT restarting playback (the audio engine
|
||||||
|
// has already transitioned to the next track gaplessly; this only updates the UI/highlight).
|
||||||
|
const syncIndex = (index: number) => setState((s) => ({ ...s, index }));
|
||||||
|
const jump = (index: number) =>
|
||||||
|
setState((s) => ({ ...s, index: Math.max(0, Math.min(index, s.queue.length - 1)), playing: true }));
|
||||||
|
const next = () =>
|
||||||
|
setState((s) =>
|
||||||
|
s.index < s.queue.length - 1 ? { ...s, index: s.index + 1, playing: true } : { ...s, playing: false },
|
||||||
|
);
|
||||||
|
const prev = () => setState((s) => (s.index > 0 ? { ...s, index: s.index - 1, playing: true } : s));
|
||||||
|
const close = () => setState(INITIAL);
|
||||||
|
|
||||||
|
const current = state.queue[state.index];
|
||||||
|
return { ...state, current, playQueue, loadQueue, toggle, setPlaying, syncIndex, jump, next, prev, close };
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from './player-time';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position + duration, straight off the engine's per-frame feed.
|
||||||
|
*
|
||||||
|
* A scrubber genuinely wants every frame, so — unlike the lyrics — this does re-render at 60fps. Keep it
|
||||||
|
* in the smallest component that draws the bar: whatever calls this hook repaints with it.
|
||||||
|
*/
|
||||||
|
export const usePlayerClock = () => {
|
||||||
|
const [clock, setClock] = useState(() => ({ position: getPlayerTime(), duration: getPlayerDuration() }));
|
||||||
|
|
||||||
|
useEffect(() => subscribePlayerTime((position, duration) => setClock({ position, duration })), []);
|
||||||
|
|
||||||
|
return clock;
|
||||||
|
};
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import type { WidgetRegistryMeta } from 'officerdev';
|
||||||
|
// The player is this plugin's own now, so these are siblings rather than host API. Parked: nothing
|
||||||
|
// registers this widget — plugins cannot contribute widgets, and that mechanism is not built.
|
||||||
|
import type { PlayerTrack } from '../web/useMusicPlayer';
|
||||||
|
import { useMusicPlayer } from '../web/useMusicPlayer';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Music, ChevronLeft, Play, Folder } from 'lucide-react';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { Widget } from 'widgets/Widget';
|
||||||
|
|
||||||
|
// Music Player widget — a BROWSER over the library. Top-level dirs of ~/Music are "libraries" (tabs);
|
||||||
|
// within a library you drill through folders (Artist → Albums → …) until a folder has tracks (songs).
|
||||||
|
// Playback is owned by the app-wide player (useMusicPlayer): selecting a track hands it a queue.
|
||||||
|
|
||||||
|
const MUSIC_ROOT = 'Music';
|
||||||
|
|
||||||
|
type DirEntry = { name: string; type: 'directory' | 'file'; size: number; modifiedAt: number };
|
||||||
|
type LsResult = { path: string; rootDir?: string; entries: DirEntry[] };
|
||||||
|
type Track = { file: string; title?: string; artist?: string };
|
||||||
|
type AlbumMeta = { path: string; cover?: string; tracks: Track[] };
|
||||||
|
|
||||||
|
const AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma', 'mp4']);
|
||||||
|
const isAudio = (n: string) => {
|
||||||
|
const d = n.lastIndexOf('.');
|
||||||
|
return d >= 0 && AUDIO_EXT.has(n.slice(d + 1).toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MusicPlayer = () => {
|
||||||
|
const { token, get } = useClient(); // base '/api'
|
||||||
|
const player = useMusicPlayer();
|
||||||
|
|
||||||
|
const [libraries, setLibraries] = useState<string[]>([]);
|
||||||
|
const [library, setLibrary] = useState<string | null>(null);
|
||||||
|
const [cwd, setCwd] = useState<string>(MUSIC_ROOT); // home-relative path
|
||||||
|
const [dirs, setDirs] = useState<string[]>([]);
|
||||||
|
const [songs, setSongs] = useState<Track[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Top-level libraries (once).
|
||||||
|
useEffect(() => {
|
||||||
|
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
|
||||||
|
.then((r) =>
|
||||||
|
setLibraries(
|
||||||
|
r.entries
|
||||||
|
.filter((e) => e.type === 'directory')
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch(() => setLibraries([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Current folder contents when cwd changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!library) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setDirs([]);
|
||||||
|
setSongs([]);
|
||||||
|
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`)
|
||||||
|
.then(async (r) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setDirs(
|
||||||
|
r.entries
|
||||||
|
.filter((e) => e.type === 'directory')
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort(),
|
||||||
|
);
|
||||||
|
const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name);
|
||||||
|
if (audio.length) {
|
||||||
|
const rel = cwd.slice(MUSIC_ROOT.length + 1); // <library>/<…>
|
||||||
|
try {
|
||||||
|
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(rel)}`);
|
||||||
|
if (!cancelled) setSongs(meta.tracks);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setSongs(audio.sort().map((f) => ({ file: f })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [cwd, library]);
|
||||||
|
|
||||||
|
const relToMusic = cwd.slice(MUSIC_ROOT.length + 1); // '' at root, else <library>/<…>
|
||||||
|
const breadcrumb = relToMusic ? relToMusic.split('/') : [];
|
||||||
|
const coverUrl = (rel: string) =>
|
||||||
|
`/api/music/cover?path=${encodeURIComponent(rel)}${token ? `&token=${encodeURIComponent(token)}` : ''}`;
|
||||||
|
|
||||||
|
const selectLibrary = (lib: string) => {
|
||||||
|
setLibrary(lib);
|
||||||
|
setCwd(`${MUSIC_ROOT}/${lib}`);
|
||||||
|
};
|
||||||
|
const enter = (name: string) => setCwd(`${cwd}/${name}`);
|
||||||
|
const goUp = () => {
|
||||||
|
const parts = cwd.split('/');
|
||||||
|
if (parts.length <= 2) {
|
||||||
|
setLibrary(null);
|
||||||
|
setCwd(MUSIC_ROOT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCwd(parts.slice(0, -1).join('/'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const play = (i: number) => {
|
||||||
|
const queue: PlayerTrack[] = songs.map((t) => ({
|
||||||
|
albumRel: relToMusic,
|
||||||
|
file: t.file,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist,
|
||||||
|
}));
|
||||||
|
player.playQueue(queue, i);
|
||||||
|
};
|
||||||
|
const isCurrent = (file: string) => player.current?.albumRel === relToMusic && player.current?.file === file;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget title="Music Player" className="w-72">
|
||||||
|
{/* Library tabs */}
|
||||||
|
<div className="flex gap-1 overflow-x-auto px-3 pb-2">
|
||||||
|
{libraries.map((lib) => (
|
||||||
|
<button
|
||||||
|
key={lib}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectLibrary(lib)}
|
||||||
|
className={`shrink-0 rounded-full px-2.5 py-1 text-xs ${
|
||||||
|
library === lib
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{lib}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!libraries.length && <span className="px-1 text-xs text-muted-foreground">No libraries</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!library ? (
|
||||||
|
<div className="px-3 pb-4 text-center text-sm text-muted-foreground">Pick a library</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2 px-3 pb-3">
|
||||||
|
{/* breadcrumb / back */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goUp}
|
||||||
|
className="flex items-center gap-1 truncate text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} className="shrink-0" />
|
||||||
|
<span className="truncate">{breadcrumb.length ? breadcrumb.join(' / ') : library}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* album header (cover + play-all) when the folder has songs */}
|
||||||
|
{songs.length > 0 && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted">
|
||||||
|
<img
|
||||||
|
src={coverUrl(relToMusic)}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold text-foreground">
|
||||||
|
{breadcrumb[breadcrumb.length - 1] ?? library}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{breadcrumb[breadcrumb.length - 2] ?? ''}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => play(0)}
|
||||||
|
title="Play all"
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground hover:opacity-90 active:scale-95"
|
||||||
|
>
|
||||||
|
<Play size={16} className="ml-0.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* folders + songs */}
|
||||||
|
<div className="flex max-h-72 flex-col overflow-y-auto">
|
||||||
|
{dirs.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
type="button"
|
||||||
|
onClick={() => enter(d)}
|
||||||
|
className="flex items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Folder size={14} className="shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{d}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{songs.map((t, i) => (
|
||||||
|
<button
|
||||||
|
key={t.file}
|
||||||
|
type="button"
|
||||||
|
onClick={() => play(i)}
|
||||||
|
className={`flex items-center gap-2 rounded px-2 py-1 text-left text-xs hover:bg-muted ${
|
||||||
|
isCurrent(t.file) ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-4 shrink-0 text-right tabular-nums">{i + 1}</span>
|
||||||
|
<span className="truncate">{t.title ?? t.file}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{loading && <span className="px-2 py-3 text-center text-sm text-muted-foreground">Loading…</span>}
|
||||||
|
{!loading && !dirs.length && !songs.length && (
|
||||||
|
<span className="px-2 py-3 text-center text-sm text-muted-foreground">Empty</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const widgetRegistryMetas: WidgetRegistryMeta[] = [
|
||||||
|
{ key: 'music-player', name: 'Music Player', icon: Music, component: MusicPlayer },
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user