mirror of
https://github.com/Jellify-Music/App.git
synced 2026-03-18 03:00:35 -05:00
Added Genre filtering to the Tracks library view with a multi-select genre picker. (#944)
* These changes include adding an Unplayed filter and also fixing shuffle all to shuffle based on the filtered selection * Added genre filter and selection page * Forgot to hit save on this file * Fixed bug affecting genre filtering * Shuffle all query now includes the genres * Fixed trigger to triggerHaptic * Fixed trigger to triggerHaptic --------- Co-authored-by: StephenArg <stephen@vody.com> Co-authored-by: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com>
This commit is contained in:
22
src/api/queries/genre/index.ts
Normal file
22
src/api/queries/genre/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useInfiniteQuery, UseInfiniteQueryResult } from '@tanstack/react-query'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
|
||||
import { fetchGenres } from './utils'
|
||||
import { GenresQueryKey } from './keys'
|
||||
import { getApi, getUser, useJellifyLibrary } from '../../../stores'
|
||||
import { ApiLimits } from '../../../configs/query.config'
|
||||
|
||||
export const useGenres = (): UseInfiniteQueryResult<BaseItemDto[], Error> => {
|
||||
const api = getApi()
|
||||
const user = getUser()
|
||||
const [library] = useJellifyLibrary()
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: GenresQueryKey(library?.musicLibraryId, user?.id),
|
||||
queryFn: ({ pageParam }) => fetchGenres(api, user, library, pageParam),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam) => {
|
||||
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
|
||||
},
|
||||
select: (data) => data.pages.flatMap((page) => page),
|
||||
})
|
||||
}
|
||||
5
src/api/queries/genre/keys.ts
Normal file
5
src/api/queries/genre/keys.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const GenresQueryKey = (libraryId: string | undefined, userId: string | undefined) => [
|
||||
'Genres',
|
||||
libraryId,
|
||||
userId,
|
||||
]
|
||||
42
src/api/queries/genre/utils/index.ts
Normal file
42
src/api/queries/genre/utils/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { Api } from '@jellyfin/sdk'
|
||||
import { JellifyLibrary } from '../../../../types/JellifyLibrary'
|
||||
import { JellifyUser } from '../../../../types/JellifyUser'
|
||||
import { nitroFetch } from '../../../utils/nitro'
|
||||
import { isUndefined } from 'lodash'
|
||||
import { ApiLimits } from '../../../../configs/query.config'
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'
|
||||
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order'
|
||||
import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
|
||||
export function fetchGenres(
|
||||
api: Api | undefined,
|
||||
user: JellifyUser | undefined,
|
||||
library: JellifyLibrary | undefined,
|
||||
pageParam: number,
|
||||
): Promise<BaseItemDto[]> {
|
||||
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')
|
||||
|
||||
nitroFetch<{ Items: BaseItemDto[] }>(api, '/Genres', {
|
||||
ParentId: library.musicLibraryId,
|
||||
UserId: user.id,
|
||||
SortBy: [ItemSortBy.SortName],
|
||||
SortOrder: [SortOrder.Ascending],
|
||||
Recursive: true,
|
||||
Fields: [ItemFields.PrimaryImageAspectRatio, ItemFields.ItemCounts],
|
||||
StartIndex: pageParam * ApiLimits.Library,
|
||||
Limit: ApiLimits.Library,
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.Items) return resolve(data.Items)
|
||||
else return resolve([])
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
return reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -37,6 +37,7 @@ const useTracks: (
|
||||
const isLibraryFavorites = filters.tracks.isFavorites
|
||||
const isDownloaded = filters.tracks.isDownloaded ?? false
|
||||
const isLibraryUnplayed = filters.tracks.isUnplayed ?? false
|
||||
const libraryGenreIds = filters.tracks.genreIds
|
||||
|
||||
// Use provided values or fallback to library context
|
||||
// If artistId is present, we use isFavoritesParam if provided, otherwise false (default to showing all artist tracks)
|
||||
@@ -76,6 +77,7 @@ const useTracks: (
|
||||
artistId,
|
||||
finalSortBy,
|
||||
finalSortOrder,
|
||||
isDownloaded ? undefined : libraryGenreIds,
|
||||
),
|
||||
queryFn: ({ pageParam }) => {
|
||||
if (!isDownloaded) {
|
||||
@@ -89,6 +91,7 @@ const useTracks: (
|
||||
finalSortBy,
|
||||
finalSortOrder,
|
||||
artistId,
|
||||
libraryGenreIds,
|
||||
)
|
||||
} else
|
||||
return (downloadedTracks ?? [])
|
||||
|
||||
@@ -15,6 +15,7 @@ export const TracksQueryKey = (
|
||||
artistId?: string,
|
||||
sortBy?: string,
|
||||
sortOrder?: string,
|
||||
genreIds?: string[],
|
||||
) => [
|
||||
TrackQueryKeys.AllTracks,
|
||||
library?.musicLibraryId,
|
||||
@@ -25,4 +26,5 @@ export const TracksQueryKey = (
|
||||
artistId,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
genreIds && genreIds.length > 0 ? `genres:${genreIds.sort().join(',')}` : undefined,
|
||||
]
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function fetchTracks(
|
||||
sortBy: ItemSortBy = ItemSortBy.SortName,
|
||||
sortOrder: SortOrder = SortOrder.Ascending,
|
||||
artistId?: string,
|
||||
genreIds?: string[],
|
||||
) {
|
||||
return new Promise<BaseItemDto[]>((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject('Client instance not set')
|
||||
@@ -54,6 +55,7 @@ export default function fetchTracks(
|
||||
SortOrder: [sortOrder],
|
||||
Fields: [ItemFields.SortName],
|
||||
ArtistIds: artistId ? [artistId] : undefined,
|
||||
GenreIds: genreIds && genreIds.length > 0 ? genreIds : undefined,
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.Items) return resolve(data.Items)
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import React from 'react'
|
||||
import { YStack, XStack } from 'tamagui'
|
||||
import { YStack, XStack, Button } from 'tamagui'
|
||||
import { Text } from '../Global/helpers/text'
|
||||
import { CheckboxWithLabel } from '../Global/helpers/checkbox-with-label'
|
||||
import useLibraryStore from '../../stores/library'
|
||||
import { triggerHaptic } from '../../hooks/use-haptic-feedback'
|
||||
import { FiltersProps } from './types'
|
||||
import Icon from '../Global/components/icon'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { RootStackParamList } from '../../screens/types'
|
||||
import { trigger } from 'react-native-haptic-feedback'
|
||||
|
||||
export default function Filters({ currentTab }: FiltersProps): React.JSX.Element {
|
||||
export default function Filters({
|
||||
currentTab,
|
||||
navigation,
|
||||
}: FiltersProps & {
|
||||
navigation?: NativeStackNavigationProp<RootStackParamList>
|
||||
}): React.JSX.Element {
|
||||
const { filters, setTracksFilters, setAlbumsFilters, setArtistsFilters } = useLibraryStore()
|
||||
if (!currentTab || currentTab === 'Playlists') {
|
||||
return <></>
|
||||
@@ -16,6 +25,8 @@ export default function Filters({ currentTab }: FiltersProps): React.JSX.Element
|
||||
const isFavorites = currentFilters.isFavorites
|
||||
const isDownloaded = currentFilters.isDownloaded ?? false
|
||||
const isUnplayed = currentFilters.isUnplayed ?? false
|
||||
const selectedGenreIds = currentFilters.genreIds ?? []
|
||||
const hasGenresSelected = selectedGenreIds.length > 0
|
||||
|
||||
const handleFavoritesToggle = (checked: boolean | 'indeterminate') => {
|
||||
triggerHaptic('impactLight')
|
||||
@@ -42,8 +53,12 @@ export default function Filters({ currentTab }: FiltersProps): React.JSX.Element
|
||||
}
|
||||
}
|
||||
|
||||
const showDownloadedFilter = currentTab === 'Tracks'
|
||||
const showUnplayedFilter = currentTab === 'Tracks'
|
||||
const isTracksTab = currentTab === 'Tracks'
|
||||
|
||||
const handleGenreSelect = () => {
|
||||
triggerHaptic('impactLight')
|
||||
navigation?.navigate('GenreSelection')
|
||||
}
|
||||
|
||||
const handleUnplayedToggle = (checked: boolean | 'indeterminate') => {
|
||||
triggerHaptic('impactLight')
|
||||
@@ -74,7 +89,7 @@ export default function Filters({ currentTab }: FiltersProps): React.JSX.Element
|
||||
/>
|
||||
</XStack>
|
||||
|
||||
{showDownloadedFilter && (
|
||||
{isTracksTab && (
|
||||
<XStack alignItems='center' justifyContent='space-between'>
|
||||
<CheckboxWithLabel
|
||||
id='filter-downloaded'
|
||||
@@ -82,11 +97,12 @@ export default function Filters({ currentTab }: FiltersProps): React.JSX.Element
|
||||
onCheckedChange={handleDownloadedToggle}
|
||||
label='Downloaded'
|
||||
size='$6'
|
||||
disabled={hasGenresSelected}
|
||||
/>
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
{showUnplayedFilter && (
|
||||
{isTracksTab && (
|
||||
<XStack alignItems='center' justifyContent='space-between'>
|
||||
<CheckboxWithLabel
|
||||
id='filter-unplayed'
|
||||
@@ -97,6 +113,37 @@ export default function Filters({ currentTab }: FiltersProps): React.JSX.Element
|
||||
/>
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
{isTracksTab && (
|
||||
<XStack alignItems='center' justifyContent='space-between' marginTop='$3'>
|
||||
<Button
|
||||
variant='outlined'
|
||||
size='$4'
|
||||
onPress={handleGenreSelect}
|
||||
pressStyle={{ opacity: 0.6 }}
|
||||
animation='quick'
|
||||
flex={1}
|
||||
justifyContent='space-between'
|
||||
disabled={isDownloaded}
|
||||
>
|
||||
<Text
|
||||
color={
|
||||
isDownloaded
|
||||
? '$borderColor'
|
||||
: hasGenresSelected
|
||||
? '$primary'
|
||||
: '$neutral'
|
||||
}
|
||||
>
|
||||
{`Genres ${hasGenresSelected ? `(${selectedGenreIds.length})` : ''}`}
|
||||
</Text>
|
||||
<Icon
|
||||
name={hasGenresSelected ? 'filter-variant' : 'filter'}
|
||||
color={hasGenresSelected ? '$primary' : '$borderColor'}
|
||||
/>
|
||||
</Button>
|
||||
</XStack>
|
||||
)}
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
|
||||
@@ -22,9 +22,12 @@ const alphabet = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||
*/
|
||||
export default function AZScroller({
|
||||
onLetterSelect,
|
||||
alphabet: customAlphabet,
|
||||
}: {
|
||||
onLetterSelect: (letter: string) => Promise<void>
|
||||
alphabet?: string[]
|
||||
}) {
|
||||
const alphabetToUse = customAlphabet ?? alphabet
|
||||
const theme = useTheme()
|
||||
|
||||
const [operationPending, setOperationPending] = useState<boolean>(false)
|
||||
@@ -66,8 +69,8 @@ export default function AZScroller({
|
||||
const relativeY = e.absoluteY - alphabetSelectorTopY.current
|
||||
setOverlayPositionY(relativeY - letterHeight.current * 1.5)
|
||||
const index = Math.floor(relativeY / letterHeight.current)
|
||||
if (alphabet[index]) {
|
||||
const letter = alphabet[index]
|
||||
if (alphabetToUse[index]) {
|
||||
const letter = alphabetToUse[index]
|
||||
selectedLetter.value = letter
|
||||
setOverlayLetter(letter)
|
||||
scheduleOnRN(showOverlay)
|
||||
@@ -77,8 +80,8 @@ export default function AZScroller({
|
||||
const relativeY = e.absoluteY - alphabetSelectorTopY.current
|
||||
setOverlayPositionY(relativeY - letterHeight.current * 1.5)
|
||||
const index = Math.floor(relativeY / letterHeight.current)
|
||||
if (alphabet[index]) {
|
||||
const letter = alphabet[index]
|
||||
if (alphabetToUse[index]) {
|
||||
const letter = alphabetToUse[index]
|
||||
selectedLetter.value = letter
|
||||
setOverlayLetter(letter)
|
||||
scheduleOnRN(showOverlay)
|
||||
@@ -104,8 +107,8 @@ export default function AZScroller({
|
||||
const relativeY = e.absoluteY - alphabetSelectorTopY.current
|
||||
setOverlayPositionY(relativeY - letterHeight.current * 1.5)
|
||||
const index = Math.floor(relativeY / letterHeight.current)
|
||||
if (alphabet[index]) {
|
||||
const letter = alphabet[index]
|
||||
if (alphabetToUse[index]) {
|
||||
const letter = alphabetToUse[index]
|
||||
selectedLetter.value = letter
|
||||
setOverlayLetter(letter)
|
||||
scheduleOnRN(showOverlay)
|
||||
@@ -147,13 +150,23 @@ export default function AZScroller({
|
||||
<YStack
|
||||
minWidth={'$2'}
|
||||
maxWidth={'$3'}
|
||||
marginVertical={'auto'}
|
||||
justifyContent='flex-start'
|
||||
alignItems='center'
|
||||
alignContent='center'
|
||||
onLayout={() => {
|
||||
paddingVertical={0}
|
||||
paddingHorizontal={0}
|
||||
onLayout={(event) => {
|
||||
// Capture layout height before async operations
|
||||
const layoutHeight = event.nativeEvent.layout.height
|
||||
const totalLetters = alphabetToUse.length
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
alphabetSelectorRef.current?.measureInWindow((x, y, width, height) => {
|
||||
// Use the actual layout height to calculate letter positions more accurately
|
||||
if (totalLetters > 0 && layoutHeight > 0) {
|
||||
// Recalculate letter height based on actual container height
|
||||
letterHeight.current = layoutHeight / totalLetters
|
||||
}
|
||||
alphabetSelectorTopY.current = y
|
||||
|
||||
if (Platform.OS === 'android') alphabetSelectorTopY.current += 20
|
||||
@@ -162,14 +175,14 @@ export default function AZScroller({
|
||||
}}
|
||||
ref={alphabetSelectorRef}
|
||||
>
|
||||
{alphabet.map((letter, index) => {
|
||||
{alphabetToUse.map((letter, index) => {
|
||||
const letterElement = (
|
||||
<Text
|
||||
key={letter}
|
||||
fontSize='$6'
|
||||
textAlign='center'
|
||||
color='$neutral'
|
||||
height={'$1'}
|
||||
lineHeight={'$1'}
|
||||
userSelect='none'
|
||||
>
|
||||
{letter}
|
||||
@@ -177,7 +190,7 @@ export default function AZScroller({
|
||||
)
|
||||
|
||||
return index === 0 ? (
|
||||
<View height={'$1'} key={letter} onLayout={handleLetterLayout}>
|
||||
<View key={letter} onLayout={handleLetterLayout}>
|
||||
{letterElement}
|
||||
</View>
|
||||
) : (
|
||||
|
||||
@@ -18,7 +18,11 @@ export function CheckboxWithLabel({
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox>
|
||||
|
||||
<Label color={theme.primary.val} size={size} htmlFor={checkboxId}>
|
||||
<Label
|
||||
color={checkboxProps.disabled ? '$borderColor' : theme.primary.val}
|
||||
size={size}
|
||||
htmlFor={checkboxId}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
</XStack>
|
||||
|
||||
@@ -31,7 +31,8 @@ function LibraryTabBar(props: MaterialTopTabBarProps) {
|
||||
currentFilters &&
|
||||
(currentFilters.isFavorites === true ||
|
||||
currentFilters.isDownloaded === true ||
|
||||
currentFilters.isUnplayed === true)
|
||||
currentFilters.isUnplayed === true ||
|
||||
(currentFilters.genreIds && currentFilters.genreIds.length > 0))
|
||||
|
||||
const handleShufflePress = async () => {
|
||||
triggerHaptic('impactLight')
|
||||
@@ -134,6 +135,7 @@ function LibraryTabBar(props: MaterialTopTabBarProps) {
|
||||
isFavorites: undefined,
|
||||
isDownloaded: false,
|
||||
isUnplayed: false,
|
||||
genreIds: undefined,
|
||||
})
|
||||
} else if (currentTab === 'Albums') {
|
||||
useLibraryStore
|
||||
|
||||
@@ -66,6 +66,7 @@ export async function handleShuffle(keepCurrentTrack: boolean = true): Promise<J
|
||||
const isFavorites = filters.isFavorites === true
|
||||
const isDownloaded = filters.isDownloaded === true
|
||||
const isUnplayed = filters.isUnplayed === true
|
||||
const genreIds = filters.genreIds
|
||||
|
||||
let randomTracks: JellifyTrack[] = []
|
||||
|
||||
@@ -125,6 +126,7 @@ export async function handleShuffle(keepCurrentTrack: boolean = true): Promise<J
|
||||
Recursive: true,
|
||||
SortBy: [ItemSortBy.Random],
|
||||
Filters: apiFilters.length > 0 ? apiFilters : undefined,
|
||||
GenreIds: genreIds && genreIds.length > 0 ? genreIds : undefined,
|
||||
Limit: ApiLimits.LibraryShuffle,
|
||||
Fields: [
|
||||
ItemFields.MediaSources,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Filters from '../../components/Filters/index'
|
||||
import { FiltersProps } from '../types'
|
||||
|
||||
export default function FiltersSheet({ route }: FiltersProps): React.JSX.Element {
|
||||
return <Filters currentTab={route.params?.currentTab} />
|
||||
export default function FiltersSheet({ route, navigation }: FiltersProps): React.JSX.Element {
|
||||
return <Filters currentTab={route.params?.currentTab} navigation={navigation} />
|
||||
}
|
||||
|
||||
224
src/screens/GenreSelection/index.tsx
Normal file
224
src/screens/GenreSelection/index.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import React, { useCallback, useMemo, useState, useRef } from 'react'
|
||||
import { YStack, XStack, Button, Spinner } from 'tamagui'
|
||||
import { FlashList, ListRenderItem } from '@shopify/flash-list'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
|
||||
import { useGenres } from '../../api/queries/genre'
|
||||
import { Text } from '../../components/Global/helpers/text'
|
||||
import ItemImage from '../../components/Global/components/image'
|
||||
import Icon from '../../components/Global/components/icon'
|
||||
import { triggerHaptic } from '../../hooks/use-haptic-feedback'
|
||||
import { GenreSelectionProps } from '../types'
|
||||
import useLibraryStore from '../../stores/library'
|
||||
import { getItemName } from '../../utils/formatting/item-names'
|
||||
|
||||
export default function GenreSelectionScreen({
|
||||
navigation,
|
||||
}: GenreSelectionProps): React.JSX.Element {
|
||||
const genresInfiniteQuery = useGenres()
|
||||
const {
|
||||
data: genres,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isPending,
|
||||
isFetchingNextPage,
|
||||
} = genresInfiniteQuery
|
||||
const [selectedGenreIds, setSelectedGenreIds] = useState<string[]>(
|
||||
useLibraryStore.getState().filters.tracks.genreIds ?? [],
|
||||
)
|
||||
// Group genres by first letter for A-Z navigation
|
||||
const genresByLetter = useMemo(() => {
|
||||
if (!genres) return new Map<string, BaseItemDto[]>()
|
||||
const grouped = new Map<string, BaseItemDto[]>()
|
||||
genres.forEach((genre) => {
|
||||
const name = genre.Name ?? genre.SortName ?? ''
|
||||
const firstLetter = name.charAt(0).toUpperCase()
|
||||
const letter = /[A-Z]/.test(firstLetter) ? firstLetter : '#'
|
||||
if (!grouped.has(letter)) {
|
||||
grouped.set(letter, [])
|
||||
}
|
||||
grouped.get(letter)!.push(genre)
|
||||
})
|
||||
return grouped
|
||||
}, [genres])
|
||||
|
||||
// Flatten grouped genres for display, maintaining letter sections
|
||||
const flattenedGenres = useMemo(() => {
|
||||
const result: (BaseItemDto | string)[] = []
|
||||
const sortedLetters = Array.from(genresByLetter.keys()).sort((a, b) => {
|
||||
if (a === '#') return -1 // "#" goes to the top
|
||||
if (b === '#') return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
sortedLetters.forEach((letter) => {
|
||||
result.push(letter) // Section header
|
||||
const letterGenres = genresByLetter.get(letter)!
|
||||
letterGenres.sort((a, b) => {
|
||||
const aName = a.Name ?? a.SortName ?? ''
|
||||
const bName = b.Name ?? b.SortName ?? ''
|
||||
return aName.localeCompare(bName)
|
||||
})
|
||||
result.push(...letterGenres)
|
||||
})
|
||||
return result
|
||||
}, [genresByLetter])
|
||||
|
||||
const toggleGenre = useCallback(
|
||||
(genreId: string) => {
|
||||
triggerHaptic('impactLight')
|
||||
setSelectedGenreIds((prev) => {
|
||||
if (prev.includes(genreId)) {
|
||||
return prev.filter((id) => id !== genreId)
|
||||
} else {
|
||||
return [...prev, genreId]
|
||||
}
|
||||
})
|
||||
},
|
||||
[triggerHaptic],
|
||||
)
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
triggerHaptic('impactLight')
|
||||
useLibraryStore.getState().setTracksFilters({
|
||||
genreIds: selectedGenreIds.length > 0 ? selectedGenreIds : undefined,
|
||||
// Clear downloaded filter when genres are selected
|
||||
isDownloaded: selectedGenreIds.length > 0 ? false : undefined,
|
||||
})
|
||||
navigation.goBack()
|
||||
}, [selectedGenreIds, navigation, triggerHaptic])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
triggerHaptic('impactLight')
|
||||
setSelectedGenreIds([])
|
||||
useLibraryStore.getState().setTracksFilters({
|
||||
genreIds: undefined,
|
||||
})
|
||||
}, [triggerHaptic])
|
||||
|
||||
const renderItem: ListRenderItem<BaseItemDto | string> = ({ item }) => {
|
||||
if (typeof item === 'string') {
|
||||
// Section header
|
||||
return (
|
||||
<YStack
|
||||
paddingVertical='$2'
|
||||
paddingHorizontal='$4'
|
||||
backgroundColor='$backgroundHover'
|
||||
>
|
||||
<Text bold fontSize='$5'>
|
||||
{item}
|
||||
</Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
const isSelected = selectedGenreIds.includes(item.Id!)
|
||||
const genreName = getItemName(item)
|
||||
|
||||
return (
|
||||
<XStack
|
||||
alignItems='center'
|
||||
padding='$3'
|
||||
gap='$3'
|
||||
pressStyle={{ opacity: 0.6 }}
|
||||
animation='quick'
|
||||
onPress={() => toggleGenre(item.Id!)}
|
||||
>
|
||||
<ItemImage item={item} width='$11' height='$11' />
|
||||
<YStack flex={1}>
|
||||
<Text bold>{genreName}</Text>
|
||||
{item.SongCount !== undefined && (
|
||||
<Text color='$borderColor'>{`${item.SongCount} tracks`}</Text>
|
||||
)}
|
||||
</YStack>
|
||||
<Icon
|
||||
name={isSelected ? 'check-circle-outline' : 'circle-outline'}
|
||||
color={isSelected ? '$primary' : '$borderColor'}
|
||||
/>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
const keyExtractor = (item: BaseItemDto | string, index: number) => {
|
||||
if (typeof item === 'string') {
|
||||
return `header-${item}`
|
||||
}
|
||||
return item.Id ?? `genre-${index}`
|
||||
}
|
||||
|
||||
if (isPending && !genres) {
|
||||
return (
|
||||
<YStack flex={1} alignItems='center' justifyContent='center'>
|
||||
<Spinner size='large' />
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack flex={1} backgroundColor='$background'>
|
||||
<XStack
|
||||
justifyContent='space-between'
|
||||
alignItems='center'
|
||||
padding='$4'
|
||||
borderBottomWidth={1}
|
||||
borderBottomColor='$borderColor'
|
||||
>
|
||||
<Button variant='outlined' size='$3' onPress={() => navigation.goBack()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Text bold fontSize='$6'>
|
||||
Select Genres
|
||||
</Text>
|
||||
<Button
|
||||
variant='outlined'
|
||||
size='$3'
|
||||
onPress={handleClear}
|
||||
disabled={selectedGenreIds.length === 0}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</XStack>
|
||||
|
||||
<FlashList
|
||||
data={flattenedGenres}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
// @ts-expect-error - estimatedItemSize is required by FlashList but types are incorrect
|
||||
estimatedItemSize={70}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}}
|
||||
ListEmptyComponent={
|
||||
<YStack flex={1} justifyContent='center' alignItems='center' padding='$4'>
|
||||
<Text color='$borderColor'>No genres found</Text>
|
||||
</YStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{selectedGenreIds.length > 0 && (
|
||||
<XStack
|
||||
justifyContent='space-evenly'
|
||||
alignItems='center'
|
||||
padding='$4'
|
||||
borderTopWidth={1}
|
||||
borderTopColor='$borderColor'
|
||||
>
|
||||
<Text
|
||||
fontSize='$3'
|
||||
bold
|
||||
color='$primary'
|
||||
>{`${selectedGenreIds.length} selected`}</Text>
|
||||
<Button
|
||||
variant='outlined'
|
||||
borderColor='$primary'
|
||||
color='$primary'
|
||||
size='$3'
|
||||
onPress={handleSave}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</XStack>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import DeletePlaylist from './Library/delete-playlist'
|
||||
import { Platform } from 'react-native'
|
||||
import { formatArtistNames } from '../utils/formatting/artist-names'
|
||||
import FiltersSheet from './Filters'
|
||||
import GenreSelectionScreen from './GenreSelection'
|
||||
|
||||
const RootStack = createNativeStackNavigator<RootStackParamList>()
|
||||
|
||||
@@ -109,6 +110,16 @@ export default function Root(): React.JSX.Element {
|
||||
sheetAllowedDetents: 'fitToContents',
|
||||
}}
|
||||
/>
|
||||
|
||||
<RootStack.Screen
|
||||
name='GenreSelection'
|
||||
component={GenreSelectionScreen}
|
||||
options={{
|
||||
headerTitle: 'Select Genres',
|
||||
presentation: 'modal',
|
||||
sheetGrabberVisible: true,
|
||||
}}
|
||||
/>
|
||||
</RootStack.Navigator>
|
||||
)
|
||||
}
|
||||
|
||||
3
src/screens/types.d.ts
vendored
3
src/screens/types.d.ts
vendored
@@ -69,6 +69,8 @@ export type RootStackParamList = {
|
||||
currentTab?: 'Tracks' | 'Albums' | 'Artists' | 'Playlists'
|
||||
}
|
||||
|
||||
GenreSelection: undefined
|
||||
|
||||
AudioSpecs: {
|
||||
item: BaseItemDto
|
||||
streamingMediaSourceInfo?: MediaSourceInfo
|
||||
@@ -91,6 +93,7 @@ export type AudioSpecsProps = NativeStackScreenProps<RootStackParamList, 'AudioS
|
||||
export type DeletePlaylistProps = NativeStackScreenProps<RootStackParamList, 'DeletePlaylist'>
|
||||
|
||||
export type FiltersProps = NativeStackScreenProps<RootStackParamList, 'Filters'>
|
||||
export type GenreSelectionProps = NativeStackScreenProps<RootStackParamList, 'GenreSelection'>
|
||||
|
||||
export type GenresProps = {
|
||||
genres: InfiniteData<BaseItemDto[], unknown> | undefined
|
||||
|
||||
@@ -6,6 +6,7 @@ type TabFilterState = {
|
||||
isFavorites: boolean | undefined
|
||||
isDownloaded?: boolean // Only for Tracks tab
|
||||
isUnplayed?: boolean // Only for Tracks tab
|
||||
genreIds?: string[] // Only for Tracks tab
|
||||
}
|
||||
|
||||
type LibraryStore = {
|
||||
@@ -35,6 +36,7 @@ const useLibraryStore = create<LibraryStore>()(
|
||||
isFavorites: undefined,
|
||||
isDownloaded: false,
|
||||
isUnplayed: undefined,
|
||||
genreIds: undefined,
|
||||
},
|
||||
albums: {
|
||||
isFavorites: undefined,
|
||||
|
||||
Reference in New Issue
Block a user