Fix Add to Queue Issues (#457)

* fix issue where items added to queue weren't always queued

* On Repeat and Recently Played Sections in CarPlay now load tracks into the queue and start playback
This commit is contained in:
Violet Caulfield
2025-07-24 09:23:16 -05:00
committed by GitHub
parent 9df5b0aa7f
commit fd50111344
11 changed files with 1073 additions and 1047 deletions

View File

@@ -32,113 +32,129 @@ describe('Queue Index Util', () => {
describe('findPlayQueueIndexStart', () => {
it('should return the index of the first track that is not from selection', async () => {
const result = await findPlayQueueIndexStart([
{
id: '1',
index: 0,
url: 'https://example.com',
item: { Id: '1' },
QueuingType: QueuingType.FromSelection,
},
{
id: '2',
index: 1,
url: 'https://example.com',
item: { Id: '2' },
QueuingType: QueuingType.PlayingNext,
},
{
id: '3',
index: 2,
url: 'https://example.com',
item: { Id: '3' },
QueuingType: QueuingType.DirectlyQueued,
},
])
const result = await findPlayQueueIndexStart(
[
{
id: '1',
index: 0,
url: 'https://example.com',
item: { Id: '1' },
QueuingType: QueuingType.FromSelection,
},
{
id: '2',
index: 1,
url: 'https://example.com',
item: { Id: '2' },
QueuingType: QueuingType.PlayingNext,
},
{
id: '3',
index: 2,
url: 'https://example.com',
item: { Id: '3' },
QueuingType: QueuingType.DirectlyQueued,
},
],
0,
)
expect(result).toBe(3)
})
it('should return the index of the first track that is not from selection and after other queued tracks', async () => {
const result = await findPlayQueueIndexStart([
{
id: '1',
index: 0,
url: 'https://example.com',
item: { Id: '1' },
QueuingType: QueuingType.FromSelection,
},
{
id: '2',
index: 1,
url: 'https://example.com',
item: { Id: '2' },
QueuingType: QueuingType.PlayingNext,
},
{
id: '3',
index: 2,
url: 'https://example.com',
item: { Id: '3' },
QueuingType: QueuingType.DirectlyQueued,
},
{
id: '4',
index: 3,
url: 'https://example.com',
item: { Id: '4' },
QueuingType: QueuingType.FromSelection,
},
{
id: '5',
index: 4,
url: 'https://example.com',
item: { Id: '5' },
QueuingType: QueuingType.FromSelection,
},
])
const result = await findPlayQueueIndexStart(
[
{
id: '1',
index: 0,
url: 'https://example.com',
item: { Id: '1' },
QueuingType: QueuingType.FromSelection,
},
{
id: '2',
index: 1,
url: 'https://example.com',
item: { Id: '2' },
QueuingType: QueuingType.PlayingNext,
},
{
id: '3',
index: 2,
url: 'https://example.com',
item: { Id: '3' },
QueuingType: QueuingType.DirectlyQueued,
},
{
id: '4',
index: 3,
url: 'https://example.com',
item: { Id: '4' },
QueuingType: QueuingType.FromSelection,
},
{
id: '5',
index: 4,
url: 'https://example.com',
item: { Id: '5' },
QueuingType: QueuingType.FromSelection,
},
],
0,
)
expect(result).toBe(3)
})
it('should add in relation to the active track if shuffled, but respect queue priority', async () => {
const result = await findPlayQueueIndexStart([
{
id: '2',
index: 0,
url: 'https://example.com',
item: { Id: '2' },
QueuingType: QueuingType.FromSelection,
},
{
id: '1',
index: 1,
url: 'https://example.com',
item: { Id: '1' },
QueuingType: QueuingType.PlayingNext,
},
{
id: '3',
index: 2,
url: 'https://example.com',
item: { Id: '3' },
QueuingType: QueuingType.DirectlyQueued,
},
{
id: '5',
index: 3,
url: 'https://example.com',
item: { Id: '5' },
QueuingType: QueuingType.FromSelection,
},
{
id: '4',
index: 4,
url: 'https://example.com',
item: { Id: '4' },
QueuingType: QueuingType.FromSelection,
},
])
const result = await findPlayQueueIndexStart(
[
{
id: '2',
index: 0,
url: 'https://example.com',
item: { Id: '2' },
QueuingType: QueuingType.FromSelection,
},
{
id: '1',
index: 1,
url: 'https://example.com',
item: { Id: '1' },
QueuingType: QueuingType.PlayingNext,
},
{
id: '3',
index: 2,
url: 'https://example.com',
item: { Id: '3' },
QueuingType: QueuingType.DirectlyQueued,
},
{
id: '5',
index: 3,
url: 'https://example.com',
item: { Id: '5' },
QueuingType: QueuingType.FromSelection,
},
{
id: '4',
index: 4,
url: 'https://example.com',
item: { Id: '4' },
QueuingType: QueuingType.FromSelection,
},
{
id: '6',
index: 5,
url: 'https://example.com',
item: { Id: '6' },
QueuingType: QueuingType.FromSelection,
},
],
0,
)
expect(result).toBe(3)
})

View File

@@ -45,7 +45,7 @@
"@react-navigation/native-stack": "^7.3.21",
"@sentry/react-native": "^6.17.0",
"@shopify/flash-list": "^2.0.0-rc.12",
"@tamagui/config": "^1.132.11",
"@tamagui/config": "^1.132.12",
"@tanstack/query-sync-storage-persister": "^5.83.0",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-query-persist-client": "^5.83.0",
@@ -89,7 +89,7 @@
"react-native-uuid": "^2.0.3",
"react-native-vector-icons": "^10.2.0",
"ruby": "^0.6.1",
"tamagui": "^1.132.11"
"tamagui": "^1.132.12"
},
"devDependencies": {
"@babel/core": "^7.28.0",

View File

@@ -5,11 +5,11 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import TracksTemplate from './Tracks'
import ArtistsTemplate from './Artists'
import uuid from 'react-native-uuid'
import { Api } from '@jellyfin/sdk'
import { JellifyUser } from '../../types/JellifyUser'
import { InfiniteData } from '@tanstack/react-query'
import { QueueMutation } from '../../providers/Player/interfaces'
const CarPlayHome = (api: Api, user: JellifyUser, sessionId: string) =>
const CarPlayHome = (user: JellifyUser, loadQueue: (mutation: QueueMutation) => void) =>
new ListTemplate({
id: uuid.v4(),
title: 'Home',
@@ -54,7 +54,7 @@ const CarPlayHome = (api: Api, user: JellifyUser, sessionId: string) =>
QueryKeys.RecentlyPlayed,
]) ?? { pages: [], pageParams: [] }
CarPlay.pushTemplate(
TracksTemplate(api, sessionId, items.pages.flat(), 'Recently Played'),
TracksTemplate(items.pages.flat(), loadQueue, 'Recently Played'),
)
break
}
@@ -73,9 +73,7 @@ const CarPlayHome = (api: Api, user: JellifyUser, sessionId: string) =>
const items = queryClient.getQueryData<InfiniteData<BaseItemDto[], unknown>>([
QueryKeys.FrequentlyPlayed,
]) ?? { pages: [], pageParams: [] }
CarPlay.pushTemplate(
TracksTemplate(api, sessionId, items.pages.flat(), 'On Repeat'),
)
CarPlay.pushTemplate(TracksTemplate(items.pages.flat(), loadQueue, 'On Repeat'))
break
}
}

View File

@@ -2,14 +2,14 @@ import { TabBarTemplate } from 'react-native-carplay'
import CarPlayHome from './Home'
import CarPlayDiscover from './Discover'
import uuid from 'react-native-uuid'
import { Api } from '@jellyfin/sdk'
import { JellifyUser } from '../../types/JellifyUser'
import { QueueMutation } from '../../providers/Player/interfaces'
const CarPlayNavigation = (api: Api, user: JellifyUser, sessionId: string) =>
const CarPlayNavigation = (user: JellifyUser, loadQueue: (mutation: QueueMutation) => void) =>
new TabBarTemplate({
id: uuid.v4(),
title: 'Tabs',
templates: [CarPlayHome(api, user, sessionId), CarPlayDiscover],
templates: [CarPlayHome(user, loadQueue), CarPlayDiscover],
onTemplateSelect(template, e) {},
})

View File

@@ -1,16 +1,16 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { CarPlay, ListTemplate } from 'react-native-carplay'
import TrackPlayer from 'react-native-track-player'
import uuid from 'react-native-uuid'
import CarPlayNowPlaying from './NowPlaying'
import { queryClient } from '../../constants/query-client'
import { QueryKeys } from '../../enums/query-keys'
import { Api } from '@jellyfin/sdk'
import React from 'react'
import { QueueContext } from '../../providers/Player/queue'
import { Queue } from '../../player/types/queue-item'
import { QueueMutation } from '../../providers/Player/interfaces'
import { QueuingType } from '../../enums/queuing-type'
const TracksTemplate = (api: Api, sessionId: string, items: BaseItemDto[], queuingRef: Queue) =>
const TracksTemplate = (
items: BaseItemDto[],
loadQueue: (mutation: QueueMutation) => void,
queuingRef: Queue,
) =>
new ListTemplate({
id: uuid.v4(),
sections: [
@@ -24,12 +24,16 @@ const TracksTemplate = (api: Api, sessionId: string, items: BaseItemDto[], queui
}) ?? [],
},
],
onItemSelect: async (item) => {
const { loadQueue } = React.useContext(QueueContext)
console.debug(`loadQueue ${loadQueue}`)
await loadQueue(items, queuingRef, 0)
onItemSelect: async ({ index }) => {
loadQueue({
queuingType: QueuingType.FromSelection,
index,
tracklist: items,
queue: queuingRef,
shuffled: false,
track: items[index],
startPlayback: true,
})
CarPlay.pushTemplate(CarPlayNowPlaying)
},

View File

@@ -20,6 +20,7 @@ import { Theme, useTheme } from 'tamagui'
import Toast from 'react-native-toast-message'
import JellifyToastConfig from '../constants/toast.config'
import { useColorScheme } from 'react-native'
import { CarPlayProvider } from '../providers/CarPlay'
/**
* The main component for the Jellify app. Children are wrapped in the {@link JellifyProvider}
* @returns The {@link Jellify} component
@@ -82,6 +83,7 @@ function App(): React.JSX.Element {
<NetworkContextProvider>
<QueueProvider>
<PlayerProvider>
<CarPlayProvider />
<Root />
</PlayerProvider>
</QueueProvider>

View File

@@ -0,0 +1,56 @@
import CarPlayNavigation from '../../components/CarPlay/Navigation'
import { createContext, useEffect, useState } from 'react'
import { Platform } from 'react-native'
import { CarPlay } from 'react-native-carplay'
import { useJellifyContext } from '../index'
import { useQueueContext } from '../Player/queue'
interface CarPlayContext {
carplayConnected: boolean
}
const CarPlayContextInitializer = () => {
const { user, api, sessionId } = useJellifyContext()
const [carplayConnected, setCarPlayConnected] = useState(CarPlay ? CarPlay.connected : false)
const { useLoadNewQueue } = useQueueContext()
useEffect(() => {
function onConnect() {
setCarPlayConnected(true)
if (user && api) {
CarPlay.setRootTemplate(CarPlayNavigation(user, useLoadNewQueue))
if (Platform.OS === 'ios') {
CarPlay.enableNowPlaying(true) // https://github.com/birkir/react-native-carplay/issues/185
}
}
}
function onDisconnect() {
setCarPlayConnected(false)
}
if (CarPlay) {
CarPlay.registerOnConnect(onConnect)
CarPlay.registerOnDisconnect(onDisconnect)
return () => {
CarPlay.unregisterOnConnect(onConnect)
CarPlay.unregisterOnDisconnect(onDisconnect)
}
}
})
return {
carplayConnected,
}
}
const CarPlayContext = createContext<CarPlayContext>({
carplayConnected: false,
})
export const CarPlayProvider = () => {
const value = CarPlayContextInitializer()
return <CarPlayContext.Provider value={value} />
}

View File

@@ -403,7 +403,7 @@ const QueueContextInitailizer = () => {
}
const playInQueue = async (items: BaseItemDto[]) => {
const insertIndex = await findPlayQueueIndexStart(playQueue)
const insertIndex = await findPlayQueueIndexStart(playQueue, currentIndex)
console.debug(`Adding ${items.length} to queue at index ${insertIndex}`)
const newTracks = items.map((item) =>
@@ -847,13 +847,7 @@ export const QueueProvider: ({ children }: { children: ReactNode }) => React.JSX
// Memoize the context value to prevent unnecessary re-renders
const value = useMemo(
() => context,
[
context.currentIndex,
context.shuffled,
context.skipping,
// Functions are stable since they're defined inside the initializer
// Arrays are memoized by length to avoid reference changes
],
[context.currentIndex, context.shuffled, context.skipping, context.playQueue],
)
return <QueueContext.Provider value={value}>{children}</QueueContext.Provider>

View File

@@ -24,18 +24,14 @@ export async function findPlayNextIndexStart(playQueue: JellifyTrack[]) {
* @param playQueue The current player queue
* @returns The index to insert songs to add to the user queue
*/
export async function findPlayQueueIndexStart(playQueue: JellifyTrack[]) {
export async function findPlayQueueIndexStart(playQueue: JellifyTrack[], currentIndex: number) {
if (isEmpty(playQueue)) return 0
const activeTrack = await TrackPlayer.getActiveTrack()
const activeIndex = playQueue.findIndex((track) => track.item.Id === activeTrack?.item.Id)
if (isUndefined(activeTrack) || activeIndex === -1) return 0
if (currentIndex === -1) return 0
const insertIndex = playQueue.findIndex(
({ QueuingType: queuingType, index: itemIndex }) =>
queuingType === QueuingType.FromSelection && itemIndex > activeIndex,
({ QueuingType: queuingType }, index) =>
queuingType === QueuingType.FromSelection && index > currentIndex,
)
if (insertIndex === -1) return playQueue.length

View File

@@ -8,9 +8,6 @@ import {
useState,
useMemo,
} from 'react'
import { CarPlay } from 'react-native-carplay'
import CarPlayNavigation from '../components/CarPlay/Navigation'
import { Platform } from 'react-native'
import { JellifyLibrary } from '../types/JellifyLibrary'
import { JellifyServer } from '../types/JellifyServer'
import { JellifyUser } from '../types/JellifyUser'
@@ -50,11 +47,6 @@ interface JellifyContext {
*/
library: JellifyLibrary | undefined
/**
* Whether CarPlay / Android Auto is connected.
*/
carPlayConnected: boolean
/**
* The ID for the current session.
*/
@@ -103,8 +95,6 @@ const JellifyContextInitializer = () => {
const [loggedIn, setLoggedIn] = useState<boolean>(false)
const [carPlayConnected, setCarPlayConnected] = useState(CarPlay ? CarPlay.connected : false)
const signOut = () => {
setServer(undefined)
setUser(undefined)
@@ -144,33 +134,6 @@ const JellifyContextInitializer = () => {
else storage.delete(MMKVStorageKeys.Library)
}, [library])
useEffect(() => {
function onConnect() {
setCarPlayConnected(true)
if (user && api) {
CarPlay.setRootTemplate(CarPlayNavigation(api, user, sessionId))
if (Platform.OS === 'ios') {
CarPlay.enableNowPlaying(true) // https://github.com/birkir/react-native-carplay/issues/185
}
}
}
function onDisconnect() {
setCarPlayConnected(false)
}
if (CarPlay) {
CarPlay.registerOnConnect(onConnect)
CarPlay.registerOnDisconnect(onDisconnect)
return () => {
CarPlay.unregisterOnConnect(onConnect)
CarPlay.unregisterOnDisconnect(onDisconnect)
}
}
})
return {
loggedIn,
api,
@@ -181,7 +144,6 @@ const JellifyContextInitializer = () => {
setServer,
setUser,
setLibrary,
carPlayConnected,
signOut,
}
}
@@ -196,7 +158,6 @@ const JellifyContext = createContext<JellifyContext>({
setServer: () => {},
setUser: () => {},
setLibrary: () => {},
carPlayConnected: false,
signOut: () => {},
})
@@ -222,7 +183,6 @@ export const JellifyProvider: ({ children }: { children: ReactNode }) => React.J
context.server?.url,
context.user?.id,
context.library?.musicLibraryId,
context.carPlayConnected,
context.sessionId,
],
)

1740
yarn.lock

File diff suppressed because it is too large Load Diff