Files
music/web/MusicMiniBar.tsx
T
Claude Opus 5 6e07da7a36 music, extracted from the platform into its own repository
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>
2026-08-15 17:34:51 +00:00

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