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:
@@ -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,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 },
|
||||
];
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user