diff --git a/plugins/music/PLUGIN.md b/plugins/music/PLUGIN.md
index 3216cb6e..5ca433d7 100644
--- a/plugins/music/PLUGIN.md
+++ b/plugins/music/PLUGIN.md
@@ -91,36 +91,41 @@ to know about provenance. It now calls `mountPrefix`.
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 — the one open judgement call, and it is decided
+### 3. ~~The player~~ — moved, and the reasoning that kept it was removed rather than refuted
-**`officerdev/src/MusicPlayer/` stays in the platform.** The runbook left this open with either answer
-acceptable. What decided it was not the overlay but the state:
+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
-> `src/workspaces/widgets/MusicPlayer/`, the dashboard widget — which is _also_ 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 a second copy would mean two audio engines fighting over one pair of speakers.
+> `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.
-Given the state had to stay, splitting the engine and the bar away from the thing they drive would have
-left the same seam in a worse place. And moving them needed a shell slot that renders a plugin-provided
-component on **every route** — which 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, and reopening it for one plugin is a bad trade.
+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`.
-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 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.**
-What stayed with it, and why each: `gapless-engine` (the engine the state drives), `player-time` (the
-module-level bridge the lyrics pane meets it through), `useLyricsOpen` and `MusicHeart` +
-`useMusicFavorites` (the bar renders a heart), and `shared.ts` — the library vocabulary, which the host
-needs a third of and the plugin needs all of. One definition on the host side beats a copy either side
-of the boundary drifting apart; `plugins/music/web/shared.ts` re-exports it from the package's declared
-`officerdev/MusicPlayer/shared` subpath.
+**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:
-**What would close it:** the widget learning to come from a plugin. Not the overlay slot — that one
-should stay shut.
+```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
diff --git a/plugins/music/manifest.ts b/plugins/music/manifest.ts
index edba2839..5f2a41de 100644
--- a/plugins/music/manifest.ts
+++ b/plugins/music/manifest.ts
@@ -8,36 +8,30 @@ import type { PluginManifest } from '@@/plugins/manifest';
// 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
// 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 ── (one item, down from three)
+// ── 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 (`src/workspaces/widgets/MusicPlayer/`). Plugins cannot contribute widgets and
-// the mechanism was not worth inventing for one.
+// 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 (`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.
+// 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.
//
-// - 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.
+// `[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 ──
//
diff --git a/plugins/music/web/FavoritesView.tsx b/plugins/music/web/FavoritesView.tsx
index 6f8fbffc..1538d0e6 100644
--- a/plugins/music/web/FavoritesView.tsx
+++ b/plugins/music/web/FavoritesView.tsx
@@ -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 'officerdev';
-import { MusicHeart } from 'officerdev';
-import { useMusicFavorites } from 'officerdev';
+import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
+import { MusicHeart } from './MusicHeart';
+import { useMusicFavorites } from './useMusicFavorites';
import {
MUSIC_FAV_CHANNEL,
coverUrl,
diff --git a/plugins/music/web/LyricsPane.tsx b/plugins/music/web/LyricsPane.tsx
index f0929cfc..8657e19d 100644
--- a/plugins/music/web/LyricsPane.tsx
+++ b/plugins/music/web/LyricsPane.tsx
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef } from 'react';
import { Loader2, Music4 } from 'lucide-react';
import type { LyricLine } from './lyrics';
-import { seekPlayer } from 'officerdev';
+import { seekPlayer } from './player-time';
import { useActiveLyricIndex } from './useLyrics';
type LyricsPaneProps = {
diff --git a/plugins/music/web/LyricsPanel.tsx b/plugins/music/web/LyricsPanel.tsx
index 006b637e..6a2f274a 100644
--- a/plugins/music/web/LyricsPanel.tsx
+++ b/plugins/music/web/LyricsPanel.tsx
@@ -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 'officerdev';
-import { useMusicPlayer } from 'officerdev';
+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
diff --git a/plugins/music/web/MusicDetail.tsx b/plugins/music/web/MusicDetail.tsx
index e8aee569..3757492a 100644
--- a/plugins/music/web/MusicDetail.tsx
+++ b/plugins/music/web/MusicDetail.tsx
@@ -6,13 +6,14 @@ 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 'officerdev';
+import { MusicHeart } from './MusicHeart';
import { FavoritesView } from './FavoritesView';
-import { useMusicPlayer } from 'officerdev';
-import type { PlayerTrack } from 'officerdev';
+import { useMusicPlayer } from './useMusicPlayer';
+import type { PlayerTrack } from './useMusicPlayer';
import { LyricsPanel } from './LyricsPanel';
import { MusicMiniBar } from './MusicMiniBar';
-import { useLyricsOpen } from 'officerdev';
+import { MusicPlayerHost } from './MusicPlayerHost';
+import { useLyricsOpen } from './useLyricsOpen';
import {
MUSIC_ROOT,
MUSIC_FAV_CHANNEL,
@@ -414,6 +415,16 @@ export const MusicDetail = () => {
{content}
+ {/* 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. */}
+
);
diff --git a/src/workspaces/officerdev/src/MusicPlayer/MusicHeart.tsx b/plugins/music/web/MusicHeart.tsx
similarity index 100%
rename from src/workspaces/officerdev/src/MusicPlayer/MusicHeart.tsx
rename to plugins/music/web/MusicHeart.tsx
diff --git a/plugins/music/web/MusicMiniBar.tsx b/plugins/music/web/MusicMiniBar.tsx
index b02ddc93..fe1ce606 100644
--- a/plugins/music/web/MusicMiniBar.tsx
+++ b/plugins/music/web/MusicMiniBar.tsx
@@ -3,9 +3,9 @@ import { useClient } from 'hooks/useClient';
import { MicVocal, Pause, Play } from 'lucide-react';
import { SeekBar } from 'officerdev';
import { coverUrl, fmtClock } from './shared';
-import { seekPlayer } from 'officerdev';
-import { useLyricsOpen } from 'officerdev';
-import { useMusicPlayer } from 'officerdev';
+import { seekPlayer } from './player-time';
+import { useLyricsOpen } from './useLyricsOpen';
+import { useMusicPlayer } from './useMusicPlayer';
import { usePlayerClock } from './usePlayerClock';
/**
diff --git a/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx b/plugins/music/web/MusicPlayerHost.tsx
similarity index 99%
rename from src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx
rename to plugins/music/web/MusicPlayerHost.tsx
index ed78b0e2..5c5a270e 100644
--- a/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx
+++ b/plugins/music/web/MusicPlayerHost.tsx
@@ -3,7 +3,7 @@ import { Link, useLocation, useNavigate } from 'react-router';
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 { SeekBar } from 'officerdev';
import { MusicHeart } from './MusicHeart';
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from './shared';
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
diff --git a/src/workspaces/officerdev/src/MusicPlayer/gapless-engine.test.ts b/plugins/music/web/gapless-engine.test.ts
similarity index 100%
rename from src/workspaces/officerdev/src/MusicPlayer/gapless-engine.test.ts
rename to plugins/music/web/gapless-engine.test.ts
diff --git a/src/workspaces/officerdev/src/MusicPlayer/gapless-engine.ts b/plugins/music/web/gapless-engine.ts
similarity index 100%
rename from src/workspaces/officerdev/src/MusicPlayer/gapless-engine.ts
rename to plugins/music/web/gapless-engine.ts
diff --git a/src/workspaces/officerdev/src/MusicPlayer/player-time.ts b/plugins/music/web/player-time.ts
similarity index 100%
rename from src/workspaces/officerdev/src/MusicPlayer/player-time.ts
rename to plugins/music/web/player-time.ts
diff --git a/plugins/music/web/shared.ts b/plugins/music/web/shared.ts
index ee52210a..635dca2b 100644
--- a/plugins/music/web/shared.ts
+++ b/plugins/music/web/shared.ts
@@ -1,13 +1,152 @@
-// 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';
+// 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 };
+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 = (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 };
+
+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//") 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=`, 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;
+};
diff --git a/plugins/music/web/useLyrics.ts b/plugins/music/web/useLyrics.ts
index fef12257..fe46999b 100644
--- a/plugins/music/web/useLyrics.ts
+++ b/plugins/music/web/useLyrics.ts
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import type { LyricLine } from './lyrics';
import { activeLineIndex, parseLyrics } from './lyrics';
-import { getPlayerTime, subscribePlayerTime } from 'officerdev';
+import { getPlayerTime, subscribePlayerTime } from './player-time';
export type UseLyrics = {
loading: boolean;
diff --git a/src/workspaces/officerdev/src/MusicPlayer/useLyricsOpen.ts b/plugins/music/web/useLyricsOpen.ts
similarity index 100%
rename from src/workspaces/officerdev/src/MusicPlayer/useLyricsOpen.ts
rename to plugins/music/web/useLyricsOpen.ts
diff --git a/src/workspaces/officerdev/src/MusicPlayer/useMusicFavorites.ts b/plugins/music/web/useMusicFavorites.ts
similarity index 100%
rename from src/workspaces/officerdev/src/MusicPlayer/useMusicFavorites.ts
rename to plugins/music/web/useMusicFavorites.ts
diff --git a/src/workspaces/officerdev/src/MusicPlayer/useMusicPlayer.ts b/plugins/music/web/useMusicPlayer.ts
similarity index 100%
rename from src/workspaces/officerdev/src/MusicPlayer/useMusicPlayer.ts
rename to plugins/music/web/useMusicPlayer.ts
diff --git a/plugins/music/web/usePlayerClock.ts b/plugins/music/web/usePlayerClock.ts
index daeb44b1..b4332121 100644
--- a/plugins/music/web/usePlayerClock.ts
+++ b/plugins/music/web/usePlayerClock.ts
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
-import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from 'officerdev';
+import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from './player-time';
/**
* Position + duration, straight off the engine's per-frame feed.
diff --git a/plugins/music/widgets/index.tsx b/plugins/music/widgets/index.tsx
index 13c35a67..b39f460d 100644
--- a/plugins/music/widgets/index.tsx
+++ b/plugins/music/widgets/index.tsx
@@ -1,5 +1,8 @@
-import type { WidgetRegistryMeta, PlayerTrack } from 'officerdev';
-import { useMusicPlayer } from 'officerdev';
+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';
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
index f7916465..1842358d 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { useLocation } from 'react-router';
-import { useDock, MusicPlayerHost, usePanelFullscreen } from 'officerdev';
+import { useDock, usePanelFullscreen } from 'officerdev';
import { useCapabilities } from 'hooks/useCapabilities';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ScreenErrorFallback } from './ScreenErrorFallback';
@@ -34,8 +34,10 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
// region below is an `absolute z-2` stacking context and the header is a `fixed z-10` sibling of it. So
// "full screen" is cooperative: the panel asks, and the chrome steps aside.
const panelFullscreen = usePanelFullscreen();
- // The content region shrinks when the (in-flow) music dock takes its space; the nav dock measures its
- // reveal boundary from this element, so it always sits just above whatever's at the bottom.
+ // The nav dock measures its reveal boundary from this element, so it sits just above whatever is at the
+ // bottom of the content region. Written for the in-flow music bar, which used to be rendered here and
+ // left with `plugins/music/` on 2026-08-15; the measurement is not music-specific and still holds for
+ // anything a screen puts at its foot.
const regionRef = useRef(null);
return (
@@ -63,7 +65,6 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
-
);
}
diff --git a/src/workspaces/officerdev/src/MusicPlayer/index.ts b/src/workspaces/officerdev/src/MusicPlayer/index.ts
deleted file mode 100644
index 36fc12e3..00000000
--- a/src/workspaces/officerdev/src/MusicPlayer/index.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-// 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 ``, 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';
diff --git a/src/workspaces/officerdev/src/MusicPlayer/shared.ts b/src/workspaces/officerdev/src/MusicPlayer/shared.ts
deleted file mode 100644
index 635dca2b..00000000
--- a/src/workspaces/officerdev/src/MusicPlayer/shared.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-// 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 };
-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 = (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 };
-
-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//") 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=`, 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;
-};
diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts
index 699d0c10..da83e7b6 100644
--- a/src/workspaces/officerdev/src/index.ts
+++ b/src/workspaces/officerdev/src/index.ts
@@ -5,7 +5,6 @@ export { usePageTitleOverride, usePublishPageTitle } from './page-title';
export type { PageTitleOverride } from './page-title';
export * from './AppRegistry';
export * from './WidgetRegistry';
-export * from './MusicPlayer';
// Re-export app modules (excluding appRegistryMetas to avoid name collisions)
export {
@@ -111,9 +110,9 @@ 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`.
+// The scrubber, shared by the FileViewer's audio/video renderers 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';