Queue Provider Optimizations, Alphabetical Selector Optimizations, Artist Pagination Improvements (#467)

* make now playing change faster when loading a new queue

* queue provider optimizations

* Improvements to the alphabetical selector handling in the artists page.

* Improvements to artists pagination - fetching more artists at a given time
This commit is contained in:
Violet Caulfield
2025-08-03 18:57:57 -05:00
committed by GitHub
parent bcf18a5963
commit b15ec8b095
29 changed files with 417 additions and 263 deletions

View File

@@ -5,14 +5,23 @@ import { Event } from 'react-native-track-player'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Button, Text } from 'react-native'
import { QueueProvider, useQueueContext } from '../../src/providers/Player/queue'
import {
QueueProvider,
useCurrentIndexContext,
usePreviousContext,
useSetPlayQueueContext,
useSkipContext,
} from '../../src/providers/Player/queue'
import { eventHandler } from '../setup/rntp'
import JellifyTrack from '../../src/types/JellifyTrack'
const queryClient = new QueryClient()
const QueueConsumer = () => {
const { currentIndex, useSkip, usePrevious, playQueue, setPlayQueue } = useQueueContext()
const currentIndex = useCurrentIndexContext()
const useSkip = useSkipContext()
const usePrevious = usePreviousContext()
const setPlayQueue = useSetPlayQueueContext()
const tracklist: JellifyTrack[] = [
{

View File

@@ -5,7 +5,16 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Button, Text } from 'react-native'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { QueueProvider, useQueueContext } from '../../src/providers/Player/queue'
import {
QueueProvider,
usePlayQueueContext,
useCurrentIndexContext,
useSetPlayQueueContext,
useSetCurrentIndexContext,
useShuffledContext,
useSetUnshuffledQueueContext,
useUnshuffledQueueContext,
} from '../../src/providers/Player/queue'
import { PlayerProvider, usePlayerContext } from '../../src/providers/Player'
import JellifyTrack from '../../src/types/JellifyTrack'
import { QueuingType } from '../../src/enums/queuing-type'
@@ -82,15 +91,13 @@ const createMockTracks = (count: number): JellifyTrack[] => {
}
const TestComponent = () => {
const {
playQueue,
setPlayQueue,
currentIndex,
setCurrentIndex,
shuffled,
unshuffledQueue,
setUnshuffledQueue,
} = useQueueContext()
const playQueue = usePlayQueueContext()
const setPlayQueue = useSetPlayQueueContext()
const currentIndex = useCurrentIndexContext()
const setCurrentIndex = useSetCurrentIndexContext()
const shuffled = useShuffledContext()
const unshuffledQueue = useUnshuffledQueueContext()
const setUnshuffledQueue = useSetUnshuffledQueueContext()
const { useToggleShuffle, shuffled: playerShuffled } = usePlayerContext()
const testTracks = createMockTracks(5)

View File

@@ -39,10 +39,10 @@
"@react-native-community/netinfo": "^11.4.1",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-picker/picker": "^2.11.1",
"@react-navigation/bottom-tabs": "^7.4.4",
"@react-navigation/material-top-tabs": "^7.3.4",
"@react-navigation/native": "^7.1.16",
"@react-navigation/native-stack": "^7.3.23",
"@react-navigation/bottom-tabs": "^7.4.5",
"@react-navigation/material-top-tabs": "^7.3.5",
"@react-navigation/native": "^7.1.17",
"@react-navigation/native-stack": "^7.3.24",
"@sentry/react-native": "^6.17.0",
"@shopify/flash-list": "^2.0.1",
"@tamagui/config": "^1.132.15",
@@ -89,7 +89,9 @@
"react-native-uuid": "^2.0.3",
"react-native-vector-icons": "^10.2.0",
"ruby": "^0.6.1",
"tamagui": "^1.132.15"
"scheduler": "^0.26.0",
"tamagui": "^1.132.15",
"use-context-selector": "^2.0.0"
},
"devDependencies": {
"@babel/core": "^7.28.0",
@@ -137,4 +139,4 @@
"node": ">=18"
},
"packageManager": "yarn@1.22.22"
}
}

View File

@@ -3,43 +3,45 @@ import { Api } from '@jellyfin/sdk/lib/api'
import {
BaseItemDto,
BaseItemKind,
ItemFields,
ItemSortBy,
SortOrder,
} from '@jellyfin/sdk/lib/generated-client/models'
import { getArtistsApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { JellifyUser } from '../../types/JellifyUser'
import QueryConfig from './query.config'
import { alphabet } from '../../providers/Library'
export function fetchArtists(
api: Api | undefined,
user: JellifyUser | undefined,
library: JellifyLibrary | undefined,
page: string | number,
page: number,
isFavorite: boolean | undefined,
sortBy: ItemSortBy[] = [ItemSortBy.SortName],
sortOrder: SortOrder[] = [SortOrder.Ascending],
): Promise<{ title: string | number; data: BaseItemDto[] }> {
): Promise<BaseItemDto[]> {
console.debug('Fetching artists', page)
return new Promise((resolve, reject) => {
if (!api) return reject('No API instance provided')
if (!user) return reject('No user provided')
if (!library) return reject('Library has not been set')
getArtistsApi(api)
.getAlbumArtists({
parentId: library?.musicLibraryId,
userId: user?.id,
parentId: library.musicLibraryId,
userId: user.id,
enableUserData: true,
sortBy: sortBy,
sortOrder: sortOrder,
startIndex: typeof page === 'number' ? page * QueryConfig.limits.library : 0,
startIndex: page * QueryConfig.limits.library,
limit: QueryConfig.limits.library,
isFavorite: isFavorite,
nameStartsWith: typeof page === 'string' && page !== alphabet[0] ? page : undefined,
nameLessThan: typeof page === 'string' && page === alphabet[0] ? 'A' : undefined,
fields: [ItemFields.SortName],
})
.then((response) => {
return response.data.Items
? resolve({ title: page, data: response.data.Items })
: resolve({ title: page, data: [] })
console.debug('Artists Response received')
return response.data.Items ? resolve(response.data.Items) : resolve([])
})
.catch((error) => {
reject(error)

View File

@@ -17,7 +17,7 @@ import Icon from '../Global/components/icon'
import { mapDtoToTrack } from '../../utils/mappings'
import { useNetworkContext } from '../../providers/Network'
import { useSettingsContext } from '../../providers/Settings'
import { useQueueContext } from '../../providers/Player/queue'
import { useLoadQueueContext } from '../../providers/Player/queue'
import { QueuingType } from '../../enums/queuing-type'
import { useAlbumContext } from '../../providers/Album'
@@ -41,7 +41,7 @@ export function Album({ navigation }: AlbumProps): React.JSX.Element {
failedDownloads,
} = useNetworkContext()
const { downloadQuality, streamingQuality } = useSettingsContext()
const { useLoadNewQueue } = useQueueContext()
const useLoadNewQueue = useLoadQueueContext()
const downloadAlbum = (item: BaseItemDto[]) => {
if (!api || !sessionId) return

View File

@@ -14,7 +14,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../types'
import React from 'react'
import Icon from '../Global/components/icon'
import { useQueueContext } from '../../providers/Player/queue'
import { useLoadQueueContext } from '../../providers/Player/queue'
import { QueuingType } from '../../enums/queuing-type'
import { fetchAlbumDiscs } from '../../api/queries/item'
@@ -24,7 +24,7 @@ export default function ArtistTabBar(
) {
const { api } = useJellifyContext()
const { artist, scroll, albums } = useArtistContext()
const { useLoadNewQueue } = useQueueContext()
const useLoadNewQueue = useLoadQueueContext()
const { width } = useSafeAreaFrame()

View File

@@ -1,62 +1,96 @@
import React, { useEffect, useRef } from 'react'
import { getToken, getTokenValue, Separator, useTheme, XStack, YStack } from 'tamagui'
import { getToken, Separator, useTheme, XStack, YStack, Spinner } from 'tamagui'
import { Text } from '../Global/helpers/text'
import { RefreshControl } from 'react-native'
import { ActivityIndicator, RefreshControl } from 'react-native'
import { ArtistsProps } from '../types'
import ItemRow from '../Global/components/item-row'
import { useLibraryContext, useLibrarySortAndFilterContext } from '../../providers/Library'
import { useLibrarySortAndFilterContext } from '../../providers/Library'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { FlashList, FlashListRef } from '@shopify/flash-list'
import { AZScroller } from '../Global/components/alphabetical-selector'
import { useMutation } from '@tanstack/react-query'
import { isString } from 'lodash'
/**
* @param artistsInfiniteQuery - The infinite query for artists
* @param navigation - The navigation object
* @param showAlphabeticalSelector - Whether to show the alphabetical selector
* @param artistPageParams - The page params for the artists - which are the A-Z letters that have been seen
* @returns The Artists component
*/
export default function Artists({
artistsInfiniteQuery,
navigation,
showAlphabeticalSelector,
artistPageParams,
}: ArtistsProps): React.JSX.Element {
const { artistPageParams } = useLibraryContext()
const theme = useTheme()
const { isFavorites } = useLibrarySortAndFilterContext()
const artists = artistsInfiniteQuery.data ?? []
const sectionListRef = useRef<FlashListRef<string | number | BaseItemDto>>(null)
const itemHeight = getToken('$6')
const pendingLetterRef = useRef<string | null>(null)
const MemoizedItem = React.memo(ItemRow)
const artistsRef = useRef<(string | number | BaseItemDto)[]>(artistsInfiniteQuery.data ?? [])
const alphabeticalSelectorCallback = async (letter: string) => {
console.debug(`Alphabetical Selector Callback: ${letter}`)
do {
if (artistPageParams.current.includes(letter)) break
await artistsInfiniteQuery.fetchNextPage({ cancelRefetch: true })
} while (
!artistsRef.current.includes(letter) &&
artistsInfiniteQuery.hasNextPage &&
(!artistsInfiniteQuery.isFetchNextPageError || artistsInfiniteQuery.isFetchingNextPage)
)
while (
!artistPageParams!.current.has(letter.toUpperCase()) &&
artistsInfiniteQuery.hasNextPage
) {
if (!artistsInfiniteQuery.isPending) {
await artistsInfiniteQuery.fetchNextPage()
}
}
console.debug(`Alphabetical Selector Callback: ${letter} complete`)
}
const { mutate: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } = useMutation({
mutationFn: (letter: string) => alphabeticalSelectorCallback(letter),
onSuccess: (data, letter) => {
setTimeout(() => {
sectionListRef.current?.scrollToIndex({
index: artistsRef.current!.indexOf(letter),
viewPosition: 0.1,
animated: true,
})
}, 500)
pendingLetterRef.current = letter.toUpperCase()
},
})
// Effect for handling the pending alphabet selector letter
useEffect(() => {
artistsRef.current = artistsInfiniteQuery.data ?? []
console.debug(`artists: ${JSON.stringify(artistsInfiniteQuery.data)}`)
}, [artistsInfiniteQuery.data])
if (isString(pendingLetterRef.current) && artistsInfiniteQuery.data) {
const upperLetters = artists
.filter((item): item is string => typeof item === 'string')
.map((letter) => letter.toUpperCase())
.sort()
const index = upperLetters.findIndex((letter) => letter >= pendingLetterRef.current!)
if (index !== -1) {
const letterToScroll = upperLetters[index]
const scrollIndex = artists.indexOf(letterToScroll)
if (scrollIndex !== -1) {
sectionListRef.current?.scrollToIndex({
index: scrollIndex,
viewPosition: 0.1,
animated: true,
})
}
} else {
// fallback: scroll to last section
const lastLetter = upperLetters[upperLetters.length - 1]
const scrollIndex = artists.indexOf(lastLetter)
if (scrollIndex !== -1) {
sectionListRef.current?.scrollToIndex({
index: scrollIndex,
viewPosition: 0.1,
animated: true,
})
}
}
pendingLetterRef.current = null
}
}, [pendingLetterRef.current, artistsInfiniteQuery.data])
return (
<XStack flex={1}>
@@ -79,20 +113,20 @@ export default function Artists({
: item.Id!
}
ItemSeparatorComponent={() => <Separator />}
data={artistsInfiniteQuery.data}
data={artists}
refreshControl={
<RefreshControl
colors={[theme.primary.val]}
refreshing={artistsInfiniteQuery.isPending || isAlphabetSelectorPending}
progressViewOffset={getTokenValue('$10')}
refreshing={artistsInfiniteQuery.isFetching || isAlphabetSelectorPending}
onRefresh={() => artistsInfiniteQuery.refetch()}
tintColor={theme.primary.val}
/>
}
renderItem={({ index, item: artist }) =>
typeof artist === 'string' ? (
// Don't render the letter if we don't have any artists that start with it
// If the index is the last index, or the next index is not an object, then don't render the letter
index - 1 === artistsInfiniteQuery.data!.length ||
typeof artistsInfiniteQuery.data![index + 1] !== 'object' ? null : (
index - 1 === artists.length ||
typeof artists[index + 1] !== 'object' ? null : (
<XStack
padding={'$2'}
backgroundColor={'$background'}
@@ -114,17 +148,9 @@ export default function Artists({
/>
) : null
}
ListEmptyComponent={
artistsInfiniteQuery.isPending ||
artistsInfiniteQuery.isFetchingNextPage ? null : (
<YStack justifyContent='center'>
<Text>No artists</Text>
</YStack>
)
}
stickyHeaderIndices={
showAlphabeticalSelector
? artistsInfiniteQuery.data
? artists
?.map((artist, index, artists) =>
typeof artist === 'string' ? index : 0,
)
@@ -142,7 +168,9 @@ export default function Artists({
removeClippedSubviews
/>
{showAlphabeticalSelector && <AZScroller onLetterSelect={alphabetSelectorMutate} />}
{showAlphabeticalSelector && artistPageParams && (
<AZScroller onLetterSelect={alphabetSelectorMutate} />
)}
</XStack>
)
}

View File

@@ -30,7 +30,7 @@ import { fetchUserPlaylists } from '../../../api/queries/playlists'
import { useJellifyContext } from '../../../providers'
import { getImageApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { useNetworkContext } from '../../../providers/Network'
import { useQueueContext } from '../../../providers/Player/queue'
import { useAddToQueueContext } from '../../../providers/Player/queue'
import Toast from 'react-native-toast-message'
import FastImage from 'react-native-fast-image'
import Icon from '../../../components/Global/components/icon'
@@ -111,7 +111,7 @@ export default function TrackOptions({
return result
}, [playlistsWithTracks.data, track.Id])
const { useAddToQueue } = useQueueContext()
const useAddToQueue = useAddToQueueContext()
const { width } = useSafeAreaFrame()

View File

@@ -3,8 +3,9 @@ import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import { isUndefined } from 'lodash'
import { getTokenValue, Token, useTheme } from 'tamagui'
import { useJellifyContext } from '../../../providers'
import { ImageStyle, StyleProp, ViewStyle } from 'react-native'
import { ImageStyle } from 'react-native'
import FastImage from 'react-native-fast-image'
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models'
interface ImageProps {
item: BaseItemDto
@@ -28,7 +29,10 @@ export default function ItemImage({
const imageUrl =
api &&
((item.AlbumId && getImageApi(api).getItemImageUrlById(item.AlbumId)) ||
((item.AlbumId &&
getImageApi(api).getItemImageUrlById(item.AlbumId, ImageType.Primary, {
tag: item.ImageTags?.Primary,
})) ||
(item.Id && getImageApi(api).getItemImageUrlById(item.Id)) ||
'')

View File

@@ -6,7 +6,7 @@ import { Text } from '../helpers/text'
import Icon from './icon'
import { QueuingType } from '../../../enums/queuing-type'
import { RunTimeTicks } from '../helpers/time-codes'
import { useQueueContext } from '../../../providers/Player/queue'
import { useLoadQueueContext } from '../../../providers/Player/queue'
import ItemImage from './image'
import FavoriteIcon from './favorite-icon'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
@@ -36,7 +36,7 @@ export default function ItemRow({
onPress?: () => void
circular?: boolean
}): React.JSX.Element {
const { useLoadNewQueue } = useQueueContext()
const useLoadNewQueue = useLoadQueueContext()
const gestureCallback = () => {
switch (item.Type) {

View File

@@ -14,7 +14,7 @@ import FastImage from 'react-native-fast-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import { networkStatusTypes } from '../../../components/Network/internetConnectionWatcher'
import { useNetworkContext } from '../../../providers/Network'
import { useQueueContext } from '../../../providers/Player/queue'
import { useLoadQueueContext, usePlayQueueContext } from '../../../providers/Player/queue'
import { useJellifyContext } from '../../../providers'
import DownloadedIcon from './downloaded-icon'
@@ -53,7 +53,8 @@ export default function Track({
const theme = useTheme()
const { api } = useJellifyContext()
const { nowPlaying } = usePlayerContext()
const { playQueue, useLoadNewQueue } = useQueueContext()
const playQueue = usePlayQueueContext()
const useLoadNewQueue = useLoadQueueContext()
const { downloadedTracks, networkStatus } = useNetworkContext()
const isPlaying = nowPlaying?.item.Id === track.Id

View File

@@ -7,7 +7,7 @@ import { ItemCard } from '../../../components/Global/components/item-card'
import { QueuingType } from '../../../enums/queuing-type'
import { trigger } from 'react-native-haptic-feedback'
import Icon from '../../Global/components/icon'
import { useQueueContext } from '../../../providers/Player/queue'
import { useLoadQueueContext } from '../../../providers/Player/queue'
import { H4 } from '../../../components/Global/helpers/text'
import { useDisplayContext } from '../../../providers/Display/display-provider'
export default function FrequentlyPlayedTracks({
@@ -22,7 +22,7 @@ export default function FrequentlyPlayedTracks({
isFetchingFrequentlyPlayed,
} = useHomeContext()
const { useLoadNewQueue } = useQueueContext()
const useLoadNewQueue = useLoadQueueContext()
const { horizontalItems } = useDisplayContext()
return (

View File

@@ -10,7 +10,7 @@ import { trigger } from 'react-native-haptic-feedback'
import { QueuingType } from '../../../enums/queuing-type'
import HorizontalCardList from '../../../components/Global/components/horizontal-list'
import Icon from '../../Global/components/icon'
import { useQueueContext } from '../../../providers/Player/queue'
import { useLoadQueueContext } from '../../../providers/Player/queue'
import { useDisplayContext } from '../../../providers/Display/display-provider'
export default function RecentlyPlayed({
@@ -20,7 +20,7 @@ export default function RecentlyPlayed({
}): React.JSX.Element {
const { nowPlaying } = usePlayerContext()
const { useLoadNewQueue } = useQueueContext()
const useLoadNewQueue = useLoadQueueContext()
const { recentTracks, fetchNextRecentTracks, hasNextRecentTracks, isFetchingRecentTracks } =
useHomeContext()

View File

@@ -5,7 +5,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../../types'
export default function ArtistsTab(): React.JSX.Element {
const { artistsInfiniteQuery } = useLibraryContext()
const { artistsInfiniteQuery, artistPageParams } = useLibraryContext()
const navigation = useNavigation<NativeStackNavigationProp<StackParamList>>()
@@ -14,6 +14,7 @@ export default function ArtistsTab(): React.JSX.Element {
artistsInfiniteQuery={artistsInfiniteQuery}
navigation={navigation}
showAlphabeticalSelector={true}
artistPageParams={artistPageParams}
/>
)
}

View File

@@ -3,14 +3,19 @@ import { Spacer, XStack, getToken } from 'tamagui'
import PlayPauseButton from './buttons'
import Icon from '../../Global/components/icon'
import { usePlayerContext } from '../../../providers/Player'
import { useQueueContext } from '../../../providers/Player/queue'
import {
usePreviousContext,
useShuffledContext,
useSkipContext,
} from '../../../providers/Player/queue'
import { RepeatMode } from 'react-native-track-player'
export default function Controls(): React.JSX.Element {
const { usePrevious, useSkip } = useQueueContext()
const usePrevious = usePreviousContext()
const useSkip = useSkipContext()
const { useToggleShuffle, useToggleRepeatMode, repeatMode } = usePlayerContext()
const { shuffled } = useQueueContext()
const shuffled = useShuffledContext()
return (
<XStack

View File

@@ -1,6 +1,6 @@
import { useJellifyContext } from '../../../providers'
import { usePlayerContext } from '../../../providers/Player'
import { useQueueContext } from '../../../providers/Player/queue'
import { useQueueRefContext } from '../../../providers/Player/queue'
import { getToken, useWindowDimensions, XStack, YStack, useTheme } from 'tamagui'
import { Text } from '../../Global/helpers/text'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
@@ -21,7 +21,7 @@ export default function PlayerHeader({
const isPlaying = playbackState === State.Playing
const { queueRef } = useQueueContext()
const queueRef = useQueueRefContext()
const { width } = useWindowDimensions()

View File

@@ -9,7 +9,7 @@ import PlayPauseButton from './components/buttons'
import { ProgressMultiplier, TextTickerConfig } from './component.config'
import FastImage from 'react-native-fast-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import { useQueueContext } from '../../providers/Player/queue'
import { usePreviousContext, useSkipContext } from '../../providers/Player/queue'
import { useJellifyContext } from '../../providers'
import { RunTimeSeconds } from '../Global/helpers/time-codes'
import { UPDATE_INTERVAL } from '../../player/config'
@@ -23,7 +23,8 @@ export const Miniplayer = React.memo(function Miniplayer({
}): React.JSX.Element {
const { api } = useJellifyContext()
const { nowPlaying } = usePlayerContext()
const { useSkip, usePrevious } = useQueueContext()
const useSkip = useSkipContext()
const usePrevious = usePreviousContext()
// Get progress from the track player with the specified update interval
const progress = useProgress(UPDATE_INTERVAL)

View File

@@ -5,7 +5,14 @@ import { usePlayerContext } from '../../providers/Player'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import DraggableFlatList from 'react-native-draggable-flatlist'
import { Separator, XStack } from 'tamagui'
import { useQueueContext } from '../../providers/Player/queue'
import {
useRemoveUpcomingTracksContext,
useRemoveFromQueueContext,
useReorderQueueContext,
useSkipContext,
useQueueRefContext,
usePlayQueueContext,
} from '../../providers/Player/queue'
import { trigger } from 'react-native-haptic-feedback'
import { isUndefined } from 'lodash'
import { useLayoutEffect } from 'react'
@@ -17,14 +24,12 @@ export default function Queue({
}): React.JSX.Element {
const { nowPlaying } = usePlayerContext()
const {
playQueue,
queueRef,
useRemoveUpcomingTracks,
useRemoveFromQueue,
useReorderQueue,
useSkip,
} = useQueueContext()
const playQueue = usePlayQueueContext()
const queueRef = useQueueRefContext()
const useRemoveUpcomingTracks = useRemoveUpcomingTracksContext()
const useRemoveFromQueue = useRemoveFromQueueContext()
const useReorderQueue = useReorderQueueContext()
const useSkip = useSkipContext()
useLayoutEffect(() => {
navigation.setOptions({

View File

@@ -16,7 +16,7 @@ import { useNetworkContext } from '../../../../src/providers/Network'
import { useSettingsContext } from '../../../../src/providers/Settings'
import { ActivityIndicator } from 'react-native'
import { mapDtoToTrack } from '../../../utils/mappings'
import { useQueueContext } from '../../../providers/Player/queue'
import { useLoadQueueContext } from '../../../providers/Player/queue'
import { QueuingType } from '../../../enums/queuing-type'
export default function PlayliistTracklistHeader(
@@ -148,7 +148,7 @@ function PlaylistHeaderControls({
}): React.JSX.Element {
const { useDownloadMultiple, pendingDownloads } = useNetworkContext()
const { downloadQuality, streamingQuality } = useSettingsContext()
const { useLoadNewQueue } = useQueueContext()
const useLoadNewQueue = useLoadQueueContext()
const isDownloading = pendingDownloads.length != 0
const { sessionId, api } = useJellifyContext()

View File

@@ -19,9 +19,9 @@ export default function LabsTab(): React.JSX.Element {
children: (
<Button
onPress={() => {
storage.delete(QueryKeys.AllArtistsAlphabetical)
storage.delete(QueryKeys.InfiniteArtists)
queryClient.invalidateQueries({
queryKey: [QueryKeys.AllArtistsAlphabetical],
queryKey: [QueryKeys.InfiniteArtists],
})
}}
>

View File

@@ -8,6 +8,8 @@ import {
InfiniteQueryObserverResult,
UseInfiniteQueryResult,
} from '@tanstack/react-query'
import { RefObject } from 'react'
export type StackParamList = {
Login: {
screen: keyof StackParamList
@@ -169,6 +171,7 @@ export type ArtistsProps = {
navigation: NativeStackNavigationProp<StackParamList>
artistsInfiniteQuery: UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>
showAlphabeticalSelector: boolean
artistPageParams?: RefObject<Set<string>>
}
export type AlbumsProps = {
albums: (string | number | BaseItemDto)[] | undefined

View File

@@ -1,3 +1,5 @@
import { LibraryProvider } from '../providers/Library'
/**
* An enum of all the keys of query functions.
*/
@@ -86,8 +88,27 @@ export enum QueryKeys {
AllAlbums = 'AllAlbums',
StorageInUse = 'StorageInUse',
Patrons = 'Patrons',
/**
* @deprecated Use {@link InfiniteArtists} instead
*/
AllArtistsAlphabetical = 'AllArtistsAlphabetical',
/**
* @deprecated Use {@link InfiniteAlbums} instead after refactoring
* the infinite query in the {@link LibraryProvider}
*/
AllAlbumsAlphabetical = 'AllAlbumsAlphabetical',
RecentlyAddedAlbums = 'RecentlyAddedAlbums',
PublicPlaylists = 'PublicPlaylists',
/**
* Query representing the fetching of artists in an infinite query
*/
InfiniteArtists = 'InfiniteArtists',
/**
* Query representing the fetching of albums in an infinite query
*/
InfiniteAlbums = 'InfiniteAlbums',
}

View File

@@ -3,16 +3,16 @@ 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'
import { useLoadQueueContext } from '../Player/queue'
interface CarPlayContext {
carplayConnected: boolean
}
const CarPlayContextInitializer = () => {
const { user, api, sessionId } = useJellifyContext()
const { user, api } = useJellifyContext()
const [carplayConnected, setCarPlayConnected] = useState(CarPlay ? CarPlay.connected : false)
const { useLoadNewQueue } = useQueueContext()
const useLoadNewQueue = useLoadQueueContext()
useEffect(() => {
function onConnect() {

View File

@@ -15,6 +15,8 @@ import { fetchTracks } from '../../api/queries/tracks'
import { fetchAlbums } from '../../api/queries/album'
import { useLibrarySortAndFilterContext } from './sorting-filtering'
import { fetchUserPlaylists } from '../../api/queries/playlists'
import Artists from '../../components/Artists/component'
import { isString, isUndefined } from 'lodash'
export const alphabet = '#abcdefghijklmnopqrstuvwxyz'.split('')
@@ -47,7 +49,7 @@ interface LibraryContext {
isPendingAlbums: boolean
isPendingPlaylists: boolean
artistPageParams: RefObject<string[]>
artistPageParams: RefObject<Set<string>>
albumPageParams: RefObject<string[]>
isFetchingNextTracksPage: boolean
@@ -62,17 +64,12 @@ const LibraryContextInitializer = () => {
const { sortDescending, isFavorites } = useLibrarySortAndFilterContext()
const artistPageParams = useRef<string[]>([])
const artistPageParams = useRef<Set<string>>(new Set<string>())
const albumPageParams = useRef<string[]>([])
const artistsInfiniteQuery = useInfiniteQuery({
queryKey: [
QueryKeys.AllArtistsAlphabetical,
isFavorites,
sortDescending,
library?.musicLibraryId,
],
queryKey: [QueryKeys.InfiniteArtists, isFavorites, sortDescending, library?.musicLibraryId],
queryFn: ({ pageParam }) =>
fetchArtists(
api,
@@ -83,31 +80,50 @@ const LibraryContextInitializer = () => {
[ItemSortBy.SortName],
[sortDescending ? SortOrder.Descending : SortOrder.Ascending],
),
select: (data) => data.pages.flatMap((page) => [page.title, ...page.data]),
initialPageParam: alphabet[0],
maxPages: alphabet.length,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
console.debug(`Fetching next Artists page, last page: ${lastPage.title}`)
console.debug(`fetching artist params: ${allPageParams.join(', ')}`)
if (lastPageParam !== alphabet[alphabet.length - 1]) {
artistPageParams.current = [
...allPageParams,
alphabet[alphabet.indexOf(lastPageParam) + 1],
]
return alphabet[alphabet.indexOf(lastPageParam) + 1]
}
select: (data) => {
/**
* A flattened array of all artists derived from the infinite query
*/
const flattenedArtistPages = data.pages.flatMap((page) => page)
return undefined
/**
* A set of letters we've seen so we can add them to the alphabetical selector
*/
const seenLetters = new Set<string>()
/**
* The final array that will be provided to and rendered by the {@link Artists} component
*/
const flashArtistList: (string | number | BaseItemDto)[] = []
flattenedArtistPages.forEach((artist: BaseItemDto) => {
const rawLetter = isString(artist.SortName)
? artist.SortName.trim().charAt(0).toUpperCase()
: '#'
/**
* An alpha character or a hash if the artist's name doesn't start with a letter
*/
const letter = rawLetter.match(/[A-Z]/) ? rawLetter : '#'
if (!seenLetters.has(letter)) {
seenLetters.add(letter)
flashArtistList.push(letter)
}
flashArtistList.push(artist)
})
artistPageParams.current = seenLetters
return flashArtistList
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
return lastPage.length === QueryConfig.limits.library ? lastPageParam + 1 : undefined
},
getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => {
console.debug(`Artists first page: ${firstPage.title}`)
artistPageParams.current = allPageParams
if (firstPageParam !== alphabet[0]) {
artistPageParams.current = allPageParams
return alphabet[alphabet.indexOf(firstPageParam) - 1]
}
return undefined
return firstPageParam === 0 ? null : firstPageParam - 1
},
})
@@ -377,7 +393,7 @@ const LibraryContext = createContext<LibraryContext>({
hasNextAlbumsPage: false,
isPendingTracks: false,
isPendingAlbums: false,
artistPageParams: { current: [] },
artistPageParams: { current: new Set<string>() },
albumPageParams: { current: [] },
isFetchingNextTracksPage: false,
isFetchingNextAlbumsPage: false,
@@ -390,7 +406,7 @@ export const LibraryProvider = ({ children }: { children: React.ReactNode }) =>
() => context,
[
context.artistsInfiniteQuery.data,
context.artistsInfiniteQuery.isPending,
context.artistsInfiniteQuery.isFetching,
context.tracks,
context.albums,
context.playlists,

View File

@@ -2,7 +2,7 @@
Jellify is built with [`react-native-track-player`](https://rntp.dev) and has two context providers wrapped around it's functionality:
- `QueueContext`, exposed via `useQueueContext` hook
- `QueueContext`, exposed via various hooks powered by
- `PlayerProvider`, exposed via `usePlayerContext` hook
`react-native-track-player` manages it's own queue for sequential playback. Jellify manages it's own internal queue in state, relying on the `TrackPlayer`'s queue, and then exposes it to the rest of the app *synchronously*

View File

@@ -25,7 +25,17 @@ import { trigger } from 'react-native-haptic-feedback'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api'
import { useNetworkContext } from '../Network'
import { useQueueContext } from './queue'
import {
usePlayQueueContext,
useCurrentIndexContext,
useQueueRefContext,
useShuffledContext,
useUnshuffledQueueContext,
useSetUnshuffledQueueContext,
useSetPlayQueueContext,
useSetShuffledContext,
useSetCurrentIndexContext,
} from './queue'
import { PlaystateApi } from '@jellyfin/sdk/lib/generated-client/api/playstate-api'
import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher'
import { useJellifyContext } from '..'
@@ -34,13 +44,9 @@ import { useSettingsContext } from '../Settings'
import {
getTracksToPreload,
shouldStartPrefetching,
optimizePlayerQueue,
ensureUpcomingTracksInQueue,
} from '../../player/helpers/gapless'
import {
PREFETCH_THRESHOLD_SECONDS,
QUEUE_PREPARATION_THRESHOLD_SECONDS,
} from '../../player/gapless-config'
import { PREFETCH_THRESHOLD_SECONDS } from '../../player/gapless-config'
import Toast from 'react-native-toast-message'
import { shuffleJellifyTracks } from './utils/shuffle'
@@ -58,19 +64,16 @@ interface PlayerContext {
const PlayerContextInitializer = () => {
const { api, sessionId } = useJellifyContext()
const {
playQueue,
currentIndex,
queueRef,
skipping,
setShuffled,
setCurrentIndex,
unshuffledQueue,
setUnshuffledQueue,
shuffled,
setPlayQueue,
} = useQueueContext()
const playQueue = usePlayQueueContext()
const currentIndex = useCurrentIndexContext()
const queueRef = useQueueRefContext()
const unshuffledQueue = useUnshuffledQueueContext()
const setUnshuffledQueue = useSetUnshuffledQueueContext()
const setPlayQueue = useSetPlayQueueContext()
const shuffled = useShuffledContext()
const setShuffled = useSetShuffledContext()
const setCurrentIndex = useSetCurrentIndexContext()
const nowPlayingJson = storage.getString(MMKVStorageKeys.NowPlaying)
const repeatModeJson = storage.getString(MMKVStorageKeys.RepeatMode)
@@ -565,7 +568,7 @@ const PlayerContextInitializer = () => {
* Set the now playing track to the track at the current index in the play queue
*/
useEffect(() => {
if (currentIndex > -1 && playQueue.length > currentIndex && !skipping) {
if (currentIndex > -1 && playQueue.length > currentIndex) {
console.debug(`Setting now playing to queue index ${currentIndex}`)
setNowPlaying(playQueue[currentIndex])
}
@@ -573,7 +576,7 @@ const PlayerContextInitializer = () => {
if (currentIndex === -1) {
setNowPlaying(undefined)
}
}, [currentIndex, playQueue, skipping])
}, [currentIndex, playQueue])
/**
* Initialize the player. This is used to load the queue from the {@link QueueProvider}

View File

@@ -1,5 +1,4 @@
import React, { ReactNode, useContext, useEffect, useState, useMemo } from 'react'
import { createContext } from 'react'
import React, { ReactNode, useEffect, useState, useMemo, useCallback } from 'react'
import { Queue } from '../../player/types/queue-item'
import { Section } from '../../components/Player/types'
import { useMutation, UseMutationResult } from '@tanstack/react-query'
@@ -16,7 +15,7 @@ import TrackPlayer, { Event, Track, useTrackPlayerEvents } from 'react-native-tr
import { findPlayQueueIndexStart } from './utils'
import { trigger } from 'react-native-haptic-feedback'
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
import { createContext, useContext, useContextSelector } from 'use-context-selector'
import { filterTracksOnNetworkStatus } from './utils/queue'
import { shuffleJellifyTracks } from './utils/shuffle'
import { SKIP_TO_PREVIOUS_THRESHOLD } from '../../player/config'
@@ -335,13 +334,13 @@ const QueueContextInitailizer = () => {
console.debug(`Final start index is ${finalStartIndex}`)
setPlayQueue(queue)
setCurrentIndex(finalStartIndex)
setQueueRef(queuingRef)
setPlayQueue(queue)
await TrackPlayer.pause()
await TrackPlayer.setQueue(queue)
await TrackPlayer.skip(finalStartIndex)
setCurrentIndex(finalStartIndex)
console.debug(
`Queued ${queue.length} tracks, starting at ${finalStartIndex}${shuffleQueue ? ' (shuffled)' : ''}`,
@@ -433,7 +432,7 @@ const QueueContextInitailizer = () => {
console.debug(`Queue has ${newQueue.length} tracks`)
}
const previous = async () => {
const previous = useCallback(async () => {
trigger('impactMedium')
const { position } = await TrackPlayer.getProgress()
@@ -445,47 +444,50 @@ const QueueContextInitailizer = () => {
if (currentIndex > 0 && Math.floor(position) < SKIP_TO_PREVIOUS_THRESHOLD) {
TrackPlayer.skipToPrevious()
} else await TrackPlayer.seekTo(0)
}
}, [currentIndex])
const skip = async (index: number | undefined = undefined) => {
if (!isUndefined(index)) {
const track = playQueue[index]
const queue = (await TrackPlayer.getQueue()) as JellifyTrack[]
const queueIndex = queue.findIndex((t) => t.item.Id === track.item.Id)
const skip = useCallback(
async (index: number | undefined = undefined) => {
if (!isUndefined(index)) {
const track = playQueue[index]
const queue = (await TrackPlayer.getQueue()) as JellifyTrack[]
const queueIndex = queue.findIndex((t) => t.item.Id === track.item.Id)
if (queueIndex !== -1) {
// Track found in TrackPlayer queue, skip to it
await TrackPlayer.skip(queueIndex)
} else {
// Track not found - ensure upcoming tracks are properly ordered
console.debug('Track not found in TrackPlayer queue, updating upcoming tracks')
try {
await ensureUpcomingTracksInQueue(playQueue, currentIndex)
if (queueIndex !== -1) {
// Track found in TrackPlayer queue, skip to it
await TrackPlayer.skip(queueIndex)
} else {
// Track not found - ensure upcoming tracks are properly ordered
console.debug('Track not found in TrackPlayer queue, updating upcoming tracks')
try {
await ensureUpcomingTracksInQueue(playQueue, currentIndex)
// Now try to find the track again
const updatedQueue = (await TrackPlayer.getQueue()) as JellifyTrack[]
const updatedQueueIndex = updatedQueue.findIndex(
(t) => t.item.Id === track.item.Id,
)
// Now try to find the track again
const updatedQueue = (await TrackPlayer.getQueue()) as JellifyTrack[]
const updatedQueueIndex = updatedQueue.findIndex(
(t) => t.item.Id === track.item.Id,
)
if (updatedQueueIndex !== -1) {
await TrackPlayer.skip(updatedQueueIndex)
} else {
// If still not found, just update app state and let the system handle it
await TrackPlayer.skip(index)
console.debug('Updated app state to index', index)
if (updatedQueueIndex !== -1) {
await TrackPlayer.skip(updatedQueueIndex)
} else {
// If still not found, just update app state and let the system handle it
await TrackPlayer.skip(index)
console.debug('Updated app state to index', index)
}
} catch (error) {
console.warn('Failed to ensure upcoming tracks during skip:', error)
// Fallback: just update app state
setCurrentIndex(index)
}
} catch (error) {
console.warn('Failed to ensure upcoming tracks during skip:', error)
// Fallback: just update app state
setCurrentIndex(index)
}
} else {
// Default next track behavior
await TrackPlayer.skipToNext()
}
} else {
// Default next track behavior
await TrackPlayer.skipToNext()
}
}
},
[currentIndex, playQueue],
)
//#endregion Functions
//#region Hooks
@@ -744,28 +746,31 @@ const QueueContextInitailizer = () => {
//#endregion useEffect(s)
//#region Return
return {
queueRef,
playQueue,
setPlayQueue,
currentIndex,
setCurrentIndex,
skipping,
fetchQueueSectionData,
loadQueue,
useAddToQueue,
useLoadNewQueue,
useRemoveFromQueue,
useRemoveUpcomingTracks,
useReorderQueue,
useSkip,
usePrevious,
shuffled,
setShuffled,
unshuffledQueue,
setUnshuffledQueue,
resetQueue,
}
return useMemo(
() => ({
queueRef,
playQueue,
setPlayQueue,
currentIndex,
setCurrentIndex,
skipping,
fetchQueueSectionData,
loadQueue,
useAddToQueue,
useLoadNewQueue,
useRemoveFromQueue,
useRemoveUpcomingTracks,
useReorderQueue,
useSkip,
usePrevious,
shuffled,
setShuffled,
unshuffledQueue,
setUnshuffledQueue,
resetQueue,
}),
[currentIndex, playQueue, shuffled, skipping],
)
//#endregion Return
}
@@ -849,17 +854,53 @@ export const QueueProvider: ({ children }: { children: ReactNode }) => React.JSX
children: ReactNode
}) => {
// Add performance monitoring
const performanceMetrics = usePerformanceMonitor('QueueProvider', 5)
usePerformanceMonitor('QueueProvider', 5)
const context = QueueContextInitailizer()
// Memoize the context value to prevent unnecessary re-renders
const value = useMemo(
() => context,
[context.currentIndex, context.shuffled, context.skipping, context.playQueue],
)
return <QueueContext.Provider value={value}>{children}</QueueContext.Provider>
return <QueueContext.Provider value={context}>{children}</QueueContext.Provider>
}
export const useQueueContext = () => useContext(QueueContext)
export const useCurrentIndexContext = () =>
useContextSelector(QueueContext, (context) => context.currentIndex)
export const useQueueRefContext = () =>
useContextSelector(QueueContext, (context) => context.queueRef)
export const usePlayQueueContext = () =>
useContextSelector(QueueContext, (context) => context.playQueue)
export const useUnshuffledQueueContext = () =>
useContextSelector(QueueContext, (context) => context.unshuffledQueue)
export const useShuffledContext = () =>
useContextSelector(QueueContext, (context) => context.shuffled)
export const useSkippingContext = () =>
useContextSelector(QueueContext, (context) => context.skipping)
export const useFetchQueueSectionDataContext = () =>
useContextSelector(QueueContext, (context) => context.fetchQueueSectionData)
export const useLoadQueueContext = () =>
useContextSelector(QueueContext, (context) => context.useLoadNewQueue)
export const useAddToQueueContext = () =>
useContextSelector(QueueContext, (context) => context.useAddToQueue)
export const useRemoveFromQueueContext = () =>
useContextSelector(QueueContext, (context) => context.useRemoveFromQueue)
export const useRemoveUpcomingTracksContext = () =>
useContextSelector(QueueContext, (context) => context.useRemoveUpcomingTracks)
export const useReorderQueueContext = () =>
useContextSelector(QueueContext, (context) => context.useReorderQueue)
export const useSkipContext = () => useContextSelector(QueueContext, (context) => context.useSkip)
export const usePreviousContext = () =>
useContextSelector(QueueContext, (context) => context.usePrevious)
export const useResetQueueContext = () =>
useContextSelector(QueueContext, (context) => context.resetQueue)
export const useSetShuffledContext = () =>
useContextSelector(QueueContext, (context) => context.setShuffled)
export const useSetUnshuffledQueueContext = () =>
useContextSelector(QueueContext, (context) => context.setUnshuffledQueue)
export const useSetPlayQueueContext = () =>
useContextSelector(QueueContext, (context) => context.setPlayQueue)
export const useSetCurrentIndexContext = () =>
useContextSelector(QueueContext, (context) => context.setCurrentIndex)

View File

@@ -6,12 +6,12 @@ import Button from '../../components/Global/helpers/button'
import Icon from '../../components/Global/components/icon'
import { useJellifyContext } from '../../providers'
import { useNetworkContext } from '../../providers/Network'
import { useQueueContext } from '../../providers/Player/queue'
import { useResetQueueContext } from '../../providers/Player/queue'
export default function SignOutModal({ navigation }: SignOutModalProps): React.JSX.Element {
const { server } = useJellifyContext()
const { resetQueue } = useQueueContext()
const resetQueue = useResetQueueContext()
const { clearDownloads } = useNetworkContext()
return (

View File

@@ -2106,18 +2106,18 @@
invariant "^2.2.4"
nullthrows "^1.1.1"
"@react-navigation/bottom-tabs@^7.4.4":
version "7.4.4"
resolved "https://registry.yarnpkg.com/@react-navigation/bottom-tabs/-/bottom-tabs-7.4.4.tgz#8896a8877b679da9aeb2ed7dda3b4964a88e4863"
integrity sha512-/YEBu/cZUgYAaNoSfUnqoRjpbt8NOsb5YvDiKVyTcOOAF1GTbUw6kRi+AGW1Sm16CqzabO/TF2RvN1RmPS9VHg==
"@react-navigation/bottom-tabs@^7.4.5":
version "7.4.5"
resolved "https://registry.yarnpkg.com/@react-navigation/bottom-tabs/-/bottom-tabs-7.4.5.tgz#d506184e091cd00be23a4eee31014f816d58c635"
integrity sha512-Kt2/E9O7G4Kvx/NYrMVCSk+RN5auZcLgfBeCf5/vH6/weCJOxBy15Wav+3HiehZLNcQ40FJ3PeNHF0CR04idSw==
dependencies:
"@react-navigation/elements" "^2.6.1"
"@react-navigation/elements" "^2.6.2"
color "^4.2.3"
"@react-navigation/core@^7.12.3":
version "7.12.3"
resolved "https://registry.yarnpkg.com/@react-navigation/core/-/core-7.12.3.tgz#acb2edbda8e34cf73005dcc463c2e6013cdec43d"
integrity sha512-oEz5sL8KTYmCv8SQX1A4k75A7VzYadOCudp/ewOBqRXOmZdxDQA9JuN7baE9IVyaRW0QTVDy+N/Wnqx9F4aW6A==
"@react-navigation/core@^7.12.4":
version "7.12.4"
resolved "https://registry.yarnpkg.com/@react-navigation/core/-/core-7.12.4.tgz#73cc4c0989455c93bf21d7aeecc89d3a7006ccde"
integrity sha512-xLFho76FA7v500XID5z/8YfGTvjQPw7/fXsq4BIrVSqetNe/o/v+KAocEw4ots6kyv3XvSTyiWKh2g3pN6xZ9Q==
dependencies:
"@react-navigation/routers" "^7.5.1"
escape-string-regexp "^4.0.0"
@@ -2127,38 +2127,38 @@
use-latest-callback "^0.2.4"
use-sync-external-store "^1.5.0"
"@react-navigation/elements@^2.6.1":
version "2.6.1"
resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-2.6.1.tgz#c101f0b72b48024c8fe3ee09eccb871fa398f761"
integrity sha512-kVbIo+5FaqJv6MiYUR6nQHiw+10dmmH/P10C29wrH9S6fr7k69fImHGeiOI/h7SMDJ2vjWhftyEjqYO+c2LG/w==
"@react-navigation/elements@^2.6.2":
version "2.6.2"
resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-2.6.2.tgz#736c06d0a5f0100932293a0a7f0230a44b6a335b"
integrity sha512-F3xySYWSmzIKSRUlisRSvqE6wzafPJwlgP8hZmf5cnctIu+gJUYWX4e2Za4dQZCELg7luFQjj1ohPWPqm/ziUQ==
dependencies:
color "^4.2.3"
use-latest-callback "^0.2.4"
use-sync-external-store "^1.5.0"
"@react-navigation/material-top-tabs@^7.3.4":
version "7.3.4"
resolved "https://registry.yarnpkg.com/@react-navigation/material-top-tabs/-/material-top-tabs-7.3.4.tgz#9509eb503920e3fb55626a2a028e3a9b9d5153a0"
integrity sha512-sHBiIszq6FumBu8TboN+nVyWxgwbAER6UYULllbN87dDgnUtf+BucUYRAa+2pWeZBA2Q1esYl6VFj6pEFk2how==
"@react-navigation/material-top-tabs@^7.3.5":
version "7.3.5"
resolved "https://registry.yarnpkg.com/@react-navigation/material-top-tabs/-/material-top-tabs-7.3.5.tgz#d9f8ae7b44d6181d204aecad0948392664e7c491"
integrity sha512-qIsKCWUnmrojoeH2gnG1xG2AEWUu/SAaPwqfUPEwLwUlPoCbCKDR7ZIFd0Y5yPkwVVSrW3A8VGLlj/gjMDdHsQ==
dependencies:
"@react-navigation/elements" "^2.6.1"
"@react-navigation/elements" "^2.6.2"
color "^4.2.3"
react-native-tab-view "^4.1.2"
"@react-navigation/native-stack@^7.3.23":
version "7.3.23"
resolved "https://registry.yarnpkg.com/@react-navigation/native-stack/-/native-stack-7.3.23.tgz#29d9ba22d894ce2088ad672c7125dd1aebf96a0a"
integrity sha512-WQBBnPrlM0vXj5YAFnJTyrkiCyANl2KnBV8ZmUG61HkqXFwuBbnHij6eoggXH1VZkEVRxW8k0E3qqfPtEZfUjQ==
"@react-navigation/native-stack@^7.3.24":
version "7.3.24"
resolved "https://registry.yarnpkg.com/@react-navigation/native-stack/-/native-stack-7.3.24.tgz#16f777a059272427fb35dea9a83619565ff27250"
integrity sha512-Q2kWil9K5GxU2wKkmAgbkEdctiy0NpFafaIbRM0YzpMNbqzrrPwDaNjOO4A3BEVY6YO4ldg+TpJdj37YyD/qPQ==
dependencies:
"@react-navigation/elements" "^2.6.1"
"@react-navigation/elements" "^2.6.2"
warn-once "^0.1.1"
"@react-navigation/native@^7.1.16":
version "7.1.16"
resolved "https://registry.yarnpkg.com/@react-navigation/native/-/native-7.1.16.tgz#2474b717d77da8857c5c55c608847aac6391d2e2"
integrity sha512-JnnK81JYJ6PiMsuBEshPGHwfagRnH8W7SYdWNrPxQdNtakkHtG4u0O9FmrOnKiPl45DaftCcH1g+OVTFFgWa0Q==
"@react-navigation/native@^7.1.17":
version "7.1.17"
resolved "https://registry.yarnpkg.com/@react-navigation/native/-/native-7.1.17.tgz#88d557c0f5000aa2741e4368c59719526f1394c4"
integrity sha512-uEcYWi1NV+2Qe1oELfp9b5hTYekqWATv2cuwcOAg5EvsIsUPtzFrKIasgUXLBRGb9P7yR5ifoJ+ug4u6jdqSTQ==
dependencies:
"@react-navigation/core" "^7.12.3"
"@react-navigation/core" "^7.12.4"
escape-string-regexp "^4.0.0"
fast-deep-equal "^3.1.3"
nanoid "^3.3.11"
@@ -9767,6 +9767,11 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
use-context-selector@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/use-context-selector/-/use-context-selector-2.0.0.tgz#3b5dafec7aa947c152d4f0aa7f250e99a205df3d"
integrity sha512-owfuSmUNd3eNp3J9CdDl0kMgfidV+MkDvHPpvthN5ThqM+ibMccNE0k+Iq7TWC6JPFvGZqanqiGCuQx6DyV24g==
use-latest-callback@^0.2.4:
version "0.2.4"
resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.2.4.tgz#35c0f028f85a3f4cf025b06011110e87cc18f57e"