From 06c822f513b08ef5608732106fb85a9a56eab203 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:33:22 -0400 Subject: [PATCH] fix long queue issues --- patches/react-native-track-player+4.1.2.patch | 65 ++++-- src/audio/playbackController.ts | 78 +++++--- src/audio/queueLoader.ts | 185 ++++++++++++++++++ src/components/queue/QueueTray.tsx | 32 ++- src/components/queue/useQueue.ts | 9 +- src/stores/queueStore.ts | 10 +- .../kotlinaudio/players/BaseAudioPlayer.kt | 54 ++++- 7 files changed, 370 insertions(+), 63 deletions(-) create mode 100644 src/audio/queueLoader.ts diff --git a/patches/react-native-track-player+4.1.2.patch b/patches/react-native-track-player+4.1.2.patch index 497852a..69358ce 100644 --- a/patches/react-native-track-player+4.1.2.patch +++ b/patches/react-native-track-player+4.1.2.patch @@ -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)) } diff --git a/src/audio/playbackController.ts b/src/audio/playbackController.ts index 8ec88b1..9b34c48 100644 --- a/src/audio/playbackController.ts +++ b/src/audio/playbackController.ts @@ -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 { @@ -104,24 +117,16 @@ async function playTracksInternal( ): Promise { 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 { 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 { 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 { 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 { 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 { .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 { /** Insert a track right after the current one ("Play next"). */ export async function enqueueTop(track: Track): Promise { 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 { /** Append a track to the end of the queue ("Add to queue"). */ export async function enqueueEnd(track: Track): Promise { 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 { + 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 { 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 { - 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 /** Remove a single track at an absolute queue index. */ export async function removeFromQueue(absoluteIndex: number): Promise { + await queueLoadSettled(); await TrackPlayer.remove(absoluteIndex); useQueueStore.getState().removeIndices([absoluteIndex]); syncOriginalOrderFromMirrorIfUnshuffled(); @@ -343,6 +366,7 @@ export async function removeFromQueue(absoluteIndex: number): Promise { /** Remove a group of tracks at absolute queue indices. */ export async function removeManyFromQueue(absoluteIndices: number[]): Promise { if (absoluteIndices.length === 0) return; + await queueLoadSettled(); await TrackPlayer.remove(absoluteIndices); useQueueStore.getState().removeIndices(absoluteIndices); syncOriginalOrderFromMirrorIfUnshuffled(); diff --git a/src/audio/queueLoader.ts b/src/audio/queueLoader.ts new file mode 100644 index 0000000..a4b18a6 --- /dev/null +++ b/src/audio/queueLoader.ts @@ -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; + resolveSettled: () => void; + /** Resolves once the fill loop has stopped issuing native calls. */ + loopDone: Promise; + 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 { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function supersedePreviousLoad(): Promise { + 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((resolve) => { + resolveSettled = resolve; + }); + const loopDone = new Promise((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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/src/components/queue/QueueTray.tsx b/src/components/queue/QueueTray.tsx index 992367f..efb040b 100644 --- a/src/components/queue/QueueTray.tsx +++ b/src/components/queue/QueueTray.tsx @@ -157,8 +157,13 @@ export function QueueTray({ onClose }: QueueTrayProps) { const dKey = useSharedValue(''); const dSettling = useSharedValue(false); const dIndexByKey = useSharedValue({}); + // 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, @@ -166,7 +171,8 @@ export function QueueTray({ onClose }: QueueTrayProps) { start: SharedValue, target: SharedValue, key: SharedValue, - settling: SharedValue + settling: SharedValue, + indexMap: SharedValue ) => { '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( diff --git a/src/components/queue/useQueue.ts b/src/components/queue/useQueue.ts index ba55123..9930cc1 100644 --- a/src/components/queue/useQueue.ts +++ b/src/components/queue/useQueue.ts @@ -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 }; diff --git a/src/stores/queueStore.ts b/src/stores/queueStore.ts index c8c79f6..f49b83c 100644 --- a/src/stores/queueStore.ts +++ b/src/stores/queueStore.ts @@ -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((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((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({ diff --git a/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.kt b/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.kt index 695a726..71d2b6f 100644 --- a/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.kt +++ b/vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/players/BaseAudioPlayer.kt @@ -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()) } }