feat(ui): rework every page for comfort, and make a seeded Vibe play its seed
A pass over the whole app against the Ethos laws, then a focused pass on Vibe with the operator reviewing each change. Across the app: - The player bar restores the last track it played, paused at zero, so a fresh tab opens on where the listener was instead of "nothing playing". - Track titles link to their album, matching the artist links beside them. Playback stays on the artwork tile; a title that played was the surprise. - The search field is bg-bg2. Tailwind cannot alpha-modify these var() colors, so bg-surface0/70 emitted no rule at all and the input fell back to the UA's white. - Row hover is light falling off to the right, not a flat slab. - The artwork placeholder can drop its note glyph, so TrackRow no longer layers a play icon on top of one. Vibe: - A seeded Vibe plays its seed first. The seed sits in front of the durable plan without being part of it, so the first advance consumes it locally and reports no plan feedback. - The queue drops a second recording of a song it already holds — same title, different track id, which id-based dedup let through. - Up next is read from the queue rather than the plan preview, since the seed is not a plan item. - The header carries the live profile (energy, discovery, goal) and both verbs. Keep is gone: letting a track finish already reports `completed`, which the director weighs the same. - The aura is one warm diffuse blob in the page background, warm-hued only and quieter on mobile. - Compact artwork is 32px. It was h-8 w-8, which this remapped spacing scale renders as 64px inside a 44px row, and that overflow was the "stacked" look. Verified by render at 1440x900 and 390x844, no horizontal overflow at either. 26 frontend and 122 backend tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,8 @@ interface ArtworkProps {
|
||||
rounded?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
/** Skip native lazy-loading — set true for above-the-fold artwork (e.g. PlaybackBar). */
|
||||
eager?: boolean;
|
||||
/** Drop the note glyph. For callers that draw their own icon on top (TrackRow's play overlay). */
|
||||
glyph?: boolean;
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '/api';
|
||||
@@ -25,13 +27,13 @@ function proxySrc(src: string): string {
|
||||
return src;
|
||||
}
|
||||
|
||||
export function Artwork({ seed, src, className = '', rounded = 'md', eager = false }: ArtworkProps) {
|
||||
export function Artwork({ seed, src, className = '', rounded = 'md', eager = false, glyph = true }: ArtworkProps) {
|
||||
const hue = hueFromString(seed);
|
||||
// Symmetric top sheen over a diagonal base — the highlight is centered
|
||||
// horizontally so it reads as even behind the centered note glyph.
|
||||
const gradient =
|
||||
`radial-gradient(110% 90% at 50% 0%, hsl(${hue},55%,30%) 0%, transparent 60%), ` +
|
||||
`linear-gradient(160deg, hsl(${hue},48%,23%), hsl(${(hue + 55) % 360},40%,11%))`;
|
||||
`radial-gradient(110% 90% at 50% 0%, hsl(${hue},42%,26%) 0%, transparent 60%), ` +
|
||||
`linear-gradient(160deg, hsl(${hue},36%,19%), hsl(${hue - 6},30%,10%))`;
|
||||
const r = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl', full: 'rounded-full' }[rounded];
|
||||
|
||||
// If an image source is supplied we render it lazily over the gradient
|
||||
@@ -45,7 +47,7 @@ export function Artwork({ seed, src, className = '', rounded = 'md', eager = fal
|
||||
const proxied = proxySrc(src);
|
||||
return (
|
||||
<div className={`relative overflow-hidden ${r} ${className}`} style={{ background: gradient }}>
|
||||
{!loaded && <Music className="absolute inset-0 m-auto h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />}
|
||||
{!loaded && glyph && <Music className="absolute inset-0 m-auto h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />}
|
||||
<img
|
||||
src={proxied}
|
||||
alt={seed}
|
||||
@@ -60,7 +62,7 @@ export function Artwork({ seed, src, className = '', rounded = 'md', eager = fal
|
||||
}
|
||||
return (
|
||||
<div className={`relative flex items-center justify-center overflow-hidden ${r} ${className}`} style={{ background: gradient }}>
|
||||
<Music className="h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />
|
||||
{glyph && <Music className="h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
|
||||
<div className="text-center text-sm text-muted italic">No track playing</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="track-list">
|
||||
<input
|
||||
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
|
||||
value={Math.min(position, duration || 0)}
|
||||
|
||||
@@ -4,15 +4,18 @@ type ContainerWidth = 'sm' | 'md' | 'lg' | 'full';
|
||||
|
||||
interface PageContainerProps {
|
||||
children: ReactNode;
|
||||
/** Controls max-width. sm → max-w-2xl, md → max-w-3xl (default), lg → max-w-5xl, full → no constraint. */
|
||||
/** Controls max-width. sm → reading width (prose, forms), md → default, lg → widest grid, full → no constraint. */
|
||||
width?: ContainerWidth;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Measured on a 1440px viewport: the old md (768px) left ~480px of the main
|
||||
// area empty and pinned every grid to 4–5 columns. These track the content,
|
||||
// not an arbitrary prose measure — only `sm` stays narrow, for forms and prose.
|
||||
const WIDTH_CLASSES: Record<ContainerWidth, string> = {
|
||||
sm: 'max-w-2xl',
|
||||
md: 'max-w-3xl',
|
||||
lg: 'max-w-5xl',
|
||||
sm: 'max-w-3xl',
|
||||
md: 'max-w-[1240px]',
|
||||
lg: 'max-w-[1400px]',
|
||||
full: '',
|
||||
};
|
||||
|
||||
@@ -24,7 +27,7 @@ const WIDTH_CLASSES: Record<ContainerWidth, string> = {
|
||||
*/
|
||||
export function PageContainer({ children, width = 'md', className = '' }: PageContainerProps) {
|
||||
return (
|
||||
<div className={`mx-auto space-y-6 ${WIDTH_CLASSES[width]} ${className}`}>
|
||||
<div className={`mx-auto space-y-4 ${WIDTH_CLASSES[width]} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
/** Human-written line under the title (sans). Keep it short or leave it out. */
|
||||
subtitle?: string;
|
||||
/** Machine values — counts, sizes, durations. Rendered mono, per Ethos law 1. */
|
||||
meta?: string;
|
||||
/** Optional right-aligned actions (buttons, toggles, etc.). */
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consistent page heading: a gradient title with an optional accent icon chip,
|
||||
* subtitle, and right-aligned action slot. Used across the library pages so
|
||||
* every screen opens the same way.
|
||||
* The one page heading for every screen (Ethos law 3 — shared shell). Editorial
|
||||
* sans title, an optional human subtitle, and a mono `meta` line for whatever
|
||||
* the machine knows: counts, page position, queue depth.
|
||||
*
|
||||
* Deliberately has no icon chip and no gradient fill. The nav rail and the
|
||||
* breadcrumb already name the page; a glowing accent tile on every screen made
|
||||
* the accent read as decoration rather than signal.
|
||||
*/
|
||||
export function PageHeader({ icon: Icon, title, subtitle, actions }: PageHeaderProps) {
|
||||
export function PageHeader({ title, subtitle, meta, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-end justify-between gap-4 animate-rise">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
{Icon && (
|
||||
<div className="flex h-12 w-12 flex-none items-center justify-center rounded-2xl bg-accent/15 text-accent ring-1 ring-accent/25 shadow-lg shadow-accent/10">
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3 border-b border-border pb-3 animate-rise">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-2xl font-semibold tracking-tight text-text sm:text-3xl">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && <p className="mt-1 text-sm text-secondary">{subtitle}</p>}
|
||||
{meta && (
|
||||
<p className="mt-1 truncate font-mono text-xs tabular-nums text-muted">{meta}</p>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-gradient truncate text-3xl font-extrabold tracking-tight sm:text-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && <p className="mt-1 truncate text-sm text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex flex-none items-center gap-2">{actions}</div>}
|
||||
{actions && <div className="flex flex-none flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,12 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-muted italic">Nothing playing</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-12 w-12 flex-none rounded-lg border border-border bg-bg2" aria-hidden />
|
||||
{/* Reached only before anything has ever played in this browser —
|
||||
a returning tab restores its last track instead. */}
|
||||
<div className="text-sm text-muted">Pick a track to start</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -98,8 +103,8 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
{repeat === 'one' ? <Repeat1 size={18} /> : <Repeat size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
<div className="flex w-full max-w-xl items-center gap-2">
|
||||
<span className="w-10 flex-none text-right font-mono text-xs tabular-nums text-machine">{formatDuration(position)}</span>
|
||||
<input
|
||||
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
|
||||
value={Math.min(position, duration || 0)}
|
||||
@@ -108,7 +113,7 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
className="flex-1 h-1 cursor-pointer"
|
||||
aria-label="Seek"
|
||||
/>
|
||||
<span className="text-xs text-muted w-9 tabular-nums">{formatDuration(duration)}</span>
|
||||
<span className="w-10 flex-none font-mono text-xs tabular-nums text-machine">{formatDuration(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -163,7 +163,10 @@ export function TopBar({ onToggleCommandPalette, onToggleNavigation, navigationO
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search…"
|
||||
className="w-full bg-surface0/70 border border-border rounded-md pl-8 pr-8 py-1.5 text-xs text-text placeholder:text-muted outline-none focus:border-accent focus:bg-surface0 transition-all"
|
||||
/* bg-bg2, not bg-surface0/70: Tailwind cannot alpha-modify these
|
||||
var() colors, so that class emitted nothing and the field fell
|
||||
back to the UA's white — a white pill in a warm dark room. */
|
||||
className="w-full bg-bg2 border border-border rounded-md pl-8 pr-8 py-1.5 text-xs text-text placeholder:text-muted outline-none focus:border-accent focus:bg-surface1 transition-colors"
|
||||
/>
|
||||
{q ? (
|
||||
<button
|
||||
@@ -175,7 +178,7 @@ export function TopBar({ onToggleCommandPalette, onToggleNavigation, navigationO
|
||||
<X size={12} />
|
||||
</button>
|
||||
) : (
|
||||
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 hidden sm:flex items-center rounded border border-border bg-bg2 px-1 py-0.5 text-[10px] font-medium text-muted pointer-events-none">
|
||||
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 hidden sm:flex items-center rounded border border-border bg-surface1 px-1 py-0.5 text-[10px] font-medium text-muted pointer-events-none">
|
||||
/
|
||||
</kbd>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Play, Pause, ThumbsDown, Disc3, Sparkles } from 'lucide-react';
|
||||
import { Play, Pause, ThumbsDown, Sparkles } from 'lucide-react';
|
||||
import { Link, useRouter } from '@tanstack/react-router';
|
||||
import type { Track } from '../types';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
@@ -74,12 +74,19 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex w-full items-center gap-3 rounded-lg border transition-colors ${
|
||||
compact ? 'p-2' : 'p-2.5'
|
||||
// With the title linking to its album, a seed row would otherwise only be
|
||||
// selectable by its 36px artwork tile. The whole row takes the click.
|
||||
onClick={onSelect ? handlePlay : undefined}
|
||||
role={onSelect ? 'button' : undefined}
|
||||
className={`group relative flex w-full items-center gap-2.5 rounded-md px-1.5 transition-colors ${onSelect ? 'cursor-pointer' : ''} ${
|
||||
compact ? 'h-11' : 'h-[52px]'
|
||||
} ${
|
||||
isCurrent
|
||||
? 'border-accent/60 bg-accent/10'
|
||||
: 'border-border/70 bg-surface0/50 hover:border-accent/30 hover:bg-surface1'
|
||||
? "bg-accent/10 before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-0.5 before:rounded-full before:bg-accent before:content-['']"
|
||||
// `.track-row` carries the hover light (see index.css) — a gradient that
|
||||
// falls off to the right instead of a flat slab. Display-only rows still
|
||||
// light up; what they skip is the play-icon ramp below.
|
||||
: 'track-row'
|
||||
}`}
|
||||
>
|
||||
{/* Artwork + play overlay */}
|
||||
@@ -89,27 +96,42 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
disabled={!playable}
|
||||
aria-label={playLabel}
|
||||
className={`relative flex flex-none items-center justify-center rounded overflow-hidden focus-visible:z-30 disabled:cursor-default disabled:opacity-70 ${
|
||||
compact ? 'h-9 w-9' : 'h-10 w-10'
|
||||
// 32px, written as an arbitrary value on purpose: the spacing scale is
|
||||
// remapped, so `h-8` is 64px and overflowed this 44px row — that overflow
|
||||
// was the "stacked" look, not a design choice.
|
||||
compact ? 'h-[32px] w-[32px]' : 'h-9 w-9'
|
||||
}`}>
|
||||
<Artwork seed={`${track.title} ${track.artist}`} src={track.artwork_id} className="absolute inset-0 w-full h-full" />
|
||||
{/* glyph={false}: the play/pause icon below is the only glyph this tile gets. */}
|
||||
<Artwork seed={`${track.title} ${track.artist}`} src={track.artwork_id} glyph={false} 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-70 transition-opacity group-hover:opacity-100" />
|
||||
<Play
|
||||
size={compact ? 14 : 18}
|
||||
className={`absolute z-20 text-text opacity-70 transition-opacity ${playable ? 'group-hover:opacity-100' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Title + artist */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlay}
|
||||
disabled={!playable}
|
||||
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'}
|
||||
</button>
|
||||
{/* The title navigates to the album, matching the artist links beside it.
|
||||
Playback lives on the artwork tile; a title that played was the surprise. */}
|
||||
{track.album_id ? (
|
||||
<Link
|
||||
to="/albums/$albumId"
|
||||
params={{ albumId: track.album_id }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={track.title || 'Untitled'}
|
||||
className={`block max-w-full truncate rounded text-left text-sm font-medium hover:underline ${isCurrent ? 'text-accent' : 'text-text'}`}
|
||||
>
|
||||
{track.title || 'Untitled'}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={`block max-w-full truncate text-sm font-medium ${isCurrent ? 'text-accent' : 'text-text'}`}>
|
||||
{track.title || 'Untitled'}
|
||||
</span>
|
||||
)}
|
||||
<ArtistLinks
|
||||
artists={track.artists}
|
||||
fallback={track.artist}
|
||||
@@ -126,18 +148,7 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
)}
|
||||
{track.album_id && (
|
||||
<Link
|
||||
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>
|
||||
)}
|
||||
{/* The album disc button is gone — the title itself is the album link now. */}
|
||||
<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>
|
||||
@@ -146,7 +157,7 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
|
||||
{/* Duration (hidden in compact) */}
|
||||
{!compact && (
|
||||
<div className="flex-none text-xs tabular-nums text-muted">{formatDuration(track.duration)}</div>
|
||||
<div className="flex-none pr-1 font-mono text-xs tabular-nums text-machine">{formatDuration(track.duration)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,9 @@ export function VibeAura({ profile, ambient = false }: { profile: VibeProfile; a
|
||||
const energy = clamp(profile.energy, 0.5);
|
||||
const novelty = clamp(profile.noveltyHunger ?? profile.explorationCoefficient, 0.3);
|
||||
const { energyLabel, discoveryLabel } = describeProfile(energy, novelty);
|
||||
const hue = Math.round(205 + novelty * 115 - energy * 35);
|
||||
// Warm range only (amber → gold → orange). The old 205 base put a teal-blue
|
||||
// glow in a warm brown room, which read as a different app's accent.
|
||||
const hue = Math.round(28 + novelty * 30 - energy * 12);
|
||||
const style = {
|
||||
'--vibe-hue': String(hue),
|
||||
'--vibe-pulse': `${(4.8 - energy * 2.3).toFixed(2)}s`,
|
||||
@@ -53,17 +55,11 @@ export function VibeAura({ profile, ambient = false }: { profile: VibeProfile; a
|
||||
if (ambient) {
|
||||
return (
|
||||
<div
|
||||
className="group absolute left-1/2 top-[58%] z-20 h-16 w-16 -translate-x-1/2 -translate-y-1/2 cursor-help rounded-full focus:outline-none"
|
||||
className="vibe-aura-blob pointer-events-none absolute left-1/2 top-[28%] z-0 h-[190px] w-[230px] -translate-x-1/2 -translate-y-1/2 opacity-40 mix-blend-screen sm:h-[400px] sm:w-[480px] sm:opacity-70"
|
||||
style={style}
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
aria-label={`Current Vibe: ${energyLabel} energy and ${discoveryLabel} discovery`}
|
||||
title={`Current Vibe: ${energyLabel} energy, ${discoveryLabel} discovery`}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 scale-[21.6] opacity-50 mix-blend-screen">
|
||||
{aura}
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@ import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
RouterProvider,
|
||||
createMemoryHistory,
|
||||
createRootRoute,
|
||||
createRouter,
|
||||
} from '@tanstack/react-router';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Track } from '../types';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { VibeTimeline } from './VibeTimeline';
|
||||
@@ -11,6 +18,17 @@ const track = (id: string): Track => ({
|
||||
duration: 180, state: 'LIBRARY', source_type: 'MANUAL', play_count: 0, skip_count: 0, dislike_count: 0,
|
||||
});
|
||||
|
||||
/** Track titles link to their album, so rows need a router in scope. */
|
||||
function renderInRouter(ui: ReactNode) {
|
||||
const rootRoute = createRootRoute({ component: () => ui });
|
||||
const router = createRouter({ routeTree: rootRoute, history: createMemoryHistory() });
|
||||
return render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<RouterProvider router={router as never} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('VibeTimeline', () => {
|
||||
beforeEach(() => {
|
||||
const current = track('current');
|
||||
@@ -22,16 +40,11 @@ describe('VibeTimeline', () => {
|
||||
|
||||
it('renders upcoming plan entries as display-only so they cannot hand queue ownership to ordinary playback', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<VibeTimeline currentTrack={track('current')} upcoming={[track('upcoming')]} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
renderInRouter(<VibeTimeline currentTrack={track('current')} upcoming={[track('upcoming')]} />);
|
||||
|
||||
const queued = screen.getAllByRole('button', { name: 'upcoming is queued by Vibe' });
|
||||
expect(queued).toHaveLength(2);
|
||||
const queued = await screen.findAllByRole('button', { name: 'upcoming is queued by Vibe' });
|
||||
expect(queued).toHaveLength(1);
|
||||
expect(queued[0]).toBeDisabled();
|
||||
expect(queued[1]).toBeDisabled();
|
||||
await user.click(queued[0]);
|
||||
|
||||
expect(usePlaybackStore.getState()).toMatchObject({
|
||||
|
||||
@@ -11,10 +11,10 @@ interface VibeTimelineProps {
|
||||
export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-text">
|
||||
<Radio size={18} className="text-accent" />
|
||||
<h2 className="text-lg font-semibold">Incoming recommendations</h2>
|
||||
<span className="text-xs text-muted">({upcoming.length} buffered)</span>
|
||||
<div className="flex items-center gap-2 border-b border-border pb-2">
|
||||
<Radio size={14} className="text-accent" />
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-muted">Up next</h2>
|
||||
<span className="ml-auto font-mono text-xs tabular-nums text-machine">{upcoming.length} buffered</span>
|
||||
</div>
|
||||
|
||||
{currentTrack && (
|
||||
@@ -26,7 +26,9 @@ export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
|
||||
showActions={false}
|
||||
variant="compact"
|
||||
/>
|
||||
<Badge color="accent" className="absolute right-2 top-1/2 -translate-y-1/2">Now playing</Badge>
|
||||
{/* Hidden under 640px: the badge sat on top of the title. The accent
|
||||
title and the accent edge already mark the current row. */}
|
||||
<Badge color="accent" className="absolute right-2 top-1/2 hidden -translate-y-1/2 sm:block">Now playing</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -35,7 +37,7 @@ export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
|
||||
No upcoming tracks buffered yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
<div className="track-list">
|
||||
{upcoming.map((track, i) => (
|
||||
<TrackRow
|
||||
key={`${track.id}-${i}`}
|
||||
|
||||
@@ -11,13 +11,13 @@ export function Skeleton({ className = '' }: { className?: string }) {
|
||||
/** Rows matching TrackRow height */
|
||||
export function SkeletonRows({ count = 5 }: { count?: number }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-md px-3 py-2">
|
||||
<Skeleton className="h-8 w-8 flex-none rounded" />
|
||||
<div key={i} className="flex h-[52px] items-center gap-2.5 rounded-md px-1.5">
|
||||
<Skeleton className="h-9 w-9 flex-none rounded" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
<Skeleton className="h-2.5 w-1/4" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
<Skeleton className="h-2.5 w-1/6" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-8 flex-none" />
|
||||
</div>
|
||||
@@ -29,7 +29,7 @@ export function SkeletonRows({ count = 5 }: { count?: number }) {
|
||||
/** Grid matching album/artist card layout */
|
||||
export function SkeletonGrid({ count = 10 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col gap-2 rounded-md border border-border bg-surface0 p-2">
|
||||
<Skeleton className="aspect-square rounded" />
|
||||
|
||||
+42
-1
@@ -68,6 +68,9 @@
|
||||
--ethos-secondary: #B4AA98;
|
||||
--ethos-muted: #756C5C;
|
||||
--ethos-disabled: #5a5347;
|
||||
/* Mono default (Ethos law 1) — machine values sit a step above `muted` so a
|
||||
count or a duration stays legible without competing with the human label. */
|
||||
--ethos-machine: #9C917D;
|
||||
|
||||
/* muzick fingerprint: honey amber */
|
||||
--ethos-accent: #EDA24E;
|
||||
@@ -241,8 +244,27 @@ html { scroll-behavior: smooth; }
|
||||
.vibe-aura-spark { width: 4px; height: 4px; animation: vibe-aura-spark 2.1s ease-in-out infinite; }
|
||||
.vibe-aura-spark-one { left: -4px; top: 5px; }
|
||||
.vibe-aura-spark-two { right: -3px; bottom: 7px; animation-delay: -1s; }
|
||||
/* Ambient variant: one soft organic blob of warm light behind the page. The
|
||||
scaled-up creature read as concentric rings at that size, which fought the
|
||||
content instead of sitting behind it. Gradient goes on the sized element —
|
||||
no inset-0 fill layer. */
|
||||
@keyframes vibe-aura-drift {
|
||||
from { transform: scale(var(--vibe-scale)) translate3d(-2%, -1%, 0) rotate(-4deg); }
|
||||
to { transform: scale(calc(var(--vibe-scale) * 1.12)) translate3d(3%, 2%, 0) rotate(5deg); }
|
||||
}
|
||||
.vibe-aura-blob {
|
||||
border-radius: 46% 54% 62% 38% / 55% 43% 57% 45%;
|
||||
background:
|
||||
radial-gradient(closest-side at 40% 36%, hsl(var(--vibe-hue) 92% 60% / .55), transparent 72%),
|
||||
radial-gradient(closest-side at 66% 64%, hsl(calc(var(--vibe-hue) + 28) 86% 52% / .4), transparent 74%);
|
||||
filter: blur(44px);
|
||||
animation: vibe-aura-drift var(--vibe-orbit) ease-in-out infinite alternate;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.vibe-aura-blob { filter: blur(72px); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vibe-aura-halo, .vibe-aura-core, .vibe-aura-orbit, .vibe-aura-spark { animation: none; }
|
||||
.vibe-aura-halo, .vibe-aura-core, .vibe-aura-orbit, .vibe-aura-spark, .vibe-aura-blob { animation: none; }
|
||||
}
|
||||
|
||||
/* ── Component base classes ────────────────────────────────────────────────── */
|
||||
@@ -263,6 +285,25 @@ html { scroll-behavior: smooth; }
|
||||
border-color: color-mix(in srgb, var(--ethos-accent) 40%, transparent);
|
||||
}
|
||||
|
||||
/* Track list — one dense, hairline-separated column everywhere a list of tracks
|
||||
appears. Replaces the per-page `space-y-1` around bordered row cards, which
|
||||
cost 90px per track and fit only 7 rows on a 900px screen. */
|
||||
.track-list > * + * {
|
||||
border-top: 1px solid color-mix(in srgb, var(--ethos-border) 60%, transparent);
|
||||
}
|
||||
|
||||
/* Row hover: light falling off to the right, not a filled slab. A flat
|
||||
770px-wide rectangle with a hard right edge is the ugly version, and it also
|
||||
squares off the Vibe timeline's stacked artwork. */
|
||||
.track-row:hover {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--ethos-surface1) 85%, transparent) 0%,
|
||||
color-mix(in srgb, var(--ethos-surface1) 40%, transparent) 38%,
|
||||
transparent 78%
|
||||
);
|
||||
}
|
||||
|
||||
/* Artwork frame */
|
||||
.artwork-frame {
|
||||
aspect-ratio: 1 / 1;
|
||||
|
||||
@@ -6,11 +6,16 @@
|
||||
* as the single source of truth.
|
||||
*/
|
||||
|
||||
/** Deterministic hash → hue (0..359) from an arbitrary string. */
|
||||
/**
|
||||
* Deterministic hash → hue from an arbitrary string, clamped to the warm band
|
||||
* (amber → rust → deep red, 8°..52°). Free-running 0..359 hues produced blue,
|
||||
* teal and violet placeholder tiles that fight the warm room Ethos asks for;
|
||||
* a 44° window keeps tiles distinguishable without leaving the palette.
|
||||
*/
|
||||
export function hueFromString(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
return Math.abs(h) % 360;
|
||||
return 8 + (Math.abs(h) % 45);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
export const PLAYBACK_PREF_KEYS = {
|
||||
prefetchNext: 'muzick.settings.prefetchNext',
|
||||
crossfadeMs: 'muzick.settings.crossfadeMs',
|
||||
lastTrack: 'muzick.playback.lastTrack',
|
||||
} as const;
|
||||
|
||||
/** Longest fade the UI offers. Also the longest early-advance lead. */
|
||||
@@ -53,3 +54,23 @@ export function storePrefetchNext(value: boolean): void {
|
||||
export function storeCrossfadeMs(value: number): void {
|
||||
try { localStorage.setItem(PLAYBACK_PREF_KEYS.crossfadeMs, String(value)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* The last track loaded into the player, so a fresh tab shows what was playing
|
||||
* rather than an empty bar. Restored paused — never autoplayed.
|
||||
* ponytail: stores the whole track object. It is one small row and it saves a
|
||||
* fetch on boot; if the shape drifts, the parse simply fails and the bar is empty.
|
||||
*/
|
||||
export function readStoredLastTrack<T>(): T | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(PLAYBACK_PREF_KEYS.lastTrack);
|
||||
return stored ? (JSON.parse(stored) as T) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function storeLastTrack(track: unknown): void {
|
||||
try {
|
||||
if (track) localStorage.setItem(PLAYBACK_PREF_KEYS.lastTrack, JSON.stringify(track));
|
||||
else localStorage.removeItem(PLAYBACK_PREF_KEYS.lastTrack);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -46,14 +46,14 @@ export default function AlbumDetail() {
|
||||
return rank(x) - rank(y);
|
||||
});
|
||||
return (
|
||||
<PageContainer className="space-y-8">
|
||||
<PageContainer className="space-y-6">
|
||||
<BackLink to="/albums" label="Albums" />
|
||||
<div className="flex items-end gap-5">
|
||||
<div className="w-40 h-40 flex-none rounded-xl overflow-hidden">
|
||||
<Artwork seed={data.title} src={data.artwork_id} className="w-full h-full" rounded="xl" eager />
|
||||
<div className="flex items-end gap-4 border-b border-border pb-4">
|
||||
<div className="h-28 w-28 flex-none overflow-hidden rounded-lg sm:h-32 sm:w-32">
|
||||
<Artwork seed={data.title} src={data.artwork_id} className="w-full h-full" rounded="lg" eager />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-bold text-text">{data.title}</h1>
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<h1 className="truncate text-2xl font-semibold tracking-tight text-text sm:text-3xl">{data.title}</h1>
|
||||
{artists.length > 0 && (
|
||||
<ArtistLinks
|
||||
artists={artists}
|
||||
@@ -61,7 +61,7 @@ export default function AlbumDetail() {
|
||||
className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-sm"
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm text-muted">{data.year ? `${data.year} · ` : ''}{tracks.length} {tracks.length === 1 ? 'track' : 'tracks'}</p>
|
||||
<p className="font-mono text-xs tabular-nums text-machine">{data.year ? `${data.year} · ` : ''}{tracks.length} tracks</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Play size={16} fill="currentColor" />}
|
||||
@@ -72,7 +72,7 @@ export default function AlbumDetail() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<section className="space-y-1">
|
||||
<section className="track-list">
|
||||
{tracks.length === 0 ? <EmptyState compact icon={<Music size={28} />} title="No tracks in this album" />
|
||||
: tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}
|
||||
</section>
|
||||
|
||||
@@ -25,23 +25,27 @@ export default function Albums() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader icon={Disc3} title="Albums" subtitle={albums.length ? `${albums.length} on this page` : undefined} />
|
||||
<PageHeader
|
||||
title="Albums"
|
||||
meta={albums.length ? `${albums.length} shown · page ${page + 1}` : undefined}
|
||||
/>
|
||||
{isLoading ? <SkeletonGrid count={10} />
|
||||
: isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load albums" subtitle="Something went wrong. Try reloading the page." />
|
||||
: !albums?.length ? <EmptyState compact icon={<Disc3 size={28} />} title="No albums yet" subtitle="Run a library scan in Settings to populate it." />
|
||||
: (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{albums.map((album) => (
|
||||
<Link key={album.id} to="/albums/$albumId" params={{ albumId: album.id }}
|
||||
className="card-surface group">
|
||||
<div className="artwork-frame">
|
||||
<Artwork seed={album.title} src={album.artwork_id} className="w-full h-full transition-transform duration-500 group-hover:scale-105" rounded="xl" />
|
||||
<Artwork seed={album.title} src={album.artwork_id} className="w-full h-full transition-transform duration-500 group-hover:scale-105" rounded="lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="truncate text-sm font-semibold text-text">{album.title}</div>
|
||||
<div className="truncate text-xs text-muted mt-0.5">
|
||||
{album.artist_name || 'Unknown artist'}{album.year ? ` · ${album.year}` : ''}
|
||||
<div className="truncate text-sm font-medium text-text" title={album.title}>{album.title}</div>
|
||||
<div className="mt-0.5 flex items-baseline gap-1.5 text-xs">
|
||||
<span className="truncate text-secondary">{album.artist_name || 'Unknown artist'}</span>
|
||||
{album.year && <span className="flex-none font-mono tabular-nums text-machine">{album.year}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -23,28 +23,22 @@ export default function ArtistDetail() {
|
||||
<PageContainer width="lg" className="space-y-8">
|
||||
<BackLink to="/artists" label="Artists" />
|
||||
|
||||
{/* Hero: blurred backdrop of the artist image + avatar + name */}
|
||||
<div className="relative overflow-hidden rounded-3xl border border-border/70 animate-rise">
|
||||
<div className="absolute inset-0">
|
||||
<Artwork seed={data.name} src={data.image_path} className="h-full w-full scale-110 blur-2xl opacity-40" eager />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/70 to-background/30" />
|
||||
{/* ponytail: flat hero — the blurred-backdrop version was the frosted-glass trap */}
|
||||
<div className="animate-rise flex items-end gap-4 border-b border-border pb-4">
|
||||
<div className="h-24 w-24 flex-none overflow-hidden rounded-full sm:h-28 sm:w-28">
|
||||
<Artwork seed={data.name} src={data.image_path} className="h-full w-full" rounded="full" eager />
|
||||
</div>
|
||||
<div className="relative flex items-end gap-5 p-6 sm:p-8">
|
||||
<div className="h-28 w-28 sm:h-36 sm:w-36 flex-none rounded-full overflow-hidden ring-4 ring-background shadow-2xl shadow-black/50">
|
||||
<Artwork seed={data.name} src={data.image_path} className="h-full w-full" rounded="full" eager />
|
||||
</div>
|
||||
<div className="min-w-0 pb-1">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-muted">Artist</div>
|
||||
<h1 className="text-gradient text-4xl sm:text-5xl font-extrabold tracking-tight truncate">{data.name}</h1>
|
||||
<p className="mt-1.5 text-sm text-muted">{albums.length} {albums.length === 1 ? 'album' : 'albums'}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted">Artist</div>
|
||||
<h1 className="truncate text-2xl font-semibold tracking-tight text-text sm:text-3xl">{data.name}</h1>
|
||||
<p className="mt-1 font-mono text-xs tabular-nums text-machine">{albums.length} albums</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-text">Albums</h2>
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-muted">Albums</h2>
|
||||
{albums.length === 0 ? <EmptyState compact icon={<Disc3 size={28} />} title="No albums by this artist" /> : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
|
||||
{albums.map((album) => (
|
||||
<Link key={album.id} to="/albums/$albumId" params={{ albumId: album.id }}
|
||||
className="card-surface group">
|
||||
|
||||
@@ -25,20 +25,23 @@ export default function Artists() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader icon={Users} title="Artists" subtitle="Browse by artist" />
|
||||
<PageHeader
|
||||
title="Artists"
|
||||
meta={artists.length ? `${artists.length} shown · page ${page + 1}` : undefined}
|
||||
/>
|
||||
{isLoading ? <SkeletonGrid count={10} />
|
||||
: isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load artists" subtitle="Something went wrong. Try reloading the page." />
|
||||
: !artists?.length ? <EmptyState compact icon={<Users size={28} />} title="No artists yet" subtitle="Run a library scan in Settings to populate it." />
|
||||
: (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
|
||||
{artists.map((artist) => (
|
||||
<Link key={artist.id} to="/artists/$artistId" params={{ artistId: artist.id }}
|
||||
className="card-surface group items-center p-4">
|
||||
<div className="w-24 h-24 rounded-full overflow-hidden ring-2 ring-transparent group-hover:ring-accent/40 shadow-lg shadow-black/30 transition-all">
|
||||
className="card-surface group items-center p-2">
|
||||
<div className="aspect-square w-full overflow-hidden rounded-full ring-1 ring-border transition-all group-hover:ring-accent/40">
|
||||
<Artwork seed={artist.name} src={artist.image_path} className="w-full h-full transition-transform duration-500 group-hover:scale-105" rounded="full" />
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-text truncate w-full text-center group-hover:text-accent transition-colors">{artist.name}</div>
|
||||
<div className="w-full truncate text-center text-xs font-medium text-text transition-colors group-hover:text-accent" title={artist.name}>{artist.name}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Disc3, Sparkles } from 'lucide-react';
|
||||
import { Disc3, Sparkles, AlertCircle } from 'lucide-react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { genreService } from '../services/genreService';
|
||||
import { startVibeSession } from '../services/vibeSession';
|
||||
import { TrackRow } from '../components/TrackRow';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { SkeletonGrid, SkeletonRows } from '../components/LoadingState';
|
||||
import type { Genre, Track } from '../types';
|
||||
|
||||
export default function Discover() {
|
||||
@@ -43,35 +46,39 @@ export default function Discover() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-3xl font-bold text-text">Discover</h1>
|
||||
<p className="text-muted">Browse by genre, then play tracks or start a vibe.</p>
|
||||
</header>
|
||||
<PageHeader
|
||||
title="Discover"
|
||||
subtitle="Pick a genre, then play it or seed a vibe from it."
|
||||
meta={genres.data?.length ? `${genres.data.length} genres` : undefined}
|
||||
/>
|
||||
|
||||
{genres.isLoading ? (
|
||||
<p className="text-sm text-muted">Loading genres…</p>
|
||||
<SkeletonGrid count={12} />
|
||||
) : genres.isError ? (
|
||||
<p className="text-sm text-muted">Couldn't load genres.</p>
|
||||
<EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load genres" subtitle="Something went wrong. Try reloading the page." />
|
||||
) : (genres.data ?? []).length === 0 ? (
|
||||
<p className="text-sm text-muted">No genres available yet.</p>
|
||||
<EmptyState compact icon={<Disc3 size={28} />} title="No genres yet" subtitle="Genres appear after you scan and enrich your library." />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{(genres.data ?? []).map((genre) => {
|
||||
const active = selected?.id === genre.id;
|
||||
return (
|
||||
<button
|
||||
key={genre.id}
|
||||
onClick={() => setSelected(genre)}
|
||||
className={`flex flex-col gap-2 rounded-xl border p-4 text-left transition-colors ${
|
||||
className={`flex h-16 flex-col justify-center gap-0.5 rounded-lg border px-3 text-left transition-colors ${
|
||||
active
|
||||
? 'border-accent/60 bg-accent/10'
|
||||
: 'border-border bg-surface0 hover:bg-surface1'
|
||||
: 'border-border bg-surface0/60 hover:bg-surface1'
|
||||
}`}
|
||||
>
|
||||
<Disc3 size={22} className={active ? 'text-accent' : 'text-muted'} />
|
||||
<div className="truncate font-semibold text-text">{genre.name}</div>
|
||||
<div className={`truncate text-sm font-medium ${active ? 'text-accent' : 'text-text'}`} title={genre.name}>
|
||||
{genre.name}
|
||||
</div>
|
||||
{typeof genre.track_count === 'number' && (
|
||||
<div className="text-xs text-muted">{genre.track_count} tracks</div>
|
||||
<div className="font-mono text-xs tabular-nums text-machine">
|
||||
{genre.track_count.toLocaleString()} tracks
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@@ -81,8 +88,13 @@ export default function Discover() {
|
||||
|
||||
{selected && (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-text">{selected.name}</h2>
|
||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
||||
<h2 className="flex items-baseline gap-2 text-lg font-medium text-text">
|
||||
{selected.name}
|
||||
<span className="font-mono text-xs tabular-nums text-machine">
|
||||
{(genreTracks.data?.length ?? 0).toLocaleString()} tracks
|
||||
</span>
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => void startGenreVibe()}
|
||||
disabled={startingVibe || !genreTracks.data || genreTracks.data.length === 0}
|
||||
@@ -100,13 +112,13 @@ export default function Discover() {
|
||||
)}
|
||||
|
||||
{genreTracks.isLoading ? (
|
||||
<p className="text-sm text-muted">Loading tracks…</p>
|
||||
<SkeletonRows count={6} />
|
||||
) : genreTracks.isError ? (
|
||||
<p className="text-sm text-muted">Couldn't load tracks for this genre.</p>
|
||||
<EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load tracks" subtitle="Something went wrong. Try reloading the page." />
|
||||
) : (genreTracks.data ?? []).length === 0 ? (
|
||||
<p className="text-sm text-muted">No tracks found for this genre.</p>
|
||||
<EmptyState compact icon={<Disc3 size={28} />} title="No tracks in this genre" />
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="track-list">
|
||||
{(genreTracks.data ?? []).map((track, i) => (
|
||||
<TrackRow
|
||||
key={`${track.id}-${i}`}
|
||||
|
||||
@@ -7,14 +7,46 @@ import { TrackRow } from '../components/TrackRow';
|
||||
import { BackLink } from '../components/BackLink';
|
||||
import { Button } from '../components/ethos/Button';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { Pagination } from '../components/Pagination';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { SkeletonRows, SkeletonGrid } from '../components/LoadingState';
|
||||
import { hueFromString } from '../lib/color';
|
||||
import type { Genre, Track } from '../types';
|
||||
|
||||
const GENRE_TRACKS_PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* One genre entry. Flat surface, hairline separation, mono count — the previous
|
||||
* version painted every card with a hue derived from its name, which turned the
|
||||
* page into 40 unrelated colour fields and read as decoration, not information.
|
||||
*/
|
||||
function GenreButton({
|
||||
genre,
|
||||
onSelect,
|
||||
primary = false,
|
||||
}: {
|
||||
genre: Genre;
|
||||
onSelect: (g: Genre) => void;
|
||||
primary?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(genre)}
|
||||
className={`group flex w-full items-center gap-2.5 bg-bg1 px-3 text-left transition-colors hover:bg-surface0 ${
|
||||
primary ? 'h-12 rounded-t-lg' : 'h-11'
|
||||
}`}
|
||||
>
|
||||
<Tag size={primary ? 16 : 14} className="flex-none text-muted transition-colors group-hover:text-accent" />
|
||||
<span className={`min-w-0 flex-1 truncate ${primary ? 'text-sm font-medium text-text' : 'text-xs text-secondary group-hover:text-text'}`}>
|
||||
{genre.name}
|
||||
</span>
|
||||
<span className="flex-none font-mono text-xs tabular-nums text-machine">
|
||||
{(genre.track_count ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Genres() {
|
||||
const [selected, setSelected] = useState<Genre | null>(null);
|
||||
const [genrePage, setGenrePage] = useState(0);
|
||||
@@ -36,22 +68,25 @@ export default function Genres() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<BackLink to="/genres" label="Genres" />
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="flex items-center gap-3 text-3xl font-bold text-text"><Tag size={28} className="text-accent" />{selected.name}</h1>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Play size={16} fill="currentColor" />}
|
||||
onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }}
|
||||
disabled={!tracks.length}
|
||||
>
|
||||
Play all
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader
|
||||
title={selected.name}
|
||||
meta={`${(selected.track_count ?? tracks.length).toLocaleString()} tracks`}
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Play size={16} fill="currentColor" />}
|
||||
onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }}
|
||||
disabled={!tracks.length}
|
||||
>
|
||||
Play all
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{tracksQ.isLoading ? <SkeletonRows count={8} />
|
||||
: tracks.length === 0 ? <EmptyState compact icon={<Music size={28} />} title="No tracks in this genre" />
|
||||
: (
|
||||
<>
|
||||
<div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>
|
||||
<div className="track-list">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>
|
||||
<Pagination
|
||||
page={genrePage}
|
||||
hasNext={hasNext}
|
||||
@@ -68,60 +103,44 @@ export default function Genres() {
|
||||
const genres = genresQ.data ?? [];
|
||||
const roots = genres.filter((g) => !g.parent_id);
|
||||
const childrenOf = (id: string) => genres.filter((g) => g.parent_id === id);
|
||||
const orphans = genres.filter((g) => g.parent_id && !genres.some((p) => p.id === g.parent_id));
|
||||
const flat = [...roots.filter((g) => childrenOf(g.id).length === 0), ...orphans];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<h1 className="flex items-center gap-3 text-3xl font-bold text-text"><Tag size={28} className="text-accent" />Genres</h1>
|
||||
<PageHeader
|
||||
title="Genres"
|
||||
meta={genres.length ? `${genres.length} genres · ${roots.length} top level` : undefined}
|
||||
/>
|
||||
{genresQ.isLoading ? <SkeletonGrid count={10} />
|
||||
: genresQ.isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load genres" subtitle="Something went wrong. Try reloading the page." />
|
||||
: !genres.length ? <EmptyState compact icon={<Tag size={28} />} title="No genres yet" subtitle="Genres appear after you scan and enrich your library." />
|
||||
: (
|
||||
<div className="space-y-6">
|
||||
{roots.map((genre) => {
|
||||
const hue = hueFromString(genre.name);
|
||||
<div className="space-y-4">
|
||||
{roots.filter((g) => childrenOf(g.id).length > 0).map((genre) => {
|
||||
const subs = childrenOf(genre.id);
|
||||
return (
|
||||
<div key={genre.id} className="space-y-2">
|
||||
<button onClick={() => setSelected(genre)}
|
||||
className="group flex items-center gap-3 rounded-xl border border-border/70 p-4 w-full text-left transition-colors hover:border-accent/40"
|
||||
style={{ background: `linear-gradient(135deg, hsl(${hue},40%,15%), hsl(${(hue+60)%360},30%,10%))` }}>
|
||||
<Tag size={20} className="text-muted group-hover:text-accent flex-none" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-text">{genre.name}</div>
|
||||
<div className="text-xs text-muted">{genre.track_count ?? 0} tracks</div>
|
||||
</div>
|
||||
</button>
|
||||
<section key={genre.id} className="rounded-lg border border-border bg-surface0/40">
|
||||
<GenreButton genre={genre} onSelect={setSelected} primary />
|
||||
{subs.length > 0 && (
|
||||
<div className="ml-6 grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
|
||||
{subs.map((sub) => {
|
||||
const subHue = hueFromString(sub.name);
|
||||
return (
|
||||
<button key={sub.id} onClick={() => setSelected(sub)}
|
||||
className="group flex flex-col items-start gap-1 rounded-lg border border-border/70 p-3 text-left transition-colors hover:border-accent/40"
|
||||
style={{ background: `linear-gradient(135deg, hsl(${subHue},30%,12%), hsl(${(subHue+60)%360},25%,8%))` }}>
|
||||
<div className="w-full truncate text-sm font-medium text-text">{sub.name}</div>
|
||||
<div className="text-xs text-muted">{sub.track_count ?? 0} tracks</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="grid grid-cols-2 gap-px border-t border-border bg-border/40 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{subs.map((sub) => (
|
||||
<GenreButton key={sub.id} genre={sub} onSelect={setSelected} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Orphan genres (parent_id set but parent not in list) fall back to flat display */}
|
||||
{genres.filter((g) => g.parent_id && !genres.find((p) => p.id === g.parent_id)).map((genre) => {
|
||||
const hue = hueFromString(genre.name);
|
||||
return (
|
||||
<button key={genre.id} onClick={() => setSelected(genre)}
|
||||
className="group flex flex-col items-start gap-2 rounded-xl border border-border/70 p-4 text-left transition-colors hover:border-accent/40"
|
||||
style={{ background: `linear-gradient(135deg, hsl(${hue},40%,15%), hsl(${(hue+60)%360},30%,10%))` }}>
|
||||
<Tag size={20} className="text-muted group-hover:text-accent" />
|
||||
<div className="w-full truncate font-medium text-text">{genre.name}</div>
|
||||
<div className="text-xs text-muted">{genre.track_count ?? 0} tracks</div>
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{/* Childless roots + orphans (parent missing from the list) share one grid —
|
||||
a section per genre wastes a full row on a single tag. */}
|
||||
{flat.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border/40 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{flat.map((genre) => (
|
||||
<GenreButton key={genre.id} genre={genre} onSelect={setSelected} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
|
||||
+32
-31
@@ -1,20 +1,22 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Zap, Music, Disc3, Users } from 'lucide-react';
|
||||
import { Zap } from 'lucide-react';
|
||||
import { ShelfRow } from '../components/ShelfRow';
|
||||
import { MediaCard } from '../components/MediaCard';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import { Skeleton } from '../components/LoadingState';
|
||||
import { historyService } from '../services/historyService';
|
||||
import { trackService } from '../services/trackService';
|
||||
import { fetchLibraryStats, type LibraryStats } from '../services/libraryService';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import type { HistoryEntry, Track } from '../types';
|
||||
|
||||
const SHORTCUTS = [
|
||||
{ label: 'Songs', icon: Music, to: '/tracks' as const },
|
||||
{ label: 'Albums', icon: Disc3, to: '/albums' as const },
|
||||
{ label: 'Artists', icon: Users, to: '/artists' as const },
|
||||
];
|
||||
/** `128h 04m` — total library playtime, sized to read at a glance. */
|
||||
function formatTotalTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours.toLocaleString()}h ${minutes.toString().padStart(2, '0')}m`;
|
||||
}
|
||||
|
||||
/** A row of placeholder cards matching MediaCard's shelf width. */
|
||||
function ShelfSkeleton({ count = 6 }: { count?: number }) {
|
||||
@@ -44,6 +46,17 @@ export default function Home() {
|
||||
queryFn: () => trackService.listTracks({ limit: 12, sort_by: 'play_count', order: 'DESC' }),
|
||||
});
|
||||
|
||||
const stats = useQuery<LibraryStats>({
|
||||
queryKey: ['library-stats'],
|
||||
queryFn: fetchLibraryStats,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const counts = stats.data
|
||||
? `${stats.data.tracks.toLocaleString()} tracks · ${stats.data.albums.toLocaleString()} albums · ` +
|
||||
`${stats.data.artists.toLocaleString()} artists · ${formatTotalTime(stats.data.duration)}`
|
||||
: null;
|
||||
|
||||
const playFrom = (list: Track[], index: number) => {
|
||||
setQueue(list.slice(index));
|
||||
playTrack(list[index]);
|
||||
@@ -53,35 +66,23 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<PageContainer width="lg" className="space-y-8">
|
||||
<div className="animate-rise">
|
||||
<h1 className="text-gradient text-4xl font-extrabold tracking-tight">Good listening</h1>
|
||||
<p className="text-muted mt-1.5">Your music, your way.</p>
|
||||
</div>
|
||||
|
||||
{/* Vibe hero + quick shortcuts */}
|
||||
<section className="grid gap-3 sm:grid-cols-[2fr_3fr]">
|
||||
{/* Vibe hero. The three library shortcuts that used to sit beside it were
|
||||
duplicates of the nav rail two inches to the left — the space now goes
|
||||
to the one action this page is for, plus what the library actually holds. */}
|
||||
<section className="animate-rise flex flex-wrap items-end justify-between gap-4 border-b border-border pb-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-text sm:text-4xl">Good listening</h1>
|
||||
<p className="mt-1.5 font-mono text-xs tabular-nums text-machine">
|
||||
{counts ?? 'reading library…'}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/vibe"
|
||||
className="group relative overflow-hidden rounded-2xl border border-accent/30 bg-gradient-to-br from-accent/30 to-accent/5 p-5 flex flex-col justify-between min-h-[7rem] transition-all hover:-translate-y-0.5 hover:shadow-xl hover:shadow-accent/10"
|
||||
className="group flex flex-none items-center gap-2.5 rounded-lg border border-accent/40 bg-accent/10 px-4 py-2.5 text-sm font-medium text-accent transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<Zap size={22} className="text-accent transition-transform group-hover:scale-110" />
|
||||
<div>
|
||||
<div className="text-lg font-bold text-text">Start a Vibe</div>
|
||||
<div className="text-xs text-muted">Endless recommendations from your library.</div>
|
||||
</div>
|
||||
<Zap size={18} className="transition-transform group-hover:scale-110" />
|
||||
Start a Vibe
|
||||
</Link>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{SHORTCUTS.map(({ label, icon: Icon, to }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className="group flex flex-col items-center justify-center gap-2 rounded-2xl border border-border/70 bg-surface0/60 p-4 transition-all hover:-translate-y-0.5 hover:border-accent/40 hover:bg-surface1"
|
||||
>
|
||||
<Icon size={22} className="text-muted transition-colors group-hover:text-accent" />
|
||||
<span className="text-sm font-semibold text-text">{label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ShelfRow title="Continue Listening" viewAllTo="/tracks">
|
||||
|
||||
+58
-33
@@ -2,13 +2,9 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
Play,
|
||||
Pause,
|
||||
RotateCcw,
|
||||
Terminal,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
@@ -61,16 +57,32 @@ function formatDuration(ms: number): string {
|
||||
|
||||
/* ─────────────────────────────────────────── Sub-components ──────────────────────────────────────── */
|
||||
|
||||
function StatCard({ icon: Icon, label, value, color }: { icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; label: string; value: number; color: string }) {
|
||||
/**
|
||||
* One queue counter. Every tile used to carry its own hue and a giant ghost icon,
|
||||
* which made a queue at rest look like an alarm panel. Now the number is mono and
|
||||
* neutral; colour appears only when the value is one that wants attention.
|
||||
*/
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone?: 'neutral' | 'active' | 'bad';
|
||||
}) {
|
||||
const live = value > 0;
|
||||
const valueColor = !live
|
||||
? 'text-disabled'
|
||||
: tone === 'bad'
|
||||
? 'text-red'
|
||||
: tone === 'active'
|
||||
? 'text-accent'
|
||||
: 'text-text';
|
||||
return (
|
||||
<div className="bg-surface0 border border-border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted uppercase tracking-wide">{label}</p>
|
||||
<p className="text-3xl font-bold mt-1" style={{ color }}>{value.toLocaleString()}</p>
|
||||
</div>
|
||||
<Icon className="w-8 h-8 opacity-30" style={{ color }} />
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-surface0/50 px-3 py-2.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted">{label}</p>
|
||||
<p className={`mt-1 font-mono text-2xl tabular-nums ${valueColor}`}>{value.toLocaleString()}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -384,29 +396,38 @@ export default function JobsPage() {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3 border-b border-border p-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Jobs</h1>
|
||||
<p className="text-sm text-muted">Background task queue monitoring</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-text sm:text-3xl">Jobs</h1>
|
||||
<p className="mt-1 font-mono text-xs tabular-nums text-machine">
|
||||
{stats
|
||||
? `${stats.waiting + stats.active + stats.delayed} queued · ${stats.failed} failed`
|
||||
: 'reading queue…'}
|
||||
{autoRefresh ? ' · refresh 5s' : ' · refresh off'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { refetchStats(); refetchHistory(); }}
|
||||
className="flex items-center gap-1.5 text-xs text-muted hover:text-text transition-colors"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-xs text-secondary transition-colors hover:border-accent/40 hover:text-text"
|
||||
title="Refresh now"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
Refresh
|
||||
</button>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
className="w-4 h-4 accent-accent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
aria-pressed={autoRefresh}
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs transition-colors ${
|
||||
autoRefresh
|
||||
? 'border-accent/50 bg-accent/10 text-accent'
|
||||
: 'border-border text-secondary hover:text-text'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${autoRefresh ? 'bg-accent' : 'bg-disabled'}`} />
|
||||
Auto-refresh
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -451,16 +472,20 @@ export default function JobsPage() {
|
||||
|
||||
{/* ── Overview tab ── */}
|
||||
{selectedTab === 'overview' && !stats && !loadError && (
|
||||
<div className="text-center py-16 text-muted text-sm">Loading queue stats…</div>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="skeleton h-[70px] rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedTab === 'overview' && stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<StatCard icon={Clock} label="Waiting" value={stats.waiting} color="#eab308" />
|
||||
<StatCard icon={Play} label="Active" value={stats.active} color="#a78bfa" />
|
||||
<StatCard icon={CheckCircle} label="Completed" value={stats.completed} color="#22c55e" />
|
||||
<StatCard icon={AlertCircle} label="Failed" value={stats.failed} color="#f87171" />
|
||||
<StatCard icon={RotateCcw} label="Delayed" value={stats.delayed} color="#60a5fa" />
|
||||
<StatCard icon={Pause} label="Paused" value={stats.paused} color="#6b7280" />
|
||||
<StatCard label="Waiting" value={stats.waiting} />
|
||||
<StatCard label="Active" value={stats.active} tone="active" />
|
||||
<StatCard label="Completed" value={stats.completed} />
|
||||
<StatCard label="Failed" value={stats.failed} tone="bad" />
|
||||
<StatCard label="Delayed" value={stats.delayed} />
|
||||
<StatCard label="Paused" value={stats.paused} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ShieldAlert, RotateCcw, Trash2, Clock, AlertCircle } from 'lucide-react';
|
||||
import { ShieldAlert, RotateCcw, Trash2, AlertCircle } from 'lucide-react';
|
||||
import { quarantineService } from '../services/quarantineService';
|
||||
import { Badge } from '../components/ethos/Badge';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { SkeletonRows } from '../components/LoadingState';
|
||||
import { toast } from '../store/useToastStore';
|
||||
@@ -56,12 +57,11 @@ export default function Quarantine() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div>
|
||||
<h1 className="flex items-center gap-3 text-3xl font-bold text-text">
|
||||
<ShieldAlert size={28} className="text-accent" /> Quarantine
|
||||
</h1>
|
||||
<p className="text-muted mt-1">Disliked tracks pending deletion. Restore before the timer expires.</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Quarantine"
|
||||
subtitle="Disliked tracks pending deletion. Restore before the timer expires."
|
||||
meta={entries.length ? `${entries.length} held` : undefined}
|
||||
/>
|
||||
|
||||
{isLoading ? <SkeletonRows count={3} />
|
||||
: isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load quarantine list" subtitle="Something went wrong. Try reloading the page." />
|
||||
@@ -72,25 +72,23 @@ export default function Quarantine() {
|
||||
subtitle="Disliked tracks will appear here during the grace period before being deleted."
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
<ul className="track-list rounded-lg border border-border bg-surface0/40 px-1.5">
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.track_id} className="flex items-center gap-3 rounded-lg border border-border bg-surface0 p-3">
|
||||
<li key={entry.track_id} className="flex h-14 items-center gap-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-text truncate">{entry.track_title}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-text">{entry.track_title}</span>
|
||||
{stateLabel(entry.state)}
|
||||
</div>
|
||||
<div className="text-xs text-muted">{entry.track_artist}</div>
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted">
|
||||
<Clock size={12} /> {countdown(entry)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-secondary">{entry.track_artist}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="flex-none font-mono text-xs tabular-nums text-machine">{countdown(entry)}</span>
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => restore.mutate(entry.track_id)}
|
||||
disabled={restore.isPending}
|
||||
title="Restore to library"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface1 disabled:opacity-50 transition-colors"
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-text transition-colors hover:bg-surface1 disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw size={14} /> Restore
|
||||
</button>
|
||||
@@ -98,7 +96,7 @@ export default function Quarantine() {
|
||||
onClick={() => { if (confirm(`Permanently delete "${entry.track_title}"?`)) hardDelete.mutate(entry.track_id); }}
|
||||
disabled={hardDelete.isPending}
|
||||
title="Delete now"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-red-500/40 px-3 py-1.5 text-sm text-red-400 hover:bg-red-500/10 disabled:opacity-50 transition-colors"
|
||||
className="flex items-center gap-1.5 rounded-md border border-red-500/40 px-2 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Search as SearchIcon, SearchX } from 'lucide-react';
|
||||
import { searchService } from '../services/searchService';
|
||||
import { TrackRow } from '../components/TrackRow';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { LoadingState } from '../components/LoadingState';
|
||||
import type { SearchResponse, Track } from '../types';
|
||||
@@ -26,17 +27,11 @@ export default function Search() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="animate-rise">
|
||||
<h1 className="text-gradient text-3xl font-extrabold tracking-tight sm:text-4xl">Search</h1>
|
||||
{query.length > 0 ? (
|
||||
<p className="mt-1.5 text-sm text-muted">
|
||||
{busy ? 'Searching' : `${tracks.length} ${tracks.length === 1 ? 'result' : 'results'}`} for{' '}
|
||||
<span className="font-semibold text-text">“{query}”</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1.5 text-sm text-muted">Search your library from the bar above.</p>
|
||||
)}
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Search"
|
||||
subtitle={query.length > 0 ? `“${query}”` : 'Search your library from the bar above.'}
|
||||
meta={query.length > 0 ? (busy ? 'searching…' : `${tracks.length} results`) : undefined}
|
||||
/>
|
||||
|
||||
{query.length === 0 ? (
|
||||
<EmptyState
|
||||
@@ -51,7 +46,7 @@ export default function Search() {
|
||||
) : tracks.length === 0 ? (
|
||||
<EmptyState icon={<SearchX size={28} />} title="No results" subtitle={`Nothing matched “${query}”.`} />
|
||||
) : (
|
||||
<section className="space-y-1 rounded-2xl border border-border/70 bg-surface0/40 p-2 animate-rise">
|
||||
<section className="track-list animate-rise rounded-lg border border-border bg-surface0/30 px-1 py-1">
|
||||
{tracks.map((t, i) => (
|
||||
<TrackRow key={t.id} track={t} queue={tracks} index={i} />
|
||||
))}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Volume2, Info, Scan, RefreshCw, Globe, Copy, ChevronDown, ChevronRight,
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import api from '../services/api';
|
||||
import { settingsService, type EnrichSettingKey, type EnrichSettings } from '../services/settingsService';
|
||||
import { STORAGE_KEYS, readStoredVolume } from '../lib/theme';
|
||||
@@ -94,13 +95,13 @@ function EnrichToggles() {
|
||||
return (
|
||||
<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">
|
||||
className="w-full flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2 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>
|
||||
<div className="text-xs text-muted truncate">{desc}</div>
|
||||
</div>
|
||||
<div className={`shrink-0 relative w-10 h-5 rounded-full transition-colors ${on ? 'bg-accent' : 'bg-surface2'}`}>
|
||||
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full shadow-sm transition-transform ${on ? 'bg-on-accent translate-x-5' : 'bg-secondary'}`} />
|
||||
<div className={`relative h-[20px] w-[36px] shrink-0 rounded-full transition-colors ${on ? 'bg-accent' : 'bg-surface2'}`}>
|
||||
<div className={`absolute left-[2px] top-[2px] h-[16px] w-[16px] rounded-full transition-transform ${on ? 'bg-on-accent translate-x-[16px]' : 'bg-secondary'}`} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -295,34 +296,31 @@ export default function Settings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer width="sm" className="space-y-8 lg:max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-text">Settings</h1>
|
||||
<p className="text-muted mt-1">Preferences are stored locally in this browser.</p>
|
||||
</div>
|
||||
<PageContainer width="sm" className="space-y-6 lg:max-w-3xl">
|
||||
<PageHeader title="Settings" subtitle="Preferences are stored locally in this browser." />
|
||||
|
||||
<section className="rounded-xl border border-border/70 bg-surface0 p-5 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-text flex items-center gap-2"><Volume2 size={20} className="text-accent" />Default volume</h2>
|
||||
<section className="rounded-xl border border-border bg-surface0/50 p-3 space-y-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-text"><Volume2 size={15} className="text-accent" />Default volume</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<input type="range" min={0} max={1} step={0.01} value={volume}
|
||||
onChange={(e) => handleVolume(Number(e.target.value))}
|
||||
className="flex-1" aria-label="Volume" />
|
||||
<span className="w-12 text-right text-sm tabular-nums text-text">{Math.round(volume * 100)}%</span>
|
||||
<span className="w-12 flex-none text-right font-mono text-xs tabular-nums text-machine">{Math.round(volume * 100)}%</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border/70 bg-surface0 p-5 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-text flex items-center gap-2"><Radio size={20} className="text-accent" />Transitions</h2>
|
||||
<section className="rounded-xl border border-border bg-surface0/50 p-3 space-y-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-text"><Radio size={15} className="text-accent" />Transitions</h2>
|
||||
|
||||
<button type="button" onClick={() => setPrefetchNext(!prefetchNext)}
|
||||
role="switch" aria-checked={prefetchNext}
|
||||
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">
|
||||
className="w-full flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2 text-left transition-colors hover:bg-surface1">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-text">Preload next track</div>
|
||||
<div className="text-xs text-muted">Buffers the upcoming track {PREFETCH_LEAD_SECONDS}s before the current one ends</div>
|
||||
</div>
|
||||
<div className={`shrink-0 relative w-10 h-5 rounded-full transition-colors ${prefetchNext ? 'bg-accent' : 'bg-surface2'}`}>
|
||||
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full shadow-sm transition-transform ${prefetchNext ? 'bg-on-accent translate-x-5' : 'bg-secondary'}`} />
|
||||
<div className={`relative h-[20px] w-[36px] shrink-0 rounded-full transition-colors ${prefetchNext ? 'bg-accent' : 'bg-surface2'}`}>
|
||||
<div className={`absolute left-[2px] top-[2px] h-[16px] w-[16px] rounded-full transition-transform ${prefetchNext ? 'bg-on-accent translate-x-[16px]' : 'bg-secondary'}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -332,7 +330,7 @@ export default function Settings() {
|
||||
<input id="crossfade" type="range" min={0} max={MAX_CROSSFADE_MS} step={100} value={crossfadeMs}
|
||||
onChange={(e) => setCrossfadeMs(Number(e.target.value))}
|
||||
className="flex-1" />
|
||||
<span className="w-12 text-right text-sm tabular-nums text-text">
|
||||
<span className="w-12 flex-none text-right font-mono text-xs tabular-nums text-machine">
|
||||
{crossfadeMs === 0 ? 'Off' : `${(crossfadeMs / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
</div>
|
||||
@@ -344,8 +342,8 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border/70 bg-surface0 p-5 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-text flex items-center gap-2"><Scan size={20} className="text-accent" />Library</h2>
|
||||
<section className="rounded-xl border border-border bg-surface0/50 p-3 space-y-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-text"><Scan size={15} className="text-accent" />Library</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<AdminAction icon={Scan} label="Scan library" busyLabel="Scanning…" doneLabel="Scan enqueued"
|
||||
@@ -371,7 +369,7 @@ export default function Settings() {
|
||||
</div>
|
||||
<p className="text-xs text-muted/70 -mt-1">
|
||||
<strong>Reprocess artists</strong> re-resolves canonical names, MBIDs and
|
||||
images for every artist, and merges duplicates. <strong>Re-enrich metadata</strong>
|
||||
images for every artist, and merges duplicates. <strong>Re-enrich metadata</strong>{' '}
|
||||
re-queries MusicBrainz/Discogs for all tracks (album titles, years, cover
|
||||
art) without re-scanning files. Both run in the background.
|
||||
</p>
|
||||
@@ -380,8 +378,8 @@ export default function Settings() {
|
||||
<DuplicatesSection />
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border/70 bg-surface0 p-5 space-y-3">
|
||||
<h2 className="text-lg font-semibold text-text flex items-center gap-2"><Info size={20} className="text-accent" />About</h2>
|
||||
<section className="rounded-xl border border-border bg-surface0/50 p-3 space-y-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-text"><Info size={15} className="text-accent" />About</h2>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between"><dt className="text-muted">Application</dt><dd className="text-text font-medium">muzick</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-muted">Version</dt><dd className="text-text font-medium tabular-nums">0.1.0</dd></div>
|
||||
|
||||
@@ -24,11 +24,14 @@ export default function Tracks() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader icon={Music} title="Songs" subtitle="Everything in your library" />
|
||||
<PageHeader
|
||||
title="Songs"
|
||||
meta={tracks.length ? `${tracks.length} shown · page ${page + 1}` : undefined}
|
||||
/>
|
||||
{isLoading ? <SkeletonRows count={8} />
|
||||
: isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load tracks" subtitle="Something went wrong. Try reloading the page." />
|
||||
: tracks.length === 0 ? <EmptyState compact icon={<Music size={28} />} title={page === 0 ? 'No tracks yet' : 'No more tracks'} subtitle={page === 0 ? 'Run a library scan in Settings to populate it.' : undefined} />
|
||||
: <div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>}
|
||||
: <div className="track-list">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>}
|
||||
{(tracks.length > 0 || page > 0) && (
|
||||
<Pagination
|
||||
page={page}
|
||||
|
||||
+82
-82
@@ -1,16 +1,16 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Heart, Loader2, Play, Shuffle, ThumbsDown, Sparkles, Square } from 'lucide-react';
|
||||
import { advanceVibe, endVibeSession, reportVibeEvent, startVibeSession, vibeErrorMessage } from '../services/vibeSession';
|
||||
import { Loader2, Play, Shuffle, ThumbsDown, Square } from 'lucide-react';
|
||||
import { advanceVibe, endVibeSession, startVibeSession, vibeErrorMessage } from '../services/vibeSession';
|
||||
import { trackService } from '../services/trackService';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
import { TrackRow } from '../components/TrackRow';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import type { Track } from '../types';
|
||||
import { VibeTimeline } from '../components/VibeTimeline';
|
||||
import { VibeAura } from '../components/VibeAura';
|
||||
import { toast } from '../store/useToastStore';
|
||||
|
||||
const SEED_LIST_SIZE = 50;
|
||||
|
||||
@@ -24,12 +24,11 @@ function sampleTracks(tracks: Track[], count: number): Track[] {
|
||||
}
|
||||
|
||||
export default function Vibe() {
|
||||
const { currentTrack } = usePlaybackStore();
|
||||
const { currentTrack, queue } = usePlaybackStore();
|
||||
const {
|
||||
activeSessionId,
|
||||
buffer,
|
||||
initialBatchStatus,
|
||||
planVersion,
|
||||
profile,
|
||||
} = useVibeStore();
|
||||
|
||||
@@ -80,13 +79,6 @@ export default function Vibe() {
|
||||
void startSession(seed);
|
||||
}, [libraryTracks, startSession]);
|
||||
|
||||
const handleKeep = useCallback(() => {
|
||||
if (currentTrack) {
|
||||
void reportVibeEvent('kept', currentTrack.id).catch((feedbackError) => setError(vibeErrorMessage(feedbackError)));
|
||||
toast.success(`Kept "${currentTrack.title}"`);
|
||||
}
|
||||
}, [currentTrack]);
|
||||
|
||||
const handleDislike = useCallback(() => {
|
||||
if (currentTrack) {
|
||||
void advanceVibe('disliked').catch((feedbackError) => setError(vibeErrorMessage(feedbackError)));
|
||||
@@ -98,28 +90,39 @@ export default function Vibe() {
|
||||
setEmpty(false);
|
||||
}, []);
|
||||
|
||||
const upcoming = buffer;
|
||||
// The aura shows the profile as light, which is not readable. These are the
|
||||
// same numbers the director steers on (Ethos law 5 — show the machinery).
|
||||
const profileMeta = useMemo(() => {
|
||||
if (initialBatchStatus === 'loading') return 'planning…';
|
||||
const pct = (value: number | undefined, fallback: number) =>
|
||||
`${Math.round(Math.min(1, Math.max(0, value ?? fallback)) * 100)}%`;
|
||||
const parts = [
|
||||
`energy ${pct(profile.energy, 0.5)}`,
|
||||
`discovery ${pct(profile.noveltyHunger ?? profile.explorationCoefficient, 0.3)}`,
|
||||
];
|
||||
const goal = profile.sessionGoal;
|
||||
if (goal?.target) parts.push(`goal ${goal.progress ?? 0}/${goal.target}`);
|
||||
return parts.join(' · ');
|
||||
}, [profile, initialBatchStatus]);
|
||||
|
||||
// Read what is actually queued rather than the plan preview: the seed plays
|
||||
// first and is not a plan item, so the preview alone would misreport what's next.
|
||||
const upcoming = useMemo(() => {
|
||||
const index = queue.findIndex((track) => track.id === currentTrack?.id);
|
||||
return index >= 0 ? queue.slice(index + 1) : buffer;
|
||||
}, [queue, currentTrack, buffer]);
|
||||
|
||||
// ---- Start screen (no active session) ----
|
||||
if (!activeSessionId) {
|
||||
return (
|
||||
<PageContainer width="sm" className="py-8">
|
||||
<header className="space-y-2 text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-accent/10 px-4 py-1.5 text-sm text-accent">
|
||||
<Sparkles size={16} />
|
||||
Rolling Vibe
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-text">Start a Vibe</h1>
|
||||
<p className="text-muted">
|
||||
An infinite, ever-rolling stream of recommendations seeded from a track you love.
|
||||
</p>
|
||||
<p className="mx-auto max-w-md text-xs text-muted/70">
|
||||
Pick a seed below and playback starts immediately, with a rolling
|
||||
timeline of upcoming tracks. <strong className="text-muted">Keep</strong> what you love,
|
||||
<strong className="text-muted"> Dislike & skip</strong> what you don't — the vibe
|
||||
adapts as you go.
|
||||
</p>
|
||||
</header>
|
||||
<PageContainer>
|
||||
{/* Three stacked paragraphs of explanation used to sit here. A seed, a
|
||||
shuffle and a track list say the same thing by being used. */}
|
||||
<PageHeader
|
||||
title="Start a Vibe"
|
||||
subtitle="Pick a seed. Playback starts at once and the queue re-plans as you keep or skip."
|
||||
meta={libraryTracks.length ? `${libraryTracks.length.toLocaleString()} tracks eligible` : undefined}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
|
||||
@@ -127,17 +130,17 @@ export default function Vibe() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{currentTrack && (
|
||||
<button
|
||||
onClick={startFromCurrent}
|
||||
disabled={starting}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-accent/60 bg-accent/10 p-4 text-left transition-colors hover:bg-accent/20 disabled:opacity-60"
|
||||
className="flex h-16 w-full items-center gap-3 rounded-lg border border-accent/50 bg-accent/10 px-3 text-left transition-colors hover:bg-accent/20 disabled:opacity-60"
|
||||
>
|
||||
<Play size={20} className="text-accent" />
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-text">Vibe from “{currentTrack.title}”</div>
|
||||
<div className="text-sm text-muted">{currentTrack.artist}</div>
|
||||
<Play size={18} className="flex-none text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-text">Vibe from “{currentTrack.title}”</div>
|
||||
<div className="truncate text-xs text-secondary">{currentTrack.artist}</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
@@ -145,27 +148,29 @@ export default function Vibe() {
|
||||
<button
|
||||
onClick={surpriseMe}
|
||||
disabled={starting || libraryLoading || libraryTracks.length === 0}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-border bg-surface0 p-4 text-left transition-colors hover:bg-surface1 disabled:opacity-60"
|
||||
className="flex h-16 w-full items-center gap-3 rounded-lg border border-border bg-surface0/60 px-3 text-left transition-colors hover:bg-surface1 disabled:opacity-60"
|
||||
>
|
||||
<Shuffle size={20} className="text-text" />
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-text">Surprise me</div>
|
||||
<div className="text-sm text-muted">
|
||||
<Shuffle size={18} className="flex-none text-text" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-text">Surprise me</div>
|
||||
<div className="truncate text-xs text-secondary">
|
||||
{libraryLoading
|
||||
? 'Loading your library…'
|
||||
? 'reading library…'
|
||||
: libraryTracks.length === 0
|
||||
? 'No library tracks available to seed a vibe.'
|
||||
: `Start from a random track across ${libraryTracks.length.toLocaleString()} library tracks.`}
|
||||
? 'No eligible tracks to seed from.'
|
||||
: 'Random seed from the whole library.'}
|
||||
</div>
|
||||
</div>
|
||||
{starting && <Loader2 size={18} className="animate-spin text-muted" />}
|
||||
{starting && <Loader2 size={18} className="flex-none animate-spin text-muted" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!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">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-muted">Or pick a seed track</h2>
|
||||
{/* Two columns of the sampled 50 — one 320px-tall scroller showed 6
|
||||
of them and left the rest behind a scrollbar. */}
|
||||
<ul className="track-list grid rounded-lg border border-border bg-surface0/30 px-1 sm:grid-cols-2 sm:gap-x-4 sm:[&>*:nth-child(2)]:border-t-0">
|
||||
{seedTracks.map((track, index) => (
|
||||
<li key={track.id}>
|
||||
<TrackRow
|
||||
@@ -189,20 +194,34 @@ export default function Vibe() {
|
||||
<PageContainer width="sm" className="relative isolate py-6">
|
||||
<VibeAura profile={profile} ambient />
|
||||
<div className="relative z-10 space-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={22} className="text-accent" />
|
||||
<h1 className="text-2xl font-bold text-text">Vibing</h1>
|
||||
{initialBatchStatus === 'loading' && <Loader2 size={16} className="animate-spin text-muted" />}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleEnd}
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3 py-1.5 text-sm text-text transition-colors hover:border-red-500/60 hover:text-red-300"
|
||||
>
|
||||
<Square size={14} />
|
||||
End Vibe
|
||||
</button>
|
||||
</header>
|
||||
<PageHeader
|
||||
title="Vibing"
|
||||
// The buffer count belongs to Up next, and the plan revision is a
|
||||
// debugging number. The live profile does belong here.
|
||||
meta={profileMeta}
|
||||
// Both verbs sit in one cluster. Dislike used to live a row down,
|
||||
// which put 200px of empty header between the two things to press.
|
||||
actions={
|
||||
<>
|
||||
{currentTrack && (
|
||||
<button
|
||||
onClick={handleDislike}
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-surface0/60 px-3 py-2 text-sm text-text transition-colors hover:border-red/60 hover:text-red"
|
||||
>
|
||||
<ThumbsDown size={16} />
|
||||
Dislike & skip
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleEnd}
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm text-text transition-colors hover:border-red/60 hover:text-red"
|
||||
>
|
||||
<Square size={14} />
|
||||
End Vibe
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{(empty || initialBatchStatus === 'exhausted' || initialBatchStatus === 'failed') && (
|
||||
<div className="rounded-lg border border-border bg-surface0 p-4 text-sm text-muted">
|
||||
@@ -218,27 +237,8 @@ export default function Vibe() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{planVersion && <p className="text-xs text-muted/70">Plan revision {planVersion}; upcoming tracks may change as you listen.</p>}
|
||||
|
||||
{currentTrack && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleKeep}
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-text transition-colors hover:border-pink-500/60 hover:text-pink-300"
|
||||
>
|
||||
<Heart size={16} />
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDislike}
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-text transition-colors hover:border-red-500/60 hover:text-red-300"
|
||||
>
|
||||
<ThumbsDown size={16} />
|
||||
Dislike & skip
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keep is gone: letting a track finish already reports `completed`,
|
||||
which the director weighs the same as an explicit keep. */}
|
||||
<VibeTimeline currentTrack={currentTrack} upcoming={upcoming} />
|
||||
</div>
|
||||
</PageContainer>
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
// Barrel re-export for the library-related services. The previous version of this
|
||||
// file pointed at `/library/*` paths, but the backend registers library routes at
|
||||
// the `/api` root (see backend/src/app.ts). Use the per-entity services instead.
|
||||
import api from './api';
|
||||
|
||||
export interface LibraryStats {
|
||||
tracks: number;
|
||||
albums: number;
|
||||
artists: number;
|
||||
/** Total playtime in seconds. */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
// GET /api/library/stats
|
||||
export async function fetchLibraryStats(): Promise<LibraryStats> {
|
||||
const res = await api.get<LibraryStats>('/library/stats');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export { trackService } from './trackService';
|
||||
export { artistService } from './artistService';
|
||||
export { albumService } from './albumService';
|
||||
|
||||
@@ -49,6 +49,14 @@ export interface VibeEventResponse extends DurableVibeSessionResponse {
|
||||
idempotent: boolean;
|
||||
}
|
||||
|
||||
/** Coarse local calendar context, used only for short-lived Vibe preferences. */
|
||||
export interface VibeCalendarContext {
|
||||
localHour: number;
|
||||
weekday: number;
|
||||
month: number;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
/** A durable, idempotent advancement past a plan item the player cannot load. */
|
||||
export interface VibeUnplayableItemInput {
|
||||
eventId: string;
|
||||
@@ -58,8 +66,8 @@ export interface VibeUnplayableItemInput {
|
||||
}
|
||||
|
||||
export const vibeService = {
|
||||
async start(seedTrackId?: string): Promise<DurableVibeSessionResponse> {
|
||||
const res = await api.post<DurableVibeSessionResponse>('/v2/vibe/sessions', { seedTrackId });
|
||||
async start(seedTrackId?: string, context?: VibeCalendarContext): Promise<DurableVibeSessionResponse> {
|
||||
const res = await api.post<DurableVibeSessionResponse>('/v2/vibe/sessions', { seedTrackId, context });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -41,20 +41,37 @@ describe('durable Vibe session client', () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
|
||||
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ status: 'complete', tracks: [track('one'), track('two')] });
|
||||
await expect(startVibeSession(track('one'))).resolves.toMatchObject({ status: 'complete', tracks: [track('one'), track('two')] });
|
||||
|
||||
expect(start).toHaveBeenCalledWith('one', expect.objectContaining({
|
||||
localHour: expect.any(Number), weekday: expect.any(Number), month: expect.any(Number),
|
||||
}));
|
||||
expect(next).toHaveBeenCalledWith('session-a', 1);
|
||||
expect(useVibeStore.getState()).toMatchObject({ activeSessionId: 'session-a', planVersion: 1, buffer: [track('two')] });
|
||||
expect(usePlaybackStore.getState().currentTrack).toEqual(track('one'));
|
||||
});
|
||||
|
||||
it('drops a second recording of a song already in the queue', async () => {
|
||||
// Same title, different track: a cover or another artist's version. One sitting
|
||||
// should not play the same song twice.
|
||||
getTrack.mockImplementation((id: string) =>
|
||||
Promise.resolve({ ...track(id), title: id === 'cover' ? 'One' : track(id).title })
|
||||
);
|
||||
start.mockResolvedValue(response(1, item('one'), [item('cover'), item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('cover'), item('two')]));
|
||||
|
||||
await startVibeSession({ ...track('one'), title: 'one' });
|
||||
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two']);
|
||||
});
|
||||
|
||||
it('replans, version-serves, and removes stale prefetched tracks before advancing', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
|
||||
next
|
||||
.mockResolvedValueOnce(response(1, item('one', true), [item('stale')]))
|
||||
.mockResolvedValueOnce(response(2, item('two', true), [item('three')]));
|
||||
event.mockResolvedValue({ ...response(2, item('two'), [item('three')]), replanned: true, event: {}, idempotent: false });
|
||||
await startVibeSession(track('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await advanceVibe('skipped');
|
||||
|
||||
@@ -70,7 +87,7 @@ describe('durable Vibe session client', () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('stale')]));
|
||||
event.mockResolvedValue({ ...response(2, item('fresh'), [item('fresh'), item('later')]), replanned: true, event: {}, idempotent: false });
|
||||
await startVibeSession(track('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await reportVibeEvent('kept', 'one');
|
||||
|
||||
@@ -90,7 +107,7 @@ describe('durable Vibe session client', () => {
|
||||
event: {},
|
||||
idempotent: true,
|
||||
});
|
||||
await startVibeSession(track('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await reportVibeEvent('kept', 'one');
|
||||
|
||||
@@ -104,7 +121,7 @@ describe('durable Vibe session client', () => {
|
||||
event.mockRejectedValue(new AxiosError('gone', undefined, undefined, undefined, {
|
||||
data: {}, status: 404, statusText: 'Not Found', headers: {}, config: {} as never,
|
||||
}));
|
||||
await startVibeSession(track('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await expect(reportVibeEvent('kept', 'one')).rejects.toThrow('gone');
|
||||
|
||||
@@ -115,7 +132,7 @@ describe('durable Vibe session client', () => {
|
||||
it('hands ordinary playback back to browse queues without Vibe reporting or next interception', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
await startVibeSession(track('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
const ordinary = track('ordinary');
|
||||
const playback = usePlaybackStore.getState();
|
||||
@@ -132,7 +149,7 @@ describe('durable Vibe session client', () => {
|
||||
it('serializes material events and ignores an older plan revision', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('old')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('old')]));
|
||||
await startVibeSession(track('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
let resolveFirst!: (value: ReturnType<typeof response> & { event: object; idempotent: boolean }) => void;
|
||||
event.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; }));
|
||||
@@ -156,7 +173,7 @@ describe('durable Vibe session client', () => {
|
||||
event.mockRejectedValueOnce(new Error('network dropped')).mockResolvedValueOnce({
|
||||
...response(1), event: {}, idempotent: true,
|
||||
});
|
||||
await startVibeSession(track('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await reportVibeEvent('progress', 'one', 30000, 180000);
|
||||
|
||||
@@ -173,7 +190,7 @@ describe('durable Vibe session client', () => {
|
||||
? Promise.resolve({ ...track(id), state: 'HIDDEN' })
|
||||
: Promise.resolve(track(id)));
|
||||
|
||||
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ status: 'complete', tracks: [track('good'), track('later')] });
|
||||
await expect(startVibeSession(track('good'))).resolves.toMatchObject({ status: 'complete', tracks: [track('good'), track('later')] });
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(advancePastUnplayable).toHaveBeenCalledWith('session-a', 1, expect.objectContaining({
|
||||
@@ -193,7 +210,7 @@ describe('durable Vibe session client', () => {
|
||||
? Promise.resolve({ ...track(id), state: 'HIDDEN' })
|
||||
: Promise.resolve(track(id)));
|
||||
|
||||
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({
|
||||
await expect(startVibeSession(track('good'))).resolves.toMatchObject({
|
||||
status: 'complete', tracks: [track('good'), track('later')],
|
||||
});
|
||||
|
||||
@@ -214,7 +231,7 @@ describe('durable Vibe session client', () => {
|
||||
? Promise.resolve({ ...track(id), state: 'MISSING' })
|
||||
: Promise.resolve(track(id)));
|
||||
|
||||
await startVibeSession(track('seed'));
|
||||
await startVibeSession(track('good'));
|
||||
|
||||
expect(advancePastUnplayable).toHaveBeenCalledTimes(2);
|
||||
expect(advancePastUnplayable.mock.calls[0][2].eventId)
|
||||
@@ -225,7 +242,7 @@ describe('durable Vibe session client', () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two', false, 1)]));
|
||||
next.mockResolvedValueOnce(response(1, item('one', true), [item('two', false, 1)]));
|
||||
advancePastUnplayable.mockResolvedValueOnce(response(1, item('two', true, 1), [item('later', false, 2)]));
|
||||
await startVibeSession(track('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await advancePastUnplayableVibeTrack('one');
|
||||
|
||||
@@ -237,11 +254,29 @@ describe('durable Vibe session client', () => {
|
||||
expect(useVibeStore.getState().currentPlanItem).toMatchObject({ track_id: 'two', ordinal: 1 });
|
||||
});
|
||||
|
||||
it('plays the seed first and steps off it without reporting plan feedback', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
|
||||
await startVibeSession(track('seed'));
|
||||
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('seed');
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['seed', 'one', 'two']);
|
||||
|
||||
await advanceVibe('completed');
|
||||
|
||||
// The seed is not a plan item: no feedback, no second serve, and the plan's
|
||||
// own first item is what plays next.
|
||||
expect(event).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('one');
|
||||
});
|
||||
|
||||
it('ends a Vibe by removing Vibe ownership and clearing the local queue', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
end.mockResolvedValue(response(1));
|
||||
await startVibeSession(track('seed'));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
await endVibeSession();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useVibeStore } from '../store/useVibeStore';
|
||||
import {
|
||||
vibeService,
|
||||
type DurableVibeSessionResponse,
|
||||
type VibeCalendarContext,
|
||||
type VibeEventType,
|
||||
type VibePlanItem,
|
||||
} from './vibeService';
|
||||
@@ -18,6 +19,13 @@ export interface StartedVibeSession {
|
||||
let startInFlight: Promise<StartedVibeSession> | null = null;
|
||||
let advanceInFlight: Promise<void> | null = null;
|
||||
let materialTail: Promise<void> = Promise.resolve();
|
||||
// A seeded Vibe plays its seed first — asking for a vibe "from this track" and
|
||||
// getting a different track is the surprise. The seed sits in front of the
|
||||
// durable plan without being part of it, so the first advance must consume it
|
||||
// locally instead of reporting feedback and serving the next item.
|
||||
// ponytail: no 'completed' event is sent for the seed. The listener chose it
|
||||
// explicitly; the director already has that signal from the session's seed id.
|
||||
let seedPendingTrackId: string | null = null;
|
||||
|
||||
interface PendingEvent {
|
||||
sessionId: string;
|
||||
@@ -50,6 +58,23 @@ function newEventId(): string {
|
||||
});
|
||||
}
|
||||
|
||||
function localCalendarContext(): VibeCalendarContext {
|
||||
const now = new Date();
|
||||
let timeZone: string | undefined;
|
||||
try {
|
||||
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
|
||||
} catch {
|
||||
// Some embedded players omit Intl time-zone support. The coarse calendar
|
||||
// fields still provide useful, non-identifying context.
|
||||
}
|
||||
return {
|
||||
localHour: now.getHours(),
|
||||
weekday: now.getDay(),
|
||||
month: now.getMonth() + 1,
|
||||
timeZone,
|
||||
};
|
||||
}
|
||||
|
||||
function isPlayable(track: Track): boolean {
|
||||
return !['HIDDEN', 'MISSING', 'DELETED'].includes(track.state);
|
||||
}
|
||||
@@ -83,6 +108,24 @@ async function hydratePreview(items: VibePlanItem[]): Promise<Track[]> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Two recordings of one song — a cover, a remaster, another artist's version —
|
||||
* are distinct track ids but read as a duplicate in one sitting. The title is the
|
||||
* key; remixes and live cuts name themselves in the title, so they survive.
|
||||
* ponytail: title string match, no normalisation beyond case and edges. Add
|
||||
* feat./punctuation stripping only if real duplicates keep getting through.
|
||||
*/
|
||||
const songKey = (track: Track) => (track.title || track.id).trim().toLowerCase();
|
||||
|
||||
function dedupeSongs(tracks: Track[], seen = new Set<string>()): Track[] {
|
||||
return tracks.filter((track) => {
|
||||
const key = songKey(track);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace only the queue after the currently playing Vibe track. */
|
||||
function replaceUnplayedQueue(preview: Track[]): void {
|
||||
const playback = usePlaybackStore.getState();
|
||||
@@ -95,9 +138,18 @@ function replaceUnplayedQueue(preview: Track[]): void {
|
||||
const history = queueIndex >= 0
|
||||
? playback.queue.slice(0, queueIndex + 1)
|
||||
: current ? [current] : [];
|
||||
const seen = new Set(history.map((track) => track.id));
|
||||
const future = preview.filter((track) => !seen.has(track.id));
|
||||
playback.setVibeQueue([...history, ...future]);
|
||||
// While the seed plays, the durable cursor's own track sits between it and the
|
||||
// preview. A `preview` list never contains that served item, so keep it — but
|
||||
// only while it is still the cursor, never after it has been retired.
|
||||
const next = playback.queue[queueIndex + 1];
|
||||
const served = queueIndex >= 0
|
||||
&& playback.queue[queueIndex]?.id === seedPendingTrackId
|
||||
&& next
|
||||
&& useVibeStore.getState().currentPlanItem?.track_id === next.id
|
||||
? [next]
|
||||
: [];
|
||||
const future = dedupeSongs(preview, new Set([...history, ...served].map(songKey)));
|
||||
playback.setVibeQueue([...history, ...served, ...future]);
|
||||
}
|
||||
|
||||
function isCurrentVibeOwner(sessionId: string): boolean {
|
||||
@@ -188,6 +240,7 @@ async function resolvePlayableResponse(
|
||||
}
|
||||
|
||||
function deactivateBrokenSession(): void {
|
||||
seedPendingTrackId = null;
|
||||
const playback = usePlaybackStore.getState();
|
||||
playback.setVibeAdvanceHandler(null);
|
||||
useVibeStore.getState().reset();
|
||||
@@ -307,6 +360,16 @@ export function advanceVibe(reason: VibeAdvanceReason): Promise<void> {
|
||||
const current = usePlaybackStore.getState().currentTrack;
|
||||
if (!vibe.activeSessionId || !current || !isCurrentVibeOwner(vibe.activeSessionId)) return;
|
||||
|
||||
// The seed is not a plan item — step off it without touching the cursor.
|
||||
if (seedPendingTrackId && current.id === seedPendingTrackId) {
|
||||
seedPendingTrackId = null;
|
||||
usePlaybackStore.getState().advance();
|
||||
// A dislike still has to reach the director; a completed seed carries no
|
||||
// information the session's seed id does not already hold.
|
||||
if (reason !== 'completed') void sendEvent(reason, current.id).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const feedback = await sendEvent(reason, current.id);
|
||||
if (!feedback?.planVersion) {
|
||||
@@ -350,7 +413,15 @@ export function advancePastUnplayableVibeTrack(trackId: string): Promise<void> {
|
||||
const vibe = useVibeStore.getState();
|
||||
const playback = usePlaybackStore.getState();
|
||||
const currentItem = vibe.currentPlanItem;
|
||||
if (!vibe.activeSessionId || !currentItem || currentItem.track_id !== trackId || !isCurrentVibeOwner(vibe.activeSessionId)) return;
|
||||
if (!vibe.activeSessionId || !isCurrentVibeOwner(vibe.activeSessionId)) return;
|
||||
// The seed has no durable cursor to advance — a seed that will not stream is
|
||||
// simply stepped over, leaving the plan's first item to play next.
|
||||
if (seedPendingTrackId === trackId) {
|
||||
seedPendingTrackId = null;
|
||||
playback.advance();
|
||||
return;
|
||||
}
|
||||
if (!currentItem || currentItem.track_id !== trackId) return;
|
||||
|
||||
try {
|
||||
const advanced = await advanceResponsePastUnplayable(vibe.activeSessionId, {
|
||||
@@ -393,7 +464,7 @@ function installVibeAdvanceHandler(): void {
|
||||
export async function startVibeSession(seed: Track): Promise<StartedVibeSession> {
|
||||
if (startInFlight) return startInFlight;
|
||||
startInFlight = serializeMaterial<StartedVibeSession>(async () => {
|
||||
const started = await vibeService.start(seed.id);
|
||||
const started = await vibeService.start(seed.id, localCalendarContext());
|
||||
if (!started.planVersion) return { status: 'exhausted', tracks: [] };
|
||||
const served = await serveNextPlayable(started.sessionId, started.planVersion);
|
||||
if (!served) return { status: 'exhausted', tracks: [] };
|
||||
@@ -411,10 +482,15 @@ export async function startVibeSession(seed: Track): Promise<StartedVibeSession>
|
||||
vibe.setInitialBatchStatus('idle');
|
||||
|
||||
const playback = usePlaybackStore.getState();
|
||||
playback.setVibeQueue([served.now, ...served.preview]);
|
||||
playback.playTrack(served.now);
|
||||
const seedFirst = isPlayable(seed) && seed.id !== served.now.id;
|
||||
seedPendingTrackId = seedFirst ? seed.id : null;
|
||||
const queue = dedupeSongs(
|
||||
seedFirst ? [seed, served.now, ...served.preview] : [served.now, ...served.preview]
|
||||
);
|
||||
playback.setVibeQueue(queue);
|
||||
playback.playTrack(queue[0]);
|
||||
installVibeAdvanceHandler();
|
||||
return { status: 'complete', tracks: [served.now, ...served.preview] };
|
||||
return { status: 'complete', tracks: queue };
|
||||
});
|
||||
try {
|
||||
return await startInFlight;
|
||||
@@ -429,6 +505,7 @@ export async function endVibeSession(): Promise<void> {
|
||||
try {
|
||||
if (sessionId) await vibeService.end(sessionId);
|
||||
} finally {
|
||||
seedPendingTrackId = null;
|
||||
const playback = usePlaybackStore.getState();
|
||||
playback.setVibeAdvanceHandler(null);
|
||||
useVibeStore.getState().reset();
|
||||
|
||||
@@ -3,8 +3,10 @@ import type { Track } from '../types';
|
||||
import {
|
||||
clampCrossfadeMs,
|
||||
readStoredCrossfadeMs,
|
||||
readStoredLastTrack,
|
||||
readStoredPrefetchNext,
|
||||
storeCrossfadeMs,
|
||||
storeLastTrack,
|
||||
storePrefetchNext,
|
||||
} from '../lib/playbackPrefs';
|
||||
|
||||
@@ -85,13 +87,17 @@ function advanceTo(queue: Track[], index: number) {
|
||||
};
|
||||
}
|
||||
|
||||
// A fresh tab opens on the last track it played, paused at zero — an empty
|
||||
// player bar told the listener nothing about where they were.
|
||||
const restoredTrack = readStoredLastTrack<Track>();
|
||||
|
||||
export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
currentTrack: null,
|
||||
queue: [],
|
||||
currentIndex: -1,
|
||||
currentTrack: restoredTrack,
|
||||
queue: restoredTrack ? [restoredTrack] : [],
|
||||
currentIndex: restoredTrack ? 0 : -1,
|
||||
isPlaying: false,
|
||||
position: 0,
|
||||
duration: 0,
|
||||
duration: restoredTrack?.duration ?? 0,
|
||||
volume: 1,
|
||||
shuffle: false,
|
||||
repeat: 'none',
|
||||
@@ -275,3 +281,13 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
return { repeat: next };
|
||||
}),
|
||||
}));
|
||||
|
||||
// One subscription instead of a write in playTrack, advance, prev and the
|
||||
// shuffle pick — every path that changes the track goes through here.
|
||||
let lastPersistedTrackId = restoredTrack?.id ?? null;
|
||||
usePlaybackStore.subscribe((state) => {
|
||||
const id = state.currentTrack?.id ?? null;
|
||||
if (id === lastPersistedTrackId) return;
|
||||
lastPersistedTrackId = id;
|
||||
storeLastTrack(state.currentTrack);
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export default {
|
||||
secondary: 'var(--ethos-secondary)',
|
||||
muted: 'var(--ethos-muted)',
|
||||
disabled: 'var(--ethos-disabled)',
|
||||
machine: 'var(--ethos-machine)',
|
||||
accent: 'var(--ethos-accent)',
|
||||
'accent-h': 'var(--ethos-accent-hover)',
|
||||
green: 'var(--ethos-green)',
|
||||
|
||||
@@ -10,7 +10,9 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
// ponytail: the deployed nginx injects the Authorization header, so dev can
|
||||
// point at it (DEV_API_TARGET=http://localhost:5174) instead of a bare backend.
|
||||
'/api': process.env.DEV_API_TARGET || 'http://localhost:3000',
|
||||
}
|
||||
},
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user