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
@@ -0,0 +1,45 @@
import { render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
const { advancePastUnplayableVibeTrack, reportVibeEvent } = vi.hoisted(() => ({
advancePastUnplayableVibeTrack: vi.fn().mockResolvedValue(undefined),
reportVibeEvent: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../services/vibeSession', () => ({ advancePastUnplayableVibeTrack, reportVibeEvent }));
import { AudioEngine } from './AudioEngine';
const track = (id: string): Track => ({
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist', album_id: 'album',
duration: 180, state: 'LIBRARY', source_type: 'MANUAL', play_count: 0, skip_count: 0, dislike_count: 0,
});
describe('AudioEngine', () => {
beforeEach(() => {
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined);
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
useVibeStore.getState().reset();
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'song' });
usePlaybackStore.setState({
currentTrack: track('song'), queue: [track('song')], currentIndex: 0, isPlaying: false,
queueOwner: 'vibe', vibeAdvanceHandler: () => undefined,
});
});
afterEach(() => vi.restoreAllMocks());
it('uses the durable unplayable advancement when a Vibe stream errors after metadata resolved', async () => {
const { container } = render(<AudioEngine />);
const audio = container.querySelector('audio')!;
audio.dispatchEvent(new Event('error'));
audio.dispatchEvent(new Event('error'));
await waitFor(() => expect(advancePastUnplayableVibeTrack).toHaveBeenCalledWith('song'));
expect(advancePastUnplayableVibeTrack).toHaveBeenCalledTimes(1);
expect(reportVibeEvent).not.toHaveBeenCalledWith('skipped', 'song');
});
});
+35 -27
View File
@@ -2,17 +2,9 @@ import { useEffect, useRef } from 'react';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import { trackService } from '../services/trackService';
import { vibeService } from '../services/vibeService';
import { advancePastUnplayableVibeTrack, reportVibeEvent } from '../services/vibeSession';
import type { Track } from '../types';
// Track ids whose next natural feedback transition should be skipped because
// the caller (e.g. Vibe.tsx's dislike button) already recorded feedback for
// them explicitly. Consumed once, then cleared.
const suppressedFeedbackIds = new Set<string>();
export function suppressAutoFeedback(trackId: string): void {
suppressedFeedbackIds.add(trackId);
}
// Threshold (seconds) above which a store position change is treated as a user
// scrub and applied to the audio element. Keeps the timeupdate -> setPosition ->
// effect loop from fighting itself.
@@ -48,6 +40,8 @@ export const AudioEngine = () => {
const endedNaturallyRef = useRef(false);
// Track whether the current track has crossed the completion threshold.
const crossedThresholdRef = useRef(false);
const lastProgressSecondRef = useRef(-1);
const streamErrorTrackIdRef = useRef<string | null>(null);
// --- DOM -> store: media events -----------------------------------------
useEffect(() => {
@@ -66,6 +60,13 @@ export const AudioEngine = () => {
) {
crossedThresholdRef.current = true;
}
const vibe = useVibeStore.getState();
const track = store().currentTrack;
const elapsed = Math.floor(audio.currentTime);
if (vibe.activeSessionId && store().queueOwner === 'vibe' && track && elapsed > 0 && elapsed % 30 === 0 && elapsed !== lastProgressSecondRef.current) {
lastProgressSecondRef.current = elapsed;
void reportVibeEvent('progress', track.id, Math.round(audio.currentTime * 1000), Math.round((audio.duration || 0) * 1000)).catch(() => undefined);
}
};
const onLoadedMetadata = () => {
if (Number.isFinite(audio.duration)) store().setDuration(audio.duration);
@@ -83,7 +84,20 @@ export const AudioEngine = () => {
// feedback, on the resulting track-change, so completion is recorded
// exactly once per track.
endedNaturallyRef.current = true;
store().next();
store().nextWithReason('completed');
};
const onError = () => {
const playback = store();
const track = playback.currentTrack;
const vibe = useVibeStore.getState();
// Metadata can be available while the stream itself is no longer
// readable. Vibe must advance that exact durable cursor, not fall back
// to ordinary queue navigation or feedback-driven replanning.
if (!track || !vibe.activeSessionId || playback.queueOwner !== 'vibe' || streamErrorTrackIdRef.current === track.id) return;
streamErrorTrackIdRef.current = track.id;
void advancePastUnplayableVibeTrack(track.id)
.catch(() => undefined)
.finally(() => { streamErrorTrackIdRef.current = null; });
};
audio.addEventListener('timeupdate', onTimeUpdate);
@@ -91,6 +105,7 @@ export const AudioEngine = () => {
audio.addEventListener('play', onPlay);
audio.addEventListener('pause', onPause);
audio.addEventListener('ended', onEnded);
audio.addEventListener('error', onError);
return () => {
audio.removeEventListener('timeupdate', onTimeUpdate);
@@ -98,6 +113,7 @@ export const AudioEngine = () => {
audio.removeEventListener('play', onPlay);
audio.removeEventListener('pause', onPause);
audio.removeEventListener('ended', onEnded);
audio.removeEventListener('error', onError);
};
}, []);
@@ -109,28 +125,19 @@ export const AudioEngine = () => {
const applyTrack = (id: string | null) => {
if (id === loadedIdRef.current) return;
// The previously loaded track is changing. If it didn't end naturally and
// hadn't crossed the completion threshold, record a skip (best-effort).
// If it crossed the threshold OR ended naturally, record as completed.
// The durable Vibe controller owns normal next/ended navigation. It
// records the outcome, receives a new plan revision, then calls the raw
// advance method. Do not emit a second event here after that transition.
const prevId = loadedIdRef.current;
const completed = endedNaturallyRef.current || crossedThresholdRef.current;
// Only vibe sessions want this feedback — plain library browsing
// shouldn't write skip/completed evidence for tracks merely sampled.
const inVibeSession = !!useVibeStore.getState().activeSessionId;
if (prevId && inVibeSession) {
if (suppressedFeedbackIds.delete(prevId)) {
// Caller already recorded explicit feedback (e.g. dislike) for
// this track — don't also record the implicit transition.
} else {
try {
void vibeService.feedback(prevId, completed ? 'completed' : 'skipped', useVibeStore.getState().activeSessionId ?? undefined).catch(() => {});
} catch {
/* best-effort */
}
}
const playback = usePlaybackStore.getState();
const inVibePlayback = !!useVibeStore.getState().activeSessionId && playback.queueOwner === 'vibe';
if (prevId && inVibePlayback && !playback.vibeAdvanceHandler) {
void reportVibeEvent(completed ? 'completed' : 'skipped', prevId).catch(() => undefined);
}
endedNaturallyRef.current = false;
crossedThresholdRef.current = false;
lastProgressSecondRef.current = -1;
loadedIdRef.current = id;
if (!id) {
@@ -141,6 +148,7 @@ export const AudioEngine = () => {
audio.src = trackService.getStreamUrl(id);
audio.load();
if (inVibePlayback) void reportVibeEvent('playback_started', id).catch(() => undefined);
if (usePlaybackStore.getState().isPlaying) {
void audio.play().catch(() => {});
}
+10 -3
View File
@@ -26,9 +26,11 @@ interface TrackRowProps {
showVibe?: boolean;
/** Override ordinary queue playback, for contextual actions such as Vibe seed rows. */
onSelect?: (track: Track) => void;
/** Display-only rows keep their surrounding playback controller authoritative. */
playable?: boolean;
}
export function TrackRow({ track, queue, showActions = true, variant = 'default', showVibe = false, onSelect }: TrackRowProps) {
export function TrackRow({ track, queue, showActions = true, variant = 'default', showVibe = false, onSelect, playable = true }: TrackRowProps) {
const { setQueue, playTrack, play, pause, currentTrack, isPlaying } = usePlaybackStore();
const dislikeTrack = useDislikeTrack();
const router = useRouter();
@@ -36,6 +38,7 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
const compact = variant === 'compact';
const handlePlay = () => {
if (!playable) return;
if (onSelect) {
onSelect(track);
return;
@@ -47,7 +50,9 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
playTrack(track);
};
const playLabel = isCurrent && isPlaying
const playLabel = !playable
? `${track.title || 'Track'} is queued by Vibe`
: isCurrent && isPlaying
? `Pause ${track.title || 'track'}`
: `Play ${track.title || 'track'}`;
@@ -81,8 +86,9 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
<button
type="button"
onClick={handlePlay}
disabled={!playable}
aria-label={playLabel}
className={`relative flex flex-none items-center justify-center rounded overflow-hidden focus-visible:z-30 ${
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'
}`}>
<Artwork seed={`${track.title} ${track.artist}`} src={track.artwork_id} className="absolute inset-0 w-full h-full" />
@@ -98,6 +104,7 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
<button
type="button"
onClick={handlePlay}
disabled={!playable}
className={`block max-w-full truncate rounded text-left font-medium hover:underline ${isCurrent ? 'text-accent' : 'text-text'} ${compact ? 'text-sm' : 'text-sm'}`}
aria-label={playLabel}
>
@@ -0,0 +1,41 @@
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 type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { VibeTimeline } from './VibeTimeline';
const track = (id: string): Track => ({
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist', album_id: 'album',
duration: 180, state: 'LIBRARY', source_type: 'MANUAL', play_count: 0, skip_count: 0, dislike_count: 0,
});
describe('VibeTimeline', () => {
beforeEach(() => {
const current = track('current');
usePlaybackStore.setState({
currentTrack: current, queue: [current, track('upcoming')], currentIndex: 0,
isPlaying: true, queueOwner: 'vibe', vibeAdvanceHandler: () => undefined,
});
});
it('renders upcoming plan entries as display-only so they cannot hand queue ownership to ordinary playback', async () => {
const user = userEvent.setup();
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<VibeTimeline currentTrack={track('current')} upcoming={[track('upcoming')]} />
</QueryClientProvider>
);
const queued = screen.getAllByRole('button', { name: 'upcoming is queued by Vibe' });
expect(queued).toHaveLength(2);
expect(queued[0]).toBeDisabled();
expect(queued[1]).toBeDisabled();
await user.click(queued[0]);
expect(usePlaybackStore.getState()).toMatchObject({
queueOwner: 'vibe', currentTrack: track('current'),
});
});
});
+1
View File
@@ -44,6 +44,7 @@ export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
index={0}
showActions={false}
variant="compact"
playable={false}
/>
))}
</div>