feat(vibe): visualize the listening profile
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { VibeAura } from './VibeAura';
|
||||||
|
|
||||||
|
describe('VibeAura', () => {
|
||||||
|
it('turns the live recommendation profile into an accessible ambient state', () => {
|
||||||
|
render(<VibeAura profile={{ energy: 0.82, noveltyHunger: 0.78 }} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('img', { name: 'Current Vibe: charged energy and adventurous discovery' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Your Vibe is charged')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
export interface VibeProfile {
|
||||||
|
energy?: number;
|
||||||
|
noveltyHunger?: number;
|
||||||
|
explorationCoefficient?: number;
|
||||||
|
discoveryRadius?: number;
|
||||||
|
sessionGoal?: { type?: string; progress?: number; target?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (value: number | undefined, fallback: number) =>
|
||||||
|
Math.min(1, Math.max(0, typeof value === 'number' ? value : fallback));
|
||||||
|
|
||||||
|
function describeProfile(energy: number, novelty: number) {
|
||||||
|
const energyLabel = energy < 0.36 ? 'settled' : energy > 0.68 ? 'charged' : 'flowing';
|
||||||
|
const discoveryLabel = novelty < 0.36 ? 'familiar' : novelty > 0.64 ? 'adventurous' : 'balanced';
|
||||||
|
return { energyLabel, discoveryLabel };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A small, ambient representation of the live recommendation profile. */
|
||||||
|
export function VibeAura({ profile }: { profile: VibeProfile }) {
|
||||||
|
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);
|
||||||
|
const style = {
|
||||||
|
'--vibe-hue': String(hue),
|
||||||
|
'--vibe-pulse': `${(4.8 - energy * 2.3).toFixed(2)}s`,
|
||||||
|
'--vibe-orbit': `${(11 - energy * 4).toFixed(2)}s`,
|
||||||
|
'--vibe-scale': String((0.9 + energy * 0.16).toFixed(2)),
|
||||||
|
} as CSSProperties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group relative grid h-16 w-16 shrink-0 place-items-center rounded-full focus:outline-none"
|
||||||
|
style={style}
|
||||||
|
role="img"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Current Vibe: ${energyLabel} energy and ${discoveryLabel} discovery`}
|
||||||
|
>
|
||||||
|
<div className="vibe-aura-halo" />
|
||||||
|
<div className="vibe-aura-orbit vibe-aura-orbit-one" />
|
||||||
|
<div className="vibe-aura-orbit vibe-aura-orbit-two" />
|
||||||
|
<div className="vibe-aura-core">
|
||||||
|
<span className="vibe-aura-spark vibe-aura-spark-one" />
|
||||||
|
<span className="vibe-aura-spark vibe-aura-spark-two" />
|
||||||
|
<span className="vibe-aura-heart" />
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute right-0 top-[calc(100%+0.4rem)] z-10 w-44 rounded-md border border-border bg-surface1 px-3 py-2 text-xs leading-relaxed text-muted opacity-0 shadow-xl transition-opacity group-hover:opacity-100 group-focus:opacity-100">
|
||||||
|
<span className="block font-medium text-text">Your Vibe is {energyLabel}</span>
|
||||||
|
<span>Leaning {discoveryLabel}; it shifts as you listen.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -183,6 +183,68 @@ html { scroll-behavior: smooth; }
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Vibe's profile aura: a small, old-player-inspired creature whose color and
|
||||||
|
movement come from the active recommendation state. */
|
||||||
|
@keyframes vibe-aura-pulse {
|
||||||
|
0%, 100% { transform: scale(var(--vibe-scale)) rotate(-7deg); filter: brightness(.9); }
|
||||||
|
50% { transform: scale(calc(var(--vibe-scale) * 1.12)) rotate(8deg); filter: brightness(1.28); }
|
||||||
|
}
|
||||||
|
@keyframes vibe-aura-orbit {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
@keyframes vibe-aura-spark {
|
||||||
|
0%, 100% { transform: translateY(0) scale(.75); opacity: .38; }
|
||||||
|
50% { transform: translateY(-5px) scale(1.15); opacity: 1; }
|
||||||
|
}
|
||||||
|
.vibe-aura-halo {
|
||||||
|
position: absolute;
|
||||||
|
inset: 7px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: hsl(var(--vibe-hue) 88% 62% / .22);
|
||||||
|
filter: blur(12px);
|
||||||
|
animation: vibe-aura-pulse var(--vibe-pulse) ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.vibe-aura-core {
|
||||||
|
position: relative;
|
||||||
|
width: 30px;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid hsl(var(--vibe-hue) 95% 86% / .72);
|
||||||
|
border-radius: 46% 54% 58% 42% / 43% 45% 55% 57%;
|
||||||
|
background: radial-gradient(circle at 36% 28%, hsl(var(--vibe-hue) 100% 93%), hsl(var(--vibe-hue) 88% 61%) 38%, hsl(calc(var(--vibe-hue) + 35) 68% 31%) 100%);
|
||||||
|
box-shadow: inset 3px 2px 8px hsl(0 0% 100% / .34), 0 0 17px hsl(var(--vibe-hue) 92% 59% / .78);
|
||||||
|
animation: vibe-aura-pulse var(--vibe-pulse) ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.vibe-aura-orbit {
|
||||||
|
position: absolute;
|
||||||
|
width: 47px;
|
||||||
|
height: 47px;
|
||||||
|
border: 1px solid hsl(var(--vibe-hue) 90% 76% / .36);
|
||||||
|
border-radius: 47% 53% 43% 57%;
|
||||||
|
animation: vibe-aura-orbit var(--vibe-orbit) linear infinite;
|
||||||
|
}
|
||||||
|
.vibe-aura-orbit-two {
|
||||||
|
width: 56px;
|
||||||
|
height: 37px;
|
||||||
|
border-color: hsl(calc(var(--vibe-hue) + 55) 90% 76% / .24);
|
||||||
|
animation-direction: reverse;
|
||||||
|
animation-duration: calc(var(--vibe-orbit) * 1.45);
|
||||||
|
}
|
||||||
|
.vibe-aura-heart,
|
||||||
|
.vibe-aura-spark {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: hsl(0 0% 100% / .9);
|
||||||
|
box-shadow: 0 0 8px hsl(0 0% 100% / .92);
|
||||||
|
}
|
||||||
|
.vibe-aura-heart { width: 7px; height: 7px; left: 11px; top: 15px; }
|
||||||
|
.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; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.vibe-aura-halo, .vibe-aura-core, .vibe-aura-orbit, .vibe-aura-spark { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Component base classes ────────────────────────────────────────────────── */
|
/* ── Component base classes ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/* Card surface — standard container */
|
/* Card surface — standard container */
|
||||||
|
|||||||
+12
-24
@@ -9,6 +9,7 @@ import { TrackRow } from '../components/TrackRow';
|
|||||||
import { PageContainer } from '../components/PageContainer';
|
import { PageContainer } from '../components/PageContainer';
|
||||||
import type { Track } from '../types';
|
import type { Track } from '../types';
|
||||||
import { VibeTimeline } from '../components/VibeTimeline';
|
import { VibeTimeline } from '../components/VibeTimeline';
|
||||||
|
import { VibeAura } from '../components/VibeAura';
|
||||||
import { toast } from '../store/useToastStore';
|
import { toast } from '../store/useToastStore';
|
||||||
|
|
||||||
const SEED_LIST_SIZE = 50;
|
const SEED_LIST_SIZE = 50;
|
||||||
@@ -22,19 +23,6 @@ function sampleTracks(tracks: Track[], count: number): Track[] {
|
|||||||
return sampled.slice(0, count);
|
return sampled.slice(0, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VibeLearningHint() {
|
|
||||||
return (
|
|
||||||
<details className="rounded-lg border border-border bg-surface0 px-4 py-3 text-sm text-muted">
|
|
||||||
<summary className="cursor-pointer font-medium text-text">How Vibe learns</summary>
|
|
||||||
<div className="mt-3 space-y-2 text-xs leading-relaxed text-muted">
|
|
||||||
<p>Your seed track sets the first direction. From there, Vibe learns from what you finish, skip, dislike, or keep, alongside your library history and favourites.</p>
|
|
||||||
<p>It also avoids recent repeats and reshapes the upcoming queue as you listen.</p>
|
|
||||||
<p className="text-muted/70">It does not infer your location, weather, microphone input, or device activity.</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Vibe() {
|
export default function Vibe() {
|
||||||
const { currentTrack } = usePlaybackStore();
|
const { currentTrack } = usePlaybackStore();
|
||||||
const {
|
const {
|
||||||
@@ -42,6 +30,7 @@ export default function Vibe() {
|
|||||||
buffer,
|
buffer,
|
||||||
initialBatchStatus,
|
initialBatchStatus,
|
||||||
planVersion,
|
planVersion,
|
||||||
|
profile,
|
||||||
} = useVibeStore();
|
} = useVibeStore();
|
||||||
|
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
@@ -132,8 +121,6 @@ export default function Vibe() {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<VibeLearningHint />
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
|
<div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
|
||||||
{error}
|
{error}
|
||||||
@@ -206,17 +193,18 @@ export default function Vibe() {
|
|||||||
<h1 className="text-2xl font-bold text-text">Vibing</h1>
|
<h1 className="text-2xl font-bold text-text">Vibing</h1>
|
||||||
{initialBatchStatus === 'loading' && <Loader2 size={16} className="animate-spin text-muted" />}
|
{initialBatchStatus === 'loading' && <Loader2 size={16} className="animate-spin text-muted" />}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
onClick={handleEnd}
|
<VibeAura profile={profile} />
|
||||||
className="flex items-center gap-2 rounded-lg border border-border px-3 py-1.5 text-sm text-text transition-colors hover:border-red-500/60 hover:text-red-300"
|
<button
|
||||||
>
|
onClick={handleEnd}
|
||||||
<Square size={14} />
|
className="flex items-center gap-2 rounded-lg border border-border px-3 py-1.5 text-sm text-text transition-colors hover:border-red-500/60 hover:text-red-300"
|
||||||
End Vibe
|
>
|
||||||
</button>
|
<Square size={14} />
|
||||||
|
End Vibe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<VibeLearningHint />
|
|
||||||
|
|
||||||
{(empty || initialBatchStatus === 'exhausted' || initialBatchStatus === 'failed') && (
|
{(empty || initialBatchStatus === 'exhausted' || initialBatchStatus === 'failed') && (
|
||||||
<div className="rounded-lg border border-border bg-surface0 p-4 text-sm text-muted">
|
<div className="rounded-lg border border-border bg-surface0 p-4 text-sm text-muted">
|
||||||
{initialBatchStatus === 'failed'
|
{initialBatchStatus === 'failed'
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ async function reconcilePreview(sessionId: string, response: DurableVibeSessionR
|
|||||||
if (!isCurrentVibeOwner(sessionId)) return [];
|
if (!isCurrentVibeOwner(sessionId)) return [];
|
||||||
const preview = await hydratePreview(response.preview);
|
const preview = await hydratePreview(response.preview);
|
||||||
if (!isCurrentVibeOwner(sessionId) || !useVibeStore.getState().setPlan(response.planVersion, preview)) return [];
|
if (!isCurrentVibeOwner(sessionId) || !useVibeStore.getState().setPlan(response.planVersion, preview)) return [];
|
||||||
|
useVibeStore.getState().setProfile(response.state);
|
||||||
replaceUnplayedQueue(preview);
|
replaceUnplayedQueue(preview);
|
||||||
return preview;
|
return preview;
|
||||||
}
|
}
|
||||||
@@ -321,6 +322,7 @@ export function advanceVibe(reason: VibeAdvanceReason): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!useVibeStore.getState().setPlan(served.response.planVersion, served.preview)) return;
|
if (!useVibeStore.getState().setPlan(served.response.planVersion, served.preview)) return;
|
||||||
|
useVibeStore.getState().setProfile(served.response.state);
|
||||||
useVibeStore.getState().setCurrentPlanItem(served.response.now);
|
useVibeStore.getState().setCurrentPlanItem(served.response.now);
|
||||||
replaceUnplayedQueue([served.now, ...served.preview]);
|
replaceUnplayedQueue([served.now, ...served.preview]);
|
||||||
usePlaybackStore.getState().advance();
|
usePlaybackStore.getState().advance();
|
||||||
@@ -367,6 +369,7 @@ export function advancePastUnplayableVibeTrack(trackId: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!useVibeStore.getState().setPlan(served.response.planVersion, served.preview)) return;
|
if (!useVibeStore.getState().setPlan(served.response.planVersion, served.preview)) return;
|
||||||
|
useVibeStore.getState().setProfile(served.response.state);
|
||||||
useVibeStore.getState().setCurrentPlanItem(served.response.now);
|
useVibeStore.getState().setCurrentPlanItem(served.response.now);
|
||||||
replaceUnplayedQueue([served.now, ...served.preview]);
|
replaceUnplayedQueue([served.now, ...served.preview]);
|
||||||
playback.advance();
|
playback.advance();
|
||||||
@@ -403,6 +406,7 @@ export async function startVibeSession(seed: Track): Promise<StartedVibeSession>
|
|||||||
vibe.setActiveSession({ sessionId: started.sessionId, seedTrackId: seed.id });
|
vibe.setActiveSession({ sessionId: started.sessionId, seedTrackId: seed.id });
|
||||||
vibe.setCenterTrack(seed);
|
vibe.setCenterTrack(seed);
|
||||||
vibe.setPlan(served.response.planVersion, served.preview);
|
vibe.setPlan(served.response.planVersion, served.preview);
|
||||||
|
vibe.setProfile(served.response.state);
|
||||||
vibe.setCurrentPlanItem(served.response.now);
|
vibe.setCurrentPlanItem(served.response.now);
|
||||||
vibe.setInitialBatchStatus('idle');
|
vibe.setInitialBatchStatus('idle');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Track, VibeSession } from '../types';
|
import type { Track, VibeSession } from '../types';
|
||||||
import type { VibePlanItem } from '../services/vibeService';
|
import type { VibePlanItem } from '../services/vibeService';
|
||||||
|
import type { VibeProfile } from '../components/VibeAura';
|
||||||
|
|
||||||
// The durable plan is authoritative. `buffer` is only its currently
|
// The durable plan is authoritative. `buffer` is only its currently
|
||||||
// uncommitted, hydrated preview; it may be replaced at any feedback boundary.
|
// uncommitted, hydrated preview; it may be replaced at any feedback boundary.
|
||||||
@@ -10,6 +11,7 @@ interface VibeState {
|
|||||||
planVersion: number | null;
|
planVersion: number | null;
|
||||||
/** Durable cursor for the track currently in Vibe playback. */
|
/** Durable cursor for the track currently in Vibe playback. */
|
||||||
currentPlanItem: VibePlanItem | null;
|
currentPlanItem: VibePlanItem | null;
|
||||||
|
profile: VibeProfile;
|
||||||
centerTrack: Track | null;
|
centerTrack: Track | null;
|
||||||
buffer: Track[];
|
buffer: Track[];
|
||||||
initialBatchStatus: 'idle' | 'loading' | 'exhausted' | 'failed';
|
initialBatchStatus: 'idle' | 'loading' | 'exhausted' | 'failed';
|
||||||
@@ -20,6 +22,7 @@ interface VibeState {
|
|||||||
/** Returns false when a response belongs to an older plan revision. */
|
/** Returns false when a response belongs to an older plan revision. */
|
||||||
setPlan: (planVersion: number | null, preview: Track[]) => boolean;
|
setPlan: (planVersion: number | null, preview: Track[]) => boolean;
|
||||||
setCurrentPlanItem: (item: VibePlanItem | null) => void;
|
setCurrentPlanItem: (item: VibePlanItem | null) => void;
|
||||||
|
setProfile: (profile: VibeProfile) => void;
|
||||||
setInitialBatchStatus: (status: VibeState['initialBatchStatus']) => void;
|
setInitialBatchStatus: (status: VibeState['initialBatchStatus']) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -29,6 +32,7 @@ const initialState = {
|
|||||||
seedTrackId: null as string | null,
|
seedTrackId: null as string | null,
|
||||||
planVersion: null as number | null,
|
planVersion: null as number | null,
|
||||||
currentPlanItem: null as VibePlanItem | null,
|
currentPlanItem: null as VibePlanItem | null,
|
||||||
|
profile: {} as VibeProfile,
|
||||||
centerTrack: null as Track | null,
|
centerTrack: null as Track | null,
|
||||||
buffer: [] as Track[],
|
buffer: [] as Track[],
|
||||||
initialBatchStatus: 'idle' as const,
|
initialBatchStatus: 'idle' as const,
|
||||||
@@ -51,6 +55,7 @@ export const useVibeStore = create<VibeState>((set, get) => ({
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
setCurrentPlanItem: (currentPlanItem) => set({ currentPlanItem }),
|
setCurrentPlanItem: (currentPlanItem) => set({ currentPlanItem }),
|
||||||
|
setProfile: (profile) => set({ profile }),
|
||||||
setInitialBatchStatus: (initialBatchStatus) => set({ initialBatchStatus }),
|
setInitialBatchStatus: (initialBatchStatus) => set({ initialBatchStatus }),
|
||||||
reset: () => set({ ...initialState }),
|
reset: () => set({ ...initialState }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user