diff --git a/README.md b/README.md index dcf5b23c..4afa93fb 100644 --- a/README.md +++ b/README.md @@ -188,16 +188,15 @@ Install via [Altstore](https://altstore.io) or your favorite sideloading utility ### Roadmap #### 1.1.0 (Socket To Me Baby) - March '26 -- Android Auto/CarPlay Support -- Websocket Support (Server online status) -- Home Screen Updates -- Discover Screen Updates -- Artist Screen Redesign -- Library Redesign +- Gapless Playback +- WebSocket Support (Server online status) +- Library Enhancements - Quick Connect Support - Allow Self-Signed Certificates #### 1.2.0 (We Made a Language For Us Two...) - June '26 +- Android Auto/CarPlay Support +- EQ Controls - Collaborative Playlists - App Customization Options - Desktop Support (Experimental) @@ -207,12 +206,10 @@ Install via [Altstore](https://altstore.io) or your favorite sideloading utility - Tablet Support #### 2.0.0 - December '26 -- Gapless Playback - Seerr (formerly Jellyseerr) Integration - JellyJam -- EQ Controls -#### 3.0.0 - TBD +#### 3.0.0 - December '27 - Watch Support - tvOS (Apple and Android) diff --git a/src/api/queries/album/index.ts b/src/api/queries/album/index.ts index 0c74a34d..5af3fe55 100644 --- a/src/api/queries/album/index.ts +++ b/src/api/queries/album/index.ts @@ -48,6 +48,8 @@ const useAlbums: () => [ : ItemSortBy.Album const sortDescending = librarySortDescendingState.albums ?? false const isFavorites = filters.albums.isFavorites + const yearMin = filters.albums.yearMin + const yearMax = filters.albums.yearMax const albumPageParams = useRef>(new Set()) @@ -72,6 +74,8 @@ const useAlbums: () => [ library?.musicLibraryId, librarySortBy, sortDescending, + yearMin, + yearMax, ], queryFn: ({ pageParam }) => fetchAlbums( @@ -82,6 +86,8 @@ const useAlbums: () => [ isFavorites, [librarySortBy ?? ItemSortBy.SortName], [sortDescending ? SortOrder.Descending : SortOrder.Ascending], + yearMin, + yearMax, ), initialPageParam: 0, select: selectAlbums, diff --git a/src/api/queries/album/utils/album.ts b/src/api/queries/album/utils/album.ts index 44b05b72..668dfa7a 100644 --- a/src/api/queries/album/utils/album.ts +++ b/src/api/queries/album/utils/album.ts @@ -9,9 +9,9 @@ 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 '../../../../configs/query.config' import { nitroFetch } from '../../../utils/nitro' +import buildYearsParam from '../../../../utils/mapping/build-years-param' export function fetchAlbums( api: Api | undefined, @@ -21,12 +21,16 @@ export function fetchAlbums( isFavorite: boolean | undefined, sortBy: ItemSortBy[] = [ItemSortBy.SortName], sortOrder: SortOrder[] = [SortOrder.Ascending], + yearMin?: number, + yearMax?: number, ): Promise { 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') + const yearsParam = buildYearsParam(yearMin, yearMax) + nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', { ParentId: library.musicLibraryId, IncludeItemTypes: [BaseItemKind.MusicAlbum], @@ -38,9 +42,15 @@ export function fetchAlbums( IsFavorite: isFavorite, Fields: [ItemFields.SortName], Recursive: true, - }).then((data) => { - return data.Items ? resolve(data.Items) : resolve([]) + Years: yearsParam, }) + .then((data) => { + return data.Items ? resolve(data.Items) : 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 c9c0f586..617897f1 100644 --- a/src/api/queries/track/index.ts +++ b/src/api/queries/track/index.ts @@ -44,6 +44,8 @@ const useTracks: ( const isDownloaded = filters.tracks.isDownloaded ?? false const isLibraryUnplayed = filters.tracks.isUnplayed ?? false const libraryGenreIds = filters.tracks.genreIds + const libraryYearMin = filters.tracks.yearMin + const libraryYearMax = filters.tracks.yearMax // 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) @@ -95,6 +97,8 @@ const useTracks: ( finalSortBy, finalSortOrder, isDownloaded ? undefined : libraryGenreIds, + libraryYearMin, + libraryYearMax, ), queryFn: ({ pageParam }) => { if (!isDownloaded) { @@ -109,21 +113,33 @@ const useTracks: ( finalSortOrder, artistId, libraryGenreIds, + libraryYearMin, + libraryYearMax, ) - } else - return (downloadedTracks ?? []) - .map(({ item }) => item) - .sort((a, b) => { - const aName = a.Name ?? '' - const bName = b.Name ?? '' - if (aName < bName) return -1 - else if (aName === bName) return 0 - else return 1 - }) - .filter((track) => { - if (!isFavorites) return true - else return isDownloadedTrackAlsoFavorite(user, track) + } else { + let items = (downloadedTracks ?? []).map(({ item }) => item) + if (libraryYearMin != null || libraryYearMax != null) { + const min = libraryYearMin ?? 0 + const max = libraryYearMax ?? new Date().getFullYear() + items = items.filter((track) => { + const y = + 'ProductionYear' in track + ? (track as BaseItemDto).ProductionYear + : undefined + if (y == null) return false + return y >= min && y <= max }) + } + const sortByForCompare = + finalSortBy === ItemSortBy.SortName ? ItemSortBy.Name : finalSortBy + items = items.sort((a, b) => + compareDownloadedTracks(a, b, sortByForCompare, finalSortOrder), + ) + return items.filter((track) => { + if (!isFavorites) return true + else return isDownloadedTrackAlsoFavorite(user, track) + }) + } }, initialPageParam: 0, getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { @@ -147,3 +163,45 @@ function isDownloadedTrackAlsoFavorite(user: JellifyUser | undefined, track: Bas return userData?.IsFavorite ?? false } + +function getSortValue(item: BaseItemDto, sortBy: ItemSortBy): string | number { + switch (sortBy) { + case ItemSortBy.Name: + case ItemSortBy.SortName: + return item.Name ?? item.SortName ?? '' + case ItemSortBy.Album: + return item.Album ?? '' + case ItemSortBy.Artist: + return item.AlbumArtist ?? item.Artists?.[0] ?? '' + case ItemSortBy.DateCreated: + return item.DateCreated ? new Date(item.DateCreated).getTime() : 0 + case ItemSortBy.PlayCount: + return item.UserData?.PlayCount ?? 0 + case ItemSortBy.PremiereDate: + return item.PremiereDate ? new Date(item.PremiereDate).getTime() : 0 + case ItemSortBy.Runtime: + return item.RunTimeTicks ?? 0 + default: + return item.Name ?? item.SortName ?? '' + } +} + +function compareDownloadedTracks( + a: BaseItemDto, + b: BaseItemDto, + sortBy: ItemSortBy, + sortOrder: SortOrder, +): number { + const aVal = getSortValue(a, sortBy) + const bVal = getSortValue(b, sortBy) + const isDesc = sortOrder === SortOrder.Descending + let cmp: number + if (typeof aVal === 'number' && typeof bVal === 'number') { + cmp = aVal - bVal + } else { + const aStr = String(aVal) + const bStr = String(bVal) + cmp = aStr.localeCompare(bStr, undefined, { sensitivity: 'base' }) + } + return isDesc ? -cmp : cmp +} diff --git a/src/api/queries/track/keys.ts b/src/api/queries/track/keys.ts index 219598e0..616fe657 100644 --- a/src/api/queries/track/keys.ts +++ b/src/api/queries/track/keys.ts @@ -16,6 +16,8 @@ export const TracksQueryKey = ( sortBy?: string, sortOrder?: string, genreIds?: string[], + yearMin?: number, + yearMax?: number, ) => [ TrackQueryKeys.AllTracks, library?.musicLibraryId, @@ -27,4 +29,6 @@ export const TracksQueryKey = ( sortBy, sortOrder, genreIds && genreIds.length > 0 ? `genres:${genreIds.sort().join(',')}` : undefined, + yearMin, + yearMax, ] diff --git a/src/api/queries/track/utils/index.ts b/src/api/queries/track/utils/index.ts index d308b6c7..61a878ce 100644 --- a/src/api/queries/track/utils/index.ts +++ b/src/api/queries/track/utils/index.ts @@ -12,6 +12,7 @@ import { nitroFetch } from '../../../utils/nitro' import { isUndefined } from 'lodash' import { ApiLimits } from '../../../../configs/query.config' import { JellifyUser } from '../../../../types/JellifyUser' +import buildYearsParam from '../../../../utils/mapping/build-years-param' export default function fetchTracks( api: Api | undefined, @@ -24,6 +25,8 @@ export default function fetchTracks( sortOrder: SortOrder = SortOrder.Ascending, artistId?: string, genreIds?: string[], + yearMin?: number, + yearMax?: number, ) { return new Promise((resolve, reject) => { if (isUndefined(api)) return reject('Client instance not set') @@ -43,6 +46,8 @@ export default function fetchTracks( filters.push(ItemFilter.IsUnplayed) } + const yearsParam = buildYearsParam(yearMin, yearMax) + nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', { IncludeItemTypes: [BaseItemKind.Audio], ParentId: library.musicLibraryId, @@ -56,6 +61,7 @@ export default function fetchTracks( Fields: [ItemFields.SortName], ArtistIds: artistId ? [artistId] : undefined, GenreIds: genreIds && genreIds.length > 0 ? genreIds : undefined, + Years: yearsParam, }) .then((data) => { if (data.Items) return resolve(data.Items) diff --git a/src/api/queries/years/index.ts b/src/api/queries/years/index.ts new file mode 100644 index 00000000..c9951ff9 --- /dev/null +++ b/src/api/queries/years/index.ts @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchLibraryYears } from './utils' +import { LibraryYearsQueryKey } from './keys' +import { getApi, getUser, useJellifyLibrary } from '../../../stores' + +export function useLibraryYears(): { + years: number[] + isPending: boolean + isError: boolean +} { + const api = getApi() + const user = getUser() + const [library] = useJellifyLibrary() + + const { + data: years = [], + isPending, + isError, + } = useQuery({ + queryKey: LibraryYearsQueryKey(library?.musicLibraryId, user?.id), + queryFn: () => fetchLibraryYears(api, library, user?.id), + enabled: Boolean(api && library && user?.id), + staleTime: 5 * 60 * 1000, + }) + + return { years, isPending, isError } +} diff --git a/src/api/queries/years/keys.ts b/src/api/queries/years/keys.ts new file mode 100644 index 00000000..869f4c8d --- /dev/null +++ b/src/api/queries/years/keys.ts @@ -0,0 +1,5 @@ +export const LibraryYearsQueryKey = (libraryId: string | undefined, userId: string | undefined) => [ + 'LibraryYears', + libraryId, + userId, +] diff --git a/src/api/queries/years/utils/index.ts b/src/api/queries/years/utils/index.ts new file mode 100644 index 00000000..b4505a3e --- /dev/null +++ b/src/api/queries/years/utils/index.ts @@ -0,0 +1,36 @@ +import { Api } from '@jellyfin/sdk' +import { JellifyLibrary } from '../../../../types/JellifyLibrary' +import { nitroFetch } from '../../../utils/nitro' +import { isUndefined } from 'lodash' +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models' + +export type ItemsFiltersResponse = { + Genres?: string[] | null + Years?: number[] | null + Tags?: string[] | null + OfficialRatings?: string[] | null +} + +/** + * Fetches available filter values (genres, years) for the music library via /Items/Filters. + * Uses MusicAlbum so years reflect album release dates in the library. + * Returns sorted ascending list of year numbers. + */ +export async function fetchLibraryYears( + api: Api | undefined, + library: JellifyLibrary | undefined, + userId: string | undefined, +): Promise { + if (isUndefined(api)) throw new Error('Client instance not set') + if (isUndefined(library)) throw new Error('Library instance not set') + if (isUndefined(userId)) throw new Error('User id required') + + const data = await nitroFetch(api, '/Items/Filters', { + UserId: userId, + ParentId: library.musicLibraryId, + IncludeItemTypes: [BaseItemKind.MusicAlbum], + }) + + const years = data?.Years ?? [] + return [...years].filter((y) => typeof y === 'number' && !Number.isNaN(y)).sort((a, b) => a - b) +} diff --git a/src/components/Albums/component.tsx b/src/components/Albums/component.tsx index d32aab73..a5b39201 100644 --- a/src/components/Albums/component.tsx +++ b/src/components/Albums/component.tsx @@ -77,7 +77,11 @@ export default function Albums({ ) ) : typeof album === 'number' ? null : typeof album === 'object' ? ( - + ) : null const onEndReached = () => { diff --git a/src/components/Filters/index.tsx b/src/components/Filters/index.tsx index 4aa32347..566f1851 100644 --- a/src/components/Filters/index.tsx +++ b/src/components/Filters/index.tsx @@ -8,7 +8,6 @@ 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, @@ -27,6 +26,11 @@ export default function Filters({ const isUnplayed = currentFilters.isUnplayed ?? false const selectedGenreIds = currentFilters.genreIds ?? [] const hasGenresSelected = selectedGenreIds.length > 0 + const yearMin = currentFilters.yearMin + const yearMax = currentFilters.yearMax + const hasYearRange = yearMin != null || yearMax != null + const yearRangeLabel = + yearMin != null || yearMax != null ? `${yearMin ?? '…'} – ${yearMax ?? '…'}` : null const handleFavoritesToggle = (checked: boolean | 'indeterminate') => { triggerHaptic('impactLight') @@ -60,6 +64,13 @@ export default function Filters({ navigation?.navigate('GenreSelection') } + const handleYearRangeSelect = () => { + triggerHaptic('impactLight') + navigation?.navigate('YearSelection', { + tab: currentTab === 'Tracks' || currentTab === 'Albums' ? currentTab : 'Tracks', + }) + } + const handleUnplayedToggle = (checked: boolean | 'indeterminate') => { triggerHaptic('impactLight') if (currentTab === 'Tracks') { @@ -144,6 +155,37 @@ export default function Filters({ )} + + {(isTracksTab || currentTab === 'Albums') && ( + + + + )} ) diff --git a/src/components/Global/components/Track/content.tsx b/src/components/Global/components/Track/content.tsx index be89e6e8..fc350f78 100644 --- a/src/components/Global/components/Track/content.tsx +++ b/src/components/Global/components/Track/content.tsx @@ -142,15 +142,26 @@ export default function TrackRowContent({ - - {trackName} - + + + {trackName} + + {!shouldShowArtists && isExplicit(track as JellifyTrack) && ( + + + + )} + {shouldShowArtists && ( diff --git a/src/components/Global/components/Track/index.tsx b/src/components/Global/components/Track/index.tsx index 2b5a181d..2f08179e 100644 --- a/src/components/Global/components/Track/index.tsx +++ b/src/components/Global/components/Track/index.tsx @@ -37,6 +37,7 @@ export interface TrackProps { editing?: boolean | undefined sortingByAlbum?: boolean | undefined sortingByReleasedDate?: boolean | undefined + sortingByPlayCount?: boolean | undefined } export default function Track({ @@ -54,6 +55,7 @@ export default function Track({ editing, sortingByAlbum, sortingByReleasedDate, + sortingByPlayCount, }: TrackProps): React.JSX.Element { const theme = useTheme() const [artworkAreaWidth, setArtworkAreaWidth] = useState(0) @@ -141,7 +143,9 @@ export default function Track({ ? track.Album : sortingByReleasedDate ? `${track.ProductionYear?.toString()} • ${track.Artists?.join(' • ')}` - : track.Artists?.join(' • ')) ?? '' + : sortingByPlayCount + ? `${track.UserData?.PlayCount?.toString()} • ${track.Artists?.join(' • ')}` + : track.Artists?.join(' • ')) ?? '' // Memoize track name const trackName = track.Name ?? 'Untitled Track' diff --git a/src/components/Global/components/item-row.tsx b/src/components/Global/components/item-row.tsx index 9cdc1b46..9b6a1874 100644 --- a/src/components/Global/components/item-row.tsx +++ b/src/components/Global/components/item-row.tsx @@ -38,6 +38,7 @@ interface ItemRowProps { onLongPress?: () => void navigation?: Pick, 'navigate' | 'dispatch'> queueName?: Queue + sortingByReleasedDate?: boolean | undefined } /** @@ -58,6 +59,7 @@ function ItemRow({ onPress, onLongPress, queueName, + sortingByReleasedDate, }: ItemRowProps): React.JSX.Element { const artworkAreaWidth = useSharedValue(0) @@ -170,7 +172,7 @@ function ItemRow({ > - + @@ -235,7 +237,13 @@ function ItemRow({ ) } -function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element { +function ItemRowDetails({ + item, + sortingByReleasedDate, +}: { + item: BaseItemDto + sortingByReleasedDate?: boolean | undefined +}): React.JSX.Element { const route = useRoute>() const shouldRenderArtistName = @@ -253,7 +261,10 @@ function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element { {shouldRenderArtistName && ( - {formatArtistName(item.AlbumArtist)} + {formatArtistName( + item.AlbumArtist, + sortingByReleasedDate ? item.ProductionYear?.toString() : undefined, + )} )} diff --git a/src/components/Library/tab-bar.tsx b/src/components/Library/tab-bar.tsx index 399f377b..a54d3659 100644 --- a/src/components/Library/tab-bar.tsx +++ b/src/components/Library/tab-bar.tsx @@ -32,7 +32,9 @@ function LibraryTabBar(props: MaterialTopTabBarProps) { (currentFilters.isFavorites === true || currentFilters.isDownloaded === true || currentFilters.isUnplayed === true || - (currentFilters.genreIds && currentFilters.genreIds.length > 0)) + (currentFilters.genreIds && currentFilters.genreIds.length > 0) || + currentFilters.yearMin != null || + currentFilters.yearMax != null) const handleShufflePress = async () => { triggerHaptic('impactLight') @@ -163,11 +165,15 @@ function LibraryTabBar(props: MaterialTopTabBarProps) { isDownloaded: false, isUnplayed: false, genreIds: undefined, + yearMin: undefined, + yearMax: undefined, }) } else if (currentTab === 'Albums') { - useLibraryStore - .getState() - .setAlbumsFilters({ isFavorites: undefined }) + useLibraryStore.getState().setAlbumsFilters({ + isFavorites: undefined, + yearMin: undefined, + yearMax: undefined, + }) } else if (currentTab === 'Artists') { useLibraryStore .getState() diff --git a/src/components/SortOptions/index.tsx b/src/components/SortOptions/index.tsx index 9361bb1a..77dd30c5 100644 --- a/src/components/SortOptions/index.tsx +++ b/src/components/SortOptions/index.tsx @@ -20,7 +20,6 @@ const TRACK_SORT_OPTIONS: { value: ItemSortBy; label: string }[] = [ const ALBUM_SORT_OPTIONS: { value: ItemSortBy; label: string }[] = [ { value: ItemSortBy.SortName, label: 'Album' }, { value: ItemSortBy.Artist, label: 'Artist' }, - { value: ItemSortBy.PlayCount, label: 'Play Count' }, { value: ItemSortBy.DateCreated, label: 'Date Added' }, { value: ItemSortBy.PremiereDate, label: 'Release Date' }, ] diff --git a/src/components/Tracks/component.tsx b/src/components/Tracks/component.tsx index 97444596..722b437d 100644 --- a/src/components/Tracks/component.tsx +++ b/src/components/Tracks/component.tsx @@ -96,6 +96,7 @@ export default function Tracks({ queue={queue} sortingByAlbum={sortBy === ItemSortBy.Album} sortingByReleasedDate={sortBy === ItemSortBy.PremiereDate} + sortingByPlayCount={sortBy === ItemSortBy.PlayCount} /> ) : ( diff --git a/src/hooks/player/functions/shuffle.ts b/src/hooks/player/functions/shuffle.ts index 272829ff..4c401804 100644 --- a/src/hooks/player/functions/shuffle.ts +++ b/src/hooks/player/functions/shuffle.ts @@ -71,6 +71,8 @@ export async function handleShuffle(): Promise { const isDownloaded = filters.isDownloaded === true const isUnplayed = filters.isUnplayed === true const genreIds = filters.genreIds + const yearMin = filters.yearMin + const yearMax = filters.yearMax let randomTracks: JellifyTrack[] = [] @@ -91,6 +93,16 @@ export async function handleShuffle(): Promise { // Filter downloaded tracks let filteredDownloads = downloadedTracks + // Filter by year range + if (yearMin != null || yearMax != null) { + const min = yearMin ?? 0 + const max = yearMax ?? new Date().getFullYear() + filteredDownloads = filteredDownloads.filter((download) => { + const y = download.item.ProductionYear + return y != null && y >= min && y <= max + }) + } + // Filter by favorites if (isFavorites) { filteredDownloads = filteredDownloads.filter((download) => { @@ -122,6 +134,19 @@ export async function handleShuffle(): Promise { apiFilters.push(ItemFilter.IsUnplayed) } + // Build years param for year range filter + const yearsParam = + yearMin != null || yearMax != null + ? (() => { + const min = yearMin ?? 0 + const max = yearMax ?? new Date().getFullYear() + if (min > max) return undefined + const years: string[] = [] + for (let y = min; y <= max; y++) years.push(String(y)) + return years.length > 0 ? years : undefined + })() + : undefined + // Fetch random tracks from Jellyfin with filters const data = await nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', { ParentId: library.musicLibraryId, @@ -131,6 +156,7 @@ export async function handleShuffle(): Promise { SortBy: [ItemSortBy.Random], Filters: apiFilters.length > 0 ? apiFilters : undefined, GenreIds: genreIds && genreIds.length > 0 ? genreIds : undefined, + Years: yearsParam, Limit: ApiLimits.LibraryShuffle, Fields: [ ItemFields.MediaSources, diff --git a/src/screens/GenreSelection/index.tsx b/src/screens/GenreSelection/index.tsx index e5dcfa27..aaa9aaf5 100644 --- a/src/screens/GenreSelection/index.tsx +++ b/src/screens/GenreSelection/index.tsx @@ -94,6 +94,44 @@ export default function GenreSelectionScreen({ }) }, [triggerHaptic]) + const allLoadedGenreIds = useMemo( + () => genres?.map((g) => g.Id!).filter(Boolean) ?? [], + [genres], + ) + const allSelected = + allLoadedGenreIds.length > 0 && selectedGenreIds.length === allLoadedGenreIds.length + + const handleSelectAll = useCallback(() => { + triggerHaptic('impactLight') + setSelectedGenreIds([...allLoadedGenreIds]) + }, [allLoadedGenreIds, triggerHaptic]) + + const renderListHeader = useCallback( + () => ( + + + Select all + {genres != null && ( + {`${allLoadedGenreIds.length} genres`} + )} + + + + ), + [handleSelectAll, allSelected, allLoadedGenreIds.length, genres], + ) + const renderItem: ListRenderItem = ({ item }) => { if (typeof item === 'string') { // Section header @@ -181,6 +219,7 @@ export default function GenreSelectionScreen({ data={flattenedGenres} renderItem={renderItem} keyExtractor={keyExtractor} + ListHeaderComponent={renderListHeader} // @ts-expect-error - estimatedItemSize is required by FlashList but types are incorrect estimatedItemSize={70} onEndReached={() => { diff --git a/src/screens/YearSelection/index.tsx b/src/screens/YearSelection/index.tsx new file mode 100644 index 00000000..a8d5c13d --- /dev/null +++ b/src/screens/YearSelection/index.tsx @@ -0,0 +1,295 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { YStack, XStack, Button, Spinner } from 'tamagui' +import { Modal, ScrollView, Pressable } from 'react-native' +import { Text } from '../../components/Global/helpers/text' +import Icon from '../../components/Global/components/icon' +import { triggerHaptic } from '../../hooks/use-haptic-feedback' +import { YearSelectionProps } from '../types' +import useLibraryStore from '../../stores/library' +import { useLibraryYears } from '../../api/queries/years' + +const ANY = 'any' +type Picking = 'min' | 'max' | null + +export default function YearSelectionScreen({ + navigation, + route, +}: YearSelectionProps): React.JSX.Element { + const tab = route.params?.tab ?? 'Tracks' + const { years: availableYears, isPending, isError } = useLibraryYears() + const storeFilters = useLibraryStore.getState().filters[tab === 'Albums' ? 'albums' : 'tracks'] + const [minYear, setMinYear] = useState(storeFilters.yearMin ?? ANY) + const [maxYear, setMaxYear] = useState(storeFilters.yearMax ?? ANY) + const [picking, setPicking] = useState(null) + + // Min year options: if maxYear is set, only years <= maxYear + const minYearOptions = useMemo(() => { + if (availableYears.length === 0) return [] + const max = typeof maxYear === 'number' ? maxYear : Math.max(...availableYears) + return availableYears.filter((y) => y <= max) + }, [availableYears, maxYear]) + + // Max year options: if minYear is set, only years >= minYear + const maxYearOptions = useMemo(() => { + if (availableYears.length === 0) return [] + const min = typeof minYear === 'number' ? minYear : Math.min(...availableYears) + return availableYears.filter((y) => y >= min) + }, [availableYears, minYear]) + + const handleOpenMin = useCallback(() => { + triggerHaptic('impactLight') + setPicking('min') + }, []) + + const handleOpenMax = useCallback(() => { + triggerHaptic('impactLight') + setPicking('max') + }, []) + + const handleSelectMin = useCallback( + (year: number | typeof ANY) => { + triggerHaptic('impactLight') + setMinYear(year) + setPicking(null) + if (year !== ANY && typeof maxYear === 'number' && year > maxYear) { + setMaxYear(year) + } + }, + [maxYear], + ) + + const handleSelectMax = useCallback( + (year: number | typeof ANY) => { + triggerHaptic('impactLight') + setMaxYear(year) + setPicking(null) + if (year !== ANY && typeof minYear === 'number' && year < minYear) { + setMinYear(year) + } + }, + [minYear], + ) + + const handleSave = useCallback(() => { + triggerHaptic('impactLight') + const payload = { + yearMin: minYear === ANY ? undefined : minYear, + yearMax: maxYear === ANY ? undefined : maxYear, + } + if (tab === 'Albums') { + useLibraryStore.getState().setAlbumsFilters(payload) + } else { + useLibraryStore.getState().setTracksFilters(payload) + } + navigation.goBack() + }, [minYear, maxYear, navigation, tab]) + + const handleClear = useCallback(() => { + triggerHaptic('impactLight') + setMinYear(ANY) + setMaxYear(ANY) + const payload = { yearMin: undefined, yearMax: undefined } + if (tab === 'Albums') { + useLibraryStore.getState().setAlbumsFilters(payload) + } else { + useLibraryStore.getState().setTracksFilters(payload) + } + }, [tab]) + + const hasSelection = minYear !== ANY || maxYear !== ANY + const rangeLabel = + minYear !== ANY || maxYear !== ANY + ? `${minYear === ANY ? '…' : minYear} – ${maxYear === ANY ? '…' : maxYear}` + : null + + const minLabel = minYear === ANY ? 'Any' : String(minYear) + const maxLabel = maxYear === ANY ? 'Any' : String(maxYear) + + if (isPending && availableYears.length === 0) { + return ( + + + + ) + } + + if (isError) { + return ( + + Could not load years + + + ) + } + + const pickerOptions = picking === 'min' ? minYearOptions : maxYearOptions + const onSelectOption = picking === 'min' ? handleSelectMin : handleSelectMax + const currentValue = picking === 'min' ? minYear : maxYear + + return ( + + + + + Year range + + + + + + + Min year + + + + {minLabel} + + + + + + Max year + + + + {maxLabel} + + + + + + {/* Dropdown picker modal */} + setPicking(null)} + > + setPicking(null)} + > + e.stopPropagation()}> + + + {picking === 'min' ? 'Select min year' : 'Select max year'} + + + onSelectOption(ANY)} + style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} + > + + + Any + + + + {pickerOptions.map((y) => ( + onSelectOption(y)} + style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} + > + + + {String(y)} + + + + ))} + + + + + + + {hasSelection && ( + + + {rangeLabel ?? ''} + + + + )} + + ) +} diff --git a/src/screens/index.tsx b/src/screens/index.tsx index 38947c43..261899ca 100644 --- a/src/screens/index.tsx +++ b/src/screens/index.tsx @@ -19,6 +19,7 @@ import { formatArtistNames } from '../utils/formatting/artist-names' import FiltersSheet from './Filters' import SortOptionsSheet from './SortOptions' import GenreSelectionScreen from './GenreSelection' +import YearSelectionScreen from './YearSelection' const RootStack = createNativeStackNavigator() @@ -132,6 +133,16 @@ export default function Root(): React.JSX.Element { sheetGrabberVisible: true, }} /> + + ) } diff --git a/src/screens/types.d.ts b/src/screens/types.d.ts index d5d38511..c7efc519 100644 --- a/src/screens/types.d.ts +++ b/src/screens/types.d.ts @@ -74,6 +74,7 @@ export type RootStackParamList = { } GenreSelection: undefined + YearSelection: { tab?: 'Tracks' | 'Albums' } AudioSpecs: { item: BaseItemDto @@ -99,6 +100,7 @@ export type DeletePlaylistProps = NativeStackScreenProps export type SortOptionsProps = NativeStackScreenProps export type GenreSelectionProps = NativeStackScreenProps +export type YearSelectionProps = NativeStackScreenProps export type GenresProps = { genres: InfiniteData | undefined diff --git a/src/stores/library.ts b/src/stores/library.ts index 08465e33..57c16914 100644 --- a/src/stores/library.ts +++ b/src/stores/library.ts @@ -10,6 +10,8 @@ type TabFilterState = { isDownloaded?: boolean // Only for Tracks tab isUnplayed?: boolean // Only for Tracks tab genreIds?: string[] // Only for Tracks tab + yearMin?: number // Tracks and Albums + yearMax?: number // Tracks and Albums } type SortState = Record @@ -93,9 +95,13 @@ const useLibraryStore = create()( isDownloaded: false, isUnplayed: undefined, genreIds: undefined, + yearMin: undefined, + yearMax: undefined, }, albums: { isFavorites: undefined, + yearMin: undefined, + yearMax: undefined, }, artists: { isFavorites: undefined, diff --git a/src/types/JellifyTrack.ts b/src/types/JellifyTrack.ts index bb16052b..d0dc427c 100644 --- a/src/types/JellifyTrack.ts +++ b/src/types/JellifyTrack.ts @@ -20,6 +20,7 @@ export type BaseItemDtoSlimified = Pick< | 'RunTimeTicks' | 'OfficialRating' | 'CustomRating' + | 'ProductionYear' > /** diff --git a/src/utils/formatting/artist-names.ts b/src/utils/formatting/artist-names.ts index eee75ab3..dc496b9d 100644 --- a/src/utils/formatting/artist-names.ts +++ b/src/utils/formatting/artist-names.ts @@ -1,9 +1,13 @@ -export function formatArtistName(artistName: string | null | undefined): string { - if (!artistName) return 'Unknown Artist' - return artistName +export function formatArtistName( + artistName: string | null | undefined, + releaseDate?: string | null | undefined, +): string { + const unknownArtist = 'Unknown Artist' + if (!artistName) return releaseDate ? `${releaseDate} • ${unknownArtist}` : unknownArtist + return releaseDate ? `${releaseDate} • ${artistName}` : artistName } export function formatArtistNames(artistNames: string[] | null | undefined): string { if (!artistNames || artistNames.length === 0) return 'Unknown Artist' - return artistNames.map(formatArtistName).join(' • ') + return artistNames.map((artistName) => formatArtistName(artistName)).join(' • ') } diff --git a/src/utils/mapping/build-years-param.ts b/src/utils/mapping/build-years-param.ts new file mode 100644 index 00000000..13fc5a1b --- /dev/null +++ b/src/utils/mapping/build-years-param.ts @@ -0,0 +1,9 @@ +export default function buildYearsParam(yearMin?: number, yearMax?: number): string[] | undefined { + if (yearMin == null && yearMax == null) return undefined + const min = yearMin ?? 0 + const max = yearMax ?? new Date().getFullYear() + if (min > max) return undefined + const years: string[] = [] + for (let y = min; y <= max; y++) years.push(String(y)) + return years.length > 0 ? years : undefined +}