feat(vibe): reconcile mutable session previews in playback
This commit is contained in:
@@ -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(() => {});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user