feat: enhance discovery, vibe sessions, and library enrichment
This commit is contained in:
@@ -9,16 +9,14 @@ import { LyricsOverlay } from './LyricsOverlay';
|
||||
import { Toaster } from './Toaster';
|
||||
import { CommandPalette } from './CommandPalette';
|
||||
import { KeyboardListener, useKeyboard } from '../hooks/useKeyboard';
|
||||
import { Inspector, type InspectorMode } from './Inspector';
|
||||
|
||||
export default function AppShell() {
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const [navigationOpen, setNavigationOpen] = useState(false);
|
||||
const [lyricsOpen, setLyricsOpen] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [inspector, setInspector] = useState<{ mode: InspectorMode; id: string } | null>(null);
|
||||
|
||||
const togglePalette = useCallback(() => setPaletteOpen((p) => !p), []);
|
||||
const closeInspector = useCallback(() => setInspector(null), []);
|
||||
|
||||
// Ctrl+K — command palette (uses `code` so it works on any keyboard layout)
|
||||
useKeyboard({
|
||||
@@ -39,24 +37,28 @@ export default function AppShell() {
|
||||
handler: () => window.history.forward(),
|
||||
});
|
||||
|
||||
// Esc — closes inspector, palette, etc.
|
||||
useKeyboard({
|
||||
code: 'Escape',
|
||||
handler: () => {
|
||||
if (inspector) closeInspector();
|
||||
if (navigationOpen) setNavigationOpen(false);
|
||||
else if (queueOpen) setQueueOpen(false);
|
||||
else if (lyricsOpen) setLyricsOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-bg0 text-text overflow-hidden">
|
||||
<div className="flex h-screen h-[100dvh] flex-col overflow-hidden bg-bg0 text-text">
|
||||
<KeyboardListener />
|
||||
<TopBar onToggleCommandPalette={togglePalette} />
|
||||
<TopBar
|
||||
onToggleCommandPalette={togglePalette}
|
||||
onToggleNavigation={() => setNavigationOpen((open) => !open)}
|
||||
navigationOpen={navigationOpen}
|
||||
/>
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
<NavRail />
|
||||
<main className="flex-1 overflow-y-auto p-4 pb-8">
|
||||
<NavRail open={navigationOpen} onClose={() => setNavigationOpen(false)} />
|
||||
<main className="min-w-0 flex-1 overflow-y-auto p-3 pb-6 sm:p-4 sm:pb-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
{inspector && <Inspector mode={inspector.mode} id={inspector.id} onClose={closeInspector} />}
|
||||
{queueOpen && <NowPlayingPanel onClose={() => setQueueOpen(false)} />}
|
||||
{lyricsOpen && <LyricsOverlay onClose={() => setLyricsOpen(false)} />}
|
||||
</div>
|
||||
|
||||
@@ -24,13 +24,9 @@ function deduplicateArtists(artists: TrackArtist[]): TrackArtist[] {
|
||||
map.set(a.id, a);
|
||||
}
|
||||
}
|
||||
// Preserve original order, skipping duplicates.
|
||||
const seen = new Set<string>();
|
||||
return artists.filter((a) => {
|
||||
if (seen.has(a.id)) return false;
|
||||
seen.add(a.id);
|
||||
return true;
|
||||
});
|
||||
// Keep the position of the selected credit and omit every duplicate. This
|
||||
// makes the documented main-over-featured preference real.
|
||||
return artists.filter((a) => map.get(a.id) === a);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -123,7 +123,7 @@ export const AudioEngine = () => {
|
||||
// this track — don't also record the implicit transition.
|
||||
} else {
|
||||
try {
|
||||
void vibeService.feedback(prevId, completed ? 'completed' : 'skipped').catch(() => {});
|
||||
void vibeService.feedback(prevId, completed ? 'completed' : 'skipped', useVibeStore.getState().activeSessionId ?? undefined).catch(() => {});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { X, Play } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import type { AlbumWithTracks, ArtistWithAlbums } from '../types';
|
||||
import { albumService } from '../services/albumService';
|
||||
import { artistService } from '../services/artistService';
|
||||
import { Artwork } from './Artwork';
|
||||
import { Button } from './ethos/Button';
|
||||
import { TrackRow } from './TrackRow';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
|
||||
type InspectorMode = 'album' | 'artist' | 'track';
|
||||
|
||||
interface InspectorProps {
|
||||
mode: InspectorMode;
|
||||
id: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function AlbumInspector({ id, onClose }: { id: string; onClose: () => void }) {
|
||||
const { setQueue, playTrack } = usePlaybackStore();
|
||||
const { data, isLoading } = useQuery<AlbumWithTracks>({
|
||||
queryKey: ['album', id],
|
||||
queryFn: () => albumService.getAlbum(id),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="skeleton h-40 w-full rounded" />
|
||||
<div className="skeleton h-4 w-2/3" />
|
||||
<div className="skeleton h-3 w-1/3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const tracks = data.tracks ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted">Album</span>
|
||||
<button onClick={onClose} className="text-muted hover:text-text p-0.5 rounded">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{/* Artwork + meta */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="w-full aspect-square rounded-md overflow-hidden">
|
||||
<Artwork seed={data.title} src={data.artwork_id} className="w-full h-full" rounded="md" eager />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text truncate">{data.title}</h2>
|
||||
<p className="text-xs text-secondary">{data.artist_name || 'Unknown artist'}{data.year ? ` · ${data.year}` : ''}</p>
|
||||
<p className="text-xs text-muted mt-0.5">{tracks.length} tracks</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Play size={14} fill="currentColor" />}
|
||||
onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }}
|
||||
disabled={!tracks.length}
|
||||
className="w-full"
|
||||
>
|
||||
Play album
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Track list */}
|
||||
<div className="border-t border-border">
|
||||
<div className="px-4 py-2 text-[10px] font-semibold uppercase tracking-wider text-muted">Tracks</div>
|
||||
<div className="space-y-0.5 px-2 pb-3">
|
||||
{tracks.map((t, i) => (
|
||||
<TrackRow key={t.id} track={t} queue={tracks} index={i} showActions={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtistInspector({ id, onClose }: { id: string; onClose: () => void }) {
|
||||
const { data, isLoading } = useQuery<ArtistWithAlbums>({
|
||||
queryKey: ['artist', id],
|
||||
queryFn: () => artistService.getArtist(id),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="skeleton h-32 w-32 rounded-full mx-auto" />
|
||||
<div className="skeleton h-4 w-1/2 mx-auto" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const albums = data.albums ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted">Artist</span>
|
||||
<button onClick={onClose} className="text-muted hover:text-text p-0.5 rounded">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1">
|
||||
<div className="p-4 space-y-3 text-center">
|
||||
<div className="w-24 h-24 rounded-full overflow-hidden mx-auto ring-2 ring-border">
|
||||
<Artwork seed={data.name} src={data.image_path} className="w-full h-full" rounded="full" eager />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text">{data.name}</h2>
|
||||
<p className="text-xs text-muted">{albums.length} albums</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{albums.length > 0 && (
|
||||
<div className="border-t border-border">
|
||||
<div className="px-4 py-2 text-[10px] font-semibold uppercase tracking-wider text-muted">Albums</div>
|
||||
<div className="grid grid-cols-3 gap-2 p-2">
|
||||
{albums.map((album) => (
|
||||
<Link
|
||||
key={album.id}
|
||||
to="/albums/$albumId"
|
||||
params={{ albumId: album.id }}
|
||||
className="flex flex-col gap-1 rounded-md p-1.5 hover:bg-surface0 transition-colors"
|
||||
>
|
||||
<div className="aspect-square rounded-sm overflow-hidden">
|
||||
<Artwork seed={album.title} src={album.artwork_id} className="w-full h-full" />
|
||||
</div>
|
||||
<span className="text-xs text-text truncate">{album.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { InspectorMode };
|
||||
|
||||
/**
|
||||
* Inspector panel — right-side detail view for albums, artists, and tracks.
|
||||
* Replaces full-page navigations with a slide-in panel per Ethos conventions.
|
||||
*/
|
||||
export function Inspector({ mode, id, onClose }: InspectorProps) {
|
||||
return (
|
||||
<aside className="w-80 flex flex-col border-l border-border bg-bg1 overflow-hidden shrink-0 animate-slide-in">
|
||||
{mode === 'album' && <AlbumInspector id={id} onClose={onClose} />}
|
||||
{mode === 'artist' && <ArtistInspector id={id} onClose={onClose} />}
|
||||
{mode === 'track' && (
|
||||
<div className="p-4 text-sm text-muted text-center py-10">
|
||||
Track inspector coming soon
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
Home, Music, Disc3, Users, Tag, Compass,
|
||||
Terminal, ShieldAlert,
|
||||
Zap,
|
||||
Settings, Sparkles,
|
||||
Settings, Sparkles, X,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface NavItem {
|
||||
@@ -59,15 +60,86 @@ const active =
|
||||
'bg-accent/10 text-accent font-medium ' +
|
||||
"before:content-[''] before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:rounded-full before:bg-accent";
|
||||
|
||||
export function NavRail() {
|
||||
interface NavRailProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NavRail({ open, onClose }: NavRailProps) {
|
||||
const [desktop, setDesktop] = useState(false);
|
||||
const drawerRef = useRef<HTMLElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia('(min-width: 1024px)');
|
||||
const update = () => setDesktop(query.matches);
|
||||
update();
|
||||
query.addEventListener('change', update);
|
||||
return () => query.removeEventListener('change', update);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (open && !desktop) closeRef.current?.focus();
|
||||
}, [open, desktop]);
|
||||
useEffect(() => {
|
||||
if (!open || desktop || !drawerRef.current) return;
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
const trap = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Tab' || !drawerRef.current) return;
|
||||
const focusable = [...drawerRef.current.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)].filter((element) => !element.hasAttribute('hidden'));
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault(); last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault(); first.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', trap);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', trap);
|
||||
previous?.focus();
|
||||
};
|
||||
}, [open, desktop]);
|
||||
|
||||
// Do not leave an off-screen mobile drawer in the tab order. The desktop
|
||||
// rail remains mounted independently of the drawer state.
|
||||
if (!desktop && !open) return null;
|
||||
return (
|
||||
<aside className="w-48 flex flex-col bg-bg1 border-r border-border shrink-0 overflow-y-auto">
|
||||
<>
|
||||
{open && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 z-30 bg-black/60 lg:hidden"
|
||||
aria-label="Close navigation"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
<aside
|
||||
ref={drawerRef}
|
||||
aria-label="Main navigation"
|
||||
aria-modal={!desktop || undefined}
|
||||
role={desktop ? undefined : 'dialog'}
|
||||
className={`absolute inset-y-0 left-0 z-40 flex w-72 max-w-[85vw] flex-col overflow-y-auto border-r border-border bg-bg1 shadow-2xl transition-transform duration-200 lg:relative lg:z-auto lg:w-48 lg:max-w-none lg:translate-x-0 lg:shadow-none ${
|
||||
open ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{/* App branding */}
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-accent text-on-accent">
|
||||
<Sparkles size={14} />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-text tracking-tight">muzick</span>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ml-auto rounded-md p-2 text-muted hover:bg-surface0 hover:text-text lg:hidden"
|
||||
aria-label="Close navigation"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
@@ -85,6 +157,7 @@ export function NavRail() {
|
||||
activeOptions={{ exact: exact ?? false }}
|
||||
activeProps={{ className: `${base} ${active}` }}
|
||||
inactiveProps={{ className: `${base} ${inactive}` }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon size={15} className="flex-none transition-transform group-hover:scale-110" />
|
||||
<span className="truncate">{label}</span>
|
||||
@@ -100,6 +173,7 @@ export function NavRail() {
|
||||
<div className="px-3 py-2 text-[10px] text-disabled border-t border-border">
|
||||
muzick · v0.1
|
||||
</div>
|
||||
</aside>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { NowPlayingPanel } from './NowPlayingPanel';
|
||||
|
||||
describe('NowPlayingPanel', () => {
|
||||
it('announces itself as a modal and keeps keyboard focus inside', async () => {
|
||||
usePlaybackStore.setState({ currentTrack: null, queue: [], currentIndex: -1, isPlaying: false });
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<NowPlayingPanel onClose={() => undefined} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Now playing queue' });
|
||||
const close = screen.getByRole('button', { name: 'Close now playing' });
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
expect(close).toHaveFocus();
|
||||
await user.tab({ shift: true });
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toHaveFocus();
|
||||
await user.tab();
|
||||
expect(close).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { X, Play, Pause, SkipBack, SkipForward, Disc3 } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
@@ -8,6 +9,31 @@ import { TrackRow, formatDuration } from './TrackRow';
|
||||
import { albumService } from '../services/albumService';
|
||||
|
||||
export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => { closeRef.current?.focus(); }, []);
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
const trap = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Tab' || !panelRef.current) return;
|
||||
const focusable = [...panelRef.current.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)].filter((element) => !element.hasAttribute('hidden'));
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault(); last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault(); first.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', trap);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', trap);
|
||||
previous?.focus();
|
||||
};
|
||||
}, []);
|
||||
const { currentTrack, queue, isPlaying, position, duration, play, pause, next, prev, setPosition } = usePlaybackStore();
|
||||
|
||||
const currentIdx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1;
|
||||
@@ -22,25 +48,25 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
|
||||
const artwork = albumQ.data?.artwork_id ?? currentTrack?.artwork_id ?? null;
|
||||
|
||||
return (
|
||||
<aside className="w-96 flex flex-col border-l border-border/70 bg-bg1/80 backdrop-blur-sm overflow-hidden shrink-0">
|
||||
<aside ref={panelRef} role="dialog" aria-modal="true" aria-label="Now playing queue" className="absolute inset-0 z-30 flex w-full flex-col overflow-hidden border-l border-border/70 bg-bg1 backdrop-blur-sm animate-slide-in sm:left-auto sm:w-96 lg:relative lg:z-auto lg:bg-bg1/80">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/70">
|
||||
<span className="text-sm font-semibold text-text">Now Playing</span>
|
||||
<button onClick={onClose} className="text-muted hover:text-text p-1 rounded">
|
||||
<button ref={closeRef} onClick={onClose} aria-label="Close now playing" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface1">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="p-3 space-y-3 sm:p-4 sm:space-y-4">
|
||||
{currentTrack?.album_id ? (
|
||||
<Link to="/albums/$albumId" params={{ albumId: currentTrack.album_id }}
|
||||
className="group block aspect-square rounded-xl overflow-hidden relative shadow-lg shadow-black/40" title="Go to album">
|
||||
className="group mx-auto block aspect-square w-full max-w-sm rounded-xl overflow-hidden relative shadow-lg shadow-black/40" title="Go to album">
|
||||
<Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} src={artwork} className="w-full h-full transition-transform group-hover:scale-105" rounded="xl" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-colors">
|
||||
<Disc3 size={28} className="text-on-accent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="aspect-square rounded-xl overflow-hidden shadow-lg shadow-black/40">
|
||||
<div className="mx-auto aspect-square w-full max-w-sm rounded-xl overflow-hidden shadow-lg shadow-black/40">
|
||||
<Artwork seed={currentTrack ? `${currentTrack.title} ${currentTrack.artist}` : 'empty'} src={artwork} className="w-full h-full" rounded="xl" />
|
||||
</div>
|
||||
)}
|
||||
@@ -74,8 +100,8 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<button onClick={prev} className="text-muted hover:text-text"><SkipBack size={20} /></button>
|
||||
<div className="flex items-center justify-center gap-4 sm:gap-6">
|
||||
<button onClick={prev} aria-label="Previous" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0"><SkipBack size={20} /></button>
|
||||
<button
|
||||
onClick={() => isPlaying ? pause() : play()}
|
||||
disabled={!currentTrack}
|
||||
@@ -83,7 +109,7 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
|
||||
>
|
||||
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
||||
</button>
|
||||
<button onClick={next} className="text-muted hover:text-text"><SkipForward size={20} /></button>
|
||||
<button onClick={next} aria-label="Next" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0"><SkipForward size={20} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ interface PanelHeaderProps {
|
||||
className?: string;
|
||||
/**
|
||||
* Title styling intent:
|
||||
* - `'panel'` (default) — text-xs uppercase muted (Inspector, LyricsOverlay)
|
||||
* - `'panel'` (default) — text-xs uppercase muted (side panels, LyricsOverlay)
|
||||
* - `'heading'` — text-sm semibold text-text (NowPlayingPanel)
|
||||
*/
|
||||
intent?: 'panel' | 'heading';
|
||||
@@ -19,7 +19,7 @@ const TITLE_CLASSES = {
|
||||
|
||||
/**
|
||||
* Overlay/panel header bar — title label with an optional close button.
|
||||
* Standardizes the pattern that was hand-rolled in Inspector (×2),
|
||||
* Standardizes the pattern used by side panels,
|
||||
* NowPlayingPanel, LyricsOverlay, CommandPalette, and more.
|
||||
*/
|
||||
export function PanelHeader({ title, onClose, className = '', intent = 'panel' }: PanelHeaderProps) {
|
||||
|
||||
@@ -23,9 +23,10 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass h-20 border-t border-border/70 px-4 flex items-center gap-4 shrink-0 z-20">
|
||||
<div className="glass border-t border-border/70 px-3 py-2 shrink-0 z-20 sm:h-20 sm:px-4 sm:py-0">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 sm:flex-nowrap sm:gap-4">
|
||||
{/* Track info */}
|
||||
<div className="flex items-center gap-3 w-64 min-w-0 shrink-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 sm:w-64 sm:flex-none sm:gap-3">
|
||||
{currentTrack ? (
|
||||
<>
|
||||
{currentTrack.album_id ? (
|
||||
@@ -52,7 +53,7 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
<button
|
||||
onClick={handleDislike}
|
||||
title="Dislike (sends to quarantine)"
|
||||
className="flex-none rounded-md p-1.5 text-muted hover:bg-surface1 hover:text-red-400 transition-colors"
|
||||
className="flex-none rounded-md p-2 text-muted hover:bg-surface1 hover:text-red-400 transition-colors"
|
||||
aria-label="Dislike — move to quarantine"
|
||||
>
|
||||
<ThumbsDown size={16} />
|
||||
@@ -64,17 +65,17 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
</div>
|
||||
|
||||
{/* Controls + scrubber */}
|
||||
<div className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="order-3 flex basis-full flex-col items-center gap-1 sm:order-none sm:flex-1 sm:basis-auto">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={`p-1.5 rounded-md transition-colors ${shuffle ? 'text-accent' : 'text-muted hover:text-text'}`}
|
||||
className={`rounded-md p-2 transition-colors ${shuffle ? 'text-accent' : 'text-muted hover:text-text'}`}
|
||||
aria-label={shuffle ? 'Disable shuffle' : 'Enable shuffle'}
|
||||
title={shuffle ? 'Shuffle on' : 'Shuffle off'}
|
||||
>
|
||||
<Shuffle size={18} />
|
||||
</button>
|
||||
<button onClick={prev} className="text-muted hover:text-text" aria-label="Previous">
|
||||
<button onClick={prev} className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" aria-label="Previous">
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<button
|
||||
@@ -85,19 +86,19 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
>
|
||||
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
||||
</button>
|
||||
<button onClick={next} className="text-muted hover:text-text" aria-label="Next">
|
||||
<button onClick={next} className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" aria-label="Next">
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={cycleRepeat}
|
||||
className={`p-1.5 rounded-md transition-colors ${repeat !== 'none' ? 'text-accent' : 'text-muted hover:text-text'}`}
|
||||
className={`rounded-md p-2 transition-colors ${repeat !== 'none' ? 'text-accent' : 'text-muted hover:text-text'}`}
|
||||
aria-label={`Repeat: ${repeat}`}
|
||||
title={repeat === 'none' ? 'Repeat off' : repeat === 'all' ? 'Repeat all' : 'Repeat one'}
|
||||
>
|
||||
{repeat === 'one' ? <Repeat1 size={18} /> : <Repeat size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex w-full max-w-lg items-center gap-2">
|
||||
<div className="flex w-full max-w-lg items-center gap-1.5 sm:gap-2">
|
||||
<span className="text-xs text-muted w-9 text-right tabular-nums">{formatDuration(position)}</span>
|
||||
<input
|
||||
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
|
||||
@@ -112,18 +113,18 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
</div>
|
||||
|
||||
{/* Volume + panel toggle */}
|
||||
<div className="flex items-center gap-3 w-48 justify-end shrink-0">
|
||||
<Volume2 size={18} className="text-muted flex-none" />
|
||||
<div className="order-2 flex items-center gap-1 justify-end shrink-0 sm:order-none sm:w-48 sm:gap-3">
|
||||
<Volume2 size={18} className="hidden text-muted flex-none sm:block" />
|
||||
<input
|
||||
type="range" min={0} max={1} step={0.01} value={volume}
|
||||
onChange={(e) => setVolume(Number(e.target.value))}
|
||||
className="w-20 h-1 cursor-pointer"
|
||||
className="hidden w-20 h-1 cursor-pointer sm:block"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
<button
|
||||
onClick={onToggleLyrics}
|
||||
disabled={!currentTrack}
|
||||
className={`p-2 rounded-md transition-colors disabled:opacity-30 ${lyricsOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text'}`}
|
||||
className={`rounded-md p-2 transition-colors disabled:opacity-30 ${lyricsOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text hover:bg-surface0'}`}
|
||||
aria-label="Toggle lyrics"
|
||||
title="Lyrics"
|
||||
>
|
||||
@@ -131,13 +132,14 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleQueue}
|
||||
className={`p-2 rounded-md transition-colors ${queueOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text'}`}
|
||||
className={`rounded-md p-2 transition-colors ${queueOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text hover:bg-surface0'}`}
|
||||
aria-label="Toggle queue panel"
|
||||
title="Up Next"
|
||||
>
|
||||
<ListMusic size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Search, X, Command, ChevronRight, Wifi, WifiOff } from 'lucide-react';
|
||||
import { Search, X, Command, ChevronRight, Wifi, WifiOff, Menu } from 'lucide-react';
|
||||
import { useNavigate, useRouterState } from '@tanstack/react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchHealthStatus } from '../services/healthService';
|
||||
|
||||
interface TopBarProps {
|
||||
onToggleCommandPalette: () => void;
|
||||
onToggleNavigation: () => void;
|
||||
navigationOpen: boolean;
|
||||
}
|
||||
|
||||
/** Page title map for breadcrumbs */
|
||||
@@ -86,7 +88,7 @@ function ConnectionStatus() {
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar({ onToggleCommandPalette }: TopBarProps) {
|
||||
export function TopBar({ onToggleCommandPalette, onToggleNavigation, navigationOpen }: TopBarProps) {
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { pathname, urlQuery } = useRouterState({
|
||||
@@ -136,14 +138,23 @@ export function TopBar({ onToggleCommandPalette }: TopBarProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="glass h-12 border-b border-border flex items-center px-3 gap-3 shrink-0 z-20">
|
||||
<header className="glass h-12 border-b border-border flex items-center px-2.5 gap-2 sm:px-3 sm:gap-3 shrink-0 z-20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleNavigation}
|
||||
className="rounded-md p-2 text-muted hover:bg-surface0 hover:text-text lg:hidden"
|
||||
aria-label={navigationOpen ? 'Close navigation' : 'Open navigation'}
|
||||
aria-expanded={navigationOpen}
|
||||
>
|
||||
{navigationOpen ? <X size={18} /> : <Menu size={18} />}
|
||||
</button>
|
||||
{/* Breadcrumbs */}
|
||||
<div className="flex items-center min-w-0 flex-none max-w-[200px]">
|
||||
<div className="hidden sm:flex items-center min-w-0 flex-none max-w-[200px]">
|
||||
<Breadcrumbs pathname={pathname} />
|
||||
</div>
|
||||
|
||||
{/* Universal search */}
|
||||
<form onSubmit={handleSubmit} className="flex-1 max-w-md">
|
||||
<form onSubmit={handleSubmit} className="min-w-0 flex-1 max-w-md">
|
||||
<div className="relative group">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted pointer-events-none transition-colors group-focus-within:text-accent" />
|
||||
<input
|
||||
@@ -172,7 +183,7 @@ export function TopBar({ onToggleCommandPalette }: TopBarProps) {
|
||||
</form>
|
||||
|
||||
{/* Right section */}
|
||||
<div className="flex items-center gap-2 flex-none">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 flex-none">
|
||||
{/* Connection status */}
|
||||
<ConnectionStatus />
|
||||
|
||||
@@ -184,7 +195,7 @@ export function TopBar({ onToggleCommandPalette }: TopBarProps) {
|
||||
>
|
||||
<Command size={12} />
|
||||
<span className="hidden sm:inline">Commands</span>
|
||||
<kbd className="rounded border border-border bg-bg2 px-1 text-[10px] text-muted">
|
||||
<kbd className="hidden md:inline rounded border border-border bg-bg2 px-1 text-[10px] text-muted">
|
||||
Ctrl+K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link, useRouter } from '@tanstack/react-router';
|
||||
import type { Track } from '../types';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useDislikeTrack } from '../hooks/useDislikeTrack';
|
||||
import { vibeService } from '../services/vibeService';
|
||||
import { startVibeSession } from '../services/vibeSession';
|
||||
import { Artwork } from './Artwork';
|
||||
import { ArtistLinks } from './ArtistLinks';
|
||||
|
||||
@@ -24,9 +24,11 @@ interface TrackRowProps {
|
||||
variant?: TrackRowVariant;
|
||||
/** Show a "Vibe by track" button that starts a vibe session seeded from this track. */
|
||||
showVibe?: boolean;
|
||||
/** Override ordinary queue playback, for contextual actions such as Vibe seed rows. */
|
||||
onSelect?: (track: Track) => void;
|
||||
}
|
||||
|
||||
export function TrackRow({ track, queue, showActions = true, variant = 'default', showVibe = false }: TrackRowProps) {
|
||||
export function TrackRow({ track, queue, showActions = true, variant = 'default', showVibe = false, onSelect }: TrackRowProps) {
|
||||
const { setQueue, playTrack, play, pause, currentTrack, isPlaying } = usePlaybackStore();
|
||||
const dislikeTrack = useDislikeTrack();
|
||||
const router = useRouter();
|
||||
@@ -34,6 +36,10 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
const compact = variant === 'compact';
|
||||
|
||||
const handlePlay = () => {
|
||||
if (onSelect) {
|
||||
onSelect(track);
|
||||
return;
|
||||
}
|
||||
if (isCurrent) { isPlaying ? pause() : play(); return; }
|
||||
// Queue the whole list and start at this track, so Previous can walk back
|
||||
// into the tracks before it.
|
||||
@@ -41,6 +47,10 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
playTrack(track);
|
||||
};
|
||||
|
||||
const playLabel = isCurrent && isPlaying
|
||||
? `Pause ${track.title || 'track'}`
|
||||
: `Play ${track.title || 'track'}`;
|
||||
|
||||
const handleDislike = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
dislikeTrack(track.id);
|
||||
@@ -48,8 +58,8 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
|
||||
const handleVibe = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// Start a vibe session then navigate to the vibe page.
|
||||
vibeService.start(track.id).then(() => {
|
||||
// Start and hydrate the V2 plan before showing the Vibe page.
|
||||
startVibeSession(track).then(() => {
|
||||
router.navigate({ to: '/vibe' });
|
||||
}).catch(() => {
|
||||
// Session failed — still navigate so the user can try manually.
|
||||
@@ -59,8 +69,7 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handlePlay}
|
||||
className={`group flex w-full cursor-pointer items-center gap-3 rounded-lg border transition-colors ${
|
||||
className={`group flex w-full items-center gap-3 rounded-lg border transition-colors ${
|
||||
compact ? 'p-2' : 'p-2.5'
|
||||
} ${
|
||||
isCurrent
|
||||
@@ -69,22 +78,31 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
}`}
|
||||
>
|
||||
{/* Artwork + play overlay */}
|
||||
<div className={`relative flex flex-none items-center justify-center rounded overflow-hidden ${
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlay}
|
||||
aria-label={playLabel}
|
||||
className={`relative flex flex-none items-center justify-center rounded overflow-hidden focus-visible:z-30 ${
|
||||
compact ? 'h-9 w-9' : 'h-10 w-10'
|
||||
}`}>
|
||||
<Artwork seed={`${track.title} ${track.artist}`} src={track.artwork_id} className="absolute inset-0 w-full h-full" />
|
||||
{isCurrent && isPlaying ? (
|
||||
<Pause size={compact ? 14 : 18} className="absolute z-20 text-text opacity-100" />
|
||||
) : (
|
||||
<Play size={compact ? 14 : 18} className="absolute z-20 text-text opacity-0 group-hover:opacity-100" />
|
||||
<Play size={compact ? 14 : 18} className="absolute z-20 text-text opacity-70 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Title + artist */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`truncate font-medium ${isCurrent ? 'text-accent' : 'text-text'} ${compact ? 'text-sm' : 'text-sm'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlay}
|
||||
className={`block max-w-full truncate rounded text-left font-medium hover:underline ${isCurrent ? 'text-accent' : 'text-text'} ${compact ? 'text-sm' : 'text-sm'}`}
|
||||
aria-label={playLabel}
|
||||
>
|
||||
{track.title || 'Untitled'}
|
||||
</div>
|
||||
</button>
|
||||
<ArtistLinks
|
||||
artists={track.artists}
|
||||
fallback={track.artist}
|
||||
@@ -95,9 +113,9 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
|
||||
{/* Actions (vibe → album link → dislike) */}
|
||||
{showActions && !compact && (
|
||||
<div className="flex flex-none items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="track-row-actions flex flex-none items-center gap-1">
|
||||
{showVibe && (
|
||||
<button onClick={handleVibe} title="Vibe by track" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-accent">
|
||||
<button type="button" onClick={handleVibe} aria-label="Start a Vibe from this track" title="Vibe by track" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-accent">
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
)}
|
||||
@@ -106,13 +124,14 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
to="/albums/$albumId"
|
||||
params={{ albumId: track.album_id }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Go to album"
|
||||
title="Go to album"
|
||||
className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-text"
|
||||
>
|
||||
<Disc3 size={16} />
|
||||
</Link>
|
||||
)}
|
||||
<button onClick={handleDislike} title="Dislike" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-red-400">
|
||||
<button type="button" onClick={handleDislike} aria-label={`Dislike ${track.title || 'track'}`} title="Dislike" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-red-400">
|
||||
<ThumbsDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user