mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
fix long queue issues
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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({
|
||||
|
||||
+47
-7
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user