music becomes a plugin, and the player stays behind

The whole of music moves to plugins/music/: the sidecar (index, indexer,
stream-audio, nightly-reindex), the four Postgres tables and their queries,
the /music workspace panels, MUSIC_API.md and the reindex CLI. The platform
keeps no music routes, no music capability entry, no music screen and no
music schema.

Three things stayed, each on purpose.

cliamp and the widget were out of scope by the owner's decision. The plugin's
sidecar still serves the two cliamp sockets, so it imports cliamp-ws.ts and
pulse-audio.ts from @@/sidecar/music/ — the files stay where they were.

The player did not move, and that was the open judgement call. Deciding it
took one fact: the dashboard widget imports useMusicPlayer and PlayerTrack
from officerdev, and the platform cannot import from a plugin. So the player
STATE stays whatever is decided about the UI around it, and two copies would
mean two audio engines. Given that, the engine and the bar stayed with the
state rather than being split from the thing they drive. Moving them would
also have needed a shell slot rendering a plugin-provided component on every
route — the one escape hatch this system deleted on purpose. MusicPlayerHost
gates on can('music'), which is now the plugin's permission, so the seam
switches itself off with the plugin.

api/music/router.ts stays too: api/cliamp/relay.ts imports getMusicServerWsUrl
from it. The plugin's api/router.ts re-exports that proxy rather than building
a second one — two subscribers to the one-shot music:server port announcement
would work today and 503 on the first reconnect where only one was listening.

Two bugs found on the way, neither visible from reading.

The app-store catalogue still listed music. Availability is derived from
sidecar_installs and a PLUGIN never gets a row there, so `music` would have
been permanently unavailable — which puts /music into deniedRoutes and blanks
the screen on a server where the plugin was installed and healthy. Exactly
the headscale bug documented six lines above it in the same file, and it would
have fired on the first install. Entry removed.

[test] root was "./src", so moving lyrics.test.ts into plugins/ stopped it
running and said nothing — the count fell by nine and the suite still read
green. Root is now the repo. Positional filters cannot fix this: `bun test
plugins` matches under root and finds src/servers/plugins/ instead.

registry.test.ts tested the `personal` mechanism THROUGH the music capability.
Re-anchored on a fixture rather than on another entry, because borrowing a
feature only moves the problem to the next extraction — and three of those
four tests had been passing for the wrong reason since music's api was
commented out on 2026-08-13, when everything started resolving to "refused
because nothing is claimed". The cliamp sockets being claimed by nothing is
now pinned by a test instead of being rediscovered.

music's `personal` paths ride across on readOnlyWrites, the one field a
manifest has. isRequestAllowedAtLevel concatenates the two lists, so a read
grant permits exactly the four paths it permitted yesterday, and no field was
added to the manifest to design a per-user model that is not this work.

bunx tsgo clean. 772 tests, 762 pass, 7 fail — all seven pre-existing and
unrelated (cliamp, pty, and five capability tests that other switched-off
plugins break). Baseline was 757/10; the three that went green are the ones
re-anchored above.

