From 93619824d87e1761ff3d11df63aa2890a675d835 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 5 Aug 2026 23:53:46 +0400 Subject: [PATCH] feat(ui): rework every page for comfort, and make a seeded Vibe play its seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/components/Artwork.tsx | 12 +- frontend/src/components/NowPlayingPanel.tsx | 2 +- frontend/src/components/PageContainer.tsx | 13 +- frontend/src/components/PageHeader.tsx | 40 ++--- frontend/src/components/PlaybackBar.tsx | 13 +- frontend/src/components/TopBar.tsx | 7 +- frontend/src/components/TrackRow.tsx | 71 ++++---- frontend/src/components/VibeAura.tsx | 14 +- frontend/src/components/VibeTimeline.test.tsx | 29 +++- frontend/src/components/VibeTimeline.tsx | 14 +- frontend/src/components/ethos/Skeleton.tsx | 12 +- frontend/src/index.css | 43 ++++- frontend/src/lib/color.ts | 9 +- frontend/src/lib/playbackPrefs.ts | 21 +++ frontend/src/pages/AlbumDetail.tsx | 16 +- frontend/src/pages/Albums.tsx | 16 +- frontend/src/pages/ArtistDetail.tsx | 28 ++- frontend/src/pages/Artists.tsx | 13 +- frontend/src/pages/Discover.tsx | 52 +++--- frontend/src/pages/Genres.tsx | 125 +++++++------ frontend/src/pages/Home.tsx | 63 +++---- frontend/src/pages/Jobs.tsx | 91 ++++++---- frontend/src/pages/Quarantine.tsx | 34 ++-- frontend/src/pages/Search.tsx | 19 +- frontend/src/pages/Settings.tsx | 42 +++-- frontend/src/pages/Tracks.tsx | 7 +- frontend/src/pages/Vibe.tsx | 164 +++++++++--------- frontend/src/services/libraryService.ts | 16 ++ frontend/src/services/vibeService.ts | 12 +- frontend/src/services/vibeSession.test.ts | 61 +++++-- frontend/src/services/vibeSession.ts | 93 +++++++++- frontend/src/store/usePlaybackStore.ts | 24 ++- frontend/tailwind.config.js | 1 + frontend/vite.config.js | 4 +- 34 files changed, 745 insertions(+), 436 deletions(-) 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: {