Alphabet Selector for Albums, Dep updates (#501)

Get alphabetical selector working for albums. Now an A-Z will appear to the right of the albums list and users can use that to get to a certain letter of albums in the list

Under the hood - also updates the dependencies for React Native and Tanstack Query
This commit is contained in:
Violet Caulfield
2025-08-30 10:31:24 -05:00
committed by GitHub
parent f68f830f46
commit 9e08996706
24 changed files with 825 additions and 715 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -48,10 +48,10 @@
"@sentry/react-native": "6.17.0",
"@shopify/flash-list": "^2.0.3",
"@tamagui/config": "^1.132.23",
"@tanstack/query-async-storage-persister": "^5.85.5",
"@tanstack/react-query": "^5.85.5",
"@tanstack/react-query-persist-client": "^5.85.5",
"@testing-library/react-native": "^13.2.2",
"@tanstack/query-async-storage-persister": "^5.85.6",
"@tanstack/react-query": "^5.85.6",
"@tanstack/react-query-persist-client": "^5.85.6",
"@testing-library/react-native": "^13.2.3",
"@typedigital/telemetrydeck-react": "^0.4.1",
"axios": "^1.11.0",
"bundle": "^2.1.0",
@@ -59,10 +59,10 @@
"gem": "^2.4.3",
"invert-color": "^2.0.0",
"lodash": "^4.17.21",
"openai": "^5.12.2",
"openai": "^5.16.0",
"react": "19.1.0",
"react-freeze": "^1.0.4",
"react-native": "0.81.0",
"react-native": "0.81.1",
"react-native-background-actions": "^4.0.1",
"react-native-blob-util": "^0.22.2",
"react-native-blurhash": "2.1.1",

View File

@@ -1,44 +0,0 @@
import {
BaseItemDto,
BaseItemKind,
ItemSortBy,
SortOrder,
} from '@jellyfin/sdk/lib/generated-client/models'
import { JellifyLibrary } from '../../types/JellifyLibrary'
import { Api } from '@jellyfin/sdk'
import { fetchItem, fetchItems } from './item'
import { JellifyUser } from '../../types/JellifyUser'
export function fetchAlbums(
api: Api | undefined,
user: JellifyUser | undefined,
library: JellifyLibrary | undefined,
page: string,
isFavorite: boolean | undefined,
sortBy: ItemSortBy[] = [ItemSortBy.SortName],
sortOrder: SortOrder[] = [SortOrder.Ascending],
): Promise<{ title: string | number; data: BaseItemDto[] }> {
console.debug('Fetching albums', page)
return fetchItems(
api,
user,
library,
[BaseItemKind.MusicAlbum],
page,
sortBy,
sortOrder,
isFavorite,
)
}
export function fetchAlbumById(api: Api | undefined, albumId: string): Promise<BaseItemDto> {
return new Promise((resolve, reject) => {
fetchItem(api, albumId)
.then((item) => {
resolve(item)
})
.catch((error) => {
reject(error)
})
})
}

View File

@@ -0,0 +1,79 @@
import { useLibrarySortAndFilterContext } from '../../../providers/Library'
import { QueryKeys } from '../../../enums/query-keys'
import { useJellifyContext } from '../../../providers'
import { InfiniteData, useInfiniteQuery, UseInfiniteQueryResult } from '@tanstack/react-query'
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order'
import { fetchAlbums } from './utils/album'
import { RefObject, useCallback, useRef } from 'react'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import flattenInfiniteQueryPages from '../../../utils/query-selectors'
import { ApiLimits } from '../query.config'
import { fetchRecentlyAdded } from '../recents'
import { queryClient } from '../../../constants/query-client'
const useAlbums: () => [
RefObject<Set<string>>,
UseInfiniteQueryResult<(string | number | BaseItemDto)[]>,
] = () => {
const { api, user, library } = useJellifyContext()
const { isFavorites, sortDescending } = useLibrarySortAndFilterContext()
const albumPageParams = useRef<Set<string>>(new Set<string>())
// Memize the expensive albums select function
const selectAlbums = useCallback(
(data: InfiniteData<BaseItemDto[], unknown>) =>
flattenInfiniteQueryPages(data, albumPageParams),
[],
)
const albumsInfiniteQuery = useInfiniteQuery({
queryKey: [QueryKeys.InfiniteAlbums, isFavorites, library?.musicLibraryId],
queryFn: ({ pageParam }) =>
fetchAlbums(
api,
user,
library,
pageParam,
isFavorites,
[ItemSortBy.SortName],
[SortOrder.Ascending],
),
initialPageParam: 0,
select: selectAlbums,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
},
getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => {
return firstPageParam === 0 ? null : firstPageParam - 1
},
})
return [albumPageParams, albumsInfiniteQuery]
}
export default useAlbums
export const useRecentlyAddedAlbums = () => {
const { api, user, library } = useJellifyContext()
return useInfiniteQuery({
queryKey: [QueryKeys.RecentlyAddedAlbums, library?.musicLibraryId],
queryFn: ({ pageParam }) => fetchRecentlyAdded(api, library, pageParam),
select: (data) => data.pages.flatMap((page) => page),
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.length > 0 ? lastPageParam + 1 : undefined,
initialPageParam: 0,
})
}
export const useRefetchRecentlyAdded: () => () => void = () => {
const { library } = useJellifyContext()
return () =>
queryClient.invalidateQueries({
queryKey: [QueryKeys.RecentlyAddedAlbums, library?.musicLibraryId],
})
}

View File

@@ -0,0 +1,61 @@
import {
BaseItemDto,
BaseItemKind,
ItemFields,
ItemSortBy,
SortOrder,
} from '@jellyfin/sdk/lib/generated-client/models'
import { JellifyLibrary } from '../../../../types/JellifyLibrary'
import { Api } from '@jellyfin/sdk'
import { fetchItem, fetchItems } from '../../item'
import { JellifyUser } from '../../../../types/JellifyUser'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { ApiLimits } from '../../query.config'
export function fetchAlbums(
api: Api | undefined,
user: JellifyUser | undefined,
library: JellifyLibrary | undefined,
page: number,
isFavorite: boolean | undefined,
sortBy: ItemSortBy[] = [ItemSortBy.SortName],
sortOrder: SortOrder[] = [SortOrder.Ascending],
): Promise<BaseItemDto[]> {
console.debug('Fetching albums', 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')
getItemsApi(api)
.getItems({
parentId: library.musicLibraryId,
includeItemTypes: [BaseItemKind.MusicAlbum],
userId: user.id,
enableUserData: false, // This data is fetched lazily on component render
sortBy,
sortOrder,
startIndex: page * ApiLimits.Library,
limit: ApiLimits.Library,
isFavorite,
fields: [ItemFields.SortName],
recursive: true,
})
.then(({ data }) => {
console.debug('Albums Response receieved')
return data.Items ? resolve(data.Items) : resolve([])
})
})
}
export function fetchAlbumById(api: Api | undefined, albumId: string): Promise<BaseItemDto> {
return new Promise((resolve, reject) => {
fetchItem(api, albumId)
.then((item) => {
resolve(item)
})
.catch((error) => {
reject(error)
})
})
}

View File

@@ -10,7 +10,9 @@ import { isString, isUndefined } from 'lodash'
import { fetchArtistAlbums, fetchArtistFeaturedOn, fetchArtists } from './utils/artist'
import { useJellifyContext } from '../../../providers'
import { ApiLimits } from '../query.config'
import { useCallback, useRef } from 'react'
import { RefObject, useCallback, useRef } from 'react'
import { useLibrarySortAndFilterContext } from '../../../providers/Library'
import flattenInfiniteQueryPages from '../../../utils/query-selectors'
export const useArtistAlbums = (artist: BaseItemDto) => {
const { api, library } = useJellifyContext()
@@ -32,60 +34,22 @@ export const useArtistFeaturedOn = (artist: BaseItemDto) => {
})
}
interface AlbumArtistQueryParams {
isFavorites: boolean | undefined
sortDescending: boolean
}
export const useAlbumArtists: (
params: AlbumArtistQueryParams,
) => [
React.RefObject<Set<string>>,
export const useAlbumArtists: () => [
RefObject<Set<string>>,
UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>,
] = ({ isFavorites, sortDescending }: AlbumArtistQueryParams) => {
] = () => {
const { api, user, library } = useJellifyContext()
const { isFavorites, sortDescending } = useLibrarySortAndFilterContext()
const artistPageParams = useRef<Set<string>>(new Set<string>())
// Memoize the expensive artists select function
const selectArtists = useCallback((data: InfiniteData<BaseItemDto[], unknown>) => {
/**
* A flattened array of all artists derived from the infinite query
*/
const flattenedArtistPages = data.pages.flatMap((page) => page)
/**
* 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
}, [])
const selectArtists = useCallback(
(data: InfiniteData<BaseItemDto[], unknown>) =>
flattenInfiniteQueryPages(data, artistPageParams),
[],
)
const artistsInfiniteQuery = useInfiniteQuery({
queryKey: [QueryKeys.InfiniteArtists, isFavorites, sortDescending, library?.musicLibraryId],

View File

@@ -1,7 +1,7 @@
import { ImageFormat } from '@jellyfin/sdk/lib/generated-client/models'
export enum ApiLimits {
Library = 100,
Library = 200,
}
const QueryConfig = {

View File

@@ -1,9 +1,9 @@
import { ActivityIndicator, RefreshControl } from 'react-native'
import { getToken, Separator, XStack, YStack } from 'tamagui'
import React, { useRef } from 'react'
import React, { RefObject, useEffect, useRef } from 'react'
import { Text } from '../Global/helpers/text'
import { FlashList, ViewToken } from '@shopify/flash-list'
import { FetchNextPageOptions } from '@tanstack/react-query'
import { FlashList, FlashListRef, ViewToken } from '@shopify/flash-list'
import { UseInfiniteQueryResult } from '@tanstack/react-query'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import ItemRow from '../Global/components/item-row'
import { useNavigation } from '@react-navigation/native'
@@ -12,21 +12,18 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { warmItemContext } from '../../hooks/use-item-context'
import { useJellifyContext } from '../../providers'
import useStreamingDeviceProfile from '../../stores/device-profile'
import AZScroller, { useAlphabetSelector } from '../Global/components/alphabetical-selector'
import { isString } from 'lodash'
interface AlbumsProps {
albums: (string | number | BaseItemDto)[] | undefined
fetchNextPage: (options?: FetchNextPageOptions | undefined) => void
hasNextPage: boolean
isPending: boolean
isFetchingNextPage: boolean
albumsInfiniteQuery: UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>
showAlphabeticalSelector: boolean
albumPageParams?: RefObject<Set<string>>
}
export default function Albums({
albums,
fetchNextPage,
hasNextPage,
isPending,
albumsInfiniteQuery,
albumPageParams,
showAlphabeticalSelector,
}: AlbumsProps): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()
@@ -35,6 +32,10 @@ export default function Albums({
const deviceProfile = useStreamingDeviceProfile()
const sectionListRef = useRef<FlashListRef<string | number | BaseItemDto>>(null)
const pendingLetterRef = useRef<string | null>(null)
const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken<string | number | BaseItemDto>[] }) => {
viewableItems.forEach(({ isViewable, item }) => {
@@ -46,21 +47,63 @@ export default function Albums({
// Memoize expensive stickyHeaderIndices calculation to prevent unnecessary re-computations
const stickyHeaderIndices = React.useMemo(() => {
if (!showAlphabeticalSelector || !albums) return []
if (!showAlphabeticalSelector || !albumsInfiniteQuery.data) return []
return albums
return albumsInfiniteQuery.data
.map((album, index) => (typeof album === 'string' ? index : 0))
.filter((value, index, indices) => indices.indexOf(value) === index)
}, [showAlphabeticalSelector, albums])
}, [showAlphabeticalSelector, albumsInfiniteQuery.data])
const { mutate: alphabetSelectorMutate } = useAlphabetSelector(
(letter) => (pendingLetterRef.current = letter.toUpperCase()),
)
// Effect for handling the pending alphabet selector letter
useEffect(() => {
if (isString(pendingLetterRef.current) && albumsInfiniteQuery.data) {
const upperLetters = albumsInfiniteQuery.data
.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 = albumsInfiniteQuery.data.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 = albumsInfiniteQuery.data.indexOf(lastLetter)
if (scrollIndex !== -1) {
sectionListRef.current?.scrollToIndex({
index: scrollIndex,
viewPosition: 0.1,
animated: true,
})
}
}
pendingLetterRef.current = null
}
}, [pendingLetterRef.current, albumsInfiniteQuery.data])
return (
<XStack flex={1}>
<FlashList
ref={sectionListRef}
contentContainerStyle={{
paddingTop: getToken('$1'),
}}
contentInsetAdjustmentBehavior='automatic'
data={albums ?? []}
data={albumsInfiniteQuery.data ?? []}
keyExtractor={(item) =>
typeof item === 'string'
? item
@@ -89,7 +132,7 @@ export default function Albums({
) : null
}
ListEmptyComponent={
isPending ? (
albumsInfiniteQuery.isPending ? (
<ActivityIndicator />
) : (
<YStack justifyContent='center'>
@@ -98,15 +141,34 @@ export default function Albums({
)
}
onEndReached={() => {
if (hasNextPage) fetchNextPage()
if (albumsInfiniteQuery.hasNextPage) albumsInfiniteQuery.fetchNextPage()
}}
ListFooterComponent={isPending ? <ActivityIndicator /> : null}
ListFooterComponent={
albumsInfiniteQuery.isFetchingNextPage ? <ActivityIndicator /> : null
}
ItemSeparatorComponent={() => <Separator />}
refreshControl={<RefreshControl refreshing={isPending} />}
refreshControl={
<RefreshControl
refreshing={albumsInfiniteQuery.isFetching}
onRefresh={albumsInfiniteQuery.refetch}
/>
}
stickyHeaderIndices={stickyHeaderIndices}
removeClippedSubviews
onViewableItemsChanged={onViewableItemsChangedRef.current}
/>
{showAlphabeticalSelector && albumPageParams && (
<AZScroller
onLetterSelect={(letter) =>
alphabetSelectorMutate({
letter,
infiniteQuery: albumsInfiniteQuery,
pageParams: albumPageParams,
})
}
/>
)}
</XStack>
)
}

View File

@@ -1,14 +1,13 @@
import React, { useEffect, useRef } from 'react'
import React, { RefObject, useEffect, useMemo, useRef } from 'react'
import { getToken, Separator, useTheme, XStack } from 'tamagui'
import { Text } from '../Global/helpers/text'
import { RefreshControl } from 'react-native'
import { ArtistsProps } from '../../screens/types'
import ItemRow from '../Global/components/item-row'
import { useLibrarySortAndFilterContext } from '../../providers/Library'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { FlashList, FlashListRef, ViewToken } from '@shopify/flash-list'
import { AZScroller } from '../Global/components/alphabetical-selector'
import { useMutation } from '@tanstack/react-query'
import AZScroller, { useAlphabetSelector } from '../Global/components/alphabetical-selector'
import { UseInfiniteQueryResult, useMutation } from '@tanstack/react-query'
import { isString } from 'lodash'
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
@@ -17,6 +16,15 @@ import { warmItemContext } from '../../hooks/use-item-context'
import { useJellifyContext } from '../../providers'
import useStreamingDeviceProfile from '../../stores/device-profile'
export interface ArtistsProps {
artistsInfiniteQuery: UseInfiniteQueryResult<
BaseItemDto[] | (string | number | BaseItemDto)[],
Error
>
showAlphabeticalSelector: boolean
artistPageParams?: RefObject<Set<string>>
}
/**
* @param artistsInfiniteQuery - The infinite query for artists
* @param navigation - The navigation object
@@ -53,30 +61,20 @@ export default function Artists({
},
)
const alphabeticalSelectorCallback = async (letter: string) => {
console.debug(`Alphabetical Selector Callback: ${letter}`)
const { mutate: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } =
useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase()))
while (
!artistPageParams!.current.has(letter.toUpperCase()) &&
artistsInfiniteQuery.hasNextPage
) {
if (!artistsInfiniteQuery.isPending) {
await artistsInfiniteQuery.fetchNextPage()
}
}
console.debug(`Alphabetical Selector Callback: ${letter} complete`)
}
const stickyHeaderIndices = useMemo(() => {
if (!showAlphabeticalSelector || !artists) return []
const { mutate: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } = useMutation({
mutationFn: (letter: string) => alphabeticalSelectorCallback(letter),
onSuccess: (data: void, letter: string) => {
pendingLetterRef.current = letter.toUpperCase()
},
})
return artists
.map((artist, index, artists) => (typeof artist === 'string' ? index : 0))
.filter((value, index, indices) => indices.indexOf(value) === index)
}, [showAlphabeticalSelector, artists])
// Effect for handling the pending alphabet selector letter
useEffect(() => {
if (isString(pendingLetterRef.current) && artistsInfiniteQuery.data) {
if (isString(pendingLetterRef.current) && artists) {
const upperLetters = artists
.filter((item): item is string => typeof item === 'string')
.map((letter) => letter.toUpperCase())
@@ -168,15 +166,7 @@ export default function Artists({
/>
) : null
}
stickyHeaderIndices={
showAlphabeticalSelector
? artists
?.map((artist, index, artists) =>
typeof artist === 'string' ? index : 0,
)
.filter((value, index, indices) => indices.indexOf(value) === index)
: []
}
stickyHeaderIndices={stickyHeaderIndices}
onStartReached={() => {
if (artistsInfiniteQuery.hasPreviousPage)
artistsInfiniteQuery.fetchPreviousPage()
@@ -191,7 +181,15 @@ export default function Artists({
/>
{showAlphabeticalSelector && artistPageParams && (
<AZScroller onLetterSelect={alphabetSelectorMutate} />
<AZScroller
onLetterSelect={(letter) =>
alphabetSelectorMutate({
letter,
infiniteQuery: artistsInfiniteQuery,
pageParams: artistPageParams,
})
}
/>
)}
</XStack>
)

View File

@@ -1,14 +1,15 @@
import React from 'react'
import Artists from './component'
import { ArtistsProps } from '../../screens/types'
import Artists, { ArtistsProps } from './component'
export default function ArtistsScreen({
artistsInfiniteQuery: artistInfiniteQuery,
artistPageParams,
showAlphabeticalSelector,
}: ArtistsProps): React.JSX.Element {
return (
<Artists
artistsInfiniteQuery={artistInfiniteQuery}
artistPageParams={artistPageParams}
showAlphabeticalSelector={showAlphabeticalSelector}
/>
)

View File

@@ -8,7 +8,7 @@ import SuggestedArtists from './helpers/suggested-artists'
import { SafeAreaView } from 'react-native-safe-area-context'
export default function Index(): React.JSX.Element {
const { refreshing, refresh, recentlyAdded, publicPlaylists, suggestedArtistsInfiniteQuery } =
const { refreshing, refresh, publicPlaylists, suggestedArtistsInfiniteQuery } =
useDiscoverContext()
return (
@@ -23,12 +23,10 @@ export default function Index(): React.JSX.Element {
paddingBottom={'$15'}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} />}
>
{recentlyAdded && (
<View testID='discover-recently-added'>
<RecentlyAdded />
<Separator marginVertical={'$2'} />
</View>
)}
<View testID='discover-recently-added'>
<RecentlyAdded />
<Separator marginVertical={'$2'} />
</View>
{publicPlaylists && (
<View testID='discover-public-playlists'>

View File

@@ -8,15 +8,10 @@ import Icon from '../../Global/components/icon'
import { useNavigation } from '@react-navigation/native'
import DiscoverStackParamList from '../../../screens/Discover/types'
import navigationRef from '../../../../navigation'
import { useRecentlyAddedAlbums } from '../../../api/queries/album'
export default function RecentlyAdded(): React.JSX.Element {
const {
recentlyAdded,
fetchNextRecentlyAdded,
hasNextRecentlyAdded,
isPendingRecentlyAdded,
isFetchingNextRecentlyAdded,
} = useDiscoverContext()
const recentlyAddedAlbumsInfinityQuery = useRecentlyAddedAlbums()
const navigation = useNavigation<NativeStackNavigationProp<DiscoverStackParamList>>()
@@ -26,12 +21,7 @@ export default function RecentlyAdded(): React.JSX.Element {
alignItems='center'
onPress={() => {
navigation.navigate('RecentlyAdded', {
albums: recentlyAdded,
navigation: navigation,
fetchNextPage: fetchNextRecentlyAdded,
hasNextPage: hasNextRecentlyAdded,
isPending: isPendingRecentlyAdded,
isFetchingNextPage: isFetchingNextRecentlyAdded,
albumsInfiniteQuery: recentlyAddedAlbumsInfinityQuery,
})
}}
>
@@ -40,7 +30,7 @@ export default function RecentlyAdded(): React.JSX.Element {
</XStack>
<HorizontalCardList
data={recentlyAdded?.slice(0, 10) ?? []}
data={recentlyAddedAlbumsInfinityQuery.data?.slice(0, 10) ?? []}
renderItem={({ item }) => (
<ItemCard
caption={item.Name}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react'
import { LayoutChangeEvent, View as RNView } from 'react-native'
import { getToken, useTheme, View, YStack } from 'tamagui'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
@@ -12,6 +12,8 @@ import { Text } from '../helpers/text'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { trigger } from 'react-native-haptic-feedback'
import { useReducedHapticsSetting } from '../../../stores/settings/app'
import { UseInfiniteQueryResult, useMutation } from '@tanstack/react-query'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
const alphabet = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
/**
@@ -24,7 +26,11 @@ const alphabet = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
* @param onLetterSelect - Callback function to be called when a letter is selected
* @returns A component that displays a list of letters and a selected letter overlay
*/
export function AZScroller({ onLetterSelect }: { onLetterSelect: (letter: string) => void }) {
export default function AZScroller({
onLetterSelect,
}: {
onLetterSelect: (letter: string) => void
}) {
const { width, height } = useSafeAreaFrame()
const theme = useTheme()
const [reducedHaptics] = useReducedHapticsSetting()
@@ -197,3 +203,37 @@ export function AZScroller({ onLetterSelect }: { onLetterSelect: (letter: string
</>
)
}
export const alphabeticalSelectorCallback = async (
letter: string,
pageParams: RefObject<Set<string>>,
{
hasNextPage,
fetchNextPage,
isPending,
}: UseInfiniteQueryResult<BaseItemDto[] | (string | number | BaseItemDto)[], Error>,
) => {
while (!pageParams.current.has(letter.toUpperCase()) && hasNextPage) {
console.debug(`Fetching next page for alphabet selection`)
await fetchNextPage()
}
console.debug(`Alphabetical Selector Callback: ${letter} complete`)
}
interface AlphabetSelectorMutation {
letter: string
pageParams: RefObject<Set<string>>
infiniteQuery: UseInfiniteQueryResult<BaseItemDto[] | (string | number | BaseItemDto)[], Error>
}
export const useAlphabetSelector = (onSuccess: (letter: string) => void) => {
return useMutation({
onMutate: ({ letter }) =>
console.debug(`Alphabet selector callback started, fetching pages for ${letter}`),
mutationFn: ({ letter, pageParams, infiniteQuery }: AlphabetSelectorMutation) =>
alphabeticalSelectorCallback(letter, pageParams, infiniteQuery),
onSuccess: (data: void, { letter }: AlphabetSelectorMutation) => onSuccess(letter),
onError: (error, { letter }) =>
console.error(`Unable to paginate to letter ${letter}`, error),
})
}

View File

@@ -1,17 +1,14 @@
import useAlbums from '../../../api/queries/album'
import Albums from '../../Albums/component'
import { useAlbumsInfiniteQueryContext } from '../../../providers/Library'
function AlbumsTab(): React.JSX.Element {
const albumsInfiniteQuery = useAlbumsInfiniteQueryContext()
const [albumPageParams, albumsInfiniteQuery] = useAlbums()
return (
<Albums
albums={albumsInfiniteQuery.data}
fetchNextPage={albumsInfiniteQuery.fetchNextPage}
hasNextPage={albumsInfiniteQuery.hasNextPage}
isPending={albumsInfiniteQuery.isPending}
isFetchingNextPage={albumsInfiniteQuery.isFetchingNextPage}
albumsInfiniteQuery={albumsInfiniteQuery}
showAlphabeticalSelector={true}
albumPageParams={albumPageParams}
/>
)
}

View File

@@ -1,14 +1,8 @@
import { useAlbumArtists } from '../../../api/queries/artist'
import Artists from '../../Artists/component'
import { useLibrarySortAndFilterContext } from '../../../providers/Library'
function ArtistsTab(): React.JSX.Element {
const { isFavorites, sortDescending } = useLibrarySortAndFilterContext()
const [artistPageParams, artistsInfiniteQuery] = useAlbumArtists({
isFavorites,
sortDescending,
})
const [artistPageParams, artistsInfiniteQuery] = useAlbumArtists()
return (
<Artists

View File

@@ -16,7 +16,7 @@ export default function InfoTabIndex() {
const { data: caption } = useQuery({
queryKey: ['Info_Caption'],
queryFn: () => `${pickRandomItemFromArray(INFO_CAPTIONS)}!`,
queryFn: () => `${pickRandomItemFromArray(INFO_CAPTIONS)}`,
staleTime: ONE_HOUR,
initialData: 'Live and in stereo',
})

View File

@@ -7,6 +7,8 @@ const INFO_CAPTIONS = [
// Inside Jokes (that the internal Jellify team will get)
'Thank you, Pikachu',
'Now with 100% more nitro!', // since we use nitro modules
'git blame violet', // lol
// Movie Quotes
'Groovy, baby!', // Austin Powers

View File

@@ -4,30 +4,26 @@ import {
useInfiniteQuery,
UseInfiniteQueryResult,
} from '@tanstack/react-query'
import { fetchRecentlyAdded, fetchRecentlyPlayed } from '../../api/queries/recents'
import { fetchRecentlyPlayed } from '../../api/queries/recents'
import { QueryKeys } from '../../enums/query-keys'
import { createContext, ReactNode, useContext, useState } from 'react'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { useJellifyContext } from '..'
import { fetchPublicPlaylists } from '../../api/queries/playlists'
import { fetchArtistSuggestions } from '../../api/queries/suggestions'
import { useRefetchRecentlyAdded } from '../../api/queries/album'
interface DiscoverContext {
refreshing: boolean
refresh: () => void
recentlyAdded: BaseItemDto[] | undefined
recentlyPlayed: InfiniteData<BaseItemDto[], unknown> | undefined
publicPlaylists: BaseItemDto[] | undefined
fetchNextRecentlyAdded: () => void
fetchNextRecentlyPlayed: () => void
fetchNextPublicPlaylists: () => void
hasNextRecentlyAdded: boolean
hasNextRecentlyPlayed: boolean
hasNextPublicPlaylists: boolean
isPendingRecentlyAdded: boolean
isPendingRecentlyPlayed: boolean
isPendingPublicPlaylists: boolean
isFetchingNextRecentlyAdded: boolean
isFetchingNextRecentlyPlayed: boolean
isFetchingNextPublicPlaylists: boolean
refetchPublicPlaylists: () => void
@@ -38,21 +34,7 @@ const DiscoverContextInitializer = () => {
const { api, library, user } = useJellifyContext()
const [refreshing, setRefreshing] = useState<boolean>(false)
const {
data: recentlyAdded,
refetch: refetchRecentlyAdded,
fetchNextPage: fetchNextRecentlyAdded,
hasNextPage: hasNextRecentlyAdded,
isPending: isPendingRecentlyAdded,
isFetchingNextPage: isFetchingNextRecentlyAdded,
} = useInfiniteQuery({
queryKey: [QueryKeys.RecentlyAddedAlbums, library?.musicLibraryId],
queryFn: ({ pageParam }) => fetchRecentlyAdded(api, library, pageParam),
select: (data) => data.pages.flatMap((page) => page),
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.length > 0 ? lastPageParam + 1 : undefined,
initialPageParam: 0,
})
const refetchRecentlyAdded = useRefetchRecentlyAdded()
const {
data: publicPlaylists,
@@ -111,19 +93,14 @@ const DiscoverContextInitializer = () => {
return {
refreshing,
refresh,
recentlyAdded,
recentlyPlayed,
publicPlaylists,
fetchNextRecentlyAdded,
fetchNextRecentlyPlayed,
fetchNextPublicPlaylists,
hasNextRecentlyAdded,
hasNextRecentlyPlayed,
hasNextPublicPlaylists,
isPendingRecentlyAdded,
isPendingRecentlyPlayed,
isPendingPublicPlaylists,
isFetchingNextRecentlyAdded,
isFetchingNextRecentlyPlayed,
isFetchingNextPublicPlaylists,
refetchPublicPlaylists,
@@ -134,19 +111,14 @@ const DiscoverContextInitializer = () => {
const DiscoverContext = createContext<DiscoverContext>({
refreshing: false,
refresh: () => {},
recentlyAdded: undefined,
recentlyPlayed: undefined,
publicPlaylists: undefined,
fetchNextRecentlyAdded: () => {},
fetchNextRecentlyPlayed: () => {},
fetchNextPublicPlaylists: () => {},
hasNextRecentlyAdded: false,
hasNextRecentlyPlayed: false,
hasNextPublicPlaylists: false,
isPendingRecentlyAdded: false,
isPendingRecentlyPlayed: false,
isPendingPublicPlaylists: false,
isFetchingNextRecentlyAdded: false,
isFetchingNextRecentlyPlayed: false,
isFetchingNextPublicPlaylists: false,
refetchPublicPlaylists: () => {},

View File

@@ -1,10 +1,9 @@
import { QueryKeys } from '../../enums/query-keys'
import { BaseItemDto, ItemSortBy, SortOrder } from '@jellyfin/sdk/lib/generated-client/models'
import { useJellifyContext } from '..'
import { RefObject, useMemo, useRef } from 'react'
import { useMemo } from 'react'
import QueryConfig from '../../api/queries/query.config'
import { fetchTracks } from '../../api/queries/tracks'
import { fetchAlbums } from '../../api/queries/album'
import { useLibrarySortAndFilterContext } from './sorting-filtering'
import { fetchUserPlaylists } from '../../api/queries/playlists'
import { createContext, useContextSelector } from 'use-context-selector'
@@ -17,12 +16,9 @@ import {
export const alphabet = '#abcdefghijklmnopqrstuvwxyz'.split('')
interface LibraryContext {
albumsInfiniteQuery: UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>
tracksInfiniteQuery: UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>
// genres: BaseItemDto[] | undefined
playlistsInfiniteQuery: UseInfiniteQueryResult<BaseItemDto[], Error>
albumPageParams: RefObject<string[]>
}
type LibraryPage = {
@@ -35,8 +31,6 @@ const LibraryContextInitializer = () => {
const { sortDescending, isFavorites } = useLibrarySortAndFilterContext()
const albumPageParams = useRef<string[]>([])
const tracksInfiniteQuery = useInfiniteQuery({
queryKey: [QueryKeys.AllTracks, isFavorites, sortDescending, library?.musicLibraryId],
queryFn: ({ pageParam }) =>
@@ -59,45 +53,6 @@ const LibraryContextInitializer = () => {
select: (data) => data.pages.flatMap((page) => page),
})
const albumsInfiniteQuery = useInfiniteQuery({
queryKey: [QueryKeys.AllAlbumsAlphabetical, isFavorites, library?.musicLibraryId],
queryFn: ({ pageParam }) =>
fetchAlbums(
api,
user,
library,
pageParam,
isFavorites,
[ItemSortBy.SortName],
[SortOrder.Ascending],
),
initialPageParam: alphabet[0],
select: (data) => data.pages.flatMap((page) => [page.title, ...page.data]),
maxPages: alphabet.length,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
console.debug(`Albums last page length: ${lastPage.data.length}`)
if (lastPageParam !== alphabet[alphabet.length - 1]) {
albumPageParams.current = [
...allPageParams,
alphabet[alphabet.indexOf(lastPageParam) + 1],
]
return alphabet[alphabet.indexOf(lastPageParam) + 1]
}
return undefined
},
getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => {
console.debug(`Albums first page: ${firstPage.title}`)
albumPageParams.current = allPageParams
if (firstPageParam !== alphabet[0]) {
albumPageParams.current = allPageParams
return alphabet[alphabet.indexOf(firstPageParam) - 1]
}
return undefined
},
})
const playlistsInfiniteQuery = useInfiniteQuery({
queryKey: [QueryKeys.Playlists, library?.playlistLibraryId],
queryFn: () => fetchUserPlaylists(api, user, library),
@@ -110,59 +65,11 @@ const LibraryContextInitializer = () => {
return {
tracksInfiniteQuery,
albumsInfiniteQuery,
albumPageParams,
playlistsInfiniteQuery,
}
}
const LibraryContext = createContext<LibraryContext>({
albumPageParams: { current: [] },
albumsInfiniteQuery: {
data: undefined,
error: null,
isEnabled: true,
isStale: false,
isRefetching: false,
isError: false,
isLoading: true,
isPending: true,
isFetching: true,
isSuccess: false,
isFetched: false,
hasPreviousPage: false,
refetch: async () =>
Promise.resolve(
{} as InfiniteQueryObserverResult<(string | number | BaseItemDto)[], Error>,
),
fetchNextPage: async () =>
Promise.resolve(
{} as InfiniteQueryObserverResult<(string | number | BaseItemDto)[], Error>,
),
hasNextPage: false,
isFetchingNextPage: false,
isFetchPreviousPageError: false,
isFetchNextPageError: false,
isFetchingPreviousPage: false,
isLoadingError: false,
isRefetchError: false,
isPlaceholderData: false,
status: 'pending',
fetchStatus: 'idle',
dataUpdatedAt: 0,
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetchedAfterMount: false,
isInitialLoading: false,
isPaused: false,
fetchPreviousPage: async () =>
Promise.resolve(
{} as InfiniteQueryObserverResult<(string | number | BaseItemDto)[], Error>,
),
promise: Promise.resolve([]),
},
tracksInfiniteQuery: {
data: undefined,
error: null,
@@ -257,8 +164,6 @@ export const LibraryProvider = ({ children }: { children: React.ReactNode }) =>
[
context.tracksInfiniteQuery.data,
context.tracksInfiniteQuery.isPending,
context.albumsInfiniteQuery.data,
context.albumsInfiniteQuery.isPending,
context.playlistsInfiniteQuery.data,
context.playlistsInfiniteQuery.isPending,
],
@@ -268,10 +173,6 @@ export const LibraryProvider = ({ children }: { children: React.ReactNode }) =>
export const useTracksInfiniteQueryContext = () =>
useContextSelector(LibraryContext, (context) => context.tracksInfiniteQuery)
export const useAlbumsInfiniteQueryContext = () =>
useContextSelector(LibraryContext, (context) => context.albumsInfiniteQuery)
export const useAlbumPageParamsContext = () =>
useContextSelector(LibraryContext, (context) => context.albumPageParams)
export const usePlaylistsInfiniteQueryContext = () =>
useContextSelector(LibraryContext, (context) => context.playlistsInfiniteQuery)

View File

@@ -2,18 +2,10 @@ import { RouteProp } from '@react-navigation/native'
import Albums from '../../components/Albums/component'
import DiscoverStackParamList, { RecentlyAddedProps } from './types'
export default function RecentlyAdded({
route,
}: {
route: RouteProp<DiscoverStackParamList, 'RecentlyAdded'>
}): React.JSX.Element {
export default function RecentlyAdded({ route }: RecentlyAddedProps): React.JSX.Element {
return (
<Albums
albums={route.params.albums}
fetchNextPage={route.params.fetchNextPage}
hasNextPage={route.params.hasNextPage}
isPending={route.params.isPending}
isFetchingNextPage={route.params.isFetchingNextPage}
albumsInfiniteQuery={route.params.albumsInfiniteQuery}
showAlphabeticalSelector={false}
/>
)

View File

@@ -6,12 +6,7 @@ import { UseInfiniteQueryResult } from '@tanstack/react-query'
type DiscoverStackParamList = BaseStackParamList & {
Discover: undefined
RecentlyAdded: {
albums: BaseItemDto[] | undefined
navigation: NativeStackNavigationProp<RootStackParamList>
fetchNextPage: () => void
hasNextPage: boolean
isPending: boolean
isFetchingNextPage: boolean
albumsInfiniteQuery: UseInfiniteQueryResult<BaseItemDto[], Error>
}
PublicPlaylists: {
playlists: BaseItemDto[] | undefined

View File

@@ -83,15 +83,6 @@ export type ContextProps = NativeStackScreenProps<RootStackParamList, 'Context'>
export type AddToPlaylistProps = NativeStackScreenProps<RootStackParamList, 'AddToPlaylist'>
export type AudioSpecsProps = NativeStackScreenProps<RootStackParamList, 'AudioSpecs'>
export type ArtistsProps = {
artistsInfiniteQuery: UseInfiniteQueryResult<
BaseItemDto[] | (string | number | BaseItemDto)[],
Error
>
showAlphabeticalSelector: boolean
artistPageParams?: RefObject<Set<string>>
}
export type GenresProps = {
genres: InfiniteData<BaseItemDto[], unknown> | undefined
fetchNextPage: (options?: FetchNextPageOptions | undefined) => void

View File

@@ -0,0 +1,44 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { InfiniteData } from '@tanstack/react-query'
import { isString } from 'lodash'
import { RefObject } from 'react'
export default function flattenInfiniteQueryPages(
data: InfiniteData<BaseItemDto[], unknown>,
pageParams: RefObject<Set<string>>,
) {
/**
* A flattened array of all artists derived from the infinite query
*/
const flattenedItemPages = data.pages.flatMap((page) => page)
/**
* 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 flashListItems: (string | number | BaseItemDto)[] = []
flattenedItemPages.forEach((item: BaseItemDto) => {
const rawLetter = isString(item.SortName) ? item.SortName.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.toUpperCase())
flashListItems.push(letter.toUpperCase())
}
flashListItems.push(item)
})
pageParams.current = seenLetters
return flashListItems
}

199
yarn.lock
View File

@@ -1420,6 +1420,11 @@
resolved "https://registry.yarnpkg.com/@jest/get-type/-/get-type-30.0.1.tgz#0d32f1bbfba511948ad247ab01b9007724fc9f52"
integrity sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==
"@jest/get-type@30.1.0":
version "30.1.0"
resolved "https://registry.yarnpkg.com/@jest/get-type/-/get-type-30.1.0.tgz#4fcb4dc2ebcf0811be1c04fd1cb79c2dba431cbc"
integrity sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==
"@jest/globals@30.0.5":
version "30.0.5"
resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-30.0.5.tgz#ca70e0ac08ab40417cf8cd92bcb76116c2ccca63"
@@ -1924,10 +1929,10 @@
dependencies:
"@react-native-vector-icons/common" "^12.3.0"
"@react-native/assets-registry@0.81.0":
version "0.81.0"
resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.81.0.tgz#ff28654b6e64164137d10de7333da05b3d994f2c"
integrity sha512-rZs8ziQ1YRV3Z5Mw5AR7YcgI3q1Ya9NIx6nyuZAT9wDSSjspSi+bww+Hargh/a4JfV2Ajcxpn9X9UiFJr1ddPw==
"@react-native/assets-registry@0.81.1":
version "0.81.1"
resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.81.1.tgz#94993d165b79feeec09432f867ea2edc8a307e60"
integrity sha512-o/AeHeoiPW8x9MzxE1RSnKYc+KZMW9b7uaojobEz0G8fKgGD1R8n5CJSOiQ/0yO2fJdC5wFxMMOgy2IKwRrVgw==
"@react-native/babel-plugin-codegen@0.81.0":
version "0.81.0"
@@ -1999,12 +2004,25 @@
nullthrows "^1.1.1"
yargs "^17.6.2"
"@react-native/community-cli-plugin@0.81.0":
version "0.81.0"
resolved "https://registry.yarnpkg.com/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.0.tgz#16407f0eb71fd251ec08536085e4dbda83279d56"
integrity sha512-n04ACkCaLR54NmA/eWiDpjC16pHr7+yrbjQ6OEdRoXbm5EfL8FEre2kDAci7pfFdiSMpxdRULDlKpfQ+EV/GAQ==
"@react-native/codegen@0.81.1":
version "0.81.1"
resolved "https://registry.yarnpkg.com/@react-native/codegen/-/codegen-0.81.1.tgz#6cbe4dbe0c85a260c1fb7dce301234f527771cd6"
integrity sha512-8KoUE1j65fF1PPHlAhSeUHmcyqpE+Z7Qv27A89vSZkz3s8eqWSRu2hZtCl0D3nSgS0WW0fyrIsFaRFj7azIiPw==
dependencies:
"@react-native/dev-middleware" "0.81.0"
"@babel/core" "^7.25.2"
"@babel/parser" "^7.25.3"
glob "^7.1.1"
hermes-parser "0.29.1"
invariant "^2.2.4"
nullthrows "^1.1.1"
yargs "^17.6.2"
"@react-native/community-cli-plugin@0.81.1":
version "0.81.1"
resolved "https://registry.yarnpkg.com/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.1.tgz#83110c7e839e9385b8ac5108f3c5600ce9db4f94"
integrity sha512-FuIpZcjBiiYcVMNx+1JBqTPLs2bUIm6X4F5enYGYcetNE2nfSMUVO8SGUtTkBdbUTfKesXYSYN8wungyro28Ag==
dependencies:
"@react-native/dev-middleware" "0.81.1"
debug "^4.4.0"
invariant "^2.2.4"
metro "^0.83.1"
@@ -2012,18 +2030,18 @@
metro-core "^0.83.1"
semver "^7.1.3"
"@react-native/debugger-frontend@0.81.0":
version "0.81.0"
resolved "https://registry.yarnpkg.com/@react-native/debugger-frontend/-/debugger-frontend-0.81.0.tgz#a032e98896371095919fa04b8ac93a1d1fe96f72"
integrity sha512-N/8uL2CGQfwiQRYFUNfmaYxRDSoSeOmFb56rb0PDnP3XbS5+X9ee7X4bdnukNHLGfkRdH7sVjlB8M5zE8XJOhw==
"@react-native/debugger-frontend@0.81.1":
version "0.81.1"
resolved "https://registry.yarnpkg.com/@react-native/debugger-frontend/-/debugger-frontend-0.81.1.tgz#db71318e9cfe973cd731c59d2361700b8422a304"
integrity sha512-dwKv1EqKD+vONN4xsfyTXxn291CNl1LeBpaHhNGWASK1GO4qlyExMs4TtTjN57BnYHikR9PzqPWcUcfzpVRaLg==
"@react-native/dev-middleware@0.81.0":
version "0.81.0"
resolved "https://registry.yarnpkg.com/@react-native/dev-middleware/-/dev-middleware-0.81.0.tgz#5f4018bdca027feb903cb2902d48204c0703587c"
integrity sha512-J/HeC/+VgRyGECPPr9rAbe5S0OL6MCIrvrC/kgNKSME5+ZQLCiTpt3pdAoAMXwXiF9a02Nmido0DnyM1acXTIA==
"@react-native/dev-middleware@0.81.1":
version "0.81.1"
resolved "https://registry.yarnpkg.com/@react-native/dev-middleware/-/dev-middleware-0.81.1.tgz#3a14f416a2fc80d4f993e22bcb84ad781ac4e638"
integrity sha512-hy3KlxNOfev3O5/IuyZSstixWo7E9FhljxKGHdvVtZVNjQdM+kPMh66mxeJbB2TjdJGAyBT4DjIwBaZnIFOGHQ==
dependencies:
"@isaacs/ttlcache" "^1.4.1"
"@react-native/debugger-frontend" "0.81.0"
"@react-native/debugger-frontend" "0.81.1"
chrome-launcher "^0.15.2"
chromium-edge-launcher "^0.2.0"
connect "^3.6.5"
@@ -2057,16 +2075,21 @@
resolved "https://registry.yarnpkg.com/@react-native/eslint-plugin/-/eslint-plugin-0.81.0.tgz#5a236c92394f44f4cbfe400d7b87a7e25599dd54"
integrity sha512-kNSraBk1BuW21raXRJp8+BlTJwnpU96kRNQ9YNxfcY78k9zOH2YXiYsK0SfrDrdcl5kspiXRSj3Rueh6jvDRHw==
"@react-native/gradle-plugin@0.81.0":
version "0.81.0"
resolved "https://registry.yarnpkg.com/@react-native/gradle-plugin/-/gradle-plugin-0.81.0.tgz#6a9b0583f5f21142ddaeca72ef3e81160a8e3ce8"
integrity sha512-LGNtPXO1RKLws5ORRb4Q4YULi2qxM4qZRuARtwqM/1f2wyZVggqapoV0OXlaXaz+GiEd2ll3ROE4CcLN6J93jg==
"@react-native/gradle-plugin@0.81.1":
version "0.81.1"
resolved "https://registry.yarnpkg.com/@react-native/gradle-plugin/-/gradle-plugin-0.81.1.tgz#a7afdc962c298acf6a99142e6db78b554aba6006"
integrity sha512-RpRxs/LbWVM9Zi5jH1qBLgTX746Ei+Ui4vj3FmUCd9EXUSECM5bJpphcsvqjxM5Vfl/o2wDLSqIoFkVP/6Te7g==
"@react-native/js-polyfills@0.81.0":
version "0.81.0"
resolved "https://registry.yarnpkg.com/@react-native/js-polyfills/-/js-polyfills-0.81.0.tgz#81900a25b626e9bca8b38b545b6987695d469d59"
integrity sha512-whXZWIogzoGpqdyTjqT89M6DXmlOkWqNpWoVOAwVi8XFCMO+L7WTk604okIgO6gdGZcP1YtFpQf9JusbKrv/XA==
"@react-native/js-polyfills@0.81.1":
version "0.81.1"
resolved "https://registry.yarnpkg.com/@react-native/js-polyfills/-/js-polyfills-0.81.1.tgz#066343aca3d3aaf846335492c7114e08e9a0e975"
integrity sha512-w093OkHFfCnJKnkiFizwwjgrjh5ra53BU0ebPM3uBLkIQ6ZMNSCTZhG8ZHIlAYeIGtEinvmnSUi3JySoxuDCAQ==
"@react-native/metro-babel-transformer@0.81.0":
version "0.81.0"
resolved "https://registry.yarnpkg.com/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.81.0.tgz#f17f104f53d9976ba8a3f26c3d13dfc4f3800b54"
@@ -2092,20 +2115,20 @@
resolved "https://registry.yarnpkg.com/@react-native/normalize-color/-/normalize-color-2.1.0.tgz#939b87a9849e81687d3640c5efa2a486ac266f91"
integrity sha512-Z1jQI2NpdFJCVgpY+8Dq/Bt3d+YUi1928Q+/CZm/oh66fzM0RUl54vvuXlPJKybH4pdCZey1eDTPaLHkMPNgWA==
"@react-native/normalize-colors@0.81.0":
version "0.81.0"
resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.81.0.tgz#538db4d0b9378b73d3be009e99d44cf78c12baf7"
integrity sha512-3gEu/29uFgz+81hpUgdlOojM4rjHTIPwxpfygFNY60V6ywZih3eLDTS8kAjNZfPFHQbcYrNorJzwnL5yFF/uLw==
"@react-native/normalize-colors@0.81.1":
version "0.81.1"
resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.81.1.tgz#bf290526e1bcbb8d14e20b509ca1030d5df71585"
integrity sha512-TsaeZlE8OYFy3PSWc+1VBmAzI2T3kInzqxmwXoGU4w1d4XFkQFg271Ja9GmDi9cqV3CnBfqoF9VPwRxVlc/l5g==
"@react-native/typescript-config@0.81.0":
version "0.81.0"
resolved "https://registry.yarnpkg.com/@react-native/typescript-config/-/typescript-config-0.81.0.tgz#d25dd746ac320293cd10bb8302489ec383bdabe2"
integrity sha512-BnmmXHafGitDBD5naQF1wwaJ2LY1CLMABs009tVTF4ZOPK9/IrGdoNjuiI+tjHAeug6S68MlSNyVxknZ2JBIvw==
"@react-native/virtualized-lists@0.81.0":
version "0.81.0"
resolved "https://registry.yarnpkg.com/@react-native/virtualized-lists/-/virtualized-lists-0.81.0.tgz#962ea39af006e58bfe898bb54c164b52075d491f"
integrity sha512-p14QC5INHkbMZ96158sUxkSwN6zp138W11G+CRGoLJY4Q9WRJBCe7wHR5Owyy3XczQXrIih/vxAXwgYeZ2XByg==
"@react-native/virtualized-lists@0.81.1":
version "0.81.1"
resolved "https://registry.yarnpkg.com/@react-native/virtualized-lists/-/virtualized-lists-0.81.1.tgz#b550d54a0762e85b88ba9be0b32a1675664f92ed"
integrity sha512-yG+zcMtyApW1yRwkNFvlXzEg3RIFdItuwr/zEvPCSdjaL+paX4rounpL0YX5kS9MsDIE5FXfcqINXg7L0xuwPg==
dependencies:
invariant "^2.2.4"
nullthrows "^1.1.1"
@@ -3379,52 +3402,52 @@
resolved "https://registry.yarnpkg.com/@tamagui/z-index-stack/-/z-index-stack-1.132.23.tgz#a74f06f3b6a6191951f396105f39a10aec0144aa"
integrity sha512-djbRW7FWzuc9bCIVXG00pVa6McM8/H8R4JOL+szxSy1iAo0P2k0OzWfBb+ZbbjTye068fBPGIniq4X7+3huS1Q==
"@tanstack/query-async-storage-persister@^5.85.5":
version "5.85.5"
resolved "https://registry.yarnpkg.com/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.85.5.tgz#4e12cea74665088e9e5f70c0046e51e7b5baba0e"
integrity sha512-E1N+eMPWfV0PwTNa8tRqyOgIzFJSGvrC5hVIxNehLL/jucPvLi0QUlIG/KC4Vg6jVarONSLhONCM4dkSugEUFw==
"@tanstack/query-async-storage-persister@^5.85.6":
version "5.85.6"
resolved "https://registry.yarnpkg.com/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.85.6.tgz#a215d1a78fe23efab45b6f31d942cfe4515c5c36"
integrity sha512-f2C0tMVEo6oFdcNE1xOYGJ5KB02cIEDPjbWuqEivJfrlWDqsPlnHnfxTYkuMcddTHTClDf/sqtjuB95zggeEsQ==
dependencies:
"@tanstack/query-persist-client-core" "5.85.5"
"@tanstack/query-persist-client-core" "5.85.6"
"@tanstack/query-core@5.85.5":
version "5.85.5"
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.85.5.tgz#c4adc126bb3a927e4d60280bf3cf62210700147c"
integrity sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w==
"@tanstack/query-core@5.85.6":
version "5.85.6"
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.85.6.tgz#2af90f3c56c38fd2194e0ed1996122373c4bfca5"
integrity sha512-hCj0TktzdCv2bCepIdfwqVwUVWb+GSHm1Jnn8w+40lfhQ3m7lCO7ADRUJy+2unxQ/nzjh2ipC6ye69NDW3l73g==
"@tanstack/query-persist-client-core@5.85.5":
version "5.85.5"
resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.85.5.tgz#04ba995509d5e279771b37c0e0a21879476f3576"
integrity sha512-2JQiyiTVaaUu8pwPqOp6tjNa64ZN+0T9eZ3lfksV4le1VuG99fTcAYmZFIydvzwWlSM7GEF/1kpl5bwW2Y1qfQ==
"@tanstack/query-persist-client-core@5.85.6":
version "5.85.6"
resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.85.6.tgz#92f9d3ac83f51b0bd452fe3b65047f4196c3f56a"
integrity sha512-wUdoEurIC0YCNZzR020Xcg3OsJeF4SXmEPqlNwZ6EaGKgWeNjU17hVdK+X4ZeirUm+h0muiEQx+aIQU1lk7roQ==
dependencies:
"@tanstack/query-core" "5.85.5"
"@tanstack/query-core" "5.85.6"
"@tanstack/react-query-persist-client@^5.85.5":
version "5.85.5"
resolved "https://registry.yarnpkg.com/@tanstack/react-query-persist-client/-/react-query-persist-client-5.85.5.tgz#0fa1e581b3a7564a49e097a26c69dea009b33724"
integrity sha512-KzISZPtJtWZAwH/Ln1FclaiHVwdeV04WX7wUYLe1vw7zyfcPljHeyXlmVf8nxhFm8ujMBdGQVzP2iNn6ehzjQA==
"@tanstack/react-query-persist-client@^5.85.6":
version "5.85.6"
resolved "https://registry.yarnpkg.com/@tanstack/react-query-persist-client/-/react-query-persist-client-5.85.6.tgz#40c59526f55dc4ee5ac8ddb5bc777e27e2256627"
integrity sha512-zLUfm8JlI6/s0AqvX5l5CcazdHwj5gwcv0mWYOaJJvADyFzl2wwQKqB/H4nYSeygUtrepBgPwVQKNqH9ZwlZpQ==
dependencies:
"@tanstack/query-persist-client-core" "5.85.5"
"@tanstack/query-persist-client-core" "5.85.6"
"@tanstack/react-query@^5.85.5":
version "5.85.5"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.85.5.tgz#50a1c02b50a59f93eba8f0d91d54d39c6c534c5e"
integrity sha512-/X4EFNcnPiSs8wM2v+b6DqS5mmGeuJQvxBglmDxl6ZQb5V26ouD2SJYAcC3VjbNwqhY2zjxVD15rDA5nGbMn3A==
"@tanstack/react-query@^5.85.6":
version "5.85.6"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.85.6.tgz#0885cd9e02f8a5aa228f6b5dc2122d22ba597d68"
integrity sha512-VUAag4ERjh+qlmg0wNivQIVCZUrYndqYu3/wPCVZd4r0E+1IqotbeyGTc+ICroL/PqbpSaGZg02zSWYfcvxbdA==
dependencies:
"@tanstack/query-core" "5.85.5"
"@tanstack/query-core" "5.85.6"
"@telemetrydeck/sdk@^2.0.4":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@telemetrydeck/sdk/-/sdk-2.0.4.tgz#f846151784fbc165280c74db830a91865b1381c1"
integrity sha512-x4S83AqSo6wvLJ6nRYdyJEqd9qmblUdBgsTRrjH5z++b9pnf2NMc8NpVAa48KIB1pRuP/GTGzXxVYdNoie/DVg==
"@testing-library/react-native@^13.2.2":
version "13.2.2"
resolved "https://registry.yarnpkg.com/@testing-library/react-native/-/react-native-13.2.2.tgz#8de8e6e145b8a10338f997ff7b739b79eb0b7c98"
integrity sha512-QALF+nZ4BSXBOtUs5ljLnaHKuyR+ykakYB3RYwciSrllhgZkbUjXeGkugCxrmEtQ2BUZnYVRY7AEGboMP/hucg==
"@testing-library/react-native@^13.2.3":
version "13.3.3"
resolved "https://registry.yarnpkg.com/@testing-library/react-native/-/react-native-13.3.3.tgz#4bf02911c4e18075df40b5de0e029c209fb45bda"
integrity sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==
dependencies:
chalk "^4.1.2"
jest-matcher-utils "^30.0.2"
pretty-format "^30.0.2"
jest-matcher-utils "^30.0.5"
picocolors "^1.1.1"
pretty-format "^30.0.5"
redent "^3.0.0"
"@tybys/wasm-util@^0.9.0":
@@ -6617,6 +6640,16 @@ jest-diff@30.0.5:
chalk "^4.1.2"
pretty-format "30.0.5"
jest-diff@30.1.1:
version "30.1.1"
resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-30.1.1.tgz#cfe8327c059178affac17d4c003e7096ad19583c"
integrity sha512-LUU2Gx8EhYxpdzTR6BmjL1ifgOAQJQELTHOiPv9KITaKjZvJ9Jmgigx01tuZ49id37LorpGc9dPBPlXTboXScw==
dependencies:
"@jest/diff-sequences" "30.0.1"
"@jest/get-type" "30.1.0"
chalk "^4.1.2"
pretty-format "30.0.5"
jest-docblock@30.0.1:
version "30.0.1"
resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-30.0.1.tgz#545ff59f2fa88996bd470dba7d3798a8421180b1"
@@ -6720,7 +6753,7 @@ jest-matcher-utils@30.0.4:
jest-diff "30.0.4"
pretty-format "30.0.2"
jest-matcher-utils@30.0.5, jest-matcher-utils@^30.0.2:
jest-matcher-utils@30.0.5:
version "30.0.5"
resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-30.0.5.tgz#dff3334be58faea4a5e1becc228656fbbfc2467d"
integrity sha512-uQgGWt7GOrRLP1P7IwNWwK1WAQbq+m//ZY0yXygyfWp0rJlksMSLQAA4wYQC3b6wl3zfnchyTx+k3HZ5aPtCbQ==
@@ -6730,6 +6763,16 @@ jest-matcher-utils@30.0.5, jest-matcher-utils@^30.0.2:
jest-diff "30.0.5"
pretty-format "30.0.5"
jest-matcher-utils@^30.0.5:
version "30.1.1"
resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-30.1.1.tgz#e45419d966cd2e5e7d7ade6da747035c6a3b8afc"
integrity sha512-SuH2QVemK48BNTqReti6FtjsMPFsSOD/ZzRxU1TttR7RiRsRSe78d03bb4Cx6D4bQC/80Q8U4VnaaAH9FlbZ9w==
dependencies:
"@jest/get-type" "30.1.0"
chalk "^4.1.2"
jest-diff "30.1.1"
pretty-format "30.0.5"
jest-message-util@30.0.2:
version "30.0.2"
resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-30.0.2.tgz#9dfdc37570d172f0ffdc42a0318036ff4008837f"
@@ -7942,10 +7985,10 @@ open@^7.0.3, open@^7.4.2:
is-docker "^2.0.0"
is-wsl "^2.1.1"
openai@^5.12.2:
version "5.12.2"
resolved "https://registry.yarnpkg.com/openai/-/openai-5.12.2.tgz#512ab6b80eb8414837436e208f1b951442b97761"
integrity sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==
openai@^5.16.0:
version "5.16.0"
resolved "https://registry.yarnpkg.com/openai/-/openai-5.16.0.tgz#a302d4ca92954598c79c72dd199c58994708130d"
integrity sha512-hoEH8ZNvg1HXjU9mp88L/ZH8O082Z8r6FHCXGiWAzVRrEv443aI57qhch4snu07yQydj+AUAWLenAiBXhu89Tw==
optionator@^0.9.3:
version "0.9.4"
@@ -8252,7 +8295,7 @@ pretty-format@30.0.2, pretty-format@^30.0.0:
ansi-styles "^5.2.0"
react-is "^18.3.1"
pretty-format@30.0.5, pretty-format@^30.0.2:
pretty-format@30.0.5, pretty-format@^30.0.5:
version "30.0.5"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-30.0.5.tgz#e001649d472800396c1209684483e18a4d250360"
integrity sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==
@@ -8594,19 +8637,19 @@ react-native-worklets@0.4.1:
"@babel/preset-typescript" "^7.16.7"
convert-source-map "^2.0.0"
react-native@0.81.0:
version "0.81.0"
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.81.0.tgz#ebb645f3fb2fc2ffb222d2f294ca4e81e6568f15"
integrity sha512-RDWhewHGsAa5uZpwIxnJNiv5tW2y6/DrQUjEBdAHPzGMwuMTshern2s4gZaWYeRU3SQguExVddCjiss9IBhxqA==
react-native@0.81.1:
version "0.81.1"
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.81.1.tgz#0825cde0cc00d569cbec7d2fa1abd38a66885250"
integrity sha512-k2QJzWc/CUOwaakmD1SXa4uJaLcwB2g2V9BauNIjgtXYYAeyFjx9jlNz/+wAEcHLg9bH5mgMdeAwzvXqjjh9Hg==
dependencies:
"@jest/create-cache-key-function" "^29.7.0"
"@react-native/assets-registry" "0.81.0"
"@react-native/codegen" "0.81.0"
"@react-native/community-cli-plugin" "0.81.0"
"@react-native/gradle-plugin" "0.81.0"
"@react-native/js-polyfills" "0.81.0"
"@react-native/normalize-colors" "0.81.0"
"@react-native/virtualized-lists" "0.81.0"
"@react-native/assets-registry" "0.81.1"
"@react-native/codegen" "0.81.1"
"@react-native/community-cli-plugin" "0.81.1"
"@react-native/gradle-plugin" "0.81.1"
"@react-native/js-polyfills" "0.81.1"
"@react-native/normalize-colors" "0.81.1"
"@react-native/virtualized-lists" "0.81.1"
abort-controller "^3.0.0"
anser "^1.4.9"
ansi-regex "^5.0.0"