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:
Claude Opus 5
2026-08-15 17:34:51 +00:00
commit 6e07da7a36
42 changed files with 6573 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
*.log
+363
View File
@@ -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, ~3080 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.
+284
View File
@@ -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.
+46
View File
@@ -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;
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

+185
View File
@@ -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>
);
};
+36
View File
@@ -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 />;
};
+9
View File
@@ -0,0 +1,9 @@
pcm.!default {
type pulse
fallback "sysdefault"
}
ctl.!default {
type pulse
fallback "sysdefault"
}
+66
View File
@@ -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();
});
});
+232
View File
@@ -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;
}
+48
View File
@@ -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
View File
@@ -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
View File
@@ -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;
});
}
+80
View File
@@ -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
View File
@@ -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' },
},
],
};
+120
View File
@@ -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();
+503
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -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;
}
}
+110
View File
@@ -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) } });
}
+215
View File
@@ -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>
);
};
+84
View File
@@ -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>
);
};
+49
View File
@@ -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>
);
};
+226
View File
@@ -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>
);
};
+438
View File
@@ -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>
);
};
+43
View File
@@ -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>
);
};
+103
View File
@@ -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>
);
};
+393
View File
@@ -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>
);
};
+191
View File
@@ -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);
});
});
+375
View File
@@ -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);
}
}
+11
View File
@@ -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 },
],
};
+73
View File
@@ -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);
});
});
+64
View File
@@ -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;
}
+18
View File
@@ -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 },
];
+42
View File
@@ -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
View File
@@ -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;
};
+78
View File
@@ -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;
};
+25
View File
@@ -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;
};
+52
View File
@@ -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 };
}
+47
View File
@@ -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 };
}
+16
View File
@@ -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;
};
+224
View File
@@ -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 },
];