feat: enhance discovery, vibe sessions, and library enrichment
This commit is contained in:
+87
-37
@@ -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'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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user