diff --git a/src/api/queries/genre/index.ts b/src/api/queries/genre/index.ts new file mode 100644 index 00000000..2a7dac05 --- /dev/null +++ b/src/api/queries/genre/index.ts @@ -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 => { + 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), + }) +} diff --git a/src/api/queries/genre/keys.ts b/src/api/queries/genre/keys.ts new file mode 100644 index 00000000..80b68711 --- /dev/null +++ b/src/api/queries/genre/keys.ts @@ -0,0 +1,5 @@ +export const GenresQueryKey = (libraryId: string | undefined, userId: string | undefined) => [ + 'Genres', + libraryId, + userId, +] diff --git a/src/api/queries/genre/utils/index.ts b/src/api/queries/genre/utils/index.ts new file mode 100644 index 00000000..ec8a3ae0 --- /dev/null +++ b/src/api/queries/genre/utils/index.ts @@ -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 { + return new Promise((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) + }) + }) +} diff --git a/src/api/queries/track/index.ts b/src/api/queries/track/index.ts index 1bd742b5..2666fe8c 100644 --- a/src/api/queries/track/index.ts +++ b/src/api/queries/track/index.ts @@ -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 ?? []) diff --git a/src/api/queries/track/keys.ts b/src/api/queries/track/keys.ts index cbe014d3..219598e0 100644 --- a/src/api/queries/track/keys.ts +++ b/src/api/queries/track/keys.ts @@ -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, ] diff --git a/src/api/queries/track/utils/index.ts b/src/api/queries/track/utils/index.ts index 38132157..d308b6c7 100644 --- a/src/api/queries/track/utils/index.ts +++ b/src/api/queries/track/utils/index.ts @@ -23,6 +23,7 @@ export default function fetchTracks( sortBy: ItemSortBy = ItemSortBy.SortName, sortOrder: SortOrder = SortOrder.Ascending, artistId?: string, + genreIds?: string[], ) { return new Promise((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) diff --git a/src/components/Filters/index.tsx b/src/components/Filters/index.tsx index 55d3e9db..4aa32347 100644 --- a/src/components/Filters/index.tsx +++ b/src/components/Filters/index.tsx @@ -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 +}): 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 /> - {showDownloadedFilter && ( + {isTracksTab && ( )} - {showUnplayedFilter && ( + {isTracksTab && ( )} + + {isTracksTab && ( + + + + )} ) diff --git a/src/components/Global/components/alphabetical-selector.tsx b/src/components/Global/components/alphabetical-selector.tsx index 7c2d7ef5..f1e5aa5b 100644 --- a/src/components/Global/components/alphabetical-selector.tsx +++ b/src/components/Global/components/alphabetical-selector.tsx @@ -22,9 +22,12 @@ const alphabet = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') */ export default function AZScroller({ onLetterSelect, + alphabet: customAlphabet, }: { onLetterSelect: (letter: string) => Promise + alphabet?: string[] }) { + const alphabetToUse = customAlphabet ?? alphabet const theme = useTheme() const [operationPending, setOperationPending] = useState(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({ { + 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 = ( {letter} @@ -177,7 +190,7 @@ export default function AZScroller({ ) return index === 0 ? ( - + {letterElement} ) : ( diff --git a/src/components/Global/helpers/checkbox-with-label.tsx b/src/components/Global/helpers/checkbox-with-label.tsx index df520736..92ce9713 100644 --- a/src/components/Global/helpers/checkbox-with-label.tsx +++ b/src/components/Global/helpers/checkbox-with-label.tsx @@ -18,7 +18,11 @@ export function CheckboxWithLabel({ -