Not yet verified on the live server — that is next.
This commit is contained in:
2026-08-15 01:46:43 +00:00
parent 18c4ebd0b4
commit de3340398c
44 changed files with 418 additions and 192 deletions
+10 -1
View File
@@ -22,4 +22,13 @@ env = "BUN_PUBLIC_*"
coverage = true
coverageDir = "coverage"
preload = ["./test-setup.ts"]
root = "./src"
# The repo, not just `src` — a plugin's tests are the platform's tests.
#
# This was "./src" until 2026-08-15, when music became `plugins/music/` and took `lyrics.test.ts` with
# it. `bun test` then stopped running it and said nothing: 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.
#
# Positional filters do not help — `bun test plugins` matches paths UNDER root, so it finds
# `src/servers/plugins/` and not `plugins/`. Root is the only lever.
root = "."
+70 -27
View File
@@ -29,12 +29,12 @@ 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
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.
@@ -51,13 +51,14 @@ The server maintains a cache tree that **mirrors the library**, one entry per al
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*.
`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,
@@ -66,11 +67,12 @@ GET /api/music/manifest
"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 }
"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
@@ -82,7 +84,9 @@ may have any mix of `tracks`, `videos`, and `disco`.
```
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",
@@ -97,23 +101,25 @@ Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Matc
"track": "1",
"year": "1980",
"durationSec": 312,
"lyrics": "lrc" // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
}
"lyrics": "lrc", // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
},
// …
],
"videos": [ // present only for folders that contain video files
"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)
}
"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`).
@@ -123,6 +129,7 @@ To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byt
```
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`.
@@ -131,6 +138,7 @@ Only meaningful when the manifest entry has `"cover": true`.
```
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.
@@ -140,6 +148,7 @@ when the video has no poster. Only request it when that video's `meta.videos[]`
```
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"`).
@@ -156,7 +165,9 @@ type**, so the player can split an artist's album list into sections (Studio, Li
```
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
```
Sends `ETag: <v>`; `If-None-Match: <v>``304`.
```jsonc
{
"artist": "Anthrax",
@@ -164,11 +175,12 @@ Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
"[1984] Fistful Of Metal": "Studio",
"[1985] Armed And Dangerous": "EP",
"[1994] The Island Years": "Live",
"[1991] Attack Of The Killer B's": "Compilation"
"[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`,
@@ -193,14 +205,23 @@ 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,
"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
"error": null,
}
```
@@ -209,6 +230,7 @@ GET /api/music/reindex/status → IndexStatus snapshot
```
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.
@@ -222,9 +244,19 @@ data: {"albums":15,"built":12,"skipped":3,"foldersScanned":45,"tracksIndexed":32
```
`IndexReport` (the `done` payload):
```jsonc
{ "albums": 15, "built": 12, "skipped": 3, "foldersScanned": 45,
"tracksIndexed": 320, "coversSaved": 12, "discographies": 3, "elapsedSec": 37.2, "error": null }
{
"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`).
@@ -257,7 +289,7 @@ platform straight from Postgres — same `/api/music` prefix and same auth. Keys
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` |
@@ -266,7 +298,11 @@ supplies; the server never interprets them:
- **`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"] }
{
"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).
@@ -281,9 +317,16 @@ 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" }
{
"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? }`
@@ -300,7 +343,7 @@ Server-side playlists, scoped to the calling user. Items are track **keys** —
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], … }` |
@@ -315,6 +358,6 @@ put. `404` throughout means "not yours or not there"; the two are deliberately i
- **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.)
- **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.
+18
View File
@@ -0,0 +1,18 @@
import { musicRouter } from '@@/api/music/router';
// /api/music/* — auth, then forward to officer-music.
//
// ── Why this re-exports the platform's proxy instead of creating its own ──
//
// `src/servers/api/music/router.ts` has to stay behind: `api/cliamp/relay.ts` imports
// `getMusicServerWsUrl` from it to pipe the cliamp player socket to this sidecar, and cliamp is
// deliberately out of scope — it is a second playback path that the platform still owns.
//
// So the proxy already exists, and building a SECOND `createSidecarProxy({ name: 'music' })` here would
// mean two subscribers to the one-shot `music:server` port announcement. Both would work today, and the
// first reconnect where only one of them was listening would produce a 503 nobody could explain. One
// proxy, one subscription, mounted by whoever needs it.
//
// The seam is one file, and it is inert when this plugin is not installed: the router is only reachable
// once `mountPrefix()` puts it under `/api/music`, which only happens for an installed, enabled plugin.
export const router = musicRouter;
@@ -1,5 +1,5 @@
import { eq, and, desc, asc, sql } from 'drizzle-orm';
import { db } from '../db';
import { db } from 'officerdb/db';
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
export type FavoriteKind = 'track' | 'album' | 'artist';
@@ -1,5 +1,5 @@
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
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)
+85
View File
@@ -0,0 +1,85 @@
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: three pieces stay behind deliberately. Each is a documented seam rather than a loose end,
// and each is recorded in ./PLUGIN.md with what would have to change to close it.
//
// api/router.ts re-exports the platform's music proxy — see that file for why it is not a new one
// 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 ──
//
// 1. cliamp (`/api/cliamp/ws`, `/api/cliamp/audio/ws`, `sidecar/music/cliamp-ws.ts`, `pulse-audio.ts`,
// `asoundrc`). A second playback path — the `cliamp` TUI run on the server with its terminal and its
// PulseAudio null sink piped to the browser. Already inert (the routes upgrade into commented-out
// handlers) and out of scope by the owner's decision. This sidecar still serves those sockets, so it
// imports both modules from `@@/sidecar/music/`.
//
// 2. The dashboard widget (`src/workspaces/widgets/MusicPlayer/`). Plugins cannot contribute widgets and
// the mechanism was not worth inventing for one.
//
// 3. The global player overlay (`officerdev/src/MusicPlayer/`, mounted by `DashboardLayout`). This was
// the one open judgement call and it is decided: THE PLAYER STAYS IN THE PLATFORM. Two reasons, and
// the second is the one that settles it.
//
// - Moving it needs a shell slot that renders a plugin-provided component on every route. That is
// exactly the escape hatch this system deleted on purpose — "there is no way to export a component"
// is what makes "every plugin route is a Workspace" a property of the shape rather than a rule
// someone has to remember. Reopening it for one plugin is a bad trade.
// - It would not even work. The widget above imports `useMusicPlayer` and `PlayerTrack` from
// `officerdev`, and the platform cannot import from a plugin — so the player STATE stays whatever
// is decided about the UI. Splitting the engine from the state it drives would leave the same seam
// in a worse place, and two copies of that state would mean two engines.
//
// The overlay gates on `can('music')`, which resolves against the permission below — registered at
// install and gone at uninstall. So the seam switches itself off with the plugin, with no code path
// that knows why.
//
// ── Host dependencies a manifest cannot declare ──
//
// `ffmpeg` and `ffprobe` must be on PATH: tag reading, cover compression and video poster frames. There
// is no field for a host binary and inventing one for this would be a field nothing else reads.
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',
icon: 'Music',
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_capabilities` 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'],
},
],
};
@@ -1,10 +1,10 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { join, basename } from 'node:path';
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '@@/sidecar/connect';
import { streamAudioFile } from './stream-audio';
import { cliampUpgradeData, musicWebsocket } from './cliamp-ws';
import { ensurePulseAudio } from './pulse-audio';
import { cliampUpgradeData, musicWebsocket } from '@@/sidecar/music/cliamp-ws';
import { ensurePulseAudio } from '@@/sidecar/music/pulse-audio';
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
import {
reindexNow,
@@ -37,9 +37,9 @@ import {
addPlaylistItems,
setPlaylistItems,
type FavoriteKind,
} from 'officerdb';
import { DATA_PATH } from '../../data-path';
import { API_URL } from '../../officer-url.mjs';
} 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
@@ -20,7 +20,7 @@ import { homedir } from 'node:os';
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
// phone's resync diff (fetch only changed `v`s).
import { DATA_PATH } from '../../data-path';
import { DATA_PATH } from '@@/data-path';
const HOME = homedir();
export const MUSIC_ROOT = join(HOME, 'Music');
@@ -3,9 +3,9 @@ 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 '../../MusicPlayer';
import { MusicHeart } from './MusicHeart';
import { useMusicFavorites } from './useMusicFavorites';
import { useMusicPlayer, type PlayerTrack } from 'officerdev';
import { MusicHeart } from 'officerdev';
import { useMusicFavorites } from 'officerdev';
import {
MUSIC_FAV_CHANNEL,
coverUrl,
@@ -35,8 +35,19 @@ export const FavoritesView = () => {
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)));
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);
}
@@ -63,7 +74,9 @@ export const FavoritesView = () => {
{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>
<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">
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef } from 'react';
import { Loader2, Music4 } from 'lucide-react';
import type { LyricLine } from './lyrics';
import { seekPlayer } from './player-time';
import { seekPlayer } from 'officerdev';
import { useActiveLyricIndex } from './useLyrics';
type LyricsPaneProps = {
@@ -2,8 +2,8 @@ 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';
import { useLyricsOpen } from 'officerdev';
import { useMusicPlayer } from 'officerdev';
/**
* The right-hand half of the /music detail panel when lyrics are on. It follows the PLAYING track, not
@@ -4,15 +4,15 @@ 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 '../../components/Workspace';
import { WorkspaceLayout } from '../../components/Workspace';
import { MusicHeart } from './MusicHeart';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { MusicHeart } from 'officerdev';
import { FavoritesView } from './FavoritesView';
import { useMusicPlayer } from '../../MusicPlayer';
import type { PlayerTrack } from '../../MusicPlayer';
import { LyricsPanel } from '../../MusicPlayer/LyricsPanel';
import { MusicMiniBar } from '../../MusicPlayer/MusicMiniBar';
import { useLyricsOpen } from '../../MusicPlayer/useLyricsOpen';
import { useMusicPlayer } from 'officerdev';
import type { PlayerTrack } from 'officerdev';
import { LyricsPanel } from './LyricsPanel';
import { MusicMiniBar } from './MusicMiniBar';
import { useLyricsOpen } from 'officerdev';
import {
MUSIC_ROOT,
MUSIC_FAV_CHANNEL,
@@ -1,11 +1,11 @@
import { useRef } from 'react';
import { useClient } from 'hooks/useClient';
import { MicVocal, Pause, Play } from 'lucide-react';
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
import { coverUrl, fmtClock } from '../apps/Music/shared';
import { seekPlayer } from './player-time';
import { useLyricsOpen } from './useLyricsOpen';
import { useMusicPlayer } from './useMusicPlayer';
import { SeekBar } from 'officerdev';
import { coverUrl, fmtClock } from './shared';
import { seekPlayer } from 'officerdev';
import { useLyricsOpen } from 'officerdev';
import { useMusicPlayer } from 'officerdev';
import { usePlayerClock } from './usePlayerClock';
/**
+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 },
];
+13
View File
@@ -0,0 +1,13 @@
// The library vocabulary, re-exported from the host.
//
// It lives at `officerdev/src/MusicPlayer/shared.ts` rather than here because `MusicPlayerHost` — the
// global player bar, which stays in the platform; see that directory's index.ts for why — needs a third
// of it. One definition on the host side beats a copy either side of the plugin boundary drifting apart.
//
// Taken from the `officerdev/MusicPlayer/shared` subpath rather than the `officerdev` barrel because the
// type names here (`DirEntry`, `Track`, `Manifest`) are ones the barrel already spends on the FileBrowser.
// The subpath is a declared export of the package (`"./*": "./src/*.ts"`), not a reach into its insides.
//
// Every panel in this directory imports from HERE, so the seam is one file to read rather than a
// different specifier in each of them.
export * from 'officerdev/MusicPlayer/shared';
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import type { LyricLine } from './lyrics';
import { activeLineIndex, parseLyrics } from './lyrics';
import { getPlayerTime, subscribePlayerTime } from './player-time';
import { getPlayerTime, subscribePlayerTime } from 'officerdev';
export type UseLyrics = {
loading: boolean;
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from './player-time';
import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from 'officerdev';
/**
* Position + duration, straight off the engine's per-frame feed.
-1
View File
@@ -60,7 +60,6 @@ export function App() {
<Route path="/files" element={<Dashboard.FilesScreen />} />
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
<Route path="/music" element={<Dashboard.MusicScreen />} />
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
@@ -1,23 +0,0 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /music uses the Workspace/Panel system (like /chat): two vertical panels — the library browser
// (music-browser) and the content/detail (music-detail). They do not coordinate with each other; both
// read `?path=` off the URL. No route pair and no guard: the bare /music is the library root, a real
// state, and an unknown path gets an empty listing rather than a rewritten address.
export const MusicScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/music', defaultLayout);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['music-browser', 'music-detail'], fallback: 'music-detail' }}
/>
</div>
);
};
@@ -1 +0,0 @@
export * from './MusicScreen';
@@ -13,7 +13,6 @@ export * from './Tasks';
export * from './Files';
export * from './Calendar';
export * from './Contacts';
export * from './Music';
export * from './Soulseek';
export * from './Photos';
export * from './Jellyfin';
@@ -21,7 +21,6 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/files'), title: 'Files' },
{ match: (p) => p.startsWith('/calendar'), title: 'Calendar' },
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
{ match: (p) => p.startsWith('/music'), title: 'Music' },
{ match: (p) => p.startsWith('/app-store'), title: 'App store' },
{ match: (p) => p.startsWith('/plugins'), title: 'Plugins' },
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
-1
View File
@@ -46,7 +46,6 @@ export * from './dav';
export * from './email';
export * from './invoiceshelf';
export * from './jellyfin';
export * from './music';
export * from './notify';
export * from './photos';
export * from './soulseek';
@@ -1,17 +0,0 @@
export {
getMusicFavorites,
addMusicFavorite,
removeMusicFavorite,
getNowPlaying,
setNowPlaying,
clearNowPlaying,
getPlaylists,
getPlaylist,
createPlaylist,
renamePlaylist,
deletePlaylist,
addPlaylistItems,
setPlaylistItems,
} from './queries';
export type { FavoriteKind, GroupedFavorites, NowPlaying, NowPlayingInput, PlaylistSummary, Playlist } from './queries';
-1
View File
@@ -44,7 +44,6 @@ export * from './service-connections/schema'; // service_connections
// ── Plugins — uncomment when the plugin is installed ─────────────────────────────────────────────
// export * from './email/schema'; // email_accounts officer-email
// export * from './music/schema'; // music_favorites, _playlists, _playlist_items, _now_playing
// export * from './notify/schema'; // push_devices officer-notify
// export * from './dav/schema'; // dav_app_passwords officer-caldav
// export * from './photos/schema'; // photos_config officer-photos
+16 -11
View File
@@ -386,17 +386,22 @@ export const CATALOGUE: CatalogueEntry[] = [
// form. Duplicating it here would be a second place to maintain the same credentials.
configFields: [],
},
{
id: 'music',
ui: { name: 'Music', icon: 'Music', color: '#22c55e', rootRoute: '/music', routes: ['/music'] },
process: 'officer-music',
label: 'Music',
summary: 'Index and play the library on this machine',
members: 'none',
modes: ['config'],
capability: 'music',
configFields: [{ key: 'library', label: 'Music folder', type: 'text', required: true, placeholder: '~/Music' }],
},
// Music was here until 2026-08-15, when it became `plugins/music/`. Removing it was not tidying — it
// was the headscale bug above, exactly, and it would have fired on the first install.
//
// `capabilityAvailability` reads `sidecar_installs`, and a PLUGIN never gets a row there: its install
// state lives in `plugin_installs`. So `music` would have been permanently `unavailable`, which puts
// `/music` into `deniedRoutes` — the screen blank and the dock tile withheld on a server where the
// plugin was installed, enabled and healthy. The same shape as headscale, found by reading that note
// rather than by hitting it again.
//
// The tile now comes from `pluginDockManifests()`, and the library folder is not configured at all:
// the sidecar reads `~/Music`. That `configFields` entry wrote a `service_connections` row nothing
// ever read.
//
// `[open]` This is the "two dock sources" seam. The 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.
{
id: 'wallet',
ui: { name: 'Wallet', icon: 'Bitcoin', color: '#f7931a', rootRoute: '/wallet', routes: ['/wallet'] },
+55 -15
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test';
import type { Capability } from './registry';
import {
CAPABILITIES,
CAPABILITY_BY_KEY,
@@ -57,8 +58,8 @@ describe('totality', () => {
test('refuses a claim on a socket that is not served', () => {
const s = surface();
s.wsProviders = REAL_WS.filter((p) => p !== 'cliamp');
expect(() => assertCapabilityTotality(s)).toThrow(/claims websocket 'cliamp', which is not served/);
s.wsProviders = REAL_WS.filter((p) => p !== 'terminal');
expect(() => assertCapabilityTotality(s)).toThrow(/claims websocket 'terminal', which is not served/);
});
test('no two capabilities claim the same prefix', () => {
@@ -74,7 +75,7 @@ describe('totality', () => {
test('signin is exempt and gitea is not', () => {
expect(isExemptApiPath('/api/auth/signin')).toBe(true);
expect(isExemptApiPath('/api/gitea')).toBe(false);
expect(isExemptApiPath('/api/music/albums')).toBe(false);
expect(isExemptApiPath('/api/dashboards/4')).toBe(false);
});
});
@@ -85,8 +86,9 @@ describe('path → capability', () => {
});
test('does not match a prefix that is merely a string prefix', () => {
// '/api/musicbrainz' must not resolve to the 'music' capability. A naive startsWith would.
expect(capabilityForApiPath('/api/musicbrainz')).toBeNull();
// '/api/username' must not resolve to the 'account' capability, which claims '/user'. A naive
// startsWith would. (This was '/api/musicbrainz' against 'music' until music became a plugin.)
expect(capabilityForApiPath('/api/username')).toBeNull();
});
test('longest prefix wins, so /dav and /caldav do not fight', () => {
@@ -99,34 +101,72 @@ describe('path → capability', () => {
});
test('sockets resolve to their capability', () => {
expect(capabilityForWsProvider('cliamp')?.key).toBe('music');
expect(capabilityForWsProvider('terminal')?.key).toBe('terminal');
expect(capabilityForWsProvider('chat')?.key).toBe('chat');
expect(capabilityForWsProvider('nope')).toBeNull();
});
// `cliamp` and `cliamp-audio` are SERVED in server.tsx's route table and claimed by nothing, because
// music's `ws` list was commented out on 2026-08-13 and the capability itself left with the plugin on
// 2026-08-15. They upgrade into handlers that are commented out too, so nothing is reachable — but the
// boot check cannot see the drift, since it reads `Object.keys(handlers)` rather than the route table.
//
// Pinned here so the hole is a documented fact with a test on it rather than something to rediscover.
// Closing it is the totality work in plugins/EXTRACTING-A-PLUGIN.md, not this file's.
test('the cliamp sockets are claimed by nothing — known drift, see server.tsx', () => {
expect(capabilityForWsProvider('cliamp')).toBeNull();
expect(capabilityForWsProvider('cliamp-audio')).toBeNull();
});
});
describe('levels', () => {
const music = CAPABILITY_BY_KEY.get('music')!;
// A FIXTURE, not a registry entry.
//
// These tests ran against the real `music` capability until 2026-08-15, when music left with
// `plugins/music/`. Re-anchoring them on whichever entry happens to have a `personal` list today only
// moves the problem to the next extraction — and it had already half-broken before that, because the
// moment music's `api` was commented out (2026-08-13) every path below stopped matching and three of
// these four tests passed for the wrong reason: everything is refused when nothing is claimed.
//
// `isRequestAllowedAtLevel` is a pure function of a Capability. Handing it one states what is actually
// under test — the RULE — rather than borrowing a feature that can leave.
const fixture: Capability = {
key: 'fixture',
label: 'Fixture',
description: 'Not in the registry — a shape to exercise the level rules against',
kind: 'app',
api: ['/fixture'],
personal: ['/favorites', '/now-playing'],
};
test('write permits anything within the capability', () => {
expect(isRequestAllowedAtLevel(music, 'write', 'DELETE', '/api/music/track/9')).toBe(true);
expect(isRequestAllowedAtLevel(fixture, 'write', 'DELETE', '/api/fixture/track/9')).toBe(true);
});
test('read permits safe methods', () => {
expect(isRequestAllowedAtLevel(music, 'read', 'GET', '/api/music/albums')).toBe(true);
expect(isRequestAllowedAtLevel(music, 'read', 'HEAD', '/api/music/albums')).toBe(true);
expect(isRequestAllowedAtLevel(fixture, 'read', 'GET', '/api/fixture/albums')).toBe(true);
expect(isRequestAllowedAtLevel(fixture, 'read', 'HEAD', '/api/fixture/albums')).toBe(true);
});
test('read permits mutations only under personal sub-paths', () => {
expect(isRequestAllowedAtLevel(music, 'read', 'POST', '/api/music/favorites/7')).toBe(true);
expect(isRequestAllowedAtLevel(music, 'read', 'PUT', '/api/music/now-playing')).toBe(true);
expect(isRequestAllowedAtLevel(music, 'read', 'POST', '/api/music/scan')).toBe(false);
expect(isRequestAllowedAtLevel(music, 'read', 'DELETE', '/api/music/track/9')).toBe(false);
expect(isRequestAllowedAtLevel(fixture, 'read', 'POST', '/api/fixture/favorites/7')).toBe(true);
expect(isRequestAllowedAtLevel(fixture, 'read', 'PUT', '/api/fixture/now-playing')).toBe(true);
expect(isRequestAllowedAtLevel(fixture, 'read', 'POST', '/api/fixture/scan')).toBe(false);
expect(isRequestAllowedAtLevel(fixture, 'read', 'DELETE', '/api/fixture/track/9')).toBe(false);
});
test('a personal entry does not leak across a name boundary', () => {
// '/favorites-export' must not be covered by the '/favorites' personal entry.
expect(isRequestAllowedAtLevel(music, 'read', 'POST', '/api/music/favorites-export')).toBe(false);
expect(isRequestAllowedAtLevel(fixture, 'read', 'POST', '/api/fixture/favorites-export')).toBe(false);
});
test('a plugin gets the same rule through readOnlyWrites', () => {
// The two lists are concatenated, so a plugin declaring per-caller paths on the one field its
// manifest has behaves identically to a core capability declaring `personal`. This is what music
// relies on now that it ships as one.
const plugin: Capability = { ...fixture, personal: undefined, readOnlyWrites: ['/favorites', '/now-playing'] };
expect(isRequestAllowedAtLevel(plugin, 'read', 'POST', '/api/fixture/favorites/7')).toBe(true);
expect(isRequestAllowedAtLevel(plugin, 'read', 'POST', '/api/fixture/scan')).toBe(false);
});
});
+9 -18
View File
@@ -51,9 +51,13 @@
//
// A grant carries a level, `read` or `write`. `read` permits safe methods (GET/HEAD/OPTIONS) anywhere in
// the capability, plus mutations under `personal` — sub-paths that hold the CALLER'S own data and nothing
// else. Music is the worked example: `/favorites`, `/now-playing` and `/playlists` are already per-caller
// in the sidecar contract because every sidecar request carries `X-Officer-User`. So "may a member write
// here" is a property of the endpoint, not a policy knob someone has to remember to set.
// else. `dashboards` is the worked example: a dashboard belongs to the account that made it, so the whole
// surface is personal and a read grant is really "your own, fully". So "may a member write here" is a
// property of the endpoint, not a policy knob someone has to remember to set.
//
// Music was this note's example until 2026-08-15, when it left with `plugins/music/`. A plugin declares
// the same thing through its manifest's `readOnlyWrites` — `isRequestAllowedAtLevel` merges the two lists,
// so they are one mechanism under two names.
export type CapabilityKind = 'core' | 'app' | 'confined' | 'execution' | 'admin';
@@ -75,8 +79,8 @@ export type Capability = {
routes?: string[];
/**
* Sub-paths, relative to each `api` prefix, that a READ grant may still mutate because they hold only
* the caller's own data. Matched as a prefix after the capability's own: `/favorites` on the `music`
* capability permits `POST /api/music/favorites/123`.
* the caller's own data. Matched as a prefix after the capability's own: `/devices` on the `notify`
* capability permits `POST /api/notify/devices/123`.
*/
personal?: string[];
/**
@@ -146,19 +150,6 @@ const CORE_REGISTRY: Capability[] = [
// commenting on their own issues — security theatre with a real cost and no benefit.
personal: ['/'],
},
{
key: 'music',
label: 'Music',
description: 'The music library, playback, and your own favourites and playlists',
kind: 'app',
// api: ['/music'], // plugin — switched off 2026-08-13
api: [],
// ws: ['cliamp', 'cliamp-audio'], // plugin — switched off 2026-08-13
routes: ['/music'],
// Already per-caller in the sidecar contract (X-Officer-User), which is what makes them safe to write
// at read level. The library itself — scanning, tags, file moves — is not, and is not listed.
personal: ['/favorites', '/now-playing', '/playlists', '/queue'],
},
{
key: 'photos',
label: 'Photos',
-2
View File
@@ -20,7 +20,6 @@ import { settingsRouter } from './api/settings/settings';
import { dashboardsRouter } from './api/dashboards';
import { router as fileBrowserRouter } from './api/file-browser/router';
import { pluginsRouter } from './api/plugins/router';
// import { musicRouter } from './api/music/router';
// import { vaultRouter } from './api/vault/router';
// import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router';
import { agentHandoffRouter } from './api/agent-handoff/router';
@@ -133,7 +132,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/api-keys', apiKeysRouter], // your own keys; the `account` core capability covers it
['/dashboards', dashboardsRouter],
['/file-browser', fileBrowserRouter],
// ['/music', musicRouter], // plugin — switched off 2026-08-13
// ['/slskd', slskdRouter], // plugin — switched off 2026-08-13
['/terminal', terminalRouter],
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
@@ -9,7 +9,6 @@ import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards';
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
import { appRegistryMetas as musicMetas } from '../apps/Music';
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
import { appRegistryMetas as photosMetas } from '../apps/Photos';
import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin';
@@ -33,7 +32,6 @@ export const apps = [
...chatHistoryMetas,
...widgetMetas,
...desktopMetas,
...musicMetas,
...soulseekMetas,
...photosMetas,
...jellyfinMetas,
@@ -4,8 +4,8 @@ import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities';
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
import { MusicHeart } from '../apps/Music/MusicHeart';
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from '../apps/Music/shared';
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';
@@ -1,2 +1,50 @@
export * from './useMusicPlayer';
export * from './MusicPlayerHost';
// The music player the SHELL hosts.
//
// ── Why this is still in the platform after music became a plugin ──
//
// Music was extracted on 2026-08-15 (`plugins/music/`) and this directory deliberately did not go with
// it. It is the one seam that extraction could not close, and the reason is not the overlay — it is the
// state:
//
// `useMusicPlayer` is imported from `officerdev` by `src/workspaces/widgets/MusicPlayer/`, the
// dashboard widget, which is out of scope and stays. The platform cannot import from a plugin, so the
// player state stays here whatever is decided about the UI around it — and two copies of it would mean
// two audio engines fighting over one pair of speakers.
//
// Given the state had to stay, the engine and the bar stayed with it rather than being split from the
// thing they drive. `MusicPlayerHost` is mounted once by `DashboardLayout`, OUTSIDE `<Routes>`, which is
// what makes playback survive navigation — and a plugin has no way to ask for that. Contributing one
// would mean a shell slot that renders a plugin-provided component on every route, which is exactly the
// escape hatch the plugin system deleted on purpose: there is no way to export a component, and that is
// what makes "every plugin route is a Workspace" a property of the shape rather than a rule to remember.
//
// The seam is inert without the plugin. `MusicPlayerHost` gates on `can('music')`, and `music` is now the
// plugin's permission — registered at install, gone at uninstall — so the overlay switches itself off
// with the plugin and no code here knows why.
//
// ── What the plugin imports, and from where ──
//
// The player API is below, on the `officerdev` barrel. The library VOCABULARY — `shared.ts`, the paths,
// sorting and tag shapes — is not: it declares `DirEntry`, `Track` and `Manifest`, names the barrel
// already spends on the FileBrowser. `plugins/music/web/shared.ts` takes it from the package's declared
// `officerdev/MusicPlayer/shared` subpath instead, which keeps one definition without renaming a type on
// its way through a barrel.
export { useMusicPlayer } from './useMusicPlayer';
export type { PlayerTrack, MusicPlayerState } from './useMusicPlayer';
export { MusicPlayerHost } from './MusicPlayerHost';
export { MusicHeart } from './MusicHeart';
export { useMusicFavorites } from './useMusicFavorites';
// The engine↔UI bridge. Module-level singletons on purpose: the lyrics pane and the /music scrubber live
// in another React tree from the host that owns the engine, so they meet here rather than through props.
export {
publishPlayerTime,
subscribePlayerTime,
registerPlayerSeek,
seekPlayer,
getPlayerTime,
getPlayerDuration,
} from './player-time';
export { useLyricsOpen } from './useLyricsOpen';
@@ -1,11 +0,0 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { Music, ListMusic } from 'lucide-react';
import { MusicBrowser } from './MusicBrowser';
import { MusicDetail } from './MusicDetail';
export { MusicBrowser, MusicDetail };
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 },
];
+4
View File
@@ -111,6 +111,10 @@ export {
ARCHIVE_EXTS,
} from './apps/FileViewer';
export type { FileType } from './apps/FileViewer';
// The scrubber, shared by the FileViewer's audio/video renderers, the global player bar and the /music
// panels in `plugins/music/`. On the barrel rather than reached for by subpath because the package's
// `"./*"` export maps to `.ts` only, and this is a `.tsx`.
export { SeekBar, useSeekBar } from './apps/FileViewer/renderers/SeekBar';
export { TerminalView } from './apps/Terminal';
export type { TerminalViewProps } from './apps/Terminal';
export { DesktopView } from './apps/Desktop';