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
+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(() => {});
}