feat: enhance discovery, vibe sessions, and library enrichment
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled

This commit is contained in:
kami
2026-08-01 14:40:48 +04:00
parent a0c9f42a89
commit 4c48d11e9d
54 changed files with 4136 additions and 521 deletions
+87 -37
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Heart, Loader2, Play, Shuffle, ThumbsDown, Sparkles, Square } from 'lucide-react';
import { vibeService, fetchNextBatch } from '../services/vibeService';
import { startVibeSession } from '../services/vibeSession';
import { trackService } from '../services/trackService';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
@@ -12,22 +13,29 @@ import { VibeTimeline } from '../components/VibeTimeline';
import { suppressAutoFeedback } from '../components/AudioEngine';
import { toast } from '../store/useToastStore';
const INITIAL_BATCH_SIZE = 5;
const PREFETCH_THRESHOLD = 3;
const PREFETCH_BATCH_SIZE = 3;
const SEED_LIST_SIZE = 50;
function bestEffort(p: Promise<unknown>): void {
void p.catch(() => undefined);
}
function sampleTracks(tracks: Track[], count: number): Track[] {
const sampled = [...tracks];
for (let index = sampled.length - 1; index > 0; index--) {
const pick = Math.floor(Math.random() * (index + 1));
[sampled[index], sampled[pick]] = [sampled[pick], sampled[index]];
}
return sampled.slice(0, count);
}
export default function Vibe() {
const { currentTrack, queue, setQueue, playTrack, next: playNext, pause, setCurrentTrack } = usePlaybackStore();
const { currentTrack, queue, setQueue, next: playNext, pause, setCurrentTrack } = usePlaybackStore();
const {
activeSessionId,
buffer,
setActiveSession,
setSeedTrackId,
setCenterTrack,
initialBatchStatus,
setBuffer,
appendBuffer,
reset,
@@ -37,42 +45,44 @@ export default function Vibe() {
const [prefetching, setPrefetching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [empty, setEmpty] = useState(false);
const [refillStatus, setRefillStatus] = useState<'idle' | 'exhausted' | 'failed'>('idle');
const [refillAttempt, setRefillAttempt] = useState(0);
const prefetchingRef = useRef(false);
const startingRef = useRef(false);
const { data: libraryTracks = [], isLoading: libraryLoading } = useQuery<Track[]>({
queryKey: ['library-seed'],
queryFn: () => trackService.listTracks({ limit: 50, sort_by: 'play_count', order: 'DESC' }),
// Fetch the eligible library once so Surprise me is not restricted to the
// most-played 50 tracks. The backend excludes hidden/deleted tracks.
queryFn: () => trackService.listTracks({ limit: 5000, sort_by: 'title', order: 'ASC' }),
enabled: !activeSessionId,
});
const seedTracks = useMemo(() => sampleTracks(libraryTracks, SEED_LIST_SIZE), [libraryTracks]);
const startSession = useCallback(
async (seed: Track) => {
if (startingRef.current) return;
startingRef.current = true;
setStarting(true);
setError(null);
setEmpty(false);
setRefillStatus('idle');
try {
const { sessionId } = await vibeService.start(seed.id);
setActiveSession({ sessionId, seedTrackId: seed.id });
setSeedTrackId(seed.id);
setCenterTrack(seed);
const chunk = await fetchNextBatch(INITIAL_BATCH_SIZE);
if (chunk.length === 0) {
setBuffer([]);
const result = await startVibeSession(seed);
if (result.tracks.length === 0) {
setEmpty(true);
return;
if (result.status === 'failed') {
setError('Could not load recommendations for this vibe. Please try another seed.');
}
}
setBuffer(chunk);
setQueue(chunk);
playTrack(chunk[0]);
} catch {
setError('Could not start a vibe session. Please try again.');
reset();
} finally {
startingRef.current = false;
setStarting(false);
}
},
[setActiveSession, setSeedTrackId, setCenterTrack, setBuffer, setQueue, playTrack, reset]
[]
);
const startFromCurrent = useCallback(() => {
@@ -91,26 +101,36 @@ export default function Vibe() {
useEffect(() => {
if (!activeSessionId || prefetchingRef.current) return;
if (initialBatchStatus === 'loading') return;
if (refillStatus !== 'idle') return;
if (remaining > PREFETCH_THRESHOLD) return;
prefetchingRef.current = true;
setPrefetching(true);
fetchNextBatch(PREFETCH_BATCH_SIZE)
.then((chunk) => {
if (chunk.length > 0) {
appendBuffer(chunk);
fetchNextBatch(PREFETCH_BATCH_SIZE, activeSessionId)
.then((result) => {
if (result.tracks.length > 0) {
const current = usePlaybackStore.getState().queue;
const currentIds = new Set(current.map((t) => t.id));
const fresh = chunk.filter((t) => !currentIds.has(t.id));
if (fresh.length > 0) setQueue([...current, ...fresh]);
const fresh = result.tracks.filter((t) => !currentIds.has(t.id));
if (fresh.length > 0) {
appendBuffer(fresh);
setQueue([...current, ...fresh]);
}
if (result.status === 'exhausted' || fresh.length === 0) {
setRefillStatus('exhausted');
}
} else if (result.status === 'exhausted') {
setRefillStatus('exhausted');
} else {
setRefillStatus('failed');
}
})
.catch(() => undefined)
.finally(() => {
prefetchingRef.current = false;
setPrefetching(false);
});
}, [activeSessionId, remaining, appendBuffer, setQueue]);
}, [activeSessionId, initialBatchStatus, remaining, appendBuffer, refillAttempt, refillStatus, setQueue]);
// Trim buffer to prevent unbounded growth — keep only from currentTrack onward.
useEffect(() => {
@@ -123,14 +143,14 @@ export default function Vibe() {
const handleKeep = useCallback(() => {
if (currentTrack) {
bestEffort(vibeService.feedback(currentTrack.id, 'promoted'));
bestEffort(vibeService.feedback(currentTrack.id, 'promoted', activeSessionId ?? undefined));
toast.success(`Kept "${currentTrack.title}"`);
}
}, [currentTrack]);
const handleDislike = useCallback(() => {
if (currentTrack) {
bestEffort(vibeService.feedback(currentTrack.id, 'disliked'));
bestEffort(vibeService.feedback(currentTrack.id, 'disliked', activeSessionId ?? undefined));
// AudioEngine would otherwise also record a 'skipped' on the track
// change caused by playNext() below — suppress that duplicate.
suppressAutoFeedback(currentTrack.id);
@@ -146,8 +166,14 @@ export default function Vibe() {
reset();
setEmpty(false);
setError(null);
setRefillStatus('idle');
}, [reset, pause, setQueue, setCurrentTrack]);
const retryRefill = useCallback(() => {
setRefillStatus('idle');
setRefillAttempt((attempt) => attempt + 1);
}, []);
const upcoming = currentTrack
? (() => {
const idx = buffer.findIndex((t) => t.id === currentTrack.id);
@@ -210,24 +236,25 @@ export default function Vibe() {
? 'Loading your library…'
: libraryTracks.length === 0
? 'No library tracks available to seed a vibe.'
: 'Start from a random track in your library.'}
: `Start from a random track across ${libraryTracks.length.toLocaleString()} library tracks.`}
</div>
</div>
{starting && <Loader2 size={18} className="animate-spin text-muted" />}
</button>
</div>
{!libraryLoading && libraryTracks.length > 0 && (
{!libraryLoading && seedTracks.length > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-semibold text-muted">Or pick a seed track</h2>
<ul className="max-h-72 space-y-1 overflow-y-auto">
{libraryTracks.map((track, index) => (
{seedTracks.map((track, index) => (
<li key={track.id}>
<TrackRow
track={track}
queue={libraryTracks}
queue={seedTracks}
index={index}
showActions={false}
onSelect={startSession}
/>
</li>
))}
@@ -256,9 +283,32 @@ export default function Vibe() {
</button>
</header>
{empty && (
{(empty || initialBatchStatus === 'exhausted' || initialBatchStatus === 'failed') && (
<div className="rounded-lg border border-border bg-surface0 p-4 text-sm text-muted">
No recommendations came back for this seed yet. Try ending and starting a different vibe.
{initialBatchStatus === 'failed'
? 'Could not load recommendations for this Vibe. Try starting a different one.'
: 'No recommendations came back for this seed yet. Try ending and starting a different vibe.'}
</div>
)}
{error && (
<div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
{error}
</div>
)}
{refillStatus === 'exhausted' && !empty && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-200">
This Vibe has no new recommendations to add. Playback will stop when the current queue ends; start a new Vibe to continue.
</div>
)}
{refillStatus === 'failed' && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
<span>Couldn&apos;t refresh the Vibe recommendations. Playback will stop when the current queue ends.</span>
<button onClick={retryRefill} className="flex-none rounded border border-red-400/50 px-2 py-1 text-xs hover:bg-red-500/10">
Try again
</button>
</div>
)}