port over desktop short songs fix

This commit is contained in:
Boof2015
2026-07-28 18:13:20 -04:00
parent 79fb381e67
commit 0b709725d2
6 changed files with 458 additions and 53 deletions
+1
View File
@@ -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",
+46 -5
View File
@@ -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<void> {
);
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<void> {
);
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<typeof markManualRecentPlayTransition> = 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);
}
+17 -1
View File
@@ -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<void> {
export async function loadQueueChunked(
tracks: RntpTrack[],
startIndex: number,
options: QueueLoadOptions = {},
): Promise<void> {
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;
}
+103
View File
@@ -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,
);
});
+200
View File
@@ -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;
}
+91 -47
View File
@@ -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<RecentPlayCandidate>({
path: null,
accumulatedMs: 0,
playingSinceMs: null,
recorded: false,
});
const recentPlayCandidate = useRef<RecentPlayCandidate>(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,
]);
}