feat: enhance discovery, vibe sessions, and library enrichment
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled

This commit is contained in:
kami
2026-08-01 14:40:48 +04:00
parent a0c9f42a89
commit 4c48d11e9d
54 changed files with 4136 additions and 521 deletions
+12 -10
View File
@@ -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>
+3 -7
View File
@@ -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);
}
/**
+1 -1
View File
@@ -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 */
}
-169
View File
@@ -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>
);
}
+78 -4
View File
@@ -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();
});
});
+34 -8
View File
@@ -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>
+2 -2
View File
@@ -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) {
+17 -15
View File
@@ -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>
);
}
+18 -7
View File
@@ -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>
+33 -14
View File
@@ -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>