diff --git a/package.json b/package.json index 82f52ff..a8dc780 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts", "test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts", "test:seek-bar": "node --experimental-strip-types --test src/audio/playbackClock.test.mts src/audio/playbackProgressProjection.test.mts src/components/waveformScrubDetents.test.mts", + "test:recent-play": "node --experimental-strip-types --test src/audio/recentPlayTracking.test.mts", "test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts src/lyrics/embedded.test.mts src/lyrics/resolver.test.mts", "test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts", "test:troubleshooting": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/cacheInvalidation.test.mts", diff --git a/src/audio/playbackController.ts b/src/audio/playbackController.ts index a60c5d9..a31bb9c 100644 --- a/src/audio/playbackController.ts +++ b/src/audio/playbackController.ts @@ -29,6 +29,10 @@ import { primePreparedTrackForPlayback, } from './audioProcessingStartup'; import { shouldRestartOnPrevious } from './playbackNavigation'; +import { + cancelManualRecentPlayTransition, + markManualRecentPlayTransition, +} from './recentPlayTracking'; import { AstraLibraryData, type LibraryQuery, @@ -452,11 +456,12 @@ async function startVirtualWindow( originalOrder = shuffle ? null : tracks.map((track) => track.id); usePlayerStore.getState().setShuffle(shuffle); const playbackTarget = dspTargetFromTrack(queueTracks[startIndex], 'none'); + const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null; useQueueStore.getState().setSnapshot(queueTracks, startIndex, { source }); setOptimisticTrack(queueTracks[startIndex], 'loading'); try { await prepareAudioProcessingForPlayback(playbackTarget, 'virtual-queue-play'); - await loadQueueChunked(queueTracks, startIndex); + await loadQueueChunked(queueTracks, startIndex, { manualTransitionFromPath }); await primePreparedTrackForPlayback(playbackTarget, 'virtual-queue-play'); await TrackPlayer.play(); usePlayerStore.getState().setPlaybackState('playing'); @@ -673,13 +678,14 @@ async function playTracksInternal( } const queueTracks = ordered.map(toRntpTrack); const playbackTarget = dspTargetFromTrack(queueTracks[startIndex], 'none'); + const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null; useQueueStore.getState().setSnapshot(queueTracks, startIndex, { source: startOptions.source, }); setOptimisticTrack(queueTracks[startIndex], 'loading'); try { await prepareAudioProcessingForPlayback(playbackTarget, 'queue-play'); - await loadQueueChunked(queueTracks, startIndex); + await loadQueueChunked(queueTracks, startIndex, { manualTransitionFromPath }); await primePreparedTrackForPlayback(playbackTarget, 'queue-play'); await TrackPlayer.play(); usePlayerStore.getState().setPlaybackState('playing'); @@ -703,11 +709,12 @@ export async function shuffleTracks( usePlayerStore.getState().setShuffle(true); const queueTracks = shuffleArray(tracks).map(toRntpTrack); const playbackTarget = dspTargetFromTrack(queueTracks[0], 'none'); + const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null; useQueueStore.getState().setSnapshot(queueTracks, 0, { source }); setOptimisticTrack(queueTracks[0], 'loading'); try { await prepareAudioProcessingForPlayback(playbackTarget, 'shuffle-play'); - await loadQueueChunked(queueTracks, 0); + await loadQueueChunked(queueTracks, 0, { manualTransitionFromPath }); await primePreparedTrackForPlayback(playbackTarget, 'shuffle-play'); await TrackPlayer.play(); usePlayerStore.getState().setPlaybackState('playing'); @@ -821,14 +828,19 @@ export async function skipToNext(): Promise { ); const { tracks, activeIndex } = useQueueStore.getState(); const nextIndex = activeIndex >= 0 ? activeIndex + 1 : -1; + const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null; if (nextIndex >= 0 && nextIndex < tracks.length) { useQueueStore.getState().setActiveIndex(nextIndex); setOptimisticTrack(tracks[nextIndex], usePlayerStore.getState().playbackState); } + const manualTransition = markManualRecentPlayTransition(manualTransitionFromPath); + let nativeTransitionSucceeded = false; try { await TrackPlayer.skipToNext(); + nativeTransitionSucceeded = true; await refreshActiveIndexFromNative(); } catch { + if (!nativeTransitionSucceeded) cancelManualRecentPlayTransition(manualTransition); await reconcilePlayerFromNative(); // no next track — ignore } @@ -864,14 +876,19 @@ export async function skipToPrevious(): Promise { ); const { tracks, activeIndex } = useQueueStore.getState(); const previousIndex = activeIndex > 0 ? activeIndex - 1 : -1; + const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null; if (previousIndex >= 0 && previousIndex < tracks.length) { useQueueStore.getState().setActiveIndex(previousIndex); setOptimisticTrack(tracks[previousIndex], usePlayerStore.getState().playbackState); } + const manualTransition = markManualRecentPlayTransition(manualTransitionFromPath); + let nativeTransitionSucceeded = false; try { await TrackPlayer.skipToPrevious(); + nativeTransitionSucceeded = true; await refreshActiveIndexFromNative(); } catch { + if (!nativeTransitionSucceeded) cancelManualRecentPlayTransition(manualTransition); await reconcilePlayerFromNative(); // no previous track — ignore } @@ -1110,6 +1127,7 @@ export async function jumpToQueueIndex( // waiting out the fill only when the target isn't loaded. const queuedTrack = useQueueStore.getState().tracks[index]; const playbackTarget = dspTargetFromTrack(queuedTrack, 'none'); + const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null; useQueueStore.getState().setActiveIndex(index); setOptimisticTrack(queuedTrack, 'playing'); let nativeIndex = absoluteIndexToNative(index); @@ -1117,14 +1135,19 @@ export async function jumpToQueueIndex( await queueLoadSettled(); nativeIndex = absoluteIndexToNative(index); } + let manualTransition: ReturnType = null; + let nativeTransitionSucceeded = false; try { await prepareAudioProcessingForPlayback(playbackTarget, 'queue-jump'); + manualTransition = markManualRecentPlayTransition(manualTransitionFromPath); await TrackPlayer.skip(nativeIndex); + nativeTransitionSucceeded = true; useQueueStore.getState().setActiveIndex(index); await primePreparedTrackForPlayback(playbackTarget, 'queue-jump'); await TrackPlayer.play(); usePlayerStore.getState().setPlaybackState('playing'); } catch (err) { + if (!nativeTransitionSucceeded) cancelManualRecentPlayTransition(manualTransition); await reconcilePlayerFromNative(); throw err; } @@ -1193,7 +1216,16 @@ export async function removeFromQueue( await mutateVirtualQueue('remove', { positions: [position] }); return; } - await TrackPlayer.remove(absoluteIndex); + const activeIndex = useQueueStore.getState().activeIndex; + const manualTransition = absoluteIndex === activeIndex + ? markManualRecentPlayTransition(usePlayerStore.getState().currentTrack?.path) + : null; + try { + await TrackPlayer.remove(absoluteIndex); + } catch (error) { + cancelManualRecentPlayTransition(manualTransition); + throw error; + } if (options.updateMirror !== false) { useQueueStore.getState().removeIndices([absoluteIndex]); } @@ -1216,7 +1248,16 @@ export async function removeManyFromQueue( }); return; } - await TrackPlayer.remove(absoluteIndices); + const activeIndex = useQueueStore.getState().activeIndex; + const manualTransition = absoluteIndices.includes(activeIndex) + ? markManualRecentPlayTransition(usePlayerStore.getState().currentTrack?.path) + : null; + try { + await TrackPlayer.remove(absoluteIndices); + } catch (error) { + cancelManualRecentPlayTransition(manualTransition); + throw error; + } if (options.updateMirror !== false) { useQueueStore.getState().removeIndices(absoluteIndices); } diff --git a/src/audio/queueLoader.ts b/src/audio/queueLoader.ts index e94a926..d7dca41 100644 --- a/src/audio/queueLoader.ts +++ b/src/audio/queueLoader.ts @@ -1,4 +1,8 @@ import TrackPlayer, { type Track as RntpTrack } from 'react-native-track-player'; +import { + cancelManualRecentPlayTransition, + markManualRecentPlayTransition, +} from './recentPlayTracking'; /** * Chunked feeder for RNTP's native queue. Loading a long context in one @@ -32,6 +36,10 @@ interface QueueLoad { resolveLoopDone: () => void; } +interface QueueLoadOptions { + manualTransitionFromPath?: string | null; +} + let generation = 0; let load: QueueLoad | null = null; let onLoadError: (() => void) | null = null; @@ -107,16 +115,24 @@ function finishLoad(current: QueueLoad, failed: boolean): void { * `startIndex`. Resolves once the first chunk (containing `startIndex`) is * set — the caller can `play()` immediately; the rest fills in the background. */ -export async function loadQueueChunked(tracks: RntpTrack[], startIndex: number): Promise { +export async function loadQueueChunked( + tracks: RntpTrack[], + startIndex: number, + options: QueueLoadOptions = {}, +): Promise { const gen = await supersedePreviousLoad(); if (gen !== generation) return; const current = beginLoad(gen, startIndex, 0); + const manualTransition = markManualRecentPlayTransition( + options.manualTransitionFromPath, + ); try { const first = tracks.slice(startIndex, startIndex + FIRST_CHUNK); await TrackPlayer.setQueue(first); current.loadedCount = first.length; } catch (err) { + cancelManualRecentPlayTransition(manualTransition); finishLoad(current, false); throw err; } diff --git a/src/audio/recentPlayTracking.test.mts b/src/audio/recentPlayTracking.test.mts new file mode 100644 index 0000000..020e834 --- /dev/null +++ b/src/audio/recentPlayTracking.test.mts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + advanceRecentPlayCandidate, + cancelManualRecentPlayTransition, + consumeManualRecentPlayTransition, + createRecentPlayCandidate, + evaluateRecentPlayCandidate, + finalizeRecentPlayCandidate, + MANUAL_RECENT_PLAY_TRANSITION_TTL_MS, + markManualRecentPlayTransition, + RECENT_PLAY_MIN_SECONDS, +} from './recentPlayTracking.ts'; + +function listenedCandidate(durationSeconds: number | null, listenedSeconds: number) { + const started = createRecentPlayCandidate('/music/test.flac', durationSeconds, true, 0); + return advanceRecentPlayCandidate(started, false, listenedSeconds * 1_000); +} + +test('short-track qualification uses the desktop natural-completion tolerance', () => { + assert.equal(evaluateRecentPlayCandidate(listenedCandidate(5, 4.499), true).recordPath, null); + assert.equal( + evaluateRecentPlayCandidate(listenedCandidate(5, 4.5), true).recordPath, + '/music/test.flac', + ); + assert.equal(evaluateRecentPlayCandidate(listenedCandidate(1, 0.899), true).recordPath, null); + assert.equal( + evaluateRecentPlayCandidate(listenedCandidate(1, 0.9), true).recordPath, + '/music/test.flac', + ); +}); + +test('short tracks never qualify from a manual transition', () => { + assert.equal(evaluateRecentPlayCandidate(listenedCandidate(5, 5), false).recordPath, null); + assert.equal(evaluateRecentPlayCandidate(listenedCandidate(14.9, 20), false).recordPath, null); +}); + +test('fifteen-second, long, and unknown-duration tracks retain the fifteen-second rule', () => { + assert.equal(RECENT_PLAY_MIN_SECONDS, 15); + for (const duration of [15, 180, 0, null, Number.NaN]) { + assert.equal(evaluateRecentPlayCandidate(listenedCandidate(duration, 14.999), true).recordPath, null); + assert.equal( + evaluateRecentPlayCandidate(listenedCandidate(duration, 15), false).recordPath, + '/music/test.flac', + ); + } +}); + +test('accumulation excludes paused gaps and position is not part of qualification', () => { + let candidate = createRecentPlayCandidate('/music/test.flac', 180, true, 1_000); + candidate = advanceRecentPlayCandidate(candidate, false, 6_000); + candidate = advanceRecentPlayCandidate(candidate, false, 20_000); + candidate = advanceRecentPlayCandidate(candidate, true, 25_000); + candidate = advanceRecentPlayCandidate(candidate, false, 35_000); + assert.equal(candidate.accumulatedMs, 15_000); + assert.equal(evaluateRecentPlayCandidate(candidate, false).recordPath, '/music/test.flac'); +}); + +test('natural advance and queue-end finalization reset sessions and stay idempotent', () => { + const gapless = finalizeRecentPlayCandidate( + createRecentPlayCandidate('/music/repeat.flac', 5, true, 0), + true, + 4_500, + ); + assert.equal(gapless.recordPath, '/music/repeat.flac'); + assert.equal(gapless.candidate.path, null); + + const duplicateQueueEnd = finalizeRecentPlayCandidate(gapless.candidate, true, 4_500); + assert.equal(duplicateQueueEnd.recordPath, null); + + const repeated = finalizeRecentPlayCandidate( + createRecentPlayCandidate('/music/repeat.flac', 5, true, 5_000), + true, + 9_500, + ); + assert.equal(repeated.recordPath, '/music/repeat.flac'); +}); + +test('an already-recorded long play is not recorded again when it later ends', () => { + const qualified = evaluateRecentPlayCandidate(listenedCandidate(180, 15), false); + assert.equal(qualified.recordPath, '/music/test.flac'); + assert.equal(finalizeRecentPlayCandidate(qualified.candidate, true, 20_000).recordPath, null); +}); + +test('manual transition markers are path-matched, one-shot, cancellable, and expiring', () => { + markManualRecentPlayTransition('/music/a.flac', 1_000); + assert.equal(consumeManualRecentPlayTransition('/music/b.flac', 1_100), false); + assert.equal(consumeManualRecentPlayTransition('/music/a.flac', 1_100), true); + assert.equal(consumeManualRecentPlayTransition('/music/a.flac', 1_100), false); + + const cancelled = markManualRecentPlayTransition('/music/c.flac', 2_000); + cancelManualRecentPlayTransition(cancelled); + assert.equal(consumeManualRecentPlayTransition('/music/c.flac', 2_100), false); + + markManualRecentPlayTransition('/music/d.flac', 3_000); + assert.equal( + consumeManualRecentPlayTransition( + '/music/d.flac', + 3_000 + MANUAL_RECENT_PLAY_TRANSITION_TTL_MS + 1, + ), + false, + ); +}); diff --git a/src/audio/recentPlayTracking.ts b/src/audio/recentPlayTracking.ts new file mode 100644 index 0000000..68fd8e2 --- /dev/null +++ b/src/audio/recentPlayTracking.ts @@ -0,0 +1,200 @@ +export const RECENT_PLAY_MIN_SECONDS = 15; +export const SHORT_TRACK_COMPLETION_TOLERANCE_SECONDS = 0.5; +export const SHORT_TRACK_COMPLETION_TOLERANCE_RATIO = 0.1; +export const MANUAL_RECENT_PLAY_TRANSITION_TTL_MS = 5_000; + +export interface RecentPlayCandidate { + path: string | null; + durationSeconds: number | null; + accumulatedMs: number; + playingSinceMs: number | null; + recorded: boolean; +} + +export interface RecentPlayEvaluation { + candidate: RecentPlayCandidate; + recordPath: string | null; +} + +export interface ManualRecentPlayTransitionToken { + id: number; +} + +interface PendingManualRecentPlayTransition { + id: number; + fromPath: string; + expiresAtMs: number; +} + +let nextManualTransitionId = 1; +let pendingManualTransitions: PendingManualRecentPlayTransition[] = []; + +function normalizeDuration(durationSeconds: number | null | undefined): number | null { + return Number.isFinite(durationSeconds) && (durationSeconds ?? 0) > 0 + ? (durationSeconds ?? null) + : null; +} + +function pruneExpiredManualTransitions(nowMs: number): void { + pendingManualTransitions = pendingManualTransitions.filter( + (transition) => transition.expiresAtMs >= nowMs, + ); +} + +export function emptyRecentPlayCandidate(): RecentPlayCandidate { + return { + path: null, + durationSeconds: null, + accumulatedMs: 0, + playingSinceMs: null, + recorded: false, + }; +} + +export function createRecentPlayCandidate( + path: string, + durationSeconds: number | null | undefined, + isPlaying: boolean, + nowMs: number, +): RecentPlayCandidate { + return { + path, + durationSeconds: normalizeDuration(durationSeconds), + accumulatedMs: 0, + playingSinceMs: isPlaying ? nowMs : null, + recorded: false, + }; +} + +export function withRecentPlayDuration( + candidate: RecentPlayCandidate, + durationSeconds: number | null | undefined, +): RecentPlayCandidate { + const duration = normalizeDuration(durationSeconds); + if (duration == null || duration === candidate.durationSeconds) return candidate; + return { ...candidate, durationSeconds: duration }; +} + +/** Accumulates wall-clock time only while playback is actively running. */ +export function advanceRecentPlayCandidate( + candidate: RecentPlayCandidate, + isPlaying: boolean, + nowMs: number, +): RecentPlayCandidate { + if (!candidate.path) return candidate; + + if (isPlaying) { + if (candidate.playingSinceMs == null) { + return { ...candidate, playingSinceMs: nowMs }; + } + return { + ...candidate, + accumulatedMs: candidate.accumulatedMs + Math.max(0, nowMs - candidate.playingSinceMs), + playingSinceMs: nowMs, + }; + } + + if (candidate.playingSinceMs == null) return candidate; + return { + ...candidate, + accumulatedMs: candidate.accumulatedMs + Math.max(0, nowMs - candidate.playingSinceMs), + playingSinceMs: null, + }; +} + +/** + * Matches desktop qualification: short tracks count only at a genuine natural + * completion, with a small bounded allowance for the final native event. + */ +export function recentPlayQualifies( + candidate: RecentPlayCandidate, + completedNaturally: boolean, +): boolean { + const listenedSeconds = candidate.accumulatedMs / 1_000; + const durationSeconds = candidate.durationSeconds; + + if (durationSeconds != null && durationSeconds < RECENT_PLAY_MIN_SECONDS) { + if (!completedNaturally) return false; + const toleranceSeconds = Math.min( + SHORT_TRACK_COMPLETION_TOLERANCE_SECONDS, + durationSeconds * SHORT_TRACK_COMPLETION_TOLERANCE_RATIO, + ); + return listenedSeconds >= durationSeconds - toleranceSeconds; + } + + return listenedSeconds >= RECENT_PLAY_MIN_SECONDS; +} + +export function evaluateRecentPlayCandidate( + candidate: RecentPlayCandidate, + completedNaturally: boolean, +): RecentPlayEvaluation { + if (!candidate.path || candidate.recorded || !recentPlayQualifies(candidate, completedNaturally)) { + return { candidate, recordPath: null }; + } + return { + candidate: { ...candidate, recorded: true }, + recordPath: candidate.path, + }; +} + +/** Closes the active playing span, evaluates it once, and resets for the next play. */ +export function finalizeRecentPlayCandidate( + candidate: RecentPlayCandidate, + completedNaturally: boolean, + nowMs: number, + durationSeconds?: number | null, +): RecentPlayEvaluation { + const closed = advanceRecentPlayCandidate( + withRecentPlayDuration(candidate, durationSeconds), + false, + nowMs, + ); + const evaluated = evaluateRecentPlayCandidate(closed, completedNaturally); + return { + candidate: emptyRecentPlayCandidate(), + recordPath: evaluated.recordPath, + }; +} + +/** + * Marks an explicit controller transition. Tokens are matched against the + * outgoing Astra identity path and consumed by the corresponding RNTP event. + */ +export function markManualRecentPlayTransition( + fromPath: string | null | undefined, + nowMs = Date.now(), +): ManualRecentPlayTransitionToken | null { + if (!fromPath) return null; + pruneExpiredManualTransitions(nowMs); + const token = { id: nextManualTransitionId++ }; + pendingManualTransitions.push({ + id: token.id, + fromPath, + expiresAtMs: nowMs + MANUAL_RECENT_PLAY_TRANSITION_TTL_MS, + }); + return token; +} + +export function cancelManualRecentPlayTransition( + token: ManualRecentPlayTransitionToken | null, +): void { + if (!token) return; + pendingManualTransitions = pendingManualTransitions.filter( + (transition) => transition.id !== token.id, + ); +} + +export function consumeManualRecentPlayTransition( + fromPath: string | null | undefined, + nowMs = Date.now(), +): boolean { + pruneExpiredManualTransitions(nowMs); + if (!fromPath) return false; + const index = pendingManualTransitions.findIndex( + (transition) => transition.fromPath === fromPath, + ); + if (index < 0) return false; + pendingManualTransitions.splice(index, 1); + return true; +} diff --git a/src/audio/usePlaybackSync.ts b/src/audio/usePlaybackSync.ts index a426ad9..1bff80e 100644 --- a/src/audio/usePlaybackSync.ts +++ b/src/audio/usePlaybackSync.ts @@ -1,9 +1,11 @@ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { + Event, State, useActiveTrack, usePlaybackState, useProgress, + useTrackPlayerEvents, } from 'react-native-track-player'; import { usePlayerStore } from '@/stores/playerStore'; import { useLibraryStore } from '@/stores/libraryStore'; @@ -11,18 +13,20 @@ import { useQueueStore } from '@/stores/queueStore'; import type { PlaybackState, Track } from '@/types/audio'; import { rntpToTrack } from './sampleTracks'; import { buildWidgetRecentItems, setWidgetNowPlaying } from './widgetSync'; +import { + advanceRecentPlayCandidate, + consumeManualRecentPlayTransition, + createRecentPlayCandidate, + emptyRecentPlayCandidate, + evaluateRecentPlayCandidate, + finalizeRecentPlayCandidate, + type RecentPlayCandidate, + withRecentPlayDuration, +} from './recentPlayTracking'; -const RECENT_PLAY_THRESHOLD_MS = 15_000; const SEEK_ACK_EPS = 0.75; const SEEK_ACK_TIMEOUT_MS = 3000; -interface RecentPlayCandidate { - path: string | null; - accumulatedMs: number; - playingSinceMs: number | null; - recorded: boolean; -} - interface StablePlaybackState { path: string | null; state: PlaybackState; @@ -94,12 +98,7 @@ export function usePlaybackSync(): void { const activeTrack = useActiveTrack(); const progress = useProgress(500); const playbackState = usePlaybackState(); - const recentPlayCandidate = useRef({ - path: null, - accumulatedMs: 0, - playingSinceMs: null, - recorded: false, - }); + const recentPlayCandidate = useRef(emptyRecentPlayCandidate()); const stablePlayback = useRef<{ path: string | null; state: PlaybackState }>({ path: null, state: 'stopped', @@ -115,6 +114,49 @@ export function usePlaybackSync(): void { const recordTrackPlayed = useLibraryStore((s) => s.recordTrackPlayed); const recentlyPlayedTracks = useLibraryStore((s) => s.recentlyPlayedTracks); + const recordRecentPlay = useCallback((path: string | null) => { + if (!path) return; + void recordTrackPlayed(path).catch((err) => { + console.warn('[library] playback history update failed', err); + }); + }, [recordTrackPlayed]); + + useTrackPlayerEvents( + [Event.PlaybackActiveTrackChanged, Event.PlaybackQueueEnded, Event.PlaybackState], + (event) => { + if (restoredSessionPending) return; + const now = Date.now(); + + if (event.type === Event.PlaybackActiveTrackChanged) { + const lastTrack = event.lastTrack ? rntpToTrack(event.lastTrack) : null; + const wasManual = consumeManualRecentPlayTransition(lastTrack?.path, now); + const candidate = recentPlayCandidate.current; + if (!lastTrack || candidate.path !== lastTrack.path) { + recentPlayCandidate.current = emptyRecentPlayCandidate(); + return; + } + const finalized = finalizeRecentPlayCandidate( + candidate, + !wasManual, + now, + lastTrack.duration, + ); + recentPlayCandidate.current = finalized.candidate; + recordRecentPlay(finalized.recordPath); + return; + } + + if (event.type === Event.PlaybackState && event.state !== State.Ended) return; + const finalized = finalizeRecentPlayCandidate( + recentPlayCandidate.current, + true, + now, + ); + recentPlayCandidate.current = finalized.candidate; + recordRecentPlay(finalized.recordPath); + }, + ); + useEffect(() => { if (restoredSessionPending && !activeTrack) return; const nextTrack = activeTrack ? rntpToTrack(activeTrack) : null; @@ -219,50 +261,52 @@ export function usePlaybackSync(): void { if (restoredSessionPending) return; // Use the identity path (subsonic://|jellyfin:// for remote; the file URI for // local) so history matches `tracks.path` — activeTrack.url is the stream URL. - const path = activeTrack ? rntpToTrack(activeTrack).path : null; + const track = activeTrack ? rntpToTrack(activeTrack) : null; + const path = track?.path ?? null; + const duration = Number.isFinite(progress.duration) && progress.duration > 0 + ? progress.duration + : track?.duration; const mappedPlaybackState = resolveTransientLoading( rawPlaybackState, path, stablePlayback.current ); const now = Date.now(); - const candidate = recentPlayCandidate.current; - if (!path || mappedPlaybackState === 'stopped') { - recentPlayCandidate.current = { - path: null, - accumulatedMs: 0, - playingSinceMs: null, - recorded: false, - }; + if (!path) { + recentPlayCandidate.current = emptyRecentPlayCandidate(); return; } - if (candidate.path !== path) { - candidate.path = path; - candidate.accumulatedMs = 0; - candidate.playingSinceMs = null; - candidate.recorded = false; - } + let candidate = recentPlayCandidate.current; + candidate = candidate.path === path + ? withRecentPlayDuration(candidate, duration) + : createRecentPlayCandidate( + path, + duration, + mappedPlaybackState === 'playing', + now, + ); - if (mappedPlaybackState !== 'playing') { - if (candidate.playingSinceMs != null) { - candidate.accumulatedMs += now - candidate.playingSinceMs; - candidate.playingSinceMs = null; - } + if (mappedPlaybackState === 'stopped') { + recentPlayCandidate.current = emptyRecentPlayCandidate(); return; } - if (candidate.playingSinceMs == null) { - candidate.playingSinceMs = now; - } - - const elapsedMs = candidate.accumulatedMs + (now - candidate.playingSinceMs); - if (candidate.recorded || elapsedMs < RECENT_PLAY_THRESHOLD_MS) return; - - candidate.recorded = true; - void recordTrackPlayed(path).catch((err) => { - console.warn('[library] playback history update failed', err); - }); - }, [activeTrack, rawPlaybackState, progress.position, recordTrackPlayed, restoredSessionPending]); + candidate = advanceRecentPlayCandidate( + candidate, + mappedPlaybackState === 'playing', + now, + ); + const evaluated = evaluateRecentPlayCandidate(candidate, false); + recentPlayCandidate.current = evaluated.candidate; + recordRecentPlay(evaluated.recordPath); + }, [ + activeTrack, + rawPlaybackState, + progress.duration, + progress.position, + recordRecentPlay, + restoredSessionPending, + ]); }