diff --git a/frontend/src/components/Artwork.tsx b/frontend/src/components/Artwork.tsx index 721be0e..412bdb7 100644 --- a/frontend/src/components/Artwork.tsx +++ b/frontend/src/components/Artwork.tsx @@ -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 (
- {!loaded && } + {!loaded && glyph && } {seed} - + {glyph && }
); } diff --git a/frontend/src/components/NowPlayingPanel.tsx b/frontend/src/components/NowPlayingPanel.tsx index e09119b..2b29d60 100644 --- a/frontend/src/components/NowPlayingPanel.tsx +++ b/frontend/src/components/NowPlayingPanel.tsx @@ -86,7 +86,7 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
No track playing
)} -
+
= { - 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 = { */ export function PageContainer({ children, width = 'md', className = '' }: PageContainerProps) { return ( -
+
{children}
); diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx index 27c20de..2cb8b85 100644 --- a/frontend/src/components/PageHeader.tsx +++ b/frontend/src/components/PageHeader.tsx @@ -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 ( -
-
- {Icon && ( -
- -
+
+
+

+ {title} +

+ {subtitle &&

{subtitle}

} + {meta && ( +

{meta}

)} -
-

- {title} -

- {subtitle &&

{subtitle}

} -
- {actions &&
{actions}
} + {actions &&
{actions}
}
); } diff --git a/frontend/src/components/PlaybackBar.tsx b/frontend/src/components/PlaybackBar.tsx index 0983a0d..a498105 100644 --- a/frontend/src/components/PlaybackBar.tsx +++ b/frontend/src/components/PlaybackBar.tsx @@ -60,7 +60,12 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri ) : ( -
Nothing playing
+
+
+ {/* Reached only before anything has ever played in this browser — + a returning tab restores its last track instead. */} +
Pick a track to start
+
)}
@@ -98,8 +103,8 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri {repeat === 'one' ? : }
-
- {formatDuration(position)} +
+ {formatDuration(position)} - {formatDuration(duration)} + {formatDuration(duration)}
diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index aaad43c..01b2b4d 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -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 ? ( ) : ( - + / )} diff --git a/frontend/src/components/TrackRow.tsx b/frontend/src/components/TrackRow.tsx index aa4a2f1..a20a5ce 100644 --- a/frontend/src/components/TrackRow.tsx +++ b/frontend/src/components/TrackRow.tsx @@ -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 (
{/* 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' }`}> - + {/* glyph={false}: the play/pause icon below is the only glyph this tile gets. */} + {isCurrent && isPlaying ? ( ) : ( - + )} {/* Title + artist */}
- + {/* 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 ? ( + 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'} + + ) : ( + + {track.title || 'Untitled'} + + )} )} - {track.album_id && ( - e.stopPropagation()} - aria-label="Go to album" - title="Go to album" - className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-text" - > - - - )} + {/* The album disc button is gone — the title itself is the album link now. */} @@ -146,7 +157,7 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default' {/* Duration (hidden in compact) */} {!compact && ( -
{formatDuration(track.duration)}
+
{formatDuration(track.duration)}
)}
); diff --git a/frontend/src/components/VibeAura.tsx b/frontend/src/components/VibeAura.tsx index 1841eb2..d0e29f4 100644 --- a/frontend/src/components/VibeAura.tsx +++ b/frontend/src/components/VibeAura.tsx @@ -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 (
-
- {aura} -
-
+ /> ); } diff --git a/frontend/src/components/VibeTimeline.test.tsx b/frontend/src/components/VibeTimeline.test.tsx index d223492..44fd1d3 100644 --- a/frontend/src/components/VibeTimeline.test.tsx +++ b/frontend/src/components/VibeTimeline.test.tsx @@ -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( + + + + ); +} + 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( - - - - ); + renderInRouter(); - 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({ diff --git a/frontend/src/components/VibeTimeline.tsx b/frontend/src/components/VibeTimeline.tsx index e564f95..a94ea2a 100644 --- a/frontend/src/components/VibeTimeline.tsx +++ b/frontend/src/components/VibeTimeline.tsx @@ -11,10 +11,10 @@ interface VibeTimelineProps { export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) { return (
-
- -

Incoming recommendations

- ({upcoming.length} buffered) +
+ +

Up next

+ {upcoming.length} buffered
{currentTrack && ( @@ -26,7 +26,9 @@ export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) { showActions={false} variant="compact" /> - Now playing + {/* Hidden under 640px: the badge sat on top of the title. The accent + title and the accent edge already mark the current row. */} + Now playing
)} @@ -35,7 +37,7 @@ export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) { No upcoming tracks buffered yet.
) : ( -
+
{upcoming.map((track, i) => ( +
{Array.from({ length: count }).map((_, i) => ( -
- +
+
- - + +
@@ -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 ( -
+
{Array.from({ length: count }).map((_, i) => (
diff --git a/frontend/src/index.css b/frontend/src/index.css index 833a388..e4a73c4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/lib/color.ts b/frontend/src/lib/color.ts index 90a6657..70e47b9 100644 --- a/frontend/src/lib/color.ts +++ b/frontend/src/lib/color.ts @@ -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); } /** diff --git a/frontend/src/lib/playbackPrefs.ts b/frontend/src/lib/playbackPrefs.ts index 1d5ad78..530ed2a 100644 --- a/frontend/src/lib/playbackPrefs.ts +++ b/frontend/src/lib/playbackPrefs.ts @@ -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 | 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 */ } +} diff --git a/frontend/src/pages/AlbumDetail.tsx b/frontend/src/pages/AlbumDetail.tsx index 3f89266..1dac40a 100644 --- a/frontend/src/pages/AlbumDetail.tsx +++ b/frontend/src/pages/AlbumDetail.tsx @@ -46,14 +46,14 @@ export default function AlbumDetail() { return rank(x) - rank(y); }); return ( - + -
-
- +
+
+
-
-

{data.title}

+
+

{data.title}

{artists.length > 0 && ( )} -

{data.year ? `${data.year} · ` : ''}{tracks.length} {tracks.length === 1 ? 'track' : 'tracks'}

+

{data.year ? `${data.year} · ` : ''}{tracks.length} tracks

-
+
{tracks.length === 0 ? } title="No tracks in this album" /> : tracks.map((t, i) => )}
diff --git a/frontend/src/pages/Albums.tsx b/frontend/src/pages/Albums.tsx index 1c3896e..7c79393 100644 --- a/frontend/src/pages/Albums.tsx +++ b/frontend/src/pages/Albums.tsx @@ -25,23 +25,27 @@ export default function Albums() { return ( - + {isLoading ? : isError ? } title="Couldn't load albums" subtitle="Something went wrong. Try reloading the page." /> : !albums?.length ? } title="No albums yet" subtitle="Run a library scan in Settings to populate it." /> : ( <> -
+
{albums.map((album) => (
- +
-
{album.title}
-
- {album.artist_name || 'Unknown artist'}{album.year ? ` · ${album.year}` : ''} +
{album.title}
+
+ {album.artist_name || 'Unknown artist'} + {album.year && {album.year}}
diff --git a/frontend/src/pages/ArtistDetail.tsx b/frontend/src/pages/ArtistDetail.tsx index 5e2a390..f99e5fa 100644 --- a/frontend/src/pages/ArtistDetail.tsx +++ b/frontend/src/pages/ArtistDetail.tsx @@ -23,28 +23,22 @@ export default function ArtistDetail() { - {/* Hero: blurred backdrop of the artist image + avatar + name */} -
-
- -
+ {/* ponytail: flat hero — the blurred-backdrop version was the frosted-glass trap */} +
+
+
-
-
- -
-
-
Artist
-

{data.name}

-

{albums.length} {albums.length === 1 ? 'album' : 'albums'}

-
+
+
Artist
+

{data.name}

+

{albums.length} albums

-
-

Albums

+
+

Albums

{albums.length === 0 ? } title="No albums by this artist" /> : ( -
+
{albums.map((album) => ( diff --git a/frontend/src/pages/Artists.tsx b/frontend/src/pages/Artists.tsx index 60bbb58..9353512 100644 --- a/frontend/src/pages/Artists.tsx +++ b/frontend/src/pages/Artists.tsx @@ -25,20 +25,23 @@ export default function Artists() { return ( - + {isLoading ? : isError ? } title="Couldn't load artists" subtitle="Something went wrong. Try reloading the page." /> : !artists?.length ? } title="No artists yet" subtitle="Run a library scan in Settings to populate it." /> : ( <> -
+
{artists.map((artist) => ( -
+ className="card-surface group items-center p-2"> +
-
{artist.name}
+
{artist.name}
))}
diff --git a/frontend/src/pages/Discover.tsx b/frontend/src/pages/Discover.tsx index 235aee4..00f6d7b 100644 --- a/frontend/src/pages/Discover.tsx +++ b/frontend/src/pages/Discover.tsx @@ -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 ( -
-

Discover

-

Browse by genre, then play tracks or start a vibe.

-
+ {genres.isLoading ? ( -

Loading genres…

+ ) : genres.isError ? ( -

Couldn't load genres.

+ } title="Couldn't load genres" subtitle="Something went wrong. Try reloading the page." /> ) : (genres.data ?? []).length === 0 ? ( -

No genres available yet.

+ } title="No genres yet" subtitle="Genres appear after you scan and enrich your library." /> ) : ( -
+
{(genres.data ?? []).map((genre) => { const active = selected?.id === genre.id; return ( ); @@ -81,8 +88,13 @@ export default function Discover() { {selected && (
-
-

{selected.name}

+
+

+ {selected.name} + + {(genreTracks.data?.length ?? 0).toLocaleString()} tracks + +

+ ); +} + export default function Genres() { const [selected, setSelected] = useState(null); const [genrePage, setGenrePage] = useState(0); @@ -36,22 +68,25 @@ export default function Genres() { return ( -
-

{selected.name}

- -
+ } + onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }} + disabled={!tracks.length} + > + Play all + + } + /> {tracksQ.isLoading ? : tracks.length === 0 ? } title="No tracks in this genre" /> : ( <> -
{tracks.map((t, i) => )}
+
{tracks.map((t, i) => )}
!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 ( -

Genres

+ {genresQ.isLoading ? : genresQ.isError ? } title="Couldn't load genres" subtitle="Something went wrong. Try reloading the page." /> : !genres.length ? } title="No genres yet" subtitle="Genres appear after you scan and enrich your library." /> : ( -
- {roots.map((genre) => { - const hue = hueFromString(genre.name); +
+ {roots.filter((g) => childrenOf(g.id).length > 0).map((genre) => { const subs = childrenOf(genre.id); return ( -
- +
+ {subs.length > 0 && ( -
- {subs.map((sub) => { - const subHue = hueFromString(sub.name); - return ( - - ); - })} +
+ {subs.map((sub) => ( + + ))}
)} -
- ); - })} - {/* 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 ( - +
); })} + {/* 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 && ( +
+ {flat.map((genre) => ( + + ))} +
+ )}
)} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 66d8644..d13cac4 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -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({ + 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 ( -
-

Good listening

-

Your music, your way.

-
- - {/* Vibe hero + quick shortcuts */} -
+ {/* 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. */} +
+
+

Good listening

+

+ {counts ?? 'reading library…'} +

+
- -
-
Start a Vibe
-
Endless recommendations from your library.
-
+ + Start a Vibe -
- {SHORTCUTS.map(({ label, icon: Icon, to }) => ( - - - {label} - - ))} -
diff --git a/frontend/src/pages/Jobs.tsx b/frontend/src/pages/Jobs.tsx index 0555d85..3f2d3d5 100644 --- a/frontend/src/pages/Jobs.tsx +++ b/frontend/src/pages/Jobs.tsx @@ -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 ( -
-
-
-

{label}

-

{value.toLocaleString()}

-
- -
+
+

{label}

+

{value.toLocaleString()}

); } @@ -384,29 +396,38 @@ export default function JobsPage() { return (
{/* ── Header ── */} -
+
-

Jobs

-

Background task queue monitoring

+

Jobs

+

+ {stats + ? `${stats.waiting + stats.active + stats.delayed} queued · ${stats.failed} failed` + : 'reading queue…'} + {autoRefresh ? ' · refresh 5s' : ' · refresh off'} +

-
+
-
@@ -451,16 +472,20 @@ export default function JobsPage() { {/* ── Overview tab ── */} {selectedTab === 'overview' && !stats && !loadError && ( -
Loading queue stats…
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
)} {selectedTab === 'overview' && stats && (
- - - - - - + + + + + +
)} diff --git a/frontend/src/pages/Quarantine.tsx b/frontend/src/pages/Quarantine.tsx index 83c39be..3964a98 100644 --- a/frontend/src/pages/Quarantine.tsx +++ b/frontend/src/pages/Quarantine.tsx @@ -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 ( -
-

- Quarantine -

-

Disliked tracks pending deletion. Restore before the timer expires.

-
+ {isLoading ? : isError ? } 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." /> ) : ( -
    +
      {entries.map((entry) => ( -
    • +
    • -
      - {entry.track_title} +
      + {entry.track_title} {stateLabel(entry.state)}
      -
      {entry.track_artist}
      -
      - {countdown(entry)} -
      +
      {entry.track_artist}
      -
      + {countdown(entry)} +
      @@ -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" > Delete diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx index bde1252..d04aec3 100644 --- a/frontend/src/pages/Search.tsx +++ b/frontend/src/pages/Search.tsx @@ -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 ( -
      -

      Search

      - {query.length > 0 ? ( -

      - {busy ? 'Searching' : `${tracks.length} ${tracks.length === 1 ? 'result' : 'results'}`} for{' '} - “{query}” -

      - ) : ( -

      Search your library from the bar above.

      - )} -
      + 0 ? `“${query}”` : 'Search your library from the bar above.'} + meta={query.length > 0 ? (busy ? 'searching…' : `${tracks.length} results`) : undefined} + /> {query.length === 0 ? ( } title="No results" subtitle={`Nothing matched “${query}”.`} /> ) : ( -
      +
      {tracks.map((t, i) => ( ))} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 5228483..2df74a0 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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 ( ); @@ -295,34 +296,31 @@ export default function Settings() { }; return ( - -
      -

      Settings

      -

      Preferences are stored locally in this browser.

      -
      + + -
      -

      Default volume

      +
      +

      Default volume

      handleVolume(Number(e.target.value))} className="flex-1" aria-label="Volume" /> - {Math.round(volume * 100)}% + {Math.round(volume * 100)}%
      -
      -

      Transitions

      +
      +

      Transitions

      @@ -332,7 +330,7 @@ export default function Settings() { setCrossfadeMs(Number(e.target.value))} className="flex-1" /> - + {crossfadeMs === 0 ? 'Off' : `${(crossfadeMs / 1000).toFixed(1)}s`}
      @@ -344,8 +342,8 @@ export default function Settings() {
-
-

Library

+
+

Library

Reprocess artists re-resolves canonical names, MBIDs and - images for every artist, and merges duplicates. Re-enrich metadata + images for every artist, and merges duplicates. Re-enrich metadata{' '} re-queries MusicBrainz/Discogs for all tracks (album titles, years, cover art) without re-scanning files. Both run in the background.

@@ -380,8 +378,8 @@ export default function Settings() {
-
-

About

+
+

About

Application
muzick
Version
0.1.0
diff --git a/frontend/src/pages/Tracks.tsx b/frontend/src/pages/Tracks.tsx index 6d81f9e..6283284 100644 --- a/frontend/src/pages/Tracks.tsx +++ b/frontend/src/pages/Tracks.tsx @@ -24,11 +24,14 @@ export default function Tracks() { return ( - + {isLoading ? : isError ? } title="Couldn't load tracks" subtitle="Something went wrong. Try reloading the page." /> : tracks.length === 0 ? } title={page === 0 ? 'No tracks yet' : 'No more tracks'} subtitle={page === 0 ? 'Run a library scan in Settings to populate it.' : undefined} /> - :
{tracks.map((t, i) => )}
} + :
{tracks.map((t, i) => )}
} {(tracks.length > 0 || page > 0) && ( { - 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 ( - -
-
- - Rolling Vibe -
-

Start a Vibe

-

- An infinite, ever-rolling stream of recommendations seeded from a track you love. -

-

- Pick a seed below and playback starts immediately, with a rolling - timeline of upcoming tracks. Keep what you love, - Dislike & skip what you don't — the vibe - adapts as you go. -

-
+ + {/* Three stacked paragraphs of explanation used to sit here. A seed, a + shuffle and a track list say the same thing by being used. */} + {error && (
@@ -127,17 +130,17 @@ export default function Vibe() {
)} -
+
{currentTrack && ( )} @@ -145,27 +148,29 @@ export default function Vibe() {
{!libraryLoading && seedTracks.length > 0 && (
-

Or pick a seed track

-
    +

    Or pick a seed track

    + {/* Two columns of the sampled 50 — one 320px-tall scroller showed 6 + of them and left the rest behind a scrollbar. */} +
      {seedTracks.map((track, index) => (
    • -
      -
      - -

      Vibing

      - {initialBatchStatus === 'loading' && } -
      - -
      + + {currentTrack && ( + + )} + + + } + /> {(empty || initialBatchStatus === 'exhausted' || initialBatchStatus === 'failed') && (
      @@ -218,27 +237,8 @@ export default function Vibe() {
      )} - {planVersion &&

      Plan revision {planVersion}; upcoming tracks may change as you listen.

      } - - {currentTrack && ( -
      - - -
      - )} - + {/* Keep is gone: letting a track finish already reports `completed`, + which the director weighs the same as an explicit keep. */}
      diff --git a/frontend/src/services/libraryService.ts b/frontend/src/services/libraryService.ts index 8b2157f..2617fdd 100644 --- a/frontend/src/services/libraryService.ts +++ b/frontend/src/services/libraryService.ts @@ -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 { + const res = await api.get('/library/stats'); + return res.data; +} + export { trackService } from './trackService'; export { artistService } from './artistService'; export { albumService } from './albumService'; diff --git a/frontend/src/services/vibeService.ts b/frontend/src/services/vibeService.ts index 3cdf1cb..eddf3a9 100644 --- a/frontend/src/services/vibeService.ts +++ b/frontend/src/services/vibeService.ts @@ -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 { - const res = await api.post('/v2/vibe/sessions', { seedTrackId }); + async start(seedTrackId?: string, context?: VibeCalendarContext): Promise { + const res = await api.post('/v2/vibe/sessions', { seedTrackId, context }); return res.data; }, diff --git a/frontend/src/services/vibeSession.test.ts b/frontend/src/services/vibeSession.test.ts index 6a610ea..289b23e 100644 --- a/frontend/src/services/vibeSession.test.ts +++ b/frontend/src/services/vibeSession.test.ts @@ -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 & { 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(); diff --git a/frontend/src/services/vibeSession.ts b/frontend/src/services/vibeSession.ts index 7502872..a8fad2b 100644 --- a/frontend/src/services/vibeSession.ts +++ b/frontend/src/services/vibeSession.ts @@ -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 | null = null; let advanceInFlight: Promise | null = null; let materialTail: Promise = 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 { }); } +/** + * 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()): 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 { 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 { 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 { if (startInFlight) return startInFlight; startInFlight = serializeMaterial(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 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 { try { if (sessionId) await vibeService.end(sessionId); } finally { + seedPendingTrackId = null; const playback = usePlaybackStore.getState(); playback.setVibeAdvanceHandler(null); useVibeStore.getState().reset(); diff --git a/frontend/src/store/usePlaybackStore.ts b/frontend/src/store/usePlaybackStore.ts index cb0b2a3..5e5ead7 100644 --- a/frontend/src/store/usePlaybackStore.ts +++ b/frontend/src/store/usePlaybackStore.ts @@ -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(); + export const usePlaybackStore = create((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((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); +}); diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index accf480..0caf016 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -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)', diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 27ede07..3da3662 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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: {