Merge branch 'main' into feature/nitro-player

This commit is contained in:
Stephen Arg
2026-02-06 21:47:02 +01:00
committed by GitHub
26 changed files with 669 additions and 49 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)
})
})
}

View File

@@ -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
}

View File

@@ -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,
]

View File

@@ -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)

View 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 }
}

View File

@@ -0,0 +1,5 @@
export const LibraryYearsQueryKey = (libraryId: string | undefined, userId: string | undefined) => [
'LibraryYears',
libraryId,
userId,
]

View 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)
}

View File

@@ -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 = () => {

View File

@@ -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>
)

View File

@@ -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'>

View File

@@ -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'

View File

@@ -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>
)}

View File

@@ -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()

View File

@@ -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' },
]

View File

@@ -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} />

View File

@@ -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,

View File

@@ -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={() => {

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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,

View File

@@ -20,6 +20,7 @@ export type BaseItemDtoSlimified = Pick<
| 'RunTimeTicks'
| 'OfficialRating'
| 'CustomRating'
| 'ProductionYear'
>
/**

View File

@@ -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(' • ')
}

View 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
}