mirror of
https://github.com/anultravioletaurora/Jellify.git
synced 2025-12-16 18:55:44 -06:00
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:
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { defineConfig } = require('eslint/config')
|
||||
|
||||
const tsParser = require('@typescript-eslint/parser')
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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]}`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function AddToPlaylist({
|
||||
|
||||
trigger('notificationSuccess')
|
||||
|
||||
refetch()
|
||||
if (refetch) void refetch()
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QueryKeys.ItemTracks, playlist.Id!],
|
||||
|
||||
@@ -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={() =>
|
||||
|
||||
@@ -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),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user