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 { Background } from './Background';
|
||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock';
|
import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock';
|
||||||
@@ -23,6 +23,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<MusicPlayerHost />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router';
|
import { Link, useLocation } from 'react-router';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { useMusicPlayer, MUSIC_DOCK_HEIGHT } from 'officerdev';
|
||||||
|
|
||||||
export type DockItem = {
|
export type DockItem = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -34,6 +35,8 @@ export const Dock = ({ items, className }: DockProps) => {
|
|||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const dockRef = useRef<HTMLDivElement | null>(null);
|
const dockRef = useRef<HTMLDivElement | null>(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { current } = useMusicPlayer();
|
||||||
|
const musicDockPresent = !!current;
|
||||||
|
|
||||||
const isActive = (to: string) => (to === '/' ? location.pathname === '/' : location.pathname.startsWith(to));
|
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)',
|
backgroundColor: 'var(--dock-bg)',
|
||||||
borderColor: 'var(--dock-border)',
|
borderColor: 'var(--dock-border)',
|
||||||
bottom: '16px',
|
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) => {
|
{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 pomodoroMetas } from 'widgets/Pomodoro';
|
||||||
import { widgetRegistryMetas as dailyGoalsMetas } from 'widgets/DailyGoals';
|
import { widgetRegistryMetas as dailyGoalsMetas } from 'widgets/DailyGoals';
|
||||||
import { widgetRegistryMetas as quickNotesMetas } from 'widgets/QuickNotes';
|
import { widgetRegistryMetas as quickNotesMetas } from 'widgets/QuickNotes';
|
||||||
|
import { widgetRegistryMetas as musicPlayerMetas } from 'widgets/MusicPlayer';
|
||||||
import { useWidgetRegistry } from './useWidgetRegistry';
|
import { useWidgetRegistry } from './useWidgetRegistry';
|
||||||
|
|
||||||
const widgets = [...clockMetas, ...weatherMetas, ...pomodoroMetas, ...dailyGoalsMetas, ...quickNotesMetas];
|
const widgets = [
|
||||||
|
...clockMetas,
|
||||||
|
...weatherMetas,
|
||||||
|
...pomodoroMetas,
|
||||||
|
...dailyGoalsMetas,
|
||||||
|
...quickNotesMetas,
|
||||||
|
...musicPlayerMetas,
|
||||||
|
];
|
||||||
|
|
||||||
export const WidgetRegistry = () => {
|
export const WidgetRegistry = () => {
|
||||||
useWidgetRegistry(widgets);
|
useWidgetRegistry(widgets);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from './hooks';
|
export * from './hooks';
|
||||||
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 {
|
||||||
|
|||||||
@@ -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",
|
"./Pomodoro": "./Pomodoro/index.tsx",
|
||||||
"./DailyGoals": "./DailyGoals/index.tsx",
|
"./DailyGoals": "./DailyGoals/index.tsx",
|
||||||
"./QuickNotes": "./QuickNotes/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