enhance player queue management with Zustand store integration. (#574)

What is the change

    Implement persistent player queue state with a dedicated Zustand store (MMKV-backed): queue, currentTrack, currentIndex, shuffled, unShuffledQueue, and queueRef.
    Restore the queue on app start: new initialization reads persisted state and rebuilds the TrackPlayer queue and active index.
    Unify queue mutations to keep React Native Track Player and the persisted store in sync:
        Loading a new queue sets queueRef, honors shuffle, computes start index, and writes to both RNTP and store.
        "Play Next" and "Add to Queue" update RNTP, the live queue, and unShuffledQueue consistently.
        Active track/index changes now propagate to the store and query cache.
    Tighten/refine player query/invalidations so UI reflects the latest RNTP state.
    Minor housekeeping in config/scripts to support the above (ESLint flat config, metro/jest/script updates).

What does this address

    Fixes queue not restoring after relaunch or crash; playback position and the current track persist correctly.
    Preserves shuffle state and the original unshuffled ordering for reliable shuffle toggle behavior.
    Ensures "Play Next"/"Add to Queue" behave consistently with what the user sees, avoiding duplication or desyncs.
    Reduces UI state drift by syncing RNTP events to both React Query and persistent store.


Co-authored-by: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com>
This commit is contained in:
skalthoff
2025-10-24 03:41:22 -07:00
committed by GitHub
parent f4507053dc
commit cb068da8bf
15 changed files with 129 additions and 45 deletions

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { defineConfig } = require('eslint/config')
const tsParser = require('@typescript-eslint/parser')

View File

@@ -1,3 +1,5 @@
import mockRefreshControl from './refresh-control'
jest.mock('../../src/api/info', () => {
return {
JellyfinInfo: {
@@ -31,9 +33,6 @@ jest.mock('react-native-haptic-feedback', () => {
}
})
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mockRefreshControl = require('./refresh-control').default
// Mock the network status types to avoid dependency issues
jest.mock('../../src/components/Network/internetConnectionWatcher', () => ({
networkStatusTypes: {

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-var-requires */
// Learn more https://docs.expo.io/guides/customizing-metro
const { wrapWithReanimatedMetroConfig } = require('react-native-reanimated/metro-config')

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { execSync, exec, spawn } = require('child_process')
const path = require('path')
const fs = require('fs')

View File

@@ -1,5 +1,3 @@
/* eslint-disable no-prototype-builtins */
/* eslint-disable @typescript-eslint/no-var-requires */
const fs = require('fs')
const path = require('path')
@@ -32,7 +30,7 @@ function updateEnvFile(filePath, updates) {
const [key, ...rest] = line.split('=')
const trimmedKey = key.trim()
if (updates.hasOwnProperty(trimmedKey)) {
if (Object.prototype.hasOwnProperty.call(updates, trimmedKey)) {
seenKeys.add(trimmedKey)
return `${trimmedKey}=${updates[trimmedKey]}`
}

View File

@@ -27,7 +27,9 @@ export async function downloadJellyfinFile(
if (contentType && contentType.includes('/')) {
const parts = contentType.split('/')
const container = parts[1].split(';')[0] // handles "audio/m4a; charset=utf-8"
container !== 'mpeg' && (extension = container) // don't use mpeg as an extension, use the default extension
if (container !== 'mpeg') {
extension = container // don't use mpeg as an extension, use the default extension
}
}
// Step 3: Build path

View File

@@ -97,7 +97,7 @@ export default function AddToPlaylist({
trigger('notificationSuccess')
refetch()
if (refetch) void refetch()
queryClient.invalidateQueries({
queryKey: [QueryKeys.ItemTracks, playlist.Id!],

View File

@@ -85,12 +85,14 @@ export default function Playlist({
queue={playlist}
showArtwork
onLongPress={() => {
editing
? drag()
: rootNavigation.navigate('Context', {
item: track,
navigation,
})
if (editing) {
drag()
} else {
rootNavigation.navigate('Context', {
item: track,
navigation,
})
}
}}
showRemove={editing}
onRemove={() =>

View File

@@ -1,3 +1,4 @@
import TrackPlayer from 'react-native-track-player'
import JellifyTrack from '../../../types/JellifyTrack'
import { queryClient } from '../../../constants/query-client'
import {
@@ -6,6 +7,7 @@ import {
PLAY_QUEUE_QUERY_KEY,
} from '../constants/query-keys'
import { CURRENT_INDEX_QUERY, NOW_PLAYING_QUERY } from '../constants/queries'
import { usePlayerQueueStore } from '../../../stores/player/queue'
export function getActiveIndex(): number | undefined {
return queryClient.getQueryData(ACTIVE_INDEX_QUERY_KEY) as number | undefined
@@ -27,7 +29,19 @@ export function setPlayQueue(tracks: JellifyTrack[]): void {
queryClient.setQueryData(PLAY_QUEUE_QUERY_KEY, tracks)
}
export function handleActiveTrackChanged(): void {
queryClient.refetchQueries(NOW_PLAYING_QUERY)
queryClient.refetchQueries(CURRENT_INDEX_QUERY)
export async function handleActiveTrackChanged(): Promise<void> {
const [queue, activeTrack, activeIndex] = await Promise.all([
TrackPlayer.getQueue(),
TrackPlayer.getActiveTrack(),
TrackPlayer.getActiveTrackIndex(),
])
usePlayerQueueStore.getState().setQueue(queue as JellifyTrack[])
usePlayerQueueStore.getState().setCurrentTrack((activeTrack as JellifyTrack) ?? null)
usePlayerQueueStore.getState().setCurrentIndex(activeIndex ?? null)
await Promise.all([
queryClient.refetchQueries(NOW_PLAYING_QUERY),
queryClient.refetchQueries(CURRENT_INDEX_QUERY),
])
}

View File

@@ -1,23 +1,39 @@
import { isUndefined } from 'lodash'
import { getActiveIndex, getCurrentTrack, getPlayQueue } from '.'
import TrackPlayer from 'react-native-track-player'
import { usePlayerQueueStore } from '../../../stores/player/queue'
export default async function Initialize() {
const storedPlayQueue = getPlayQueue()
const storedIndex = getActiveIndex()
const storedTrack = getCurrentTrack()
const {
queue: persistedQueue,
currentIndex: persistedIndex,
currentTrack: persistedTrack,
} = usePlayerQueueStore.getState()
const storedPlayQueue = persistedQueue.length > 0 ? persistedQueue : getPlayQueue()
const storedIndex = persistedIndex ?? getActiveIndex()
const storedTrack = persistedTrack ?? getCurrentTrack()
console.debug(
`StoredIndex: ${storedIndex}, storedPlayQueue: ${storedPlayQueue?.map((track, index) => index)}, track: ${storedTrack?.item.Id}`,
)
if (!isUndefined(storedPlayQueue) && !isUndefined(storedIndex)) {
if (
Array.isArray(storedPlayQueue) &&
storedPlayQueue.length > 0 &&
!isUndefined(storedIndex) &&
storedIndex !== null
) {
console.debug('Initializing play queue from storage')
await TrackPlayer.reset()
await TrackPlayer.add(storedPlayQueue)
await TrackPlayer.skip(storedIndex)
usePlayerQueueStore.getState().setQueue(storedPlayQueue)
usePlayerQueueStore.getState().setCurrentIndex(storedIndex)
usePlayerQueueStore.getState().setCurrentTrack(storedPlayQueue[storedIndex] ?? null)
console.debug('Initialized play queue from storage')
}
}

View File

@@ -6,15 +6,28 @@ import {
REPEAT_MODE_QUERY,
} from '../constants/queries'
import TrackPlayer from 'react-native-track-player'
import { usePlayerQueueStore } from '../../../stores/player/queue'
import JellifyTrack from '../../../types/JellifyTrack'
export function refetchActiveIndex(): void {
queryClient.refetchQueries(CURRENT_INDEX_QUERY)
export async function refetchActiveIndex(): Promise<void> {
await queryClient.refetchQueries(CURRENT_INDEX_QUERY)
const activeIndex = await TrackPlayer.getActiveTrackIndex()
usePlayerQueueStore.getState().setCurrentIndex(activeIndex ?? null)
}
export function refetchNowPlaying(): void {
queryClient.refetchQueries(NOW_PLAYING_QUERY)
export async function refetchNowPlaying(): Promise<void> {
await queryClient.refetchQueries(NOW_PLAYING_QUERY)
refetchActiveIndex()
const [activeTrack, queue] = await Promise.all([
TrackPlayer.getActiveTrack(),
TrackPlayer.getQueue(),
])
usePlayerQueueStore.getState().setCurrentTrack((activeTrack as JellifyTrack) ?? null)
usePlayerQueueStore.getState().setQueue(queue as JellifyTrack[])
await refetchActiveIndex()
}
/**
@@ -25,10 +38,10 @@ export function refetchNowPlaying(): void {
* Under the hood, this will refetch the active queue from the {@link TrackPlayer}
* and the currently playing track
*/
export function refetchPlayerQueue(): void {
queryClient.refetchQueries(QUEUE_QUERY)
export async function refetchPlayerQueue(): Promise<void> {
await queryClient.refetchQueries(QUEUE_QUERY)
refetchNowPlaying()
await refetchNowPlaying()
}
export function invalidateRepeatMode(): void {

View File

@@ -81,6 +81,10 @@ export async function loadQueue({
await TrackPlayer.setQueue(queue)
usePlayerQueueStore.getState().setQueue(queue)
usePlayerQueueStore.getState().setCurrentIndex(finalStartIndex)
usePlayerQueueStore.getState().setCurrentTrack(queue[finalStartIndex] ?? null)
console.debug(
`Queued ${queue.length} tracks, starting at ${finalStartIndex}${shuffled ? ' (shuffled)' : ''}`,
)
@@ -117,6 +121,9 @@ export const playNextInQueue = async ({
// Then update RNTP
await TrackPlayer.add(tracksToPlayNext, (currentIndex ?? 0) + 1)
const updatedQueue = (await TrackPlayer.getQueue()) as JellifyTrack[]
usePlayerQueueStore.getState().setQueue(updatedQueue)
// Add to the state unshuffled queue, using the currently playing track as the index
usePlayerQueueStore
.getState()
@@ -161,6 +168,9 @@ export const playLaterInQueue = async ({
// Then update RNTP
await TrackPlayer.add(newTracks)
const updatedQueue = (await TrackPlayer.getQueue()) as JellifyTrack[]
usePlayerQueueStore.getState().setQueue(updatedQueue)
// Update unshuffled queue with the same mapped tracks to avoid duplication
usePlayerQueueStore
.getState()

View File

@@ -4,12 +4,7 @@ import { loadQueue, playLaterInQueue, playNextInQueue } from '../functions/queue
import { isUndefined } from 'lodash'
import { previous, skip } from '../functions/controls'
import { AddToQueueMutation, QueueMutation, QueueOrderMutation } from '../interfaces'
import {
refetchNowPlaying,
refetchPlayerQueue,
invalidateRepeatMode,
refetchActiveIndex,
} from '../functions/queries'
import { refetchNowPlaying, refetchPlayerQueue, invalidateRepeatMode } from '../functions/queries'
import { QueuingType } from '../../../enums/queuing-type'
import Toast from 'react-native-toast-message'
import { handleDeshuffle, handleShuffle } from '../functions/shuffle'
@@ -232,7 +227,7 @@ export const useLoadNewQueue = () => {
queryClient.setQueryData(NOW_PLAYING_QUERY_KEY, tracks[finalStartIndex])
usePlayerQueueStore.getState().setQueueRef(variables.queue)
refetchPlayerQueue()
await refetchPlayerQueue()
},
[isCasting, remoteClient, navigation, downloadedTracks, trigger],
)
@@ -246,7 +241,7 @@ export const usePrevious = () => {
await previous()
console.debug('Skipped to previous track')
refetchNowPlaying()
await refetchNowPlaying()
}, [trigger])
}
@@ -262,7 +257,7 @@ export const useSkip = () => {
)
skip(index)
console.debug('Skipped to next track')
refetchNowPlaying()
await refetchNowPlaying()
},
[trigger],
)
@@ -330,6 +325,9 @@ export const useResetQueue = () =>
usePlayerQueueStore.getState().setUnshuffledQueue([])
usePlayerQueueStore.getState().setShuffled(false)
usePlayerQueueStore.getState().setQueueRef('Recently Played')
usePlayerQueueStore.getState().setQueue([])
usePlayerQueueStore.getState().setCurrentTrack(null)
usePlayerQueueStore.getState().setCurrentIndex(null)
await TrackPlayer.reset()
},
onSettled: refetchPlayerQueue,
@@ -351,7 +349,7 @@ export const useToggleShuffle = () => {
},
onSuccess: async (_, shuffled) => {
usePlayerQueueStore.getState().setShuffled(!shuffled)
refetchPlayerQueue()
await refetchPlayerQueue()
},
})
}

View File

@@ -96,7 +96,9 @@ export const usePlaybackState = (): State | undefined => {
useMemo(() => {
if (client && isCasting) {
client.onMediaStatusUpdated((status) => {
status?.playerState && setPlaybackState(castToRNTPState(status.playerState))
if (status?.playerState) {
setPlaybackState(castToRNTPState(status.playerState))
}
})
} else {
setPlaybackState(state)

View File

@@ -1,7 +1,8 @@
import { Queue } from '@/src/player/types/queue-item'
import JellifyTrack from '@/src/types/JellifyTrack'
import { devtools, persist } from 'zustand/middleware'
import { create } from 'zustand/react'
import { stateStorage } from '../../constants/storage'
import { create } from 'zustand'
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
type PlayerQueueStore = {
shuffled: boolean
@@ -12,6 +13,15 @@ type PlayerQueueStore = {
unShuffledQueue: JellifyTrack[]
setUnshuffledQueue: (unShuffledQueue: JellifyTrack[]) => void
queue: JellifyTrack[]
setQueue: (queue: JellifyTrack[]) => void
currentTrack: JellifyTrack | null
setCurrentTrack: (track: JellifyTrack | null) => void
currentIndex: number | null
setCurrentIndex: (index: number | null) => void
}
export const usePlayerQueueStore = create<PlayerQueueStore>()(
@@ -32,9 +42,28 @@ export const usePlayerQueueStore = create<PlayerQueueStore>()(
set({
unShuffledQueue,
}),
queue: [],
setQueue: (queue: JellifyTrack[]) =>
set({
queue,
}),
currentTrack: null,
setCurrentTrack: (currentTrack: JellifyTrack | null) =>
set({
currentTrack,
}),
currentIndex: null,
setCurrentIndex: (currentIndex: number | null) =>
set({
currentIndex,
}),
}),
{
name: 'player-queue-storage',
storage: createJSONStorage(() => stateStorage),
},
),
),
@@ -43,3 +72,7 @@ export const usePlayerQueueStore = create<PlayerQueueStore>()(
export const useShuffle = () => usePlayerQueueStore((state) => state.shuffled)
export const useQueueRef = () => usePlayerQueueStore((state) => state.queueRef)
export const useCurrentTrack = () => usePlayerQueueStore((state) => state.currentTrack)
export const useCurrentIndex = () => usePlayerQueueStore((state) => state.currentIndex)