feat: Add artist tracks tab with infinite scrolling, sorting, and filtering.

This commit is contained in:
skalthoff
2025-11-18 18:47:18 -08:00
parent a6f19dac7e
commit 6e47fec0d3
5 changed files with 221 additions and 2 deletions

View File

@@ -8,6 +8,7 @@ import {
} from '@tanstack/react-query'
import { isUndefined } from 'lodash'
import { fetchArtistAlbums, fetchArtistFeaturedOn, fetchArtists } from './utils/artist'
import fetchArtistTracks from './utils/tracks'
import { ApiLimits } from '../query.config'
import { RefObject, useCallback, useRef } from 'react'
import { useLibrarySortAndFilterContext } from '../../../providers/Library'
@@ -36,6 +37,53 @@ export const useArtistFeaturedOn = (artist: BaseItemDto) => {
})
}
export const useArtistTracks = (
artist: BaseItemDto,
isFavorite: boolean,
sortDescending: boolean,
sortBy: ItemSortBy = ItemSortBy.SortName,
) => {
const api = useApi()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
const trackPageParams = useRef<Set<string>>(new Set<string>())
const selectTracks = useCallback(
(data: InfiniteData<BaseItemDto[], unknown>) =>
flattenInfiniteQueryPages(data, trackPageParams),
[],
)
return useInfiniteQuery({
queryKey: [
QueryKeys.ArtistTracks,
library?.musicLibraryId,
artist.Id,
isFavorite,
sortDescending,
sortBy,
],
queryFn: ({ pageParam }) =>
fetchArtistTracks(
api,
user,
library,
artist,
pageParam,
isFavorite ? true : undefined,
sortBy,
sortDescending ? SortOrder.Descending : SortOrder.Ascending,
),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
},
select: selectTracks,
enabled: !isUndefined(artist.Id),
})
}
export const useAlbumArtists: () => [
RefObject<Set<string>>,
UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>,

View File

@@ -0,0 +1,56 @@
import { JellifyLibrary } from '../../../../types/JellifyLibrary'
import { Api } from '@jellyfin/sdk'
import {
BaseItemDto,
BaseItemKind,
ItemFields,
ItemSortBy,
SortOrder,
} from '@jellyfin/sdk/lib/generated-client/models'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { isUndefined } from 'lodash'
import { ApiLimits } from '../../query.config'
import { JellifyUser } from '../../../../types/JellifyUser'
export default function fetchArtistTracks(
api: Api | undefined,
user: JellifyUser | undefined,
library: JellifyLibrary | undefined,
artist: BaseItemDto,
pageParam: number,
isFavorite: boolean | undefined,
sortBy: ItemSortBy = ItemSortBy.SortName,
sortOrder: SortOrder = SortOrder.Ascending,
) {
console.debug('Fetching artist tracks', pageParam)
return new Promise<BaseItemDto[]>((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(library)) return reject('Library instance not set')
if (isUndefined(user)) return reject('User instance not set')
if (isUndefined(artist.Id)) return reject('Artist ID not set')
getItemsApi(api)
.getItems({
includeItemTypes: [BaseItemKind.Audio],
parentId: library.musicLibraryId,
enableUserData: true,
userId: user.id,
recursive: true,
artistIds: [artist.Id],
isFavorite: isFavorite,
limit: ApiLimits.Library,
startIndex: pageParam * ApiLimits.Library,
sortBy: [sortBy],
sortOrder: [sortOrder],
fields: [ItemFields.SortName],
})
.then((response) => {
if (response.data.Items) return resolve(response.data.Items)
else return resolve([])
})
.catch((error) => {
console.error(error)
return reject(error)
})
})
}

View File

@@ -8,9 +8,13 @@ import ItemRow from '../Global/components/item-row'
import ArtistHeader from './header'
import { Text } from '../Global/helpers/text'
import SimilarArtists from './similar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'
import ArtistTracks from './tracks'
import { useTheme } from 'tamagui'
export default function ArtistNavigation({
const Tab = createMaterialTopTabNavigator()
function ArtistOverview({
navigation,
}: {
navigation: NativeStackNavigationProp<BaseStackParamList>
@@ -66,3 +70,26 @@ export default function ArtistNavigation({
/>
)
}
export default function ArtistNavigation({
navigation,
}: {
navigation: NativeStackNavigationProp<BaseStackParamList>
}): React.JSX.Element {
const theme = useTheme()
return (
<Tab.Navigator
screenOptions={{
tabBarStyle: { backgroundColor: theme.background.val },
tabBarLabelStyle: { color: theme.color.val, fontWeight: 'bold' },
tabBarIndicatorStyle: { backgroundColor: theme.color.val },
}}
>
<Tab.Screen name='Overview'>
{() => <ArtistOverview navigation={navigation} />}
</Tab.Screen>
<Tab.Screen name='Tracks'>{() => <ArtistTracks navigation={navigation} />}</Tab.Screen>
</Tab.Navigator>
)
}

View File

@@ -0,0 +1,87 @@
import React, { useState } from 'react'
import { useArtistContext } from '../../providers/Artist'
import { useArtistTracks } from '../../api/queries/artist'
import { FlashList } from '@shopify/flash-list'
import ItemRow from '../Global/components/item-row'
import { XStack, Button, Text, YStack, Spinner } from 'tamagui'
import { ItemSortBy, BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import Icon from '../Global/components/icon'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '@/src/screens/types'
export default function ArtistTracks({
navigation,
}: {
navigation: NativeStackNavigationProp<BaseStackParamList>
}) {
const { artist } = useArtistContext()
const [isFavorite, setIsFavorite] = useState(false)
const [sortDescending, setSortDescending] = useState(false)
const [sortBy, setSortBy] = useState<ItemSortBy>(ItemSortBy.Name)
const { data, fetchNextPage, hasNextPage, isFetching } = useArtistTracks(
artist,
isFavorite,
sortDescending,
sortBy,
)
const tracks = data ?? []
const toggleFavorite = () => setIsFavorite(!isFavorite)
const toggleSortOrder = () => setSortDescending(!sortDescending)
const toggleSortBy = () => {
setSortBy((prev) => (prev === ItemSortBy.Name ? ItemSortBy.PremiereDate : ItemSortBy.Name))
}
return (
<YStack flex={1} backgroundColor='$background'>
<XStack padding='$3' gap='$3' alignItems='center'>
<Button
size='$3'
circular
icon={
<Icon
name={isFavorite ? 'heart' : 'heart-outline'}
color={isFavorite ? '$danger' : '$color'}
/>
}
onPress={toggleFavorite}
backgroundColor={isFavorite ? '$red3' : '$background'}
/>
<Button size='$3' onPress={toggleSortBy} flex={1}>
<Text>{sortBy === ItemSortBy.Name ? 'Name' : 'Release Date'}</Text>
</Button>
<Button
size='$3'
circular
icon={<Icon name={sortDescending ? 'sort-descending' : 'sort-ascending'} />}
onPress={toggleSortOrder}
/>
</XStack>
<FlashList
data={tracks}
renderItem={({ item }) => {
if (typeof item === 'object' && item !== null) {
return <ItemRow item={item as BaseItemDto} navigation={navigation} />
}
return (
<Text padding='$2' fontWeight='bold' backgroundColor='$background'>
{item}
</Text>
)
}}
getItemType={(item) =>
typeof item === 'object' && item !== null ? 'row' : 'sectionHeader'
}
estimatedItemSize={60}
onEndReached={() => {
if (hasNextPage) fetchNextPage()
}}
onEndReachedThreshold={0.5}
ListFooterComponent={isFetching ? <Spinner /> : null}
contentContainerStyle={{ paddingBottom: 100 }}
/>
</YStack>
)
}

View File

@@ -115,4 +115,5 @@ export enum QueryKeys {
InfiniteSuggestedArtists = 'InfiniteSuggestedArtists',
Album = 'Album',
TrackArtists = 'TrackArtists',
ArtistTracks = 'ArtistTracks',
}