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';