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>
+53
View File
@@ -100,6 +100,25 @@ body {
#root {
position: relative;
z-index: 1;
min-height: 100dvh;
}
button,
a,
input[type='range'] {
-webkit-tap-highlight-color: transparent;
}
@media (pointer: coarse) {
input[type='range']::-webkit-slider-thumb {
width: 18px;
height: 18px;
margin-top: -7px;
}
input[type='range']::-moz-range-thumb {
width: 18px;
height: 18px;
}
}
/* ── Focus ring: 2px accent, for keyboard users only ──────────────────────── */
@@ -217,6 +236,40 @@ html { scroll-behavior: smooth; }
}
.group:hover .play-overlay-btn { transform: translateY(0); }
/* Settings library actions use a compact, stateful button rather than an
unstyled native control. */
.admin-btn {
display: inline-flex;
min-height: 36px;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid var(--ethos-border-hi);
border-radius: 8px;
padding: 8px 12px;
background: var(--ethos-surface1);
color: var(--ethos-text);
font-size: 13px;
font-weight: 500;
transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
}
.admin-btn:hover:not(:disabled) {
background: var(--ethos-surface2);
border-color: color-mix(in srgb, var(--ethos-accent) 50%, transparent);
}
.admin-btn:disabled { cursor: wait; opacity: 0.65; }
.admin-btn--done { border-color: color-mix(in srgb, var(--ethos-green) 60%, transparent); color: var(--ethos-green); }
.admin-btn--error { border-color: color-mix(in srgb, var(--ethos-red) 60%, transparent); color: var(--ethos-red); }
/* Keep row actions available on touch. On pointer-and-hover devices, reveal
them when the row is hovered or any of its controls receives focus. */
.track-row-actions { opacity: 1; transition: opacity 150ms ease; }
@media (hover: hover) and (pointer: fine) {
.track-row-actions { opacity: 0; }
.group:hover .track-row-actions,
.group:focus-within .track-row-actions { opacity: 1; }
}
/* Transport button */
.transport-btn {
width: 40px;
+21 -16
View File
@@ -1,18 +1,18 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Disc3, Sparkles } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { genreService } from '../services/genreService';
import { vibeService } from '../services/vibeService';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import { startVibeSession } from '../services/vibeSession';
import { TrackRow } from '../components/TrackRow';
import { PageContainer } from '../components/PageContainer';
import type { Genre, Track } from '../types';
export default function Discover() {
const [selected, setSelected] = useState<Genre | null>(null);
const { setQueue, playTrack } = usePlaybackStore();
const { setActiveSession, setSeedTrackId, setBuffer } = useVibeStore();
const [startingVibe, setStartingVibe] = useState(false);
const [vibeError, setVibeError] = useState<string | null>(null);
const navigate = useNavigate();
const genres = useQuery<Genre[]>({
queryKey: ['genres'],
@@ -27,18 +27,17 @@ export default function Discover() {
const startGenreVibe = async () => {
const tracks = genreTracks.data;
if (!tracks || tracks.length === 0) return;
if (!tracks || tracks.length === 0 || startingVibe) return;
const seed = tracks[0];
setStartingVibe(true);
setVibeError(null);
try {
const { sessionId } = await vibeService.start(seed.id);
setActiveSession({ sessionId, seedTrackId: seed.id });
setSeedTrackId(seed.id);
setBuffer(tracks);
setQueue(tracks);
playTrack(tracks[0]);
await startVibeSession(seed);
await navigate({ to: '/vibe' });
} catch {
setQueue(tracks);
playTrack(tracks[0]);
setVibeError('Could not start a Vibe from this genre. Please try again.');
} finally {
setStartingVibe(false);
}
};
@@ -86,14 +85,20 @@ export default function Discover() {
<h2 className="text-xl font-semibold text-text">{selected.name}</h2>
<button
onClick={() => void startGenreVibe()}
disabled={!genreTracks.data || genreTracks.data.length === 0}
disabled={startingVibe || !genreTracks.data || genreTracks.data.length === 0}
className="flex items-center gap-2 rounded-lg border border-accent/60 bg-accent/10 px-3 py-1.5 text-sm font-medium text-accent transition-colors hover:bg-accent/20 disabled:opacity-50"
>
<Sparkles size={16} />
Start a vibe
{startingVibe ? 'Starting' : 'Start a vibe'}
</button>
</div>
{vibeError && (
<p className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
{vibeError}
</p>
)}
{genreTracks.isLoading ? (
<p className="text-sm text-muted">Loading tracks…</p>
) : genreTracks.isError ? (
+40 -3
View File
@@ -21,15 +21,29 @@ const ENRICH_LABELS: Record<EnrichSettingKey, { label: string; desc: string }> =
function EnrichToggles() {
const [settings, setSettings] = useState<EnrichSettings | null>(null);
const [saving, setSaving] = useState<EnrichSettingKey | null>(null);
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading');
const [updateError, setUpdateError] = useState<string | null>(null);
const loadSettings = async () => {
setLoadState('loading');
setUpdateError(null);
try {
setSettings(await settingsService.load());
setLoadState('ready');
} catch {
setLoadState('error');
}
};
useEffect(() => {
settingsService.load().then(setSettings).catch(() => {});
void loadSettings();
}, []);
const toggle = async (key: EnrichSettingKey) => {
if (!settings || saving) return;
const next = !settings[key];
setSaving(key);
setUpdateError(null);
// Optimistic update.
setSettings((prev) => prev ? { ...prev, [key]: next } : prev);
try {
@@ -37,12 +51,28 @@ function EnrichToggles() {
} catch {
// Revert on failure.
setSettings((prev) => prev ? { ...prev, [key]: !next } : prev);
setUpdateError(`Could not update ${ENRICH_LABELS[key].label}. Your previous setting was restored.`);
} finally {
setSaving(null);
}
};
if (!settings) return null;
if (loadState === 'loading') {
return <p className="pt-2 text-xs text-muted" role="status">Loading enrichment settings</p>;
}
if (loadState === 'error' || !settings) {
return (
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
<p className="text-xs text-red-400" role="alert">Couldn&apos;t load enrichment settings.</p>
<button type="button" onClick={() => void loadSettings()} className="rounded-lg border border-border px-3 py-1.5 text-xs text-text hover:bg-surface1">
Retry
</button>
</div>
);
}
const enabledCount = Object.values(settings).filter(Boolean).length;
return (
<div className="space-y-3 pt-2 border-t border-border">
@@ -53,11 +83,16 @@ function EnrichToggles() {
Which external services to query during library scan. Changes apply to
the <strong>next scan</strong>.
</p>
<p className="text-xs text-muted" role="status" aria-live="polite">
{saving ? `Saving ${ENRICH_LABELS[saving].label}` : `${enabledCount} of ${Object.keys(ENRICH_LABELS).length} enrichment jobs enabled.`}
</p>
{updateError && <p className="text-xs text-red-400" role="alert">{updateError}</p>}
{Object.entries(ENRICH_LABELS).map(([key, { label, desc }]) => {
const k = key as EnrichSettingKey;
const on = settings[k];
return (
<button key={k} onClick={() => toggle(k)} disabled={saving === k}
<button type="button" key={k} onClick={() => void toggle(k)} disabled={saving !== null}
role="switch" aria-checked={on}
className="w-full flex items-center justify-between gap-3 rounded-lg border border-border/70 px-4 py-3 text-left transition-colors hover:bg-surface1 disabled:opacity-50">
<div className="min-w-0">
<div className="text-sm font-medium text-text">{label}</div>
@@ -110,9 +145,11 @@ function AdminAction({
return (
<button
type="button"
onClick={handleClick}
disabled={state === 'loading'}
className={`admin-btn ${state === 'done' ? 'admin-btn--done' : state === 'error' ? 'admin-btn--error' : ''}`}
aria-live="polite"
>
<Icon size={16} className={state === 'loading' ? 'animate-spin' : ''} />
{state === 'loading' ? busyLabel
+87 -37
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Heart, Loader2, Play, Shuffle, ThumbsDown, Sparkles, Square } from 'lucide-react';
import { vibeService, fetchNextBatch } from '../services/vibeService';
import { startVibeSession } from '../services/vibeSession';
import { trackService } from '../services/trackService';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
@@ -12,22 +13,29 @@ import { VibeTimeline } from '../components/VibeTimeline';
import { suppressAutoFeedback } from '../components/AudioEngine';
import { toast } from '../store/useToastStore';
const INITIAL_BATCH_SIZE = 5;
const PREFETCH_THRESHOLD = 3;
const PREFETCH_BATCH_SIZE = 3;
const SEED_LIST_SIZE = 50;
function bestEffort(p: Promise<unknown>): void {
void p.catch(() => undefined);
}
function sampleTracks(tracks: Track[], count: number): Track[] {
const sampled = [...tracks];
for (let index = sampled.length - 1; index > 0; index--) {
const pick = Math.floor(Math.random() * (index + 1));
[sampled[index], sampled[pick]] = [sampled[pick], sampled[index]];
}
return sampled.slice(0, count);
}
export default function Vibe() {
const { currentTrack, queue, setQueue, playTrack, next: playNext, pause, setCurrentTrack } = usePlaybackStore();
const { currentTrack, queue, setQueue, next: playNext, pause, setCurrentTrack } = usePlaybackStore();
const {
activeSessionId,
buffer,
setActiveSession,
setSeedTrackId,
setCenterTrack,
initialBatchStatus,
setBuffer,
appendBuffer,
reset,
@@ -37,42 +45,44 @@ export default function Vibe() {
const [prefetching, setPrefetching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [empty, setEmpty] = useState(false);
const [refillStatus, setRefillStatus] = useState<'idle' | 'exhausted' | 'failed'>('idle');
const [refillAttempt, setRefillAttempt] = useState(0);
const prefetchingRef = useRef(false);
const startingRef = useRef(false);
const { data: libraryTracks = [], isLoading: libraryLoading } = useQuery<Track[]>({
queryKey: ['library-seed'],
queryFn: () => trackService.listTracks({ limit: 50, sort_by: 'play_count', order: 'DESC' }),
// Fetch the eligible library once so Surprise me is not restricted to the
// most-played 50 tracks. The backend excludes hidden/deleted tracks.
queryFn: () => trackService.listTracks({ limit: 5000, sort_by: 'title', order: 'ASC' }),
enabled: !activeSessionId,
});
const seedTracks = useMemo(() => sampleTracks(libraryTracks, SEED_LIST_SIZE), [libraryTracks]);
const startSession = useCallback(
async (seed: Track) => {
if (startingRef.current) return;
startingRef.current = true;
setStarting(true);
setError(null);
setEmpty(false);
setRefillStatus('idle');
try {
const { sessionId } = await vibeService.start(seed.id);
setActiveSession({ sessionId, seedTrackId: seed.id });
setSeedTrackId(seed.id);
setCenterTrack(seed);
const chunk = await fetchNextBatch(INITIAL_BATCH_SIZE);
if (chunk.length === 0) {
setBuffer([]);
const result = await startVibeSession(seed);
if (result.tracks.length === 0) {
setEmpty(true);
return;
if (result.status === 'failed') {
setError('Could not load recommendations for this vibe. Please try another seed.');
}
}
setBuffer(chunk);
setQueue(chunk);
playTrack(chunk[0]);
} catch {
setError('Could not start a vibe session. Please try again.');
reset();
} finally {
startingRef.current = false;
setStarting(false);
}
},
[setActiveSession, setSeedTrackId, setCenterTrack, setBuffer, setQueue, playTrack, reset]
[]
);
const startFromCurrent = useCallback(() => {
@@ -91,26 +101,36 @@ export default function Vibe() {
useEffect(() => {
if (!activeSessionId || prefetchingRef.current) return;
if (initialBatchStatus === 'loading') return;
if (refillStatus !== 'idle') return;
if (remaining > PREFETCH_THRESHOLD) return;
prefetchingRef.current = true;
setPrefetching(true);
fetchNextBatch(PREFETCH_BATCH_SIZE)
.then((chunk) => {
if (chunk.length > 0) {
appendBuffer(chunk);
fetchNextBatch(PREFETCH_BATCH_SIZE, activeSessionId)
.then((result) => {
if (result.tracks.length > 0) {
const current = usePlaybackStore.getState().queue;
const currentIds = new Set(current.map((t) => t.id));
const fresh = chunk.filter((t) => !currentIds.has(t.id));
if (fresh.length > 0) setQueue([...current, ...fresh]);
const fresh = result.tracks.filter((t) => !currentIds.has(t.id));
if (fresh.length > 0) {
appendBuffer(fresh);
setQueue([...current, ...fresh]);
}
if (result.status === 'exhausted' || fresh.length === 0) {
setRefillStatus('exhausted');
}
} else if (result.status === 'exhausted') {
setRefillStatus('exhausted');
} else {
setRefillStatus('failed');
}
})
.catch(() => undefined)
.finally(() => {
prefetchingRef.current = false;
setPrefetching(false);
});
}, [activeSessionId, remaining, appendBuffer, setQueue]);
}, [activeSessionId, initialBatchStatus, remaining, appendBuffer, refillAttempt, refillStatus, setQueue]);
// Trim buffer to prevent unbounded growth — keep only from currentTrack onward.
useEffect(() => {
@@ -123,14 +143,14 @@ export default function Vibe() {
const handleKeep = useCallback(() => {
if (currentTrack) {
bestEffort(vibeService.feedback(currentTrack.id, 'promoted'));
bestEffort(vibeService.feedback(currentTrack.id, 'promoted', activeSessionId ?? undefined));
toast.success(`Kept "${currentTrack.title}"`);
}
}, [currentTrack]);
const handleDislike = useCallback(() => {
if (currentTrack) {
bestEffort(vibeService.feedback(currentTrack.id, 'disliked'));
bestEffort(vibeService.feedback(currentTrack.id, 'disliked', activeSessionId ?? undefined));
// AudioEngine would otherwise also record a 'skipped' on the track
// change caused by playNext() below — suppress that duplicate.
suppressAutoFeedback(currentTrack.id);
@@ -146,8 +166,14 @@ export default function Vibe() {
reset();
setEmpty(false);
setError(null);
setRefillStatus('idle');
}, [reset, pause, setQueue, setCurrentTrack]);
const retryRefill = useCallback(() => {
setRefillStatus('idle');
setRefillAttempt((attempt) => attempt + 1);
}, []);
const upcoming = currentTrack
? (() => {
const idx = buffer.findIndex((t) => t.id === currentTrack.id);
@@ -210,24 +236,25 @@ export default function Vibe() {
? 'Loading your library…'
: libraryTracks.length === 0
? 'No library tracks available to seed a vibe.'
: 'Start from a random track in your library.'}
: `Start from a random track across ${libraryTracks.length.toLocaleString()} library tracks.`}
</div>
</div>
{starting && <Loader2 size={18} className="animate-spin text-muted" />}
</button>
</div>
{!libraryLoading && libraryTracks.length > 0 && (
{!libraryLoading && seedTracks.length > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-semibold text-muted">Or pick a seed track</h2>
<ul className="max-h-72 space-y-1 overflow-y-auto">
{libraryTracks.map((track, index) => (
{seedTracks.map((track, index) => (
<li key={track.id}>
<TrackRow
track={track}
queue={libraryTracks}
queue={seedTracks}
index={index}
showActions={false}
onSelect={startSession}
/>
</li>
))}
@@ -256,9 +283,32 @@ export default function Vibe() {
</button>
</header>
{empty && (
{(empty || initialBatchStatus === 'exhausted' || initialBatchStatus === 'failed') && (
<div className="rounded-lg border border-border bg-surface0 p-4 text-sm text-muted">
No recommendations came back for this seed yet. Try ending and starting a different vibe.
{initialBatchStatus === 'failed'
? 'Could not load recommendations for this Vibe. Try starting a different one.'
: 'No recommendations came back for this seed yet. Try ending and starting a different vibe.'}
</div>
)}
{error && (
<div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
{error}
</div>
)}
{refillStatus === 'exhausted' && !empty && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-200">
This Vibe has no new recommendations to add. Playback will stop when the current queue ends; start a new Vibe to continue.
</div>
)}
{refillStatus === 'failed' && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
<span>Couldn&apos;t refresh the Vibe recommendations. Playback will stop when the current queue ends.</span>
<button onClick={retryRefill} className="flex-none rounded border border-red-400/50 px-2 py-1 text-xs hover:bg-red-500/10">
Try again
</button>
</div>
)}
+32
View File
@@ -0,0 +1,32 @@
import { AxiosError } from 'axios';
import { describe, expect, it, vi } from 'vitest';
import type { Track } from '../types';
import { fetchNextBatch, vibeService } from './vibeService';
const track = (id: string): Track => ({
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist',
album_id: 'album', duration: 180, state: 'LIBRARY', source_type: 'MANUAL',
play_count: 0, skip_count: 0, dislike_count: 0,
});
function responseError(status: number, code?: string) {
return new AxiosError('request failed', undefined, undefined, undefined, {
data: code ? { code } : {}, status, statusText: 'error', headers: {}, config: {} as never,
});
}
describe('fetchNextBatch', () => {
it('uses the supplied session id and treats VIBE_PLAN_EXHAUSTED as terminal', async () => {
const next = vi.spyOn(vibeService, 'next')
.mockResolvedValueOnce({ track: track('one'), explanation: null, planRemaining: 0 })
.mockRejectedValueOnce(responseError(409, 'VIBE_PLAN_EXHAUSTED'));
await expect(fetchNextBatch(3, 'session-a')).resolves.toEqual({ tracks: [track('one')], status: 'exhausted' });
expect(next).toHaveBeenCalledWith('session-a');
});
it('does not disguise a missing or replaced session as normal exhaustion', async () => {
vi.spyOn(vibeService, 'next').mockRejectedValue(responseError(404));
await expect(fetchNextBatch(1, 'expired-session')).resolves.toEqual({ tracks: [], status: 'failed' });
});
});
+25 -10
View File
@@ -1,4 +1,5 @@
import api from './api';
import axios from 'axios';
import type { Track } from '../types';
// A candidate from the v2 recommendation plan. The plan is stored server-side
@@ -21,6 +22,13 @@ export interface VibeNextResponse {
planRemaining: number;
}
export type VibeBatchStatus = 'complete' | 'exhausted' | 'failed';
export interface VibeBatchResult {
tracks: Track[];
status: VibeBatchStatus;
}
export type VibeFeedbackAction = 'completed' | 'skipped' | 'promoted' | 'disliked';
// V2 recommendation engine. The backend stores the plan in Redis (2h TTL) and
@@ -35,16 +43,16 @@ export const vibeService = {
// GET /api/v2/vibe/next -> { track, explanation, planRemaining }
// Returns one track at a time, shifting the server-side plan.
// 404 if no active plan — caller should handle gracefully.
async next(): Promise<VibeNextResponse> {
const res = await api.get<VibeNextResponse>('/v2/vibe/next');
async next(sessionId: string): Promise<VibeNextResponse> {
const res = await api.get<VibeNextResponse>('/v2/vibe/next', { params: { sessionId } });
return res.data;
},
// POST /api/v2/vibe/feedback { trackId, action } -> { status, planRemaining }
// Action 'promoted' also calls addFavorite; 'disliked' also calls dislikeTrack.
// Triggers replan of the remaining plan.
async feedback(trackId: string, action: VibeFeedbackAction): Promise<{ status: string; planRemaining: number }> {
const res = await api.post<{ status: string; planRemaining: number }>('/v2/vibe/feedback', { trackId, action });
async feedback(trackId: string, action: VibeFeedbackAction, sessionId?: string): Promise<{ status: string; planRemaining: number }> {
const res = await api.post<{ status: string; planRemaining: number }>('/v2/vibe/feedback', { trackId, action, sessionId });
return res.data;
},
@@ -58,16 +66,23 @@ export const vibeService = {
// Fetch N tracks from the v2 plan sequentially. Each call to /next shifts the
// server-side plan, so calls must be sequential (not parallel). Stops early on
// 404 (plan exhausted or expired).
export async function fetchNextBatch(count: number): Promise<Track[]> {
// 409/VIBE_PLAN_EXHAUSTED is a normal terminal condition. A missing/replaced
// session is intentionally reported as a failure so callers can preserve the
// current playback state rather than pretending the plan completed cleanly.
export async function fetchNextBatch(count: number, sessionId: string): Promise<VibeBatchResult> {
const tracks: Track[] = [];
for (let i = 0; i < count; i++) {
try {
const { track } = await vibeService.next();
const { track } = await vibeService.next(sessionId);
tracks.push(track);
} catch {
break;
} catch (error) {
return {
tracks,
status: axios.isAxiosError(error) && error.response?.status === 409 &&
(error.response.data as { code?: string } | undefined)?.code === 'VIBE_PLAN_EXHAUSTED'
? 'exhausted' : 'failed',
};
}
}
return tracks;
return { tracks, status: 'complete' };
}
+56
View File
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
const { next, start } = vi.hoisted(() => ({ next: vi.fn(), start: vi.fn() }));
vi.mock('./vibeService', () => ({
vibeService: { start, next },
fetchNextBatch: async (count: number, sessionId: string) => {
const tracks: Track[] = [];
for (let index = 0; index < count; index++) {
try { tracks.push((await next(sessionId)).track); } catch { return { tracks, status: 'failed' as const }; }
}
return { tracks, status: 'complete' as const };
},
}));
import { startVibeSession } from './vibeSession';
const track = (id: string): Track => ({
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist',
album_id: 'album', duration: 180, state: 'LIBRARY', source_type: 'MANUAL',
play_count: 0, skip_count: 0, dislike_count: 0,
});
describe('startVibeSession', () => {
beforeEach(() => {
vi.clearAllMocks();
useVibeStore.getState().reset();
usePlaybackStore.setState({ currentTrack: null, queue: [], currentIndex: -1, isPlaying: false });
});
it('does not replace a working Vibe when the new plan cannot hydrate', async () => {
const old = track('old');
useVibeStore.getState().setActiveSession({ sessionId: 'old-session', seedTrackId: old.id });
usePlaybackStore.getState().setQueue([old]);
usePlaybackStore.getState().playTrack(old);
start.mockResolvedValue({ sessionId: 'new-session', plan: [] });
next.mockRejectedValue(new Error('missing session'));
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ tracks: [], status: 'failed' });
expect(useVibeStore.getState().activeSessionId).toBe('old-session');
expect(usePlaybackStore.getState().currentTrack?.id).toBe('old');
});
it('serializes rapid starts and hydrates only one session', async () => {
const recommended = track('recommended');
start.mockResolvedValue({ sessionId: 'session-a', plan: [] });
next.mockResolvedValue({ track: recommended });
await Promise.all([startVibeSession(track('seed-a')), startVibeSession(track('seed-b'))]);
expect(start).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledWith('session-a');
expect(useVibeStore.getState().activeSessionId).toBe('session-a');
});
});
+51
View File
@@ -0,0 +1,51 @@
import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import { fetchNextBatch, vibeService, type VibeBatchStatus } from './vibeService';
export const INITIAL_VIBE_BATCH_SIZE = 5;
export interface StartedVibeSession {
status: VibeBatchStatus;
tracks: Track[];
}
let startInFlight: Promise<StartedVibeSession> | null = null;
/**
* Start a V2 plan and immediately hand its first recommendations to playback.
* Keeping this in one place prevents entry points from accidentally replacing a
* generated Vibe queue with a normal browse queue.
*/
export async function startVibeSession(seed: Track): Promise<StartedVibeSession> {
if (startInFlight) return startInFlight;
startInFlight = beginVibeSession(seed);
try {
return await startInFlight;
} finally {
startInFlight = null;
}
}
async function beginVibeSession(seed: Track): Promise<StartedVibeSession> {
const { sessionId } = await vibeService.start(seed.id);
const vibe = useVibeStore.getState();
// Do not replace a working Vibe until the new session has produced a usable
// initial batch. This also keeps the page prefetcher attached to the old
// session while this request is in flight.
const result = await fetchNextBatch(INITIAL_VIBE_BATCH_SIZE, sessionId);
if (result.tracks.length === 0) return result;
vibe.setInitialBatchStatus('loading');
vibe.setActiveSession({ sessionId, seedTrackId: seed.id });
vibe.setSeedTrackId(seed.id);
vibe.setCenterTrack(seed);
vibe.setBuffer(result.tracks);
vibe.setInitialBatchStatus('idle');
const playback = usePlaybackStore.getState();
playback.setQueue(result.tracks);
playback.playTrack(result.tracks[0]);
return result;
}
+5
View File
@@ -9,11 +9,14 @@ interface VibeState {
seedTrackId: string | null;
centerTrack: Track | null;
buffer: Track[]; // lookahead buffer of upcoming recommended tracks
/** Outcome of the first V2 batch, including sessions initiated from Discover. */
initialBatchStatus: 'idle' | 'loading' | 'exhausted' | 'failed';
setActiveSession: (session: VibeSession | null) => void;
setSeedTrackId: (seedTrackId: string | null) => void;
setCenterTrack: (track: Track | null) => void;
setBuffer: (buffer: Track[]) => void;
setInitialBatchStatus: (status: VibeState['initialBatchStatus']) => void;
appendBuffer: (tracks: Track[]) => void;
shiftBuffer: () => Track | undefined;
reset: () => void;
@@ -24,6 +27,7 @@ const initialState = {
seedTrackId: null as string | null,
centerTrack: null as Track | null,
buffer: [] as Track[],
initialBatchStatus: 'idle' as const,
};
export const useVibeStore = create<VibeState>((set, get) => ({
@@ -39,6 +43,7 @@ export const useVibeStore = create<VibeState>((set, get) => ({
setSeedTrackId: (seedTrackId) => set({ seedTrackId }),
setCenterTrack: (centerTrack) => set({ centerTrack }),
setBuffer: (buffer) => set({ buffer }),
setInitialBatchStatus: (initialBatchStatus) => set({ initialBatchStatus }),
appendBuffer: (tracks) => set((state) => ({ buffer: [...state.buffer, ...tracks] })),
shiftBuffer: () => {
+5
View File
@@ -0,0 +1,5 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => cleanup());