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>
104 lines
3.9 KiB
TypeScript
104 lines
3.9 KiB
TypeScript
import { useRef } from 'react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { MicVocal, Pause, Play } from 'lucide-react';
|
|
import { SeekBar } from 'officerdev';
|
|
import { coverUrl, fmtClock } from './shared';
|
|
import { seekPlayer } from './player-time';
|
|
import { useLyricsOpen } from './useLyricsOpen';
|
|
import { useMusicPlayer } from './useMusicPlayer';
|
|
import { usePlayerClock } from './usePlayerClock';
|
|
|
|
/**
|
|
* The player, reduced to what the /music screen does not already show. The album view has the transport
|
|
* and the tracklist, so this is the scrubber — plus play/pause and the lyrics toggle, which are the two
|
|
* controls you can still want while browsing an album that ISN'T the one playing.
|
|
*
|
|
* It sits inside the detail panel, which is why the full dock hides on /music: two bars would be one bar
|
|
* too many, and the dock's own row costs the workspace its height on every screen.
|
|
*/
|
|
export const MusicMiniBar = () => {
|
|
const { token } = useClient();
|
|
const { current, playing, toggle } = useMusicPlayer();
|
|
const [lyricsOpen, toggleLyrics] = useLyricsOpen();
|
|
const { position, duration } = usePlayerClock();
|
|
const barRef = useRef<HTMLDivElement>(null);
|
|
|
|
if (!current) return null;
|
|
|
|
const onSeekDown = (ev: React.MouseEvent<HTMLDivElement>) => {
|
|
const seekAt = (clientX: number) => {
|
|
const bar = barRef.current;
|
|
if (!bar || !duration) return;
|
|
const rect = bar.getBoundingClientRect();
|
|
seekPlayer(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * duration);
|
|
};
|
|
ev.preventDefault();
|
|
seekAt(ev.clientX);
|
|
const onMove = (moveEv: MouseEvent) => seekAt(moveEv.clientX);
|
|
const onUp = () => {
|
|
window.removeEventListener('mousemove', onMove);
|
|
window.removeEventListener('mouseup', onUp);
|
|
};
|
|
window.addEventListener('mousemove', onMove);
|
|
window.addEventListener('mouseup', onUp);
|
|
};
|
|
|
|
return (
|
|
<div className="flex shrink-0 items-center gap-3 border-t border-border bg-card/60 px-4 py-2">
|
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
|
<img
|
|
src={coverUrl(current.albumRel, token)}
|
|
alt=""
|
|
className="h-full w-full object-cover"
|
|
onError={(ev) => {
|
|
(ev.currentTarget as HTMLImageElement).style.visibility = 'hidden';
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={toggle}
|
|
title={playing ? 'Pause' : 'Play'}
|
|
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground hover:opacity-90 active:scale-95"
|
|
>
|
|
{playing ? <Pause size={15} /> : <Play size={15} className="ml-0.5" />}
|
|
</button>
|
|
|
|
<div className="hidden w-48 min-w-0 shrink-0 sm:block">
|
|
<p className="truncate text-xs font-medium text-foreground">{current.title ?? current.file}</p>
|
|
{current.artist && <p className="truncate text-[11px] text-muted-foreground">{current.artist}</p>}
|
|
</div>
|
|
|
|
<span className="w-10 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground">
|
|
{fmtClock(position)}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<SeekBar
|
|
barRef={barRef}
|
|
onSeekDown={onSeekDown}
|
|
pct={duration ? (position / duration) * 100 : 0}
|
|
trackClass="bg-muted"
|
|
fillClass="bg-primary"
|
|
thumbClass="border-background"
|
|
/>
|
|
</div>
|
|
<span className="w-10 shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">
|
|
{fmtClock(duration)}
|
|
</span>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={toggleLyrics}
|
|
title={lyricsOpen ? 'Hide lyrics' : 'Show lyrics'}
|
|
aria-pressed={lyricsOpen}
|
|
className={`shrink-0 cursor-pointer p-1 hover:text-foreground ${
|
|
lyricsOpen ? 'text-primary' : 'text-muted-foreground'
|
|
}`}
|
|
>
|
|
<MicVocal size={16} />
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|