fix long queue issues

This commit is contained in:
Boof2015
2026-07-01 18:33:22 -04:00
parent 7f00e87823
commit 06c822f513
7 changed files with 370 additions and 63 deletions
+46 -19
View File
@@ -1,8 +1,20 @@
diff --git a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt
index b2409a0..ea4b393 100644
index b2409a0..4491bad 100644
--- a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt
+++ b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt
@@ -169,8 +169,12 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -18,9 +18,11 @@ import com.doublesymmetry.trackplayer.utils.RejectionException
import com.facebook.react.bridge.*
import com.google.android.exoplayer2.DefaultLoadControl.*
import com.google.android.exoplayer2.Player
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.*
import javax.annotation.Nonnull
@@ -169,8 +171,12 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
return
}
@@ -16,7 +28,7 @@ index b2409a0..ea4b393 100644
promise.reject(
"android_cannot_setup_player_in_background",
"On Android the app must be in the foreground when setting up the player."
@@ -179,7 +183,6 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -179,7 +185,6 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
}
// Validate buffer keys.
@@ -24,7 +36,7 @@ index b2409a0..ea4b393 100644
val minBuffer =
bundledData?.getDouble(MusicService.MIN_BUFFER_KEY)?.toMilliseconds()?.toInt()
?: DEFAULT_MIN_BUFFER_MS
@@ -251,7 +254,7 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -251,7 +256,7 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
}
@ReactMethod
@@ -33,7 +45,7 @@ index b2409a0..ea4b393 100644
if (verifyServiceBoundOrReject(callback)) return@launch
val options = Arguments.toBundle(data)
@@ -262,9 +265,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -262,13 +267,16 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
@@ -45,7 +57,14 @@ index b2409a0..ea4b393 100644
if (verifyServiceBoundOrReject(callback)) return@launch
try {
@@ -283,9 +287,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
- val tracks = readableArrayToTrackList(data);
+ // Track conversion is O(queue) work (Bundle parsing, Uri resolution) —
+ // keep it off the main thread so long queues don't freeze the UI/ANR.
+ val tracks = withContext(Dispatchers.Default) { readableArrayToTrackList(data) };
if (insertBeforeIndex < -1 || insertBeforeIndex > musicService.tracks.size) {
callback.reject("index_out_of_bounds", "The track index is out of bounds")
return@launch
@@ -283,9 +291,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
rejectWithException(callback, exception)
}
}
@@ -57,7 +76,7 @@ index b2409a0..ea4b393 100644
if (verifyServiceBoundOrReject(callback)) return@launch
if (data == null) {
callback.resolve(null)
@@ -299,16 +304,18 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -299,16 +308,18 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.reject("invalid_track_object", "Track was not a dictionary type")
}
}
@@ -78,7 +97,7 @@ index b2409a0..ea4b393 100644
if (verifyServiceBoundOrReject(callback)) return@launch
val inputIndexes = Arguments.toList(data)
if (inputIndexes != null) {
@@ -329,9 +336,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -329,9 +340,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
}
callback.resolve(null)
}
@@ -90,7 +109,7 @@ index b2409a0..ea4b393 100644
scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
@@ -346,9 +354,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -346,9 +358,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
}
@@ -102,7 +121,7 @@ index b2409a0..ea4b393 100644
if (verifyServiceBoundOrReject(callback)) return@launch
if (musicService.tracks.isEmpty())
@@ -362,9 +371,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -362,9 +375,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
@@ -114,7 +133,7 @@ index b2409a0..ea4b393 100644
if (verifyServiceBoundOrReject(callback)) return@launch
if (musicService.tracks.isEmpty())
@@ -373,17 +383,19 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -373,17 +387,19 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
musicService.clearNotificationMetadata()
callback.resolve(null)
}
@@ -136,7 +155,7 @@ index b2409a0..ea4b393 100644
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.skip(index)
@@ -394,9 +406,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -394,9 +410,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
@@ -148,7 +167,7 @@ index b2409a0..ea4b393 100644
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.skipToNext()
@@ -407,9 +420,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -407,9 +424,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
@@ -160,7 +179,7 @@ index b2409a0..ea4b393 100644
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.skipToPrevious()
@@ -420,9 +434,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -420,9 +438,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
@@ -172,7 +191,7 @@ index b2409a0..ea4b393 100644
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.stop()
@@ -431,135 +446,152 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -431,188 +450,213 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
@@ -333,9 +352,12 @@ index b2409a0..ea4b393 100644
+ fun getQueue(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
callback.resolve(Arguments.fromList(musicService.tracks.map { it.originalItem }))
}
- callback.resolve(Arguments.fromList(musicService.tracks.map { it.originalItem }))
+ // Clone on main (the service mutates originalItem there), marshal off-main.
+ val bundles = musicService.tracks.map { track -> track.originalItem?.let(::Bundle) }
+ callback.resolve(withContext(Dispatchers.Default) { Arguments.fromList(bundles) })
+ }
}
@ReactMethod
- fun setQueue(data: ReadableArray?, callback: Promise) = scope.launch {
@@ -343,7 +365,12 @@ index b2409a0..ea4b393 100644
if (verifyServiceBoundOrReject(callback)) return@launch
try {
@@ -570,49 +602,54 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
+ val tracks = withContext(Dispatchers.Default) { readableArrayToTrackList(data) }
musicService.clear()
- musicService.add(readableArrayToTrackList(data))
+ musicService.add(tracks)
callback.resolve(null)
} catch (exception: Exception) {
rejectWithException(callback, exception)
}
}
@@ -407,7 +434,7 @@ index b2409a0..ea4b393 100644
if (verifyServiceBoundOrReject(callback)) return@launch
var bundle = Bundle()
bundle.putDouble("duration", musicService.getDurationInSeconds());
@@ -620,10 +657,12 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
@@ -620,10 +664,12 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
bundle.putDouble("buffered", musicService.getBufferedPositionInSeconds());
callback.resolve(Arguments.fromBundle(bundle))
}
+51 -27
View File
@@ -8,6 +8,19 @@ import { usePlayerStore, type RepeatMode as RepeatModeStr } from '@/stores/playe
import { useQueueStore } from '@/stores/queueStore';
import { setupPlayer } from './trackPlayer';
import { SAMPLE_TRACKS, toRntpTrack } from './sampleTracks';
import {
absoluteIndexToNative,
appendUpcomingChunked,
loadQueueChunked,
queueLoadSettled,
setQueueLoadErrorHandler,
} from './queueLoader';
// If a background queue fill dies partway, the mirror no longer matches the
// native queue — re-read the truth.
setQueueLoadErrorHandler(() => {
void useQueueStore.getState().refreshFromNative();
});
/**
* Transport actions screens call. Thin wrappers over RNTP so the UI never
@@ -43,16 +56,16 @@ function rntpTrackId(track: RntpTrack): string {
async function getQueueSnapshot(): Promise<{ queue: RntpTrack[]; activeIndex: number }> {
const store = useQueueStore.getState();
const activeIndex = (await TrackPlayer.getActiveTrackIndex()) ?? -1;
if (store.hasSnapshot) {
store.setActiveIndex(activeIndex);
return { queue: useQueueStore.getState().tracks, activeIndex };
await store.refreshActiveIndex();
const { tracks, activeIndex } = useQueueStore.getState();
return { queue: tracks, activeIndex };
}
const queue = await TrackPlayer.getQueue();
store.setSnapshot(queue, activeIndex);
return { queue, activeIndex };
await store.refreshFromNative();
const { tracks, activeIndex } = useQueueStore.getState();
return { queue: tracks, activeIndex };
}
async function refreshActiveIndexFromNative(): Promise<void> {
@@ -104,24 +117,16 @@ async function playTracksInternal(
): Promise<void> {
if (tracks.length === 0) return;
await ensurePlayerReady(options);
const queueTracks = tracks.map(toRntpTrack);
await TrackPlayer.setQueue(queueTracks);
originalOrder = tracks.map((t) => t.id);
if (startIndex > 0) {
await TrackPlayer.skip(startIndex);
// Honor an already-on shuffle by scrambling the upcoming tail of the new
// context up front, so the whole queue is loaded natively in a single pass.
let ordered = tracks;
if (usePlayerStore.getState().shuffle && tracks.length - startIndex - 1 > 1) {
ordered = [...tracks.slice(0, startIndex + 1), ...shuffleArray(tracks.slice(startIndex + 1))];
}
let mirroredQueue = queueTracks;
// Honor an already-on shuffle by scrambling the upcoming tail of the new context.
if (usePlayerStore.getState().shuffle) {
const upcoming = tracks.slice(startIndex + 1);
if (upcoming.length > 1) {
const shuffledUpcoming = shuffleArray(upcoming).map(toRntpTrack);
await TrackPlayer.removeUpcomingTracks();
await TrackPlayer.add(shuffledUpcoming);
mirroredQueue = [...queueTracks.slice(0, startIndex + 1), ...shuffledUpcoming];
}
}
useQueueStore.getState().setSnapshot(mirroredQueue, startIndex);
const queueTracks = ordered.map(toRntpTrack);
useQueueStore.getState().setSnapshot(queueTracks, startIndex);
await loadQueueChunked(queueTracks, startIndex);
await TrackPlayer.play();
}
@@ -132,14 +137,15 @@ export async function shuffleTracks(tracks: Track[]): Promise<void> {
originalOrder = tracks.map((t) => t.id);
usePlayerStore.getState().setShuffle(true);
const queueTracks = shuffleArray(tracks).map(toRntpTrack);
await TrackPlayer.setQueue(queueTracks);
useQueueStore.getState().setSnapshot(queueTracks, 0);
await loadQueueChunked(queueTracks, 0);
await TrackPlayer.play();
}
/** M0 demo entry point: load the streamed sample queue if nothing is queued. */
export async function playSample(): Promise<void> {
await ensurePlayerReady();
await queueLoadSettled();
const queue = await TrackPlayer.getQueue();
if (queue.length === 0) {
const sampleQueue = SAMPLE_TRACKS.map(toRntpTrack);
@@ -206,6 +212,7 @@ export async function toggleShuffle(): Promise<void> {
const store = usePlayerStore.getState();
const next = !store.shuffle;
await ensurePlayerReady();
await queueLoadSettled();
const snapshot = await getQueueSnapshot();
const queue = snapshot.queue;
@@ -218,7 +225,7 @@ export async function toggleShuffle(): Promise<void> {
if (upcoming.length > 1) {
const shuffledUpcoming = shuffleArray(upcoming);
await TrackPlayer.removeUpcomingTracks();
await TrackPlayer.add(shuffledUpcoming);
await appendUpcomingChunked(shuffledUpcoming, activeIndex + 1);
mirroredQueue = [...queue.slice(0, activeIndex + 1), ...shuffledUpcoming];
}
} else if (originalOrder) {
@@ -230,7 +237,7 @@ export async function toggleShuffle(): Promise<void> {
.map((id) => byId.get(id))
.filter((t): t is RntpTrack => Boolean(t));
await TrackPlayer.removeUpcomingTracks();
if (restored.length) await TrackPlayer.add(restored);
if (restored.length) await appendUpcomingChunked(restored, activeIndex + 1);
mirroredQueue = [...queue.slice(0, activeIndex + 1), ...restored];
}
@@ -241,6 +248,7 @@ export async function toggleShuffle(): Promise<void> {
/** Insert a track right after the current one ("Play next"). */
export async function enqueueTop(track: Track): Promise<void> {
await ensurePlayerReady();
await queueLoadSettled();
const activeIndex = await TrackPlayer.getActiveTrackIndex();
const activeTrack = await TrackPlayer.getActiveTrack();
const insertBefore = activeIndex === undefined ? undefined : activeIndex + 1;
@@ -262,6 +270,7 @@ export async function enqueueTop(track: Track): Promise<void> {
/** Append a track to the end of the queue ("Add to queue"). */
export async function enqueueEnd(track: Track): Promise<void> {
await ensurePlayerReady();
await queueLoadSettled();
const queueTrack = toRntpTrack(track);
await TrackPlayer.add(queueTrack);
if (useQueueStore.getState().hasSnapshot) {
@@ -287,15 +296,20 @@ function moveOriginalOrderIfUnshuffled(fromIndex: number, toIndex: number): void
/** Replace everything after the current track with `upcoming` (in order). */
export async function setUpcoming(upcoming: RntpTrack[]): Promise<void> {
await queueLoadSettled();
await TrackPlayer.removeUpcomingTracks();
if (upcoming.length) await TrackPlayer.add(upcoming);
useQueueStore.getState().replaceUpcoming(upcoming);
if (upcoming.length) {
const { activeIndex } = useQueueStore.getState();
await appendUpcomingChunked(upcoming, activeIndex >= 0 ? activeIndex + 1 : 0);
}
syncOriginalOrderFromMirrorIfUnshuffled();
}
/** Move a queued item by absolute RNTP queue index. */
export async function moveQueueItem(fromAbsoluteIndex: number, toAbsoluteIndex: number): Promise<void> {
if (fromAbsoluteIndex === toAbsoluteIndex) return;
await queueLoadSettled();
await TrackPlayer.move(fromAbsoluteIndex, toAbsoluteIndex);
useQueueStore.getState().moveItem(fromAbsoluteIndex, toAbsoluteIndex);
moveOriginalOrderIfUnshuffled(fromAbsoluteIndex, toAbsoluteIndex);
@@ -303,7 +317,15 @@ export async function moveQueueItem(fromAbsoluteIndex: number, toAbsoluteIndex:
/** Jump to (and play) an absolute queue index. */
export async function jumpToQueueIndex(index: number): Promise<void> {
await TrackPlayer.skip(index);
// Mid-fill, the tapped row may not be in the native queue yet (or may sit at
// a shifted native index while the head is still prepending) — translate,
// waiting out the fill only when the target isn't loaded.
let nativeIndex = absoluteIndexToNative(index);
while (nativeIndex == null) {
await queueLoadSettled();
nativeIndex = absoluteIndexToNative(index);
}
await TrackPlayer.skip(nativeIndex);
useQueueStore.getState().setActiveIndex(index);
await TrackPlayer.play();
}
@@ -335,6 +357,7 @@ export async function requeueManyToTop(absoluteIndices: number[]): Promise<void>
/** Remove a single track at an absolute queue index. */
export async function removeFromQueue(absoluteIndex: number): Promise<void> {
await queueLoadSettled();
await TrackPlayer.remove(absoluteIndex);
useQueueStore.getState().removeIndices([absoluteIndex]);
syncOriginalOrderFromMirrorIfUnshuffled();
@@ -343,6 +366,7 @@ export async function removeFromQueue(absoluteIndex: number): Promise<void> {
/** Remove a group of tracks at absolute queue indices. */
export async function removeManyFromQueue(absoluteIndices: number[]): Promise<void> {
if (absoluteIndices.length === 0) return;
await queueLoadSettled();
await TrackPlayer.remove(absoluteIndices);
useQueueStore.getState().removeIndices(absoluteIndices);
syncOriginalOrderFromMirrorIfUnshuffled();
+185
View File
@@ -0,0 +1,185 @@
import TrackPlayer, { type Track as RntpTrack } from 'react-native-track-player';
/**
* Chunked feeder for RNTP's native queue. Loading a long context in one
* setQueue/add stalls the Android main thread for seconds (per-track Bundle →
* Track → MediaSource construction), so playback starts from a small first
* chunk and the rest streams in behind it: the upcoming tail first (it plays
* next), then the head prepended in reverse chunk order. The JS queue mirror
* holds the full context from the start; while the head is still missing,
* native indices trail absolute (mirror) indices by `headRemaining`.
*/
const FIRST_CHUNK = 50;
const CHUNK = 200;
const YIELD_MS = 24;
interface QueueLoad {
generation: number;
/** Head tracks not yet prepended: absolute = native + headRemaining. */
headRemaining: number;
/** Tracks currently in the native queue (per this loader's bookkeeping). */
loadedCount: number;
settled: Promise<void>;
resolveSettled: () => void;
/** Resolves once the fill loop has stopped issuing native calls. */
loopDone: Promise<void>;
resolveLoopDone: () => void;
}
let generation = 0;
let load: QueueLoad | null = null;
let onLoadError: (() => void) | null = null;
/** Recovery hook run when a background fill fails mid-way (mirror may drift). */
export function setQueueLoadErrorHandler(handler: () => void): void {
onLoadError = handler;
}
/** Resolves when no background fill is (or remains) in flight. */
export function queueLoadSettled(): Promise<void> {
return load ? load.settled : Promise.resolve();
}
/** Map a native RNTP queue index to an absolute (full-queue mirror) index. */
export function nativeIndexToAbsolute(nativeIndex: number): number {
return load ? nativeIndex + load.headRemaining : nativeIndex;
}
/**
* Map an absolute index to its native index, or null while that part of the
* queue has not been loaded yet.
*/
export function absoluteIndexToNative(absoluteIndex: number): number | null {
if (!load) return absoluteIndex;
const nativeIndex = absoluteIndex - load.headRemaining;
return nativeIndex >= 0 && nativeIndex < load.loadedCount ? nativeIndex : null;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function supersedePreviousLoad(): Promise<number> {
const gen = ++generation;
const previous = load;
// Wait the old fill loop out so none of its adds land after our first write.
if (previous) await previous.loopDone;
return gen;
}
function beginLoad(gen: number, headRemaining: number, loadedCount: number): QueueLoad {
let resolveSettled!: () => void;
let resolveLoopDone!: () => void;
const settled = new Promise<void>((resolve) => {
resolveSettled = resolve;
});
const loopDone = new Promise<void>((resolve) => {
resolveLoopDone = resolve;
});
const next: QueueLoad = {
generation: gen,
headRemaining,
loadedCount,
settled,
resolveSettled,
loopDone,
resolveLoopDone,
};
load = next;
return next;
}
function finishLoad(current: QueueLoad, failed: boolean): void {
current.resolveSettled();
current.resolveLoopDone();
if (load === current) load = null;
if (failed) onLoadError?.();
}
/**
* Replace the native queue with `tracks`, starting playback-ready at
* `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> {
const gen = await supersedePreviousLoad();
if (gen !== generation) return;
const current = beginLoad(gen, startIndex, 0);
try {
const first = tracks.slice(startIndex, startIndex + FIRST_CHUNK);
await TrackPlayer.setQueue(first);
current.loadedCount = first.length;
} catch (err) {
finishLoad(current, false);
throw err;
}
void fillRemainder(current, tracks, startIndex);
}
/**
* Append `tracks` after the current native queue contents in chunks (tail
* rebuilds: shuffle toggle / tray group reorders). `baseCount` is the native
* queue length at call time (indices below it stay identity-mapped). Resolves
* after the first chunk lands.
*/
export async function appendUpcomingChunked(tracks: RntpTrack[], baseCount: number): Promise<void> {
const gen = await supersedePreviousLoad();
if (gen !== generation || tracks.length === 0) return;
const current = beginLoad(gen, 0, baseCount);
try {
const first = tracks.slice(0, CHUNK);
await TrackPlayer.add(first);
current.loadedCount += first.length;
} catch (err) {
finishLoad(current, false);
throw err;
}
void fillTail(current, tracks, CHUNK).then(
() => finishLoad(current, false),
() => finishLoad(current, true),
);
}
/** Append tracks[fromIndex..] in chunks. Returns normally when superseded. */
async function fillTail(current: QueueLoad, tracks: RntpTrack[], fromIndex: number): Promise<void> {
for (let i = fromIndex; i < tracks.length; i += CHUNK) {
await sleep(YIELD_MS);
if (current.generation !== generation) return;
const chunk = tracks.slice(i, i + CHUNK);
await TrackPlayer.add(chunk);
current.loadedCount += chunk.length;
}
}
async function fillRemainder(
current: QueueLoad,
tracks: RntpTrack[],
startIndex: number,
): Promise<void> {
let failed = false;
try {
// Tail first — it's what plays next.
await fillTail(current, tracks, startIndex + current.loadedCount);
// Head second, prepended in reverse chunk order so [0..startIndex) ends up
// in original order and `absolute = native + headRemaining` holds throughout.
for (let end = startIndex; end > 0; end -= CHUNK) {
await sleep(YIELD_MS);
if (current.generation !== generation) return;
const begin = Math.max(0, end - CHUNK);
const chunk = tracks.slice(begin, end);
await TrackPlayer.add(chunk, 0);
current.headRemaining = begin;
current.loadedCount += chunk.length;
}
} catch {
failed = true;
} finally {
finishLoad(current, failed);
}
}
+25 -7
View File
@@ -157,8 +157,13 @@ export function QueueTray({ onClose }: QueueTrayProps) {
const dKey = useSharedValue('');
const dSettling = useSharedValue(false);
const dIndexByKey = useSharedValue<QueueIndexByKey>({});
// The index map is only read by worklets while a drag is active/settling, so
// it's maintained only inside that window — serializing a map with one entry
// per queued track to the UI runtime on every queue change froze long queues.
const dragInFlightRef = useRef(false);
const clearDragState = useCallback(() => {
dragInFlightRef.current = false;
runOnUI(
(
active: SharedValue<boolean>,
@@ -166,7 +171,8 @@ export function QueueTray({ onClose }: QueueTrayProps) {
start: SharedValue<number>,
target: SharedValue<number>,
key: SharedValue<string>,
settling: SharedValue<boolean>
settling: SharedValue<boolean>,
indexMap: SharedValue<QueueIndexByKey>
) => {
'worklet';
active.value = false;
@@ -175,9 +181,10 @@ export function QueueTray({ onClose }: QueueTrayProps) {
start.value = -1;
target.value = -1;
key.value = '';
indexMap.value = {};
}
)(dActive, dTy, dStart, dTarget, dKey, dSettling);
}, [dActive, dKey, dSettling, dStart, dTarget, dTy]);
)(dActive, dTy, dStart, dTarget, dKey, dSettling, dIndexByKey);
}, [dActive, dIndexByKey, dKey, dSettling, dStart, dTarget, dTy]);
const clearDragAfterReorderCommit = useCallback(() => {
requestAnimationFrame(() => {
@@ -197,7 +204,7 @@ export function QueueTray({ onClose }: QueueTrayProps) {
const setVisibleEntries = useCallback((nextEntries: QueueEntry[]) => {
entriesRef.current = nextEntries;
updateDragIndexMap(indexByEntryKey(nextEntries));
if (dragInFlightRef.current) updateDragIndexMap(indexByEntryKey(nextEntries));
setEntries(nextEntries);
}, [updateDragIndexMap]);
@@ -227,7 +234,7 @@ export function QueueTray({ onClose }: QueueTrayProps) {
)
: [];
entriesRef.current = next;
updateDragIndexMap(indexByEntryKey(next));
if (dragInFlightRef.current) updateDragIndexMap(indexByEntryKey(next));
return next;
});
});
@@ -280,6 +287,15 @@ export function QueueTray({ onClose }: QueueTrayProps) {
[baseOffset, clearDragAfterReorderCommit, commitNativeMove, setVisibleEntries]
);
const onDragArm = useCallback(() => {
dragInFlightRef.current = true;
dragArmHaptic();
}, []);
const onDragAbort = useCallback(() => {
dragInFlightRef.current = false;
}, []);
const makeDragGesture = useCallback(
(
localIndex: number,
@@ -296,7 +312,7 @@ export function QueueTray({ onClose }: QueueTrayProps) {
dKey.value = entryKey;
dSettling.value = false;
dActive.value = true;
runOnJS(dragArmHaptic)();
runOnJS(onDragArm)();
})
.onUpdate((event) => {
dTy.value = event.translationY;
@@ -321,6 +337,8 @@ export function QueueTray({ onClose }: QueueTrayProps) {
dTarget.value = -1;
dSettling.value = false;
dKey.value = '';
dIndexByKey.value = {};
runOnJS(onDragAbort)();
});
return;
}
@@ -334,7 +352,7 @@ export function QueueTray({ onClose }: QueueTrayProps) {
return longPress ? gesture.activateAfterLongPress(250) : gesture;
},
[dActive, dIndexByKey, dKey, dSettling, dStart, dTarget, dTy, finishDrag]
[dActive, dIndexByKey, dKey, dSettling, dStart, dTarget, dTy, finishDrag, onDragAbort, onDragArm]
);
const runAndRefresh = useCallback(
+7 -2
View File
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { Event, useTrackPlayerEvents, type Track as RntpTrack } from 'react-native-track-player';
import { useQueueStore } from '@/stores/queueStore';
import { nativeIndexToAbsolute } from '@/audio/queueLoader';
export interface QueueSnapshot {
tracks: RntpTrack[];
@@ -29,8 +30,12 @@ export function useQueue(active: boolean): QueueSnapshot {
useTrackPlayerEvents([Event.PlaybackActiveTrackChanged], (event) => {
if (!active) return;
if (hasSnapshot) setActiveIndex(event.index ?? -1);
else void refresh();
if (hasSnapshot) {
// Event indices are native — shifted while a chunked load is prepending the head.
setActiveIndex(event.index != null ? nativeIndexToAbsolute(event.index) : -1);
} else {
void refresh();
}
});
return { tracks, activeIndex, hasSnapshot, refresh };
+9 -1
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand';
import TrackPlayer, { type Track as RntpTrack } from 'react-native-track-player';
import { nativeIndexToAbsolute, queueLoadSettled } from '@/audio/queueLoader';
/**
* Live mirror of RNTP's native queue for the queue tray. Playback actions keep
@@ -35,6 +36,8 @@ export const useQueueStore = create<QueueStore>((set) => ({
activeIndex: -1,
hasSnapshot: false,
refreshFromNative: async () => {
// Mid chunked-load the native queue is partial and index-shifted — wait it out.
await queueLoadSettled();
const [tracks, activeIndex] = await Promise.all([
TrackPlayer.getQueue(),
TrackPlayer.getActiveTrackIndex(),
@@ -47,7 +50,12 @@ export const useQueueStore = create<QueueStore>((set) => ({
},
refreshActiveIndex: async () => {
const activeIndex = await TrackPlayer.getActiveTrackIndex();
set((s) => ({ activeIndex: normalizeActiveIndex(activeIndex, s.tracks.length) }));
set((s) => ({
activeIndex: normalizeActiveIndex(
activeIndex == null ? activeIndex : nativeIndexToAbsolute(activeIndex),
s.tracks.length,
),
}));
},
setSnapshot: (tracks, activeIndex = 0) =>
set({
@@ -92,6 +92,31 @@ abstract class BaseAudioPlayer internal constructor(
private val scope = MainScope()
private var playerConfig: PlayerConfig = playerConfig
// Shared per-player media-source machinery. Building these per item made
// large queue loads O(n) main-thread stalls (Util.getUserAgent alone is a
// PackageManager lookup); items without per-item overrides reuse them.
// All access is @MainThread (like the rest of this class), so plain lazy
// initialization is safe.
private val defaultUserAgent: String by lazy { Util.getUserAgent(context, APPLICATION_NAME) }
private val sharedLocalDataSourceFactory: DataSource.Factory by lazy {
DefaultDataSourceFactory(context, defaultUserAgent)
}
private val sharedHttpDataSourceFactory: DataSource.Factory by lazy {
enableCaching(DefaultHttpDataSource.Factory().apply {
setUserAgent(defaultUserAgent)
setAllowCrossProtocolRedirects(true)
})
}
private val sharedExtractorsFactory: DefaultExtractorsFactory by lazy {
DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true)
}
private val sharedLocalProgressiveFactory: ProgressiveMediaSource.Factory by lazy {
ProgressiveMediaSource.Factory(sharedLocalDataSourceFactory, sharedExtractorsFactory)
}
private val sharedHttpProgressiveFactory: ProgressiveMediaSource.Factory by lazy {
ProgressiveMediaSource.Factory(sharedHttpDataSourceFactory, sharedExtractorsFactory)
}
val notificationManager: NotificationManager
open val playerOptions: PlayerOptions = DefaultPlayerOptions()
@@ -460,28 +485,43 @@ abstract class BaseAudioPlayer internal constructor(
.setTag(AudioItemHolder(audioItem))
.build()
val userAgent =
if (audioItem.options == null || audioItem.options!!.userAgent.isNullOrBlank()) {
Util.getUserAgent(context, APPLICATION_NAME)
val options = audioItem.options
val hasCustomConfig =
options != null && (!options.userAgent.isNullOrBlank() || !options.headers.isNullOrEmpty())
// Fast path: no per-item overrides — reuse the shared factories.
if (options?.resourceId == null && !hasCustomConfig && audioItem.type == MediaType.DEFAULT) {
return if (isUriLocalFile(uri)) {
sharedLocalProgressiveFactory.createMediaSource(mediaItem)
} else {
audioItem.options!!.userAgent
sharedHttpProgressiveFactory.createMediaSource(mediaItem)
}
}
val userAgent =
if (options == null || options.userAgent.isNullOrBlank()) {
defaultUserAgent
} else {
options.userAgent
}
val factory: DataSource.Factory = when {
audioItem.options?.resourceId != null -> {
options?.resourceId != null -> {
val raw = RawResourceDataSource(context)
raw.open(DataSpec(uri))
DataSource.Factory { raw }
}
isUriLocalFile(uri) -> {
DefaultDataSourceFactory(context, userAgent)
if (hasCustomConfig) DefaultDataSourceFactory(context, userAgent)
else sharedLocalDataSourceFactory
}
!hasCustomConfig -> sharedHttpDataSourceFactory
else -> {
val tempFactory = DefaultHttpDataSource.Factory().apply {
setUserAgent(userAgent)
setAllowCrossProtocolRedirects(true)
audioItem.options?.headers?.let {
options?.headers?.let {
setDefaultRequestProperties(it.toMap())
}
}