mirror of
https://github.com/Jellify-Music/App.git
synced 2026-03-17 18:51:24 -05:00
Merge branch 'main' into feature/nitro-player
This commit is contained in:
15
README.md
15
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)
|
||||
|
||||
|
||||
@@ -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<Set<string>>(new Set<string>())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BaseItemDto[]> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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<BaseItemDto[]>((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)
|
||||
|
||||
27
src/api/queries/years/index.ts
Normal file
27
src/api/queries/years/index.ts
Normal file
@@ -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 }
|
||||
}
|
||||
5
src/api/queries/years/keys.ts
Normal file
5
src/api/queries/years/keys.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const LibraryYearsQueryKey = (libraryId: string | undefined, userId: string | undefined) => [
|
||||
'LibraryYears',
|
||||
libraryId,
|
||||
userId,
|
||||
]
|
||||
36
src/api/queries/years/utils/index.ts
Normal file
36
src/api/queries/years/utils/index.ts
Normal file
@@ -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<number[]> {
|
||||
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<ItemsFiltersResponse>(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)
|
||||
}
|
||||
@@ -77,7 +77,11 @@ export default function Albums({
|
||||
<FlashListStickyHeader text={album.toUpperCase()} />
|
||||
)
|
||||
) : typeof album === 'number' ? null : typeof album === 'object' ? (
|
||||
<ItemRow item={album} navigation={navigation} />
|
||||
<ItemRow
|
||||
item={album}
|
||||
navigation={navigation}
|
||||
sortingByReleasedDate={sortBy === ItemSortBy.PremiereDate}
|
||||
/>
|
||||
) : null
|
||||
|
||||
const onEndReached = () => {
|
||||
|
||||
@@ -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({
|
||||
</Button>
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
{(isTracksTab || currentTab === 'Albums') && (
|
||||
<XStack alignItems='center' justifyContent='space-between' marginTop='$4'>
|
||||
<Button
|
||||
variant='outlined'
|
||||
size='$4'
|
||||
onPress={handleYearRangeSelect}
|
||||
pressStyle={{ opacity: 0.6 }}
|
||||
animation='quick'
|
||||
flex={1}
|
||||
justifyContent='space-between'
|
||||
disabled={isTracksTab && isDownloaded}
|
||||
>
|
||||
<Text
|
||||
color={
|
||||
isTracksTab && isDownloaded
|
||||
? '$borderColor'
|
||||
: hasYearRange
|
||||
? '$primary'
|
||||
: '$neutral'
|
||||
}
|
||||
>
|
||||
{hasYearRange ? `Year range ${yearRangeLabel}` : 'Year range'}
|
||||
</Text>
|
||||
<Icon
|
||||
name={hasYearRange ? 'filter-variant' : 'filter'}
|
||||
color={hasYearRange ? '$primary' : '$borderColor'}
|
||||
/>
|
||||
</Button>
|
||||
</XStack>
|
||||
)}
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
|
||||
@@ -142,15 +142,26 @@ export default function TrackRowContent({
|
||||
|
||||
<SlidingTextArea leftGapWidth={artworkAreaWidth} hasArtwork={!!showArtwork}>
|
||||
<YStack alignItems='flex-start' justifyContent='center'>
|
||||
<Text
|
||||
key={`${track.Id}-name`}
|
||||
bold
|
||||
color={textColor}
|
||||
lineBreakStrategyIOS='standard'
|
||||
numberOfLines={1}
|
||||
>
|
||||
{trackName}
|
||||
</Text>
|
||||
<XStack alignItems='center'>
|
||||
<Text
|
||||
key={`${track.Id}-name`}
|
||||
bold
|
||||
color={textColor}
|
||||
lineBreakStrategyIOS='standard'
|
||||
numberOfLines={1}
|
||||
>
|
||||
{trackName}
|
||||
</Text>
|
||||
{!shouldShowArtists && isExplicit(track as JellifyTrack) && (
|
||||
<XStack alignSelf='center' paddingLeft='$2'>
|
||||
<Icon
|
||||
name='alpha-e-box-outline'
|
||||
color={'$borderColor'}
|
||||
xxsmall
|
||||
/>
|
||||
</XStack>
|
||||
)}
|
||||
</XStack>
|
||||
|
||||
{shouldShowArtists && (
|
||||
<XStack alignItems='center'>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -38,6 +38,7 @@ interface ItemRowProps {
|
||||
onLongPress?: () => void
|
||||
navigation?: Pick<NativeStackNavigationProp<BaseStackParamList>, '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({
|
||||
>
|
||||
<HideableArtwork item={item} circular={circular} onLayout={handleArtworkLayout} />
|
||||
<SlidingTextArea leftGapWidth={artworkAreaWidth}>
|
||||
<ItemRowDetails item={item} />
|
||||
<ItemRowDetails item={item} sortingByReleasedDate={sortingByReleasedDate} />
|
||||
</SlidingTextArea>
|
||||
|
||||
<XStack justifyContent='flex-end' alignItems='center' flexShrink={1}>
|
||||
@@ -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<RouteProp<BaseStackParamList>>()
|
||||
|
||||
const shouldRenderArtistName =
|
||||
@@ -253,7 +261,10 @@ function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element {
|
||||
|
||||
{shouldRenderArtistName && (
|
||||
<Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
{formatArtistName(item.AlbumArtist)}
|
||||
{formatArtistName(
|
||||
item.AlbumArtist,
|
||||
sortingByReleasedDate ? item.ProductionYear?.toString() : undefined,
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -96,6 +96,7 @@ export default function Tracks({
|
||||
queue={queue}
|
||||
sortingByAlbum={sortBy === ItemSortBy.Album}
|
||||
sortingByReleasedDate={sortBy === ItemSortBy.PremiereDate}
|
||||
sortingByPlayCount={sortBy === ItemSortBy.PlayCount}
|
||||
/>
|
||||
) : (
|
||||
<ItemRow navigation={navigation} item={track} />
|
||||
|
||||
@@ -71,6 +71,8 @@ export async function handleShuffle(): Promise<JellifyTrack[]> {
|
||||
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<JellifyTrack[]> {
|
||||
// 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<JellifyTrack[]> {
|
||||
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<JellifyTrack[]> {
|
||||
SortBy: [ItemSortBy.Random],
|
||||
Filters: apiFilters.length > 0 ? apiFilters : undefined,
|
||||
GenreIds: genreIds && genreIds.length > 0 ? genreIds : undefined,
|
||||
Years: yearsParam,
|
||||
Limit: ApiLimits.LibraryShuffle,
|
||||
Fields: [
|
||||
ItemFields.MediaSources,
|
||||
|
||||
@@ -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(
|
||||
() => (
|
||||
<XStack
|
||||
alignItems='center'
|
||||
padding='$3'
|
||||
gap='$3'
|
||||
pressStyle={{ opacity: 0.6 }}
|
||||
animation='quick'
|
||||
onPress={handleSelectAll}
|
||||
backgroundColor='$backgroundHover'
|
||||
>
|
||||
<YStack flex={1}>
|
||||
<Text bold>Select all</Text>
|
||||
{genres != null && (
|
||||
<Text color='$borderColor'>{`${allLoadedGenreIds.length} genres`}</Text>
|
||||
)}
|
||||
</YStack>
|
||||
<Icon
|
||||
name={allSelected ? 'check-circle-outline' : 'circle-outline'}
|
||||
color={allSelected ? '$primary' : '$borderColor'}
|
||||
/>
|
||||
</XStack>
|
||||
),
|
||||
[handleSelectAll, allSelected, allLoadedGenreIds.length, genres],
|
||||
)
|
||||
|
||||
const renderItem: ListRenderItem<BaseItemDto | string> = ({ 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={() => {
|
||||
|
||||
295
src/screens/YearSelection/index.tsx
Normal file
295
src/screens/YearSelection/index.tsx
Normal file
@@ -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<number | typeof ANY>(storeFilters.yearMin ?? ANY)
|
||||
const [maxYear, setMaxYear] = useState<number | typeof ANY>(storeFilters.yearMax ?? ANY)
|
||||
const [picking, setPicking] = useState<Picking>(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 (
|
||||
<YStack flex={1} alignItems='center' justifyContent='center'>
|
||||
<Spinner size='large' />
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<YStack flex={1} alignItems='center' justifyContent='center' padding='$4'>
|
||||
<Text color='$borderColor'>Could not load years</Text>
|
||||
<Button marginTop='$4' onPress={() => navigation.goBack()}>
|
||||
Go back
|
||||
</Button>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
const pickerOptions = picking === 'min' ? minYearOptions : maxYearOptions
|
||||
const onSelectOption = picking === 'min' ? handleSelectMin : handleSelectMax
|
||||
const currentValue = picking === 'min' ? minYear : maxYear
|
||||
|
||||
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'>
|
||||
Year range
|
||||
</Text>
|
||||
<Button variant='outlined' size='$3' onPress={handleClear} disabled={!hasSelection}>
|
||||
Clear
|
||||
</Button>
|
||||
</XStack>
|
||||
|
||||
<YStack flex={1} padding='$4' gap='$2'>
|
||||
<Text bold fontSize='$5' marginBottom='$2'>
|
||||
Min year
|
||||
</Text>
|
||||
<Pressable onPress={handleOpenMin}>
|
||||
<XStack
|
||||
alignItems='center'
|
||||
justifyContent='space-between'
|
||||
padding='$3'
|
||||
backgroundColor='$backgroundHover'
|
||||
borderRadius='$2'
|
||||
borderWidth={1}
|
||||
borderColor='$borderColor'
|
||||
>
|
||||
<Text>{minLabel}</Text>
|
||||
<Icon name='chevron-down' color='$borderColor' />
|
||||
</XStack>
|
||||
</Pressable>
|
||||
|
||||
<Text bold fontSize='$5' marginBottom='$2' marginTop='$3'>
|
||||
Max year
|
||||
</Text>
|
||||
<Pressable onPress={handleOpenMax}>
|
||||
<XStack
|
||||
alignItems='center'
|
||||
justifyContent='space-between'
|
||||
padding='$3'
|
||||
backgroundColor='$backgroundHover'
|
||||
borderRadius='$2'
|
||||
borderWidth={1}
|
||||
borderColor='$borderColor'
|
||||
>
|
||||
<Text>{maxLabel}</Text>
|
||||
<Icon name='chevron-down' color='$borderColor' />
|
||||
</XStack>
|
||||
</Pressable>
|
||||
</YStack>
|
||||
|
||||
{/* Dropdown picker modal */}
|
||||
<Modal
|
||||
visible={picking !== null}
|
||||
transparent
|
||||
animationType='fade'
|
||||
onRequestClose={() => setPicking(null)}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
}}
|
||||
onPress={() => setPicking(null)}
|
||||
>
|
||||
<Pressable style={{ maxHeight: '100%' }} onPress={(e) => e.stopPropagation()}>
|
||||
<YStack
|
||||
backgroundColor='$background'
|
||||
borderTopLeftRadius='$4'
|
||||
borderTopRightRadius='$4'
|
||||
padding='$4'
|
||||
maxHeight='100%'
|
||||
>
|
||||
<Text bold fontSize='$5' marginBottom='$3'>
|
||||
{picking === 'min' ? 'Select min year' : 'Select max year'}
|
||||
</Text>
|
||||
<ScrollView
|
||||
style={{ maxHeight: 330 }}
|
||||
showsVerticalScrollIndicator
|
||||
keyboardShouldPersistTaps='handled'
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => onSelectOption(ANY)}
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
||||
>
|
||||
<XStack
|
||||
padding='$3'
|
||||
alignItems='center'
|
||||
backgroundColor={
|
||||
currentValue === ANY
|
||||
? '$backgroundHover'
|
||||
: 'transparent'
|
||||
}
|
||||
borderRadius='$2'
|
||||
>
|
||||
<Text
|
||||
fontWeight={currentValue === ANY ? 'bold' : undefined}
|
||||
>
|
||||
Any
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
{pickerOptions.map((y) => (
|
||||
<Pressable
|
||||
key={y}
|
||||
onPress={() => onSelectOption(y)}
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
||||
>
|
||||
<XStack
|
||||
padding='$3'
|
||||
alignItems='center'
|
||||
backgroundColor={
|
||||
currentValue === y
|
||||
? '$backgroundHover'
|
||||
: 'transparent'
|
||||
}
|
||||
borderRadius='$2'
|
||||
>
|
||||
<Text
|
||||
fontWeight={currentValue === y ? 'bold' : undefined}
|
||||
>
|
||||
{String(y)}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</YStack>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
|
||||
{hasSelection && (
|
||||
<XStack
|
||||
justifyContent='space-evenly'
|
||||
alignItems='center'
|
||||
padding='$4'
|
||||
borderTopWidth={1}
|
||||
borderTopColor='$borderColor'
|
||||
>
|
||||
<Text fontSize='$3' bold color='$primary'>
|
||||
{rangeLabel ?? ''}
|
||||
</Text>
|
||||
<Button
|
||||
variant='outlined'
|
||||
borderColor='$primary'
|
||||
color='$primary'
|
||||
size='$3'
|
||||
onPress={handleSave}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</XStack>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -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<RootStackParamList>()
|
||||
|
||||
@@ -132,6 +133,16 @@ export default function Root(): React.JSX.Element {
|
||||
sheetGrabberVisible: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<RootStack.Screen
|
||||
name='YearSelection'
|
||||
component={YearSelectionScreen}
|
||||
options={{
|
||||
headerTitle: 'Year range',
|
||||
presentation: 'modal',
|
||||
sheetGrabberVisible: true,
|
||||
}}
|
||||
/>
|
||||
</RootStack.Navigator>
|
||||
)
|
||||
}
|
||||
|
||||
2
src/screens/types.d.ts
vendored
2
src/screens/types.d.ts
vendored
@@ -74,6 +74,7 @@ export type RootStackParamList = {
|
||||
}
|
||||
|
||||
GenreSelection: undefined
|
||||
YearSelection: { tab?: 'Tracks' | 'Albums' }
|
||||
|
||||
AudioSpecs: {
|
||||
item: BaseItemDto
|
||||
@@ -99,6 +100,7 @@ export type DeletePlaylistProps = NativeStackScreenProps<RootStackParamList, 'De
|
||||
export type FiltersProps = NativeStackScreenProps<RootStackParamList, 'Filters'>
|
||||
export type SortOptionsProps = NativeStackScreenProps<RootStackParamList, 'SortOptions'>
|
||||
export type GenreSelectionProps = NativeStackScreenProps<RootStackParamList, 'GenreSelection'>
|
||||
export type YearSelectionProps = NativeStackScreenProps<RootStackParamList, 'YearSelection'>
|
||||
|
||||
export type GenresProps = {
|
||||
genres: InfiniteData<BaseItemDto[], unknown> | undefined
|
||||
|
||||
@@ -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<LibraryTab, ItemSortBy>
|
||||
@@ -93,9 +95,13 @@ const useLibraryStore = create<LibraryStore>()(
|
||||
isDownloaded: false,
|
||||
isUnplayed: undefined,
|
||||
genreIds: undefined,
|
||||
yearMin: undefined,
|
||||
yearMax: undefined,
|
||||
},
|
||||
albums: {
|
||||
isFavorites: undefined,
|
||||
yearMin: undefined,
|
||||
yearMax: undefined,
|
||||
},
|
||||
artists: {
|
||||
isFavorites: undefined,
|
||||
|
||||
@@ -20,6 +20,7 @@ export type BaseItemDtoSlimified = Pick<
|
||||
| 'RunTimeTicks'
|
||||
| 'OfficialRating'
|
||||
| 'CustomRating'
|
||||
| 'ProductionYear'
|
||||
>
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(' • ')
|
||||
}
|
||||
|
||||
9
src/utils/mapping/build-years-param.ts
Normal file
9
src/utils/mapping/build-years-param.ts
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user