music: Music Player widget + app-wide player dock

The widget browses the /api/music/* library (search albums → tracklist) and hands
a queue to an app-wide player. The player (useMusicPlayer, useGlobal-backed) and
its site-wide bottom dock (MusicPlayerHost) live in the persistent DashboardLayout,
so playback survives route changes. Dock has cover/title/artist, drag-scrubbing
(SeekBar), volume (persisted), and prev/play/next/close. The nav Dock slides up by
MUSIC_DOCK_HEIGHT while the music dock is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 10:29:14 +00:00
co-authored by Claude Opus 4.8
parent 8a1e8cd79e
commit 3308e4e24d
9 changed files with 385 additions and 4 deletions
@@ -1,4 +1,4 @@
import { useDock } from 'officerdev';
import { useDock, MusicPlayerHost } from 'officerdev';
import { Background } from './Background';
import { Header } from './Header';
import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock';
@@ -23,6 +23,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{children}
</div>
</section>
<MusicPlayerHost />
</div>
);
};
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { Link, useLocation } from 'react-router';
import type { LucideIcon } from 'lucide-react';
import { useMusicPlayer, MUSIC_DOCK_HEIGHT } from 'officerdev';
export type DockItem = {
label: string;
@@ -34,6 +35,8 @@ export const Dock = ({ items, className }: DockProps) => {
const [visible, setVisible] = useState(false);
const dockRef = useRef<HTMLDivElement | null>(null);
const location = useLocation();
const { current } = useMusicPlayer();
const musicDockPresent = !!current;
const isActive = (to: string) => (to === '/' ? location.pathname === '/' : location.pathname.startsWith(to));
@@ -64,7 +67,10 @@ export const Dock = ({ items, className }: DockProps) => {
backgroundColor: 'var(--dock-bg)',
borderColor: 'var(--dock-border)',
bottom: '16px',
transform: `translateX(-50%) translateY(${visible ? '0' : 'calc(100% + 24px)'})`,
// Slide up over the site-wide music dock when it's present so the nav dock clears it.
transform: `translateX(-50%) translateY(${
visible ? (musicDockPresent ? `-${MUSIC_DOCK_HEIGHT}px` : '0') : 'calc(100% + 24px)'
})`,
}}
>
{items.map((item, index) => {
@@ -0,0 +1,163 @@
import { useEffect, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX } from 'lucide-react';
import { useSeekBar, SeekBar } from '../apps/FileViewer/renderers/SeekBar';
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
// Mounted once in the persistent DashboardLayout (outside <Routes>), so it owns the single <audio>
// element and the site-wide play dock — playback survives navigation between routes.
const MUSIC_API = '/api/music';
const fmt = (s: number): string =>
Number.isFinite(s) && s >= 0 ? `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}` : '0:00';
export const MusicPlayerHost = () => {
const { token } = useClient();
const { current, index, queue, playing, toggle, next, prev, setPlaying, close } = useMusicPlayer();
const audioRef = useRef<HTMLAudioElement>(null);
const [position, setPosition] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(() => {
const v = parseFloat(localStorage.getItem('music.volume') ?? '1');
return Number.isFinite(v) ? v : 1;
});
const [muted, setMuted] = useState(false);
const { barRef, onSeekDown } = useSeekBar(audioRef, duration);
const withToken = (u: string) => (token ? `${u}${u.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : u);
const streamUrl = (t: PlayerTrack) =>
withToken(`${MUSIC_API}/stream?path=${encodeURIComponent(`Music/${t.albumRel}/${t.file}`)}`);
const coverUrl = (rel: string) => withToken(`${MUSIC_API}/cover?path=${encodeURIComponent(rel)}`);
const trackKey = current ? `${current.albumRel}/${current.file}` : '';
// Load the current track when it changes.
useEffect(() => {
const a = audioRef.current;
if (!a || !current) return;
a.src = streamUrl(current);
setPosition(0);
if (playing) void a.play().catch(() => setPlaying(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trackKey]);
// Play/pause without reloading the source.
useEffect(() => {
const a = audioRef.current;
if (!a || !current) return;
if (playing) void a.play().catch(() => {});
else a.pause();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playing]);
// Apply volume/mute to the element.
useEffect(() => {
if (audioRef.current) audioRef.current.volume = muted ? 0 : volume;
}, [volume, muted, trackKey]);
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseFloat(e.target.value);
setVolume(v);
setMuted(v === 0);
localStorage.setItem('music.volume', String(v));
};
const subtitle = current?.artist ?? current?.albumRel.split('/').slice(-2, -1)[0] ?? '';
const pct = duration ? (position / duration) * 100 : 0;
return (
<>
<audio
ref={audioRef}
preload="metadata"
onTimeUpdate={(e) => setPosition(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
onEnded={() => next()}
/>
{current && (
<div className="fixed inset-x-0 bottom-0 z-50 flex items-center gap-3 border-t border-border bg-card/95 px-4 py-2 backdrop-blur">
{/* cover + info */}
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
<img
src={coverUrl(current.albumRel)}
alt=""
className="h-full w-full object-cover"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
}}
/>
</div>
<div className="hidden w-44 shrink-0 sm:block">
<p className="truncate text-sm font-medium text-foreground">{current.title ?? current.file}</p>
<p className="truncate text-xs text-muted-foreground">{subtitle}</p>
</div>
{/* transport */}
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={prev}
disabled={index === 0}
className="p-1.5 text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<SkipBack size={18} />
</button>
<button
type="button"
onClick={toggle}
className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-primary-foreground hover:opacity-90 active:scale-95"
>
{playing ? <Pause size={18} /> : <Play size={18} className="ml-0.5" />}
</button>
<button
type="button"
onClick={next}
disabled={index >= queue.length - 1}
className="p-1.5 text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<SkipForward size={18} />
</button>
</div>
{/* scrubber + times */}
<span className="hidden w-10 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground md:block">
{fmt(position)}
</span>
<div className="min-w-0 flex-1">
<SeekBar
barRef={barRef}
onSeekDown={onSeekDown}
pct={pct}
trackClass="bg-muted"
fillClass="bg-primary"
thumbClass="border-background"
/>
</div>
<span className="hidden w-10 shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground md:block">
{fmt(duration)}
</span>
{/* volume */}
<div className="hidden shrink-0 items-center gap-1.5 md:flex">
<button type="button" onClick={() => setMuted((m) => !m)} className="text-muted-foreground hover:text-foreground">
{muted || volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
<input
type="range"
min={0}
max={1}
step={0.01}
value={muted ? 0 : volume}
onChange={changeVolume}
className="h-1 w-16 cursor-pointer accent-primary"
/>
</div>
<button type="button" onClick={close} className="shrink-0 p-1.5 text-muted-foreground hover:text-foreground">
<X size={16} />
</button>
</div>
)}
</>
);
};
@@ -0,0 +1,2 @@
export * from './useMusicPlayer';
export * from './MusicPlayerHost';
@@ -0,0 +1,41 @@
import { useGlobal } from 'hooks/useGlobal';
// App-wide music player state (react-query-backed via useGlobal, so it's shared across the whole app and
// survives route changes). The audio element itself lives in MusicPlayerHost (mounted once in the
// persistent DashboardLayout); this hook is the control surface any component uses to drive it.
export type PlayerTrack = {
albumRel: string; // album path relative to the Music root (for stream + cover URLs)
file: string; // track filename within the album folder
title?: string;
artist?: string;
};
export type MusicPlayerState = {
queue: PlayerTrack[];
index: number;
playing: boolean;
};
const INITIAL: MusicPlayerState = { queue: [], index: 0, playing: false };
// Amount (px) the nav Dock shifts up while the music dock is present, so it clears it.
export const MUSIC_DOCK_HEIGHT = 72;
export function useMusicPlayer() {
const [state, setState] = useGlobal<MusicPlayerState>('MUSIC_PLAYER', INITIAL);
const playQueue = (queue: PlayerTrack[], index = 0) =>
setState({ queue, index: Math.max(0, Math.min(index, Math.max(0, queue.length - 1))), playing: true });
const toggle = () => setState((s) => ({ ...s, playing: !s.playing }));
const setPlaying = (playing: boolean) => setState((s) => ({ ...s, playing }));
const jump = (index: number) =>
setState((s) => ({ ...s, index: Math.max(0, Math.min(index, s.queue.length - 1)), playing: true }));
const next = () =>
setState((s) => (s.index < s.queue.length - 1 ? { ...s, index: s.index + 1, playing: true } : { ...s, playing: false }));
const prev = () => setState((s) => (s.index > 0 ? { ...s, index: s.index - 1, playing: true } : s));
const close = () => setState(INITIAL);
const current = state.queue[state.index];
return { ...state, current, playQueue, toggle, setPlaying, jump, next, prev, close };
}
@@ -3,9 +3,17 @@ import { widgetRegistryMetas as weatherMetas } from 'widgets/Weather';
import { widgetRegistryMetas as pomodoroMetas } from 'widgets/Pomodoro';
import { widgetRegistryMetas as dailyGoalsMetas } from 'widgets/DailyGoals';
import { widgetRegistryMetas as quickNotesMetas } from 'widgets/QuickNotes';
import { widgetRegistryMetas as musicPlayerMetas } from 'widgets/MusicPlayer';
import { useWidgetRegistry } from './useWidgetRegistry';
const widgets = [...clockMetas, ...weatherMetas, ...pomodoroMetas, ...dailyGoalsMetas, ...quickNotesMetas];
const widgets = [
...clockMetas,
...weatherMetas,
...pomodoroMetas,
...dailyGoalsMetas,
...quickNotesMetas,
...musicPlayerMetas,
];
export const WidgetRegistry = () => {
useWidgetRegistry(widgets);
+1
View File
@@ -1,6 +1,7 @@
export * from './hooks';
export * from './AppRegistry';
export * from './WidgetRegistry';
export * from './MusicPlayer';
// Re-export app modules (excluding appRegistryMetas to avoid name collisions)
export {
@@ -0,0 +1,158 @@
import type { WidgetRegistryMeta, PlayerTrack } from 'officerdev';
import { useMusicPlayer } from 'officerdev';
import { useState, useEffect, useMemo } from 'react';
import { Music, Search, ChevronLeft, Play } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Widget } from '../Widget';
// Music Player widget — a BROWSER over the /api/music/* library. Playback is owned by the app-wide
// player (useMusicPlayer / MusicPlayerHost): selecting a track hands a queue to the global player, which
// keeps playing across route changes and shows the site-wide dock.
const MUSIC_API = '/api/music';
type ManifestAlbum = { v: string; cover: boolean; tracks: number; disco?: boolean };
type Manifest = { version: number; generatedAt: number; albums: Record<string, ManifestAlbum> };
type Track = { file: string; title?: string; artist?: string };
type AlbumMeta = { path: string; cover?: string; tracks: Track[] };
export const MusicPlayer = () => {
const { token, get } = useClient(MUSIC_API);
const player = useMusicPlayer();
const [albums, setAlbums] = useState<string[]>([]);
const [query, setQuery] = useState('');
const [albumRel, setAlbumRel] = useState<string | null>(null);
const [tracks, setTracks] = useState<Track[]>([]);
useEffect(() => {
get<Manifest>('/manifest')
.then((m) =>
setAlbums(
Object.entries(m.albums)
.filter(([, a]) => a.tracks > 0)
.map(([rel]) => rel)
.sort(),
),
)
.catch(() => setAlbums([]));
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return (q ? albums.filter((a) => a.toLowerCase().includes(q)) : albums).slice(0, 80);
}, [albums, query]);
const coverUrl = (rel: string) =>
`${MUSIC_API}/cover?path=${encodeURIComponent(rel)}${token ? `&token=${encodeURIComponent(token)}` : ''}`;
const openAlbum = async (rel: string) => {
try {
const meta = await get<AlbumMeta>(`/meta?path=${encodeURIComponent(rel)}`);
setAlbumRel(rel);
setTracks(meta.tracks);
} catch {
/* ignore */
}
};
const play = (rel: string, ts: Track[], i: number) => {
const queue: PlayerTrack[] = ts.map((t) => ({ albumRel: rel, file: t.file, title: t.title, artist: t.artist }));
player.playQueue(queue, i);
};
const albumTitle = (rel: string) => rel.split('/').pop() ?? rel;
const artistName = (rel: string) => rel.split('/').slice(-2, -1)[0] ?? '';
const isCurrent = (rel: string, file: string) => player.current?.albumRel === rel && player.current?.file === file;
return (
<Widget title="Music Player" className="w-72">
{!albumRel ? (
// ── Browse albums ──
<div className="flex flex-col gap-2 px-3 pb-3">
<div className="flex items-center gap-2 rounded-md bg-muted px-2">
<Search size={14} className="text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search albums…"
className="w-full bg-transparent py-1.5 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="flex max-h-72 flex-col overflow-y-auto">
{filtered.map((rel) => (
<button
key={rel}
type="button"
onClick={() => openAlbum(rel)}
className="flex max-w-full flex-col items-start rounded px-2 py-1.5 text-left hover:bg-muted"
>
<span className="max-w-full truncate text-sm text-foreground">{albumTitle(rel)}</span>
<span className="max-w-full truncate text-xs text-muted-foreground">{artistName(rel)}</span>
</button>
))}
{!filtered.length && (
<span className="px-2 py-4 text-center text-sm text-muted-foreground">No albums reindex first?</span>
)}
</div>
</div>
) : (
// ── Album track list ──
<div className="flex flex-col gap-2 px-3 pb-3">
<button
type="button"
onClick={() => setAlbumRel(null)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ChevronLeft size={14} /> Library
</button>
<div className="flex items-center gap-3">
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted">
<img
src={coverUrl(albumRel)}
alt=""
className="h-full w-full object-cover"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
}}
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground">{albumTitle(albumRel)}</p>
<p className="truncate text-xs text-muted-foreground">{artistName(albumRel)}</p>
</div>
<button
type="button"
onClick={() => play(albumRel, tracks, 0)}
className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground hover:opacity-90 active:scale-95"
title="Play album"
>
<Play size={16} className="ml-0.5" />
</button>
</div>
<div className="flex max-h-56 flex-col overflow-y-auto">
{tracks.map((t, i) => (
<button
key={t.file}
type="button"
onClick={() => play(albumRel, tracks, i)}
className={`flex items-center gap-2 rounded px-2 py-1 text-left text-xs hover:bg-muted ${
isCurrent(albumRel, t.file) ? 'text-primary' : 'text-muted-foreground'
}`}
>
<span className="w-4 text-right tabular-nums">{i + 1}</span>
<span className="truncate">{t.title ?? t.file}</span>
</button>
))}
</div>
</div>
)}
</Widget>
);
};
export const widgetRegistryMetas: WidgetRegistryMeta[] = [
{ key: 'music-player', name: 'Music Player', icon: Music, component: MusicPlayer },
];
+2 -1
View File
@@ -8,6 +8,7 @@
"./Pomodoro": "./Pomodoro/index.tsx",
"./DailyGoals": "./DailyGoals/index.tsx",
"./QuickNotes": "./QuickNotes/index.tsx",
"./Workspaces": "./Workspaces/index.tsx"
"./Workspaces": "./Workspaces/index.tsx",
"./MusicPlayer": "./MusicPlayer/index.tsx"
}
}