mirror of
https://github.com/Jellify-Music/App.git
synced 2026-03-17 18:51:24 -05:00
feat: Add artist tracks tab with infinite scrolling, sorting, and filtering.
This commit is contained in:
@@ -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>,
|
||||
|
||||
56
src/api/queries/artist/utils/tracks.ts
Normal file
56
src/api/queries/artist/utils/tracks.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
87
src/components/Artist/tracks.tsx
Normal file
87
src/components/Artist/tracks.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -115,4 +115,5 @@ export enum QueryKeys {
|
||||
InfiniteSuggestedArtists = 'InfiniteSuggestedArtists',
|
||||
Album = 'Album',
|
||||
TrackArtists = 'TrackArtists',
|
||||
ArtistTracks = 'ArtistTracks',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user