the player moves to the plugin, and src/ has no music code left
officerdev/src/MusicPlayer/ → plugins/music/web/. Engine, state, bar, favourites, lyrics toggle and the library vocabulary — ten files. The barrel stops exporting a player it no longer has, and DashboardLayout stops rendering one. The reasoning that kept it was removed rather than refuted. It stayed because the dashboard widget imported useMusicPlayer from officerdev and the platform cannot import from a plugin, so the state had to stay whatever was decided about the UI. The owner moved the widget into the plugin in the previous commit, and the constraint went with it: the whole remaining dependency became one line, DashboardLayout.tsx:66. MusicPlayerHost is mounted inside the MusicDetail panel. That reads odd until you notice it already returned null on /music — the mini bar is the transport there, and the host existed purely to own the GaplessEngine. In the panel it does exactly that, and the bar code stays intact for whenever there is a slot. [phase 2] Leaving /music unmounts the host and playback stops. Deferred on the owner's call; the bar was "navigating away must not break the application", and that holds: 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 is global state so returning to /music remounts and reloads. Solving it properly 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, which keeps the rule and loses only the off-route controls. Also: the parked widget now imports the player as a sibling rather than through officerdev, and shared.ts stopped being a re-export shim now that the real file is in the plugin. Verified: tsgo clean, 797 tests / 787 pass / same 7. Server restarts, mounts /example /music /offscale, / and /music both 200, and the player is in the built bundle (music.volume, music:lyrics, now-playing?device=web all present — GaplessEngine is a class name and the production build is minified, so grepping for it proves nothing). Not verified by me: what it looks like in a browser. That needs your eyes.
This commit is contained in:
+152
-13
@@ -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<string, ManifestAlbum> };
|
||||
export type Track = {
|
||||
file: string;
|
||||
title?: string;
|
||||
artist?: string;
|
||||
albumArtist?: string;
|
||||
track?: string;
|
||||
durationSec?: number;
|
||||
};
|
||||
export type AlbumMeta = { path: string; cover?: string; tracks: Track[] };
|
||||
|
||||
/** Seconds → "m:ss" (or "h:mm:ss" once past an hour); '' when unknown. */
|
||||
export const fmtDuration = (sec?: number): string => {
|
||||
if (!sec || sec <= 0) return '';
|
||||
const s = Math.round(sec);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const ss = String(s % 60).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
|
||||
};
|
||||
|
||||
/** Seconds → "m:ss" for a running clock: unknown reads as 0:00, never blank, so it doesn't jitter. */
|
||||
export const fmtClock = (sec: number): string =>
|
||||
Number.isFinite(sec) && sec >= 0
|
||||
? `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, '0')}`
|
||||
: '0:00';
|
||||
|
||||
/** Case-insensitive subsequence fuzzy match: every char of `query` appears in order within `text`. */
|
||||
export const fuzzyMatch = (query: string, text: string): boolean => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
const t = text.toLowerCase();
|
||||
let qi = 0;
|
||||
for (let ti = 0; ti < t.length && qi < q.length; ti++) if (t[ti] === q[qi]!) qi++;
|
||||
return qi === q.length;
|
||||
};
|
||||
|
||||
/** Parse a track-number tag ("7", "07", "7/14") to a number, or null when absent/unparseable. */
|
||||
const trackNo = (t: Track): number | null => {
|
||||
const raw = t.track?.split('/')[0]?.trim();
|
||||
if (!raw) return null;
|
||||
const n = parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Canonical album track order: by the `track` NUMBER, falling back to the tag title only for tracks
|
||||
* that have no number (numbered tracks always precede unnumbered ones; filename breaks a final tie).
|
||||
* meta.json is in ffprobe/readdir order (arbitrary), so every consumer must sort with this.
|
||||
*/
|
||||
export const sortTracks = <T extends Track>(tracks: T[]): T[] => {
|
||||
const key = (t: Track) => (t.title || t.file).toLowerCase();
|
||||
return [...tracks].sort((a, b) => {
|
||||
const na = trackNo(a);
|
||||
const nb = trackNo(b);
|
||||
if (na !== null && nb !== null) return na - nb || key(a).localeCompare(key(b));
|
||||
if (na !== null) return -1;
|
||||
if (nb !== null) return 1;
|
||||
return key(a).localeCompare(key(b));
|
||||
});
|
||||
};
|
||||
export type Discography = { artist: string; albums: Record<string, string> };
|
||||
|
||||
export type FavoriteKind = 'track' | 'album' | 'artist';
|
||||
export type GroupedFavorites = { tracks: string[]; albums: string[]; artists: string[] };
|
||||
|
||||
/** Per-user "currently playing" snapshot (GET/PUT /api/music/now-playing). */
|
||||
export type NowPlaying = {
|
||||
homePath: string;
|
||||
dir: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
durationSec: number;
|
||||
positionSec: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/** homePath ("Music/<rel>/<file>") for a track — its favorite key + /stream path. */
|
||||
export const trackHomePath = (rel: string, file: string) => `${MUSIC_ROOT}/${rel}/${file}`;
|
||||
|
||||
const AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma', 'mp4']);
|
||||
export const isAudio = (n: string) => {
|
||||
const d = n.lastIndexOf('.');
|
||||
return d >= 0 && AUDIO_EXT.has(n.slice(d + 1).toLowerCase());
|
||||
};
|
||||
|
||||
// Section order for an artist's discography.
|
||||
export const TYPE_ORDER = [
|
||||
'Studio',
|
||||
'Live',
|
||||
'Compilation',
|
||||
'EP',
|
||||
'Single',
|
||||
'Soundtrack',
|
||||
'Remix',
|
||||
'DJ-Mix',
|
||||
'Demo',
|
||||
'Mixtape',
|
||||
'Bootleg',
|
||||
'Other',
|
||||
];
|
||||
|
||||
export const coverUrl = (rel: string, token: string | null) =>
|
||||
`/api/music/cover?path=${encodeURIComponent(rel)}${token ? `&token=${encodeURIComponent(token)}` : ''}`;
|
||||
|
||||
/** Path (home-relative) → rel (relative to the Music root). */
|
||||
export const toRel = (cwd: string | null) => (cwd ? cwd.slice(MUSIC_ROOT.length + 1) : '');
|
||||
|
||||
// Where you are in the library is `/music?path=<rel>`, not a `music:cwd` channel. A query param rather
|
||||
// than `/music/*` because the location is one of several things this screen holds (the lyrics split and
|
||||
// the favorites view are the others), and because a splat would have to be the last segment of the
|
||||
// route — the same reason /chat spells its group that way. `rel === ''` is the library root, which is
|
||||
// the bare /music and a real state, so there is no redirect guard.
|
||||
export const MUSIC_PATH_PARAM = 'path';
|
||||
|
||||
/** Link target for a library location. `rel` is relative to the Music root; '' is the root itself. */
|
||||
export const musicPath = (rel: string) => (rel ? `/music?${MUSIC_PATH_PARAM}=${encodeURIComponent(rel)}` : '/music');
|
||||
|
||||
/** Link target for the parent of `rel` — '' (the root) is its own parent, which is where "up" stops. */
|
||||
export const musicParentPath = (rel: string) => musicPath(rel.split('/').slice(0, -1).join('/'));
|
||||
|
||||
/**
|
||||
* The open library folder as a home-relative path ("Music/…"), or null at the root — the vocabulary the
|
||||
* panels already speak, so reading the URL costs them nothing. Each panel calls this itself; they never
|
||||
* tell each other where they are.
|
||||
*/
|
||||
export const useMusicCwd = (): string | null => {
|
||||
const rel = useSearchParams()[0].get(MUSIC_PATH_PARAM)?.trim() ?? '';
|
||||
return rel ? `${MUSIC_ROOT}/${rel}` : null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user