Files
platform/plugins/music/web/shared.ts
T
pastilhas 0a55964db5 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.
2026-08-15 14:42:13 +00:00

153 lines
6.2 KiB
TypeScript

// 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;
};