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:
+28
-23
@@ -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
|
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.
|
`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
|
The first version of this document said the player stayed in the platform and called the decision
|
||||||
acceptable. What decided it was not the overlay but the state:
|
settled by a hard constraint:
|
||||||
|
|
||||||
> `useMusicPlayer` and `PlayerTrack` are imported from `officerdev` by
|
> `useMusicPlayer` and `PlayerTrack` are imported from `officerdev` by `widgets/MusicPlayer/`, the
|
||||||
> `src/workspaces/widgets/MusicPlayer/`, the dashboard widget — which is _also_ out of scope and stays.
|
> dashboard widget — and the platform cannot import from a plugin. So the player state stays whatever is
|
||||||
> **The platform cannot import from a plugin.** So the player state stays here whatever is decided about
|
> decided about the UI.
|
||||||
> the UI around it, and a second copy would mean two audio engines fighting over one pair of speakers.
|
|
||||||
|
|
||||||
Given the state had to stay, splitting the engine and the bar away from the thing they drive would have
|
True at the time. The owner then moved the widget into the plugin, and the constraint evaporated: the
|
||||||
left the same seam in a worse place. And moving them needed a shell slot that renders a plugin-provided
|
complete remaining platform dependency became one line, `DashboardLayout.tsx:66`.
|
||||||
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.
|
|
||||||
|
|
||||||
The seam is inert without the plugin: `MusicPlayerHost` gates on `can('music')`, and `music` is now the
|
So the whole of `officerdev/src/MusicPlayer/` now lives in `web/` — engine, state, bar, favourites,
|
||||||
plugin's permission — registered at install, gone at uninstall.
|
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
|
**Where the engine is mounted, and why it is not the shell.** `MusicPlayerHost` renders inside
|
||||||
module-level bridge the lyrics pane meets it through), `useLyricsOpen` and `MusicHeart` +
|
`MusicDetail`, at the foot of the library view. That reads odd until you notice what it already did:
|
||||||
`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.
|
|
||||||
|
|
||||||
**What would close it:** the widget learning to come from a plugin. Not the overlay slot — that one
|
```tsx
|
||||||
should stay shut.
|
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
|
## Two bugs, neither visible from reading
|
||||||
|
|
||||||
|
|||||||
+12
-18
@@ -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.
|
// 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
|
// 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
|
// sidecar/ the whole /api/music contract: indexing, streaming, per-user state
|
||||||
// db/ music_favorites, _playlists, _playlist_items, _now_playing
|
// db/ music_favorites, _playlists, _playlist_items, _now_playing
|
||||||
// web/ the library panels; the shell renders the Workspace
|
// 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
|
// 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
|
// 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
|
// `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.
|
// 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
|
// 2. ~~The dashboard widget~~ — MOVED HERE, to `./widgets/`, and unregistered from WidgetRegistry.
|
||||||
// the mechanism was not worth inventing for one.
|
// Parked: plugins cannot contribute widgets and that mechanism is not built.
|
||||||
//
|
//
|
||||||
// 3. The global player overlay (`officerdev/src/MusicPlayer/`, mounted by `DashboardLayout`). This was
|
// 3. ~~The global player overlay~~ — MOVED HERE, to `./web/`. It stayed while the widget pinned
|
||||||
// the one open judgement call and it is decided: THE PLAYER STAYS IN THE PLATFORM. Two reasons, and
|
// `useMusicPlayer` in `officerdev`; once the widget left, the only platform dependency was one line
|
||||||
// the second is the one that settles it.
|
// 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
|
// `[phase 2]` Leaving /music stops playback. Giving audio a life outside the route needs a shell slot
|
||||||
// exactly the escape hatch this system deleted on purpose — "there is no way to export a component"
|
// a plugin can contribute to, or the engine hoisted to module scope. Deferred deliberately; nothing
|
||||||
// is what makes "every plugin route is a Workspace" a property of the shape rather than a rule
|
// breaks meanwhile.
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// ── Host dependencies ──
|
// ── Host dependencies ──
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { Link, useNavigate } from 'react-router';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
|
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
|
||||||
import { useMusicPlayer, type PlayerTrack } from 'officerdev';
|
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
||||||
import { MusicHeart } from 'officerdev';
|
import { MusicHeart } from './MusicHeart';
|
||||||
import { useMusicFavorites } from 'officerdev';
|
import { useMusicFavorites } from './useMusicFavorites';
|
||||||
import {
|
import {
|
||||||
MUSIC_FAV_CHANNEL,
|
MUSIC_FAV_CHANNEL,
|
||||||
coverUrl,
|
coverUrl,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
import { Loader2, Music4 } from 'lucide-react';
|
import { Loader2, Music4 } from 'lucide-react';
|
||||||
import type { LyricLine } from './lyrics';
|
import type { LyricLine } from './lyrics';
|
||||||
import { seekPlayer } from 'officerdev';
|
import { seekPlayer } from './player-time';
|
||||||
import { useActiveLyricIndex } from './useLyrics';
|
import { useActiveLyricIndex } from './useLyrics';
|
||||||
|
|
||||||
type LyricsPaneProps = {
|
type LyricsPaneProps = {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useClient } from 'hooks/useClient';
|
|||||||
import { MicVocal } from 'lucide-react';
|
import { MicVocal } from 'lucide-react';
|
||||||
import { LyricsPane } from './LyricsPane';
|
import { LyricsPane } from './LyricsPane';
|
||||||
import { useLyrics } from './useLyrics';
|
import { useLyrics } from './useLyrics';
|
||||||
import { useLyricsOpen } from 'officerdev';
|
import { useLyricsOpen } from './useLyricsOpen';
|
||||||
import { useMusicPlayer } from 'officerdev';
|
import { useMusicPlayer } from './useMusicPlayer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The right-hand half of the /music detail panel when lyrics are on. It follows the PLAYING track, not
|
* The right-hand half of the /music detail panel when lyrics are on. It follows the PLAYING track, not
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import { usePanelChannel } from 'hooks/usePanelChannel';
|
|||||||
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
|
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
|
||||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||||
import { WorkspaceLayout } from 'officerdev';
|
import { WorkspaceLayout } from 'officerdev';
|
||||||
import { MusicHeart } from 'officerdev';
|
import { MusicHeart } from './MusicHeart';
|
||||||
import { FavoritesView } from './FavoritesView';
|
import { FavoritesView } from './FavoritesView';
|
||||||
import { useMusicPlayer } from 'officerdev';
|
import { useMusicPlayer } from './useMusicPlayer';
|
||||||
import type { PlayerTrack } from 'officerdev';
|
import type { PlayerTrack } from './useMusicPlayer';
|
||||||
import { LyricsPanel } from './LyricsPanel';
|
import { LyricsPanel } from './LyricsPanel';
|
||||||
import { MusicMiniBar } from './MusicMiniBar';
|
import { MusicMiniBar } from './MusicMiniBar';
|
||||||
import { useLyricsOpen } from 'officerdev';
|
import { MusicPlayerHost } from './MusicPlayerHost';
|
||||||
|
import { useLyricsOpen } from './useLyricsOpen';
|
||||||
import {
|
import {
|
||||||
MUSIC_ROOT,
|
MUSIC_ROOT,
|
||||||
MUSIC_FAV_CHANNEL,
|
MUSIC_FAV_CHANNEL,
|
||||||
@@ -414,6 +415,16 @@ export const MusicDetail = () => {
|
|||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{content}
|
{content}
|
||||||
<MusicMiniBar />
|
<MusicMiniBar />
|
||||||
|
{/* 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. */}
|
||||||
|
<MusicPlayerHost />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useClient } from 'hooks/useClient';
|
|||||||
import { MicVocal, Pause, Play } from 'lucide-react';
|
import { MicVocal, Pause, Play } from 'lucide-react';
|
||||||
import { SeekBar } from 'officerdev';
|
import { SeekBar } from 'officerdev';
|
||||||
import { coverUrl, fmtClock } from './shared';
|
import { coverUrl, fmtClock } from './shared';
|
||||||
import { seekPlayer } from 'officerdev';
|
import { seekPlayer } from './player-time';
|
||||||
import { useLyricsOpen } from 'officerdev';
|
import { useLyricsOpen } from './useLyricsOpen';
|
||||||
import { useMusicPlayer } from 'officerdev';
|
import { useMusicPlayer } from './useMusicPlayer';
|
||||||
import { usePlayerClock } from './usePlayerClock';
|
import { usePlayerClock } from './usePlayerClock';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import { Link, useLocation, useNavigate } from 'react-router';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useCapabilities } from 'hooks/useCapabilities';
|
import { useCapabilities } from 'hooks/useCapabilities';
|
||||||
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
|
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 { MusicHeart } from './MusicHeart';
|
||||||
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from './shared';
|
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from './shared';
|
||||||
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
||||||
+152
-13
@@ -1,13 +1,152 @@
|
|||||||
// The library vocabulary, re-exported from the host.
|
// Shared types/helpers for the /music workspace panels (MusicBrowser + MusicDetail), which coordinate
|
||||||
//
|
// via the `?path=` search param and play through the app-wide useMusicPlayer.
|
||||||
// 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
|
import { useSearchParams } from 'react-router';
|
||||||
// of it. One definition on the host side beats a copy either side of the plugin boundary drifting apart.
|
|
||||||
//
|
export const MUSIC_ROOT = 'Music';
|
||||||
// Taken from the `officerdev/MusicPlayer/shared` subpath rather than the `officerdev` barrel because the
|
export const MUSIC_FAV_CHANNEL = 'music:favorites';
|
||||||
// type names here (`DirEntry`, `Track`, `Manifest`) are ones the barrel already spends on the FileBrowser.
|
// Bumped (to a fresh nonce) when a library reindex finishes, so BOTH panels re-run their manifest /
|
||||||
// The subpath is a declared export of the package (`"./*": "./src/*.ts"`), not a reach into its insides.
|
// listing / meta fetches — otherwise only the panel that triggered the reindex refreshes.
|
||||||
//
|
export const MUSIC_RESYNC_CHANNEL = 'music:resync';
|
||||||
// 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.
|
// Album folders are named "[year] Album Name" → display as "Album Name" + year.
|
||||||
export * from 'officerdev/MusicPlayer/shared';
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { LyricLine } from './lyrics';
|
import type { LyricLine } from './lyrics';
|
||||||
import { activeLineIndex, parseLyrics } from './lyrics';
|
import { activeLineIndex, parseLyrics } from './lyrics';
|
||||||
import { getPlayerTime, subscribePlayerTime } from 'officerdev';
|
import { getPlayerTime, subscribePlayerTime } from './player-time';
|
||||||
|
|
||||||
export type UseLyrics = {
|
export type UseLyrics = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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.
|
* Position + duration, straight off the engine's per-frame feed.
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { WidgetRegistryMeta, PlayerTrack } from 'officerdev';
|
import type { WidgetRegistryMeta } from 'officerdev';
|
||||||
import { useMusicPlayer } 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 { useState, useEffect } from 'react';
|
||||||
import { Music, ChevronLeft, Play, Folder } from 'lucide-react';
|
import { Music, ChevronLeft, Play, Folder } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import { useDock, MusicPlayerHost, usePanelFullscreen } from 'officerdev';
|
import { useDock, usePanelFullscreen } from 'officerdev';
|
||||||
import { useCapabilities } from 'hooks/useCapabilities';
|
import { useCapabilities } from 'hooks/useCapabilities';
|
||||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
import { ScreenErrorFallback } from './ScreenErrorFallback';
|
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
|
// 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.
|
// "full screen" is cooperative: the panel asks, and the chrome steps aside.
|
||||||
const panelFullscreen = usePanelFullscreen();
|
const panelFullscreen = usePanelFullscreen();
|
||||||
// The content region shrinks when the (in-flow) music dock takes its space; the nav dock measures its
|
// The nav dock measures its reveal boundary from this element, so it sits just above whatever is at the
|
||||||
// reveal boundary from this element, so it always sits just above whatever's at the bottom.
|
// 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<HTMLElement | null>(null);
|
const regionRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,7 +65,6 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<MusicPlayerHost />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 `<Routes>`, 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';
|
|
||||||
@@ -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<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;
|
|
||||||
};
|
|
||||||
@@ -5,7 +5,6 @@ export { usePageTitleOverride, usePublishPageTitle } from './page-title';
|
|||||||
export type { PageTitleOverride } from './page-title';
|
export type { PageTitleOverride } from './page-title';
|
||||||
export * from './AppRegistry';
|
export * from './AppRegistry';
|
||||||
export * from './WidgetRegistry';
|
export * from './WidgetRegistry';
|
||||||
export * from './MusicPlayer';
|
|
||||||
|
|
||||||
// Re-export app modules (excluding appRegistryMetas to avoid name collisions)
|
// Re-export app modules (excluding appRegistryMetas to avoid name collisions)
|
||||||
export {
|
export {
|
||||||
@@ -111,9 +110,9 @@ export {
|
|||||||
ARCHIVE_EXTS,
|
ARCHIVE_EXTS,
|
||||||
} from './apps/FileViewer';
|
} from './apps/FileViewer';
|
||||||
export type { FileType } 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
|
// The scrubber, shared by the FileViewer's audio/video renderers and the /music panels in
|
||||||
// panels in `plugins/music/`. On the barrel rather than reached for by subpath because the package's
|
// `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 maps to `.ts` only, and this is a `.tsx`.
|
||||||
export { SeekBar, useSeekBar } from './apps/FileViewer/renderers/SeekBar';
|
export { SeekBar, useSeekBar } from './apps/FileViewer/renderers/SeekBar';
|
||||||
export { TerminalView } from './apps/Terminal';
|
export { TerminalView } from './apps/Terminal';
|
||||||
export type { TerminalViewProps } from './apps/Terminal';
|
export type { TerminalViewProps } from './apps/Terminal';
|
||||||
|
|||||||
Reference in New Issue
Block a user