Everything the plugin is, moved out of officerdev/platform on 2026-08-15 — 41 files, unchanged from the tree they left. manifest.ts identity, one permission, ffmpeg/ffprobe declared api/ the sidecar proxy; the prefix comes from mountPrefix() sidecar/ the whole /api/music contract — indexing, streaming, per-user state db/ music_favorites, _playlists, _playlist_items, _now_playing web/ panels, layout, and the player: engine, bar, lyrics, favourites cliamp/ the second playback path, parked — not working, kept deliberately widgets/ the dashboard widget, parked — plugins cannot contribute widgets assets/ icon.png, the dock tile scripts/ the reindex CLI PLUGIN.md is the design record: what moved, what stayed, what broke, and why. MUSIC_API.md is the contract the phone and tablet apps speak, and the reason the sidecar's HTTP shape is not free to change. ── It does not build here, and that is the point ── The platform resolves `hooks/useClient`, `officerdev`, `officerdb/db` and `@@/*` through the workspace links in its own node_modules. Measured from this directory, outside the platform checkout, every one of them fails to resolve — 7 imports in the backend, ~29 in the frontend. So this repository is the source of truth, not yet a buildable unit. Making it one means the host API becoming something a plugin can depend on rather than something it reaches into. That is the next problem, and having the code here is what makes it unavoidable rather than theoretical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
6.2 KiB
TypeScript
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;
|
|
};
|