feat(vibe): reconcile mutable session previews in playback

This commit is contained in:
kami
2026-08-01 23:47:02 +04:00
parent 51ef7c84db
commit 57df1cfe9f
20 changed files with 1311 additions and 312 deletions
+14 -110
View File
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, 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 { advanceVibe, endVibeSession, reportVibeEvent, startVibeSession, vibeErrorMessage } from '../services/vibeSession';
import { trackService } from '../services/trackService';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
@@ -10,17 +9,10 @@ import { TrackRow } from '../components/TrackRow';
import { PageContainer } from '../components/PageContainer';
import type { Track } from '../types';
import { VibeTimeline } from '../components/VibeTimeline';
import { suppressAutoFeedback } from '../components/AudioEngine';
import { toast } from '../store/useToastStore';
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--) {
@@ -31,23 +23,17 @@ function sampleTracks(tracks: Track[], count: number): Track[] {
}
export default function Vibe() {
const { currentTrack, queue, setQueue, next: playNext, pause, setCurrentTrack } = usePlaybackStore();
const { currentTrack } = usePlaybackStore();
const {
activeSessionId,
buffer,
initialBatchStatus,
setBuffer,
appendBuffer,
reset,
planVersion,
} = useVibeStore();
const [starting, setStarting] = useState(false);
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[]>({
@@ -66,17 +52,14 @@ export default function Vibe() {
setStarting(true);
setError(null);
setEmpty(false);
setRefillStatus('idle');
try {
const result = await startVibeSession(seed);
if (result.tracks.length === 0) {
setEmpty(true);
if (result.status === 'failed') {
setError('Could not load recommendations for this vibe. Please try another seed.');
}
setError('Could not load recommendations for this vibe. Please try another seed.');
}
} catch {
setError('Could not start a vibe session. Please try again.');
} catch (startError) {
setError(vibeErrorMessage(startError));
} finally {
startingRef.current = false;
setStarting(false);
@@ -95,91 +78,25 @@ export default function Vibe() {
void startSession(seed);
}, [libraryTracks, startSession]);
const remaining = currentTrack
? queue.length - (queue.findIndex((t) => t.id === currentTrack.id) + 1)
: queue.length;
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, activeSessionId)
.then((result) => {
if (result.tracks.length > 0) {
const current = usePlaybackStore.getState().queue;
const currentIds = new Set(current.map((t) => t.id));
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');
}
})
.finally(() => {
prefetchingRef.current = false;
setPrefetching(false);
});
}, [activeSessionId, initialBatchStatus, remaining, appendBuffer, refillAttempt, refillStatus, setQueue]);
// Trim buffer to prevent unbounded growth — keep only from currentTrack onward.
useEffect(() => {
if (!activeSessionId || !currentTrack || buffer.length === 0) return;
const idx = buffer.findIndex((t) => t.id === currentTrack.id);
if (idx > 0) {
setBuffer(buffer.slice(idx));
}
}, [activeSessionId, currentTrack, buffer, setBuffer]);
const handleKeep = useCallback(() => {
if (currentTrack) {
bestEffort(vibeService.feedback(currentTrack.id, 'promoted', activeSessionId ?? undefined));
void reportVibeEvent('kept', currentTrack.id).catch((feedbackError) => setError(vibeErrorMessage(feedbackError)));
toast.success(`Kept "${currentTrack.title}"`);
}
}, [currentTrack]);
const handleDislike = useCallback(() => {
if (currentTrack) {
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);
void advanceVibe('disliked').catch((feedbackError) => setError(vibeErrorMessage(feedbackError)));
}
playNext();
}, [currentTrack, playNext]);
}, [currentTrack]);
const handleEnd = useCallback(() => {
// V2 plan expires via Redis TTL (2h). No explicit end endpoint.
pause();
setQueue([]);
setCurrentTrack(null);
reset();
void endVibeSession().catch((endError) => setError(vibeErrorMessage(endError)));
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);
return idx >= 0 ? buffer.slice(idx + 1) : buffer;
})()
: buffer;
const upcoming = buffer;
// ---- Start screen (no active session) ----
if (!activeSessionId) {
@@ -272,7 +189,7 @@ export default function Vibe() {
<div className="flex items-center gap-2">
<Sparkles size={22} className="text-accent" />
<h1 className="text-2xl font-bold text-text">Vibing</h1>
{prefetching && <Loader2 size={16} className="animate-spin text-muted" />}
{initialBatchStatus === 'loading' && <Loader2 size={16} className="animate-spin text-muted" />}
</div>
<button
onClick={handleEnd}
@@ -297,20 +214,7 @@ export default function Vibe() {
</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>
)}
{planVersion && <p className="text-xs text-muted/70">Plan revision {planVersion}; upcoming tracks may change as you listen.</p>}
{currentTrack && (
<div className="flex items-center gap-3">