mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
port over desktop short songs fix
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user