A-Z Selector for Artists, Prep for the same for Albums, Style Fixes, Verbiage Adjustments (#373)

Lots of overall improvements to styling, color usage, and wording

Adds more iconography, adjust settings tab to hide "Labs", enabled via 5 taps on "Made with <3" message

Adds flashlist, for faster lists. Lots of work on the library to add an alphabetical selector for skipping to a particular spot in the list quickly. Will be iterated on as we build out more ways to sort and view your library!

adds additional dns fixes for connecting to a local domain
This commit is contained in:
Violet Caulfield
2025-05-18 10:35:46 -05:00
committed by GitHub
parent cd755b7203
commit cdb75fad51
39 changed files with 776 additions and 263 deletions

View File

@@ -17,12 +17,13 @@
## 📄 Contents
- [Info](#-info)
- [Download](#%EF%B8%8F-download)
- [Downloading](#-downloading)
- [Screenshots](#-screenshots)
- [Features](#-features)
- [Built with](#-built-with-good-stuff)
- [Support](#-support-the-project)
- [Running Locally](#running-locally)
- [Contributing](#-contributing)
- [Special Thanks](#-special-thanks-to)
@@ -41,16 +42,28 @@ Showcasing the artwork of your library, it has a user interface congruent to wha
This app was designed with me and my dad in mind. I wanted us to have a sleek, one stop shop for live recordings of bands we like (read: the Grateful Dead). The UI was designed so that we'd find it instantly familiar and useful. CarPlay / Android Auto support was also a must for us, as we both use CarPlay religiously.
## ⬇️ Download
## ⬇️ Downloading
### Android
Head to [releases](https://github.com/Jellify-Music/App/releases) to download the required APK directly.
Head to [releases](https://github.com/Jellify-Music/App/releases) to download the required .APK directly.
Also there is [obtanium](https://github.com/ImranR98/Obtainium) to which you can add Jellify as a repo to use the above releases as a repository.
For Obtanium, click "Add App", put "https://github.com/Jellify-Music/App" as the source URL, and on the next screen toggle "prereleases". You'll now be easily able to keep your local copy in sync with new releases.
### iOS
#### The TestFlight Way
Join the [TestFlight](https://testflight.apple.com/join/etVSc7ZQ) and install the latest version from there
#### The Sideloading Way
Head to [releases](https://github.com/Jellify-Music/App/releases) to download the required .IPA directly.
Install via [Altstore](https://altstore.io) or your favorite sideloading utility
## 📱 Screenshots
@@ -284,6 +297,17 @@ This allows me to prioritize specific features, acquire additional hardware for
- [ANDROID_HOME not being set](https://stackoverflow.com/questions/26356359/error-android-home-is-not-set-and-android-command-not-in-your-path-you-must/54888107#54888107)
- [Android Auto app not showing up](https://www.reddit.com/r/AndroidAuto/s/LGYHoSPdXm)
## 👩‍💻 Contributing
We are open to any developer that wants to lend their hand at _Jellify_ development! Here's the best way to get started
- Fork this repository
- Follow the instructions for [Running Locally](#running-locally)
- Hack, hack, hack
- ???
- Submit a Pull Request to sync the main repository with your fork!
- Profit 🎉
## 🙏 Special Thanks To
- The [Jellyfin Team](https://jellyfin.org/) for making this possible with their software, SDKs, and unequivocal helpfulness.

View File

@@ -1903,6 +1903,30 @@ PODS:
- React-Core
- SDWebImage (~> 5.11.1)
- SDWebImageWebPCoder (~> 0.8.4)
- RNFlashList (1.8.0):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-hermes
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- RNFS (2.20.0):
- React-Core
- RNGestureHandler (2.25.0):
@@ -2275,6 +2299,7 @@ DEPENDENCIES:
- RNDeviceInfo (from `../node_modules/react-native-device-info`)
- RNDnsLookup (from `../node_modules/react-native-dns-lookup`)
- RNFastImage (from `../node_modules/react-native-fast-image`)
- "RNFlashList (from `../node_modules/@shopify/flash-list`)"
- RNFS (from `../node_modules/react-native-fs`)
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`)
@@ -2462,6 +2487,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-dns-lookup"
RNFastImage:
:path: "../node_modules/react-native-fast-image"
RNFlashList:
:path: "../node_modules/@shopify/flash-list"
RNFS:
:path: "../node_modules/react-native-fs"
RNGestureHandler:
@@ -2564,6 +2591,7 @@ SPEC CHECKSUMS:
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047
RNDnsLookup: db4a89381b80ec1a5153088518d2c4f8e51f2521
RNFastImage: 462a183c4b0b6b26fdfd639e1ed6ba37536c3b87
RNFlashList: 5001dd06f0003a497de3e2035653c54cf8b48e96
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
RNGestureHandler: ebef699ea17e7c0006c1074e1e423ead60ce0121
RNReactNativeHapticFeedback: 851adf794e1fcdc0664d80820fa3272ee8a6a538

View File

@@ -44,6 +44,7 @@
"@react-navigation/native-stack": "^7.3.13",
"@react-navigation/stack": "^7.3.2",
"@sentry/react-native": "^6.13.1",
"@shopify/flash-list": "^1.8.0",
"@tamagui/config": "^1.126.12",
"@tanstack/query-sync-storage-persister": "^5.76.0",
"@tanstack/react-query": "^5.76.0",

View File

@@ -1,5 +1,3 @@
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import QueryConfig from './query.config'
import {
BaseItemDto,
BaseItemKind,
@@ -13,11 +11,11 @@ import { fetchItems } from './item'
export function fetchAlbums(
api: Api | undefined,
library: JellifyLibrary | undefined,
page: number,
page: string,
isFavorite: boolean = false,
sortBy: ItemSortBy[] = [ItemSortBy.SortName],
sortOrder: SortOrder[] = [SortOrder.Ascending],
): Promise<BaseItemDto[]> {
): Promise<{ title: string | number; data: BaseItemDto[] }> {
console.debug('Fetching albums', page)
return fetchItems(api, library, [BaseItemKind.MusicAlbum], page, sortBy, sortOrder, isFavorite)

View File

@@ -12,11 +12,11 @@ import { fetchItems } from './item'
export function fetchArtists(
api: Api | undefined,
library: JellifyLibrary | undefined,
page: number,
page: string | number,
isFavorite: boolean,
sortBy: ItemSortBy[] = [ItemSortBy.SortName],
sortOrder: SortOrder[] = [SortOrder.Ascending],
): Promise<BaseItemDto[]> {
): Promise<{ title: string | number; data: BaseItemDto[] }> {
console.debug('Fetching artists', page)
return fetchItems(api, library, [BaseItemKind.MusicArtist], page, sortBy, sortOrder, isFavorite)
}

View File

@@ -10,6 +10,7 @@ import { SectionList } from 'react-native'
import { Api } from '@jellyfin/sdk/lib/api'
import { JellifyLibrary } from '../../types/JellifyLibrary'
import QueryConfig from './query.config'
import { alphabet } from '../../providers/Library'
/**
* Fetches a single Jellyfin item by it's ID
@@ -51,12 +52,12 @@ export async function fetchItems(
api: Api | undefined,
library: JellifyLibrary | undefined,
types: BaseItemKind[],
page: number = 0,
page: string | number = 0,
sortBy: ItemSortBy[] = [ItemSortBy.SortName],
sortOrder: SortOrder[] = [SortOrder.Ascending],
isFavorite: boolean,
parentId?: string | undefined,
): Promise<BaseItemDto[]> {
): Promise<{ title: string | number; data: BaseItemDto[] }> {
console.debug('Fetching items', page)
return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client not initialized')
@@ -69,12 +70,14 @@ export async function fetchItems(
sortBy,
recursive: true,
sortOrder,
startIndex: page * QueryConfig.limits.library,
startIndex: typeof page === 'number' ? page * QueryConfig.limits.library : 0,
limit: QueryConfig.limits.library,
nameStartsWith: typeof page === 'string' && page !== alphabet[0] ? page : undefined,
nameLessThan: typeof page === 'string' && page === alphabet[0] ? 'A' : undefined,
isFavorite,
})
.then(({ data }) => {
resolve(data.Items ?? [])
resolve({ title: page, data: data.Items ?? [] })
})
.catch((error) => {
reject(error)

View File

@@ -11,7 +11,7 @@ const QueryConfig = {
* The number of items to fetch for the library, set to 30
* This is used for the artists, albums, and tracks tabs in the library
*/
library: 30,
library: 75,
/**
* The number of items to fetch for the instant mix, set to 50

View File

@@ -1,43 +1,90 @@
import { ItemCard } from '../Global/components/item-card'
import { FlatList } from 'react-native'
import { ActivityIndicator, FlatList, RefreshControl } from 'react-native'
import { AlbumsProps } from '../types'
import { useDisplayContext } from '../../providers/Display/display-provider'
import { getTokens } from 'tamagui'
import { getToken, getTokens, XStack, YStack } from 'tamagui'
import Item from '../Global/components/item'
import React from 'react'
import { Text } from '../Global/helpers/text'
import { FlashList } from '@shopify/flash-list'
export default function Albums({
albums,
navigation,
fetchNextPage,
hasNextPage,
isPending,
isFetchingNextPage,
showAlphabeticalSelector,
}: AlbumsProps): React.JSX.Element {
const { numberOfColumns } = useDisplayContext()
const MemoizedItem = React.memo(Item)
const itemHeight = getToken('$6')
return (
<FlatList
contentContainerStyle={{
flexGrow: 1,
alignItems: 'center',
marginVertical: getTokens().size.$1.val,
}}
contentInsetAdjustmentBehavior='automatic'
numColumns={numberOfColumns}
data={albums?.pages.flatMap((page) => page) ?? []}
renderItem={({ index, item: album }) => (
<ItemCard
item={album}
caption={album.Name ?? 'Untitled Album'}
subCaption={album.ProductionYear?.toString() ?? ''}
squared
onPress={() => {
navigation.navigate('Album', { album })
}}
size={'$11'}
/>
)}
onEndReached={() => {
if (hasNextPage) fetchNextPage()
}}
onEndReachedThreshold={0.25}
removeClippedSubviews
/>
<XStack flex={1}>
<FlashList
contentContainerStyle={{
paddingTop: getToken('$1'),
}}
contentInsetAdjustmentBehavior='automatic'
data={albums ?? []}
renderItem={({ index, item: album }) =>
typeof album === 'string' ? (
<XStack
padding={'$2'}
backgroundColor={'$background'}
borderRadius={'$5'}
borderWidth={'$1'}
borderColor={'$borderColor'}
margin={'$2'}
>
<Text>{album.toUpperCase()}</Text>
</XStack>
) : typeof album === 'number' ? null : typeof album === 'object' ? (
<MemoizedItem
item={album}
queueName={album.Name ?? 'Unknown Album'}
navigation={navigation}
/>
) : null
}
ListEmptyComponent={
isPending ? (
<ActivityIndicator />
) : (
<YStack justifyContent='center'>
<Text>No albums</Text>
</YStack>
)
}
onEndReached={() => {
if (hasNextPage) fetchNextPage()
}}
ListFooterComponent={isPending ? <ActivityIndicator /> : null}
refreshControl={<RefreshControl refreshing={isPending} />}
stickyHeaderIndices={
showAlphabeticalSelector
? albums
?.map((album, index, albums) =>
typeof album === 'string' ? index : 0,
)
.filter((value, index, indices) => indices.indexOf(value) === index)
: []
}
keyExtractor={(item) =>
typeof item === 'string'
? item
: typeof item === 'number'
? item.toString()
: item.Id!
}
estimatedItemSize={itemHeight}
onEndReachedThreshold={0.25}
removeClippedSubviews
/>
</XStack>
)
}

View File

@@ -1,11 +1,16 @@
import React, { useEffect } from 'react'
import { ItemCard } from '../Global/components/item-card'
import { getTokens, YStack } from 'tamagui'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getToken, getTokens, XStack, YStack } from 'tamagui'
import { Text } from '../Global/helpers/text'
import { ActivityIndicator, FlatList } from 'react-native'
import { useDisplayContext } from '../../providers/Display/display-provider'
import { StackParamList } from '../types'
import { ActivityIndicator, RefreshControl } from 'react-native'
import { ArtistsProps } from '../types'
import Item from '../Global/components/item'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { alphabet } from '../../providers/Library'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { trigger } from 'react-native-haptic-feedback'
import { FlashList } from '@shopify/flash-list'
import { useLibraryContext } from '../../providers/Library'
import { sleepify } from '../../helpers/sleep'
export default function Artists({
artists,
@@ -13,47 +18,160 @@ export default function Artists({
fetchNextPage,
hasNextPage,
isPending,
isFetchingNextPage,
showAlphabeticalSelector,
}: ArtistsProps): React.JSX.Element {
const { numberOfColumns } = useDisplayContext()
const { width, height } = useSafeAreaFrame()
const { artistPageParams } = useLibraryContext()
const memoizedAlphabet = useMemo(() => alphabet, [])
const sectionListRef = useRef<FlashList<string | number | BaseItemDto>>(null)
const itemHeight = getToken('$6')
const MemoizedItem = React.memo(Item)
const artistsRef = useRef<(string | number | BaseItemDto)[]>(artists ?? [])
const [refreshing, setRefreshing] = useState(false)
const alphabeticalSelectorCallback = useCallback(async (letter: string) => {
do {
await sleepify(100)
fetchNextPage()
console.debug(
`Alphabetical Selector Callback: ${letter}, ${artistPageParams.current.join(', ')}`,
)
} while (
artistsRef.current?.indexOf(letter) === -1 ||
!artistPageParams.current.includes(letter)
)
sleepify(250).then(() => {
sectionListRef.current?.scrollToIndex({
index:
(artistsRef.current?.indexOf(letter) ?? 0) > -1
? artistsRef.current!.indexOf(letter)
: 0,
viewPosition: 0.2,
animated: true,
})
})
}, [])
useEffect(() => {
console.debug(hasNextPage)
}, [hasNextPage])
console.debug(`Fetching Artist Component: ${artistPageParams.current.join(', ')}`)
}, [artistPageParams])
useEffect(() => {
artistsRef.current = artists ?? []
}, [artists])
return (
<FlatList
contentContainerStyle={{
flexGrow: 1,
alignItems: 'center',
marginVertical: getTokens().size.$1.val,
}}
contentInsetAdjustmentBehavior='automatic'
numColumns={numberOfColumns}
data={artists?.pages.flatMap((page) => page) ?? []}
renderItem={({ index, item: artist }) => (
<ItemCard
item={artist}
caption={artist.Name ?? 'Unknown Artist'}
onPress={() => {
navigation.navigate('Artist', { artist })
}}
size={'$11'}
/>
<XStack flex={1}>
<FlashList
ref={sectionListRef}
style={{
width: getToken('$10'),
marginRight: getToken('$4'),
}}
contentContainerStyle={{
paddingTop: getToken('$1'),
}}
contentInsetAdjustmentBehavior='automatic'
keyExtractor={(item) =>
typeof item === 'string'
? item
: typeof item === 'number'
? item.toString()
: item.Id!
}
estimatedItemSize={itemHeight}
data={artists}
refreshControl={<RefreshControl refreshing={isPending} />}
renderItem={({ index, item: artist }) =>
typeof artist === 'string' ? (
<XStack
padding={'$2'}
backgroundColor={'$background'}
borderRadius={'$5'}
borderWidth={'$1'}
borderColor={'$borderColor'}
margin={'$2'}
>
<Text>{artist.toUpperCase()}</Text>
</XStack>
) : typeof artist === 'number' ? null : typeof artist === 'object' ? (
<MemoizedItem
item={artist}
queueName={artist.Name ?? 'Unknown Artist'}
navigation={navigation}
/>
) : null
}
ListEmptyComponent={
isPending || isFetchingNextPage ? (
<ActivityIndicator />
) : (
<YStack justifyContent='center'>
<Text>No artists</Text>
</YStack>
)
}
ListFooterComponent={isPending ? <ActivityIndicator /> : null}
stickyHeaderIndices={
showAlphabeticalSelector
? artists
?.map((artist, index, artists) =>
typeof artist === 'string' ? index : 0,
)
.filter((value, index, indices) => indices.indexOf(value) === index)
: []
}
onEndReached={() => {
if (hasNextPage) fetchNextPage()
}}
onEndReachedThreshold={0.8}
removeClippedSubviews={false}
/>
{showAlphabeticalSelector && (
<YStack
maxWidth={'$4'}
margin={'$2'}
minWidth={'$2'}
width={width / 8}
height={height - getTokens().size.$15.val}
alignItems='center'
justifyContent='center'
borderWidth={'$1'}
borderColor={'$borderColor'}
borderRadius={'$5'}
gap={0}
flex={1}
>
{memoizedAlphabet.map((letter) => (
<Text
display='flex'
paddingHorizontal={'$3'}
flex={1}
alignItems='center'
justifyContent='center'
key={letter}
bold
fontSize={'$6'}
onPressOut={() => {
trigger('impactLight')
alphabeticalSelectorCallback(letter)
}}
>
{letter.toUpperCase()}
</Text>
))}
</YStack>
)}
ListEmptyComponent={
isPending ? (
<ActivityIndicator />
) : (
<YStack justifyContent='center'>
<Text>No artists</Text>
</YStack>
)
}
onEndReached={() => {
if (hasNextPage) fetchNextPage()
}}
onEndReachedThreshold={0.25}
removeClippedSubviews
/>
</XStack>
)
}

View File

@@ -8,6 +8,8 @@ export default function ArtistsScreen({
fetchNextPage,
hasNextPage,
isPending,
isFetchingNextPage,
showAlphabeticalSelector,
}: ArtistsProps): React.JSX.Element {
return (
<Artists
@@ -16,6 +18,8 @@ export default function ArtistsScreen({
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isPending={isPending}
isFetchingNextPage={isFetchingNextPage}
showAlphabeticalSelector={showAlphabeticalSelector}
/>
)
}

View File

@@ -12,8 +12,13 @@ export default function RecentlyAdded({
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { recentlyAdded, fetchNextRecentlyAdded, hasNextRecentlyAdded, isPendingRecentlyAdded } =
useDiscoverContext()
const {
recentlyAdded,
fetchNextRecentlyAdded,
hasNextRecentlyAdded,
isPendingRecentlyAdded,
isFetchingNextRecentlyAdded,
} = useDiscoverContext()
return (
<View>
@@ -26,6 +31,7 @@ export default function RecentlyAdded({
fetchNextPage: fetchNextRecentlyAdded,
hasNextPage: hasNextRecentlyAdded,
isPending: isPendingRecentlyAdded,
isFetchingNextPage: isFetchingNextRecentlyAdded,
})
}}
>
@@ -34,11 +40,7 @@ export default function RecentlyAdded({
</XStack>
<HorizontalCardList
data={
(recentlyAdded?.pages[0].length ?? 0 > 10)
? recentlyAdded!.pages[0].slice(0, 10)
: recentlyAdded?.pages[0]
}
data={recentlyAdded?.slice(0, 10) ?? []}
renderItem={({ item }) => (
<ItemCard
caption={item.Name}

View File

@@ -10,6 +10,7 @@ import { RunTimeTicks } from '../helpers/time-codes'
import { useQueueContext } from '../../../providers/Player/queue'
import { usePlayerContext } from '../../../providers/Player'
import ItemImage from './image'
import FavoriteIcon from './favorite-icon'
export default function Item({
item,
@@ -26,13 +27,13 @@ export default function Item({
const { width } = useSafeAreaFrame()
return (
<View flex={1}>
<View>
<Separator />
<XStack
alignContent='center'
flex={1}
minHeight={width / 9}
minHeight={'$7'}
width={'100%'}
onPress={() => {
switch (item.Type) {
case 'MusicArtist': {
@@ -73,18 +74,13 @@ export default function Item({
})
}}
paddingVertical={'$2'}
marginHorizontal={'$1'}
paddingRight={'$2'}
>
<YStack flex={1}>
<YStack marginHorizontal={'$3'} justifyContent='center'>
<ItemImage item={item} height={'$12'} width={'$12'} />
</YStack>
<YStack
marginLeft={'$1'}
alignContent='center'
justifyContent='flex-start'
flex={3}
>
<YStack alignContent='center' justifyContent='center' flex={4}>
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.Name ?? ''}
</Text>
@@ -92,26 +88,26 @@ export default function Item({
<Text
lineBreakStrategyIOS='standard'
numberOfLines={1}
color={'$amethyst'}
color={'$borderColor'}
bold
>
{item.AlbumArtist ?? 'Untitled Artist'}
</Text>
)}
{item.Type === 'MusicAlbum' && <RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>}
</YStack>
<XStack justifyContent='space-between' alignItems='center' flex={2}>
{item.UserData?.IsFavorite ? (
<Icon small color={'$primary'} name='heart' />
) : (
<Spacer />
)}
<XStack
justifyContent='flex-end'
alignItems='center'
flex={item.Type === 'Audio' ? 2 : 1}
>
<FavoriteIcon item={item} />
{/* Runtime ticks for Songs */}
{item.Type === 'Audio' ? (
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
) : (
<Spacer />
)}
) : null}
{item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
<Icon
@@ -123,9 +119,7 @@ export default function Item({
})
}}
/>
) : (
<Spacer />
)}
) : null}
</XStack>
</XStack>
</View>

View File

@@ -39,11 +39,7 @@ export default function FrequentArtists({
</XStack>
<HorizontalCardList
data={
(frequentArtists?.pages.flatMap((page) => page).length ?? 0 > 10)
? frequentArtists?.pages.flatMap((page) => page).slice(0, 10)
: frequentArtists?.pages.flatMap((page) => page)
}
data={frequentArtists?.slice(0, 10) ?? []}
renderItem={({ item: artist }) => (
<ItemCard
item={artist}

View File

@@ -34,11 +34,7 @@ export default function RecentArtists({
</XStack>
<HorizontalCardList
data={
(recentArtists?.pages.flatMap((page) => page).length ?? 0 > 10)
? recentArtists?.pages.flatMap((page) => page).slice(0, 10)
: recentArtists?.pages.flatMap((page) => page)
}
data={recentArtists?.slice(0, 10) ?? []}
renderItem={({ item: recentArtist }) => (
<ItemCard
item={recentArtist}

View File

@@ -5,7 +5,13 @@ import { useLibraryContext } from '../../../providers/Library'
import { useNavigation } from '@react-navigation/native'
export default function AlbumsTab(): React.JSX.Element {
const { albums, fetchNextAlbumsPage, hasNextAlbumsPage, isPendingAlbums } = useLibraryContext()
const {
albums,
fetchNextAlbumsPage,
hasNextAlbumsPage,
isPendingAlbums,
isFetchingNextAlbumsPage,
} = useLibraryContext()
const navigation = useNavigation<NativeStackNavigationProp<StackParamList>>()
@@ -16,6 +22,8 @@ export default function AlbumsTab(): React.JSX.Element {
fetchNextPage={fetchNextAlbumsPage}
hasNextPage={hasNextAlbumsPage}
isPending={isPendingAlbums}
isFetchingNextPage={isFetchingNextAlbumsPage}
showAlphabeticalSelector={true}
/>
)
}

View File

@@ -5,8 +5,13 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../../types'
export default function ArtistsTab(): React.JSX.Element {
const { artists, fetchNextArtistsPage, hasNextArtistsPage, isPendingArtists } =
useLibraryContext()
const {
artists,
isPendingArtists,
fetchNextArtistsPage,
hasNextArtistsPage,
isFetchingNextArtistsPage,
} = useLibraryContext()
const navigation = useNavigation<NativeStackNavigationProp<StackParamList>>()
@@ -17,6 +22,8 @@ export default function ArtistsTab(): React.JSX.Element {
navigation={navigation}
fetchNextPage={fetchNextArtistsPage}
hasNextPage={hasNextArtistsPage}
isFetchingNextPage={isFetchingNextArtistsPage}
showAlphabeticalSelector={true}
/>
)
}

View File

@@ -1,6 +1,6 @@
import { MaterialTopTabBar, MaterialTopTabBarProps } from '@react-navigation/material-top-tabs'
import React, { useEffect } from 'react'
import { Separator, XStack, YStack } from 'tamagui'
import { Separator, Spacer, XStack, YStack } from 'tamagui'
import Icon from '../Global/components/icon'
import { useLibrarySortAndFilterContext } from '../../providers/Library/sorting-filtering'
import { Text } from '../Global/helpers/text'
@@ -25,19 +25,19 @@ export default function LibraryTabBar(props: MaterialTopTabBarProps) {
<YStack>
<MaterialTopTabBar {...props} />
<XStack
paddingHorizontal={'$4'}
paddingVertical={'$3'}
borderWidth={'$1'}
borderColor={'$purpleGray'}
marginTop={'$2'}
marginHorizontal={'$2'}
borderRadius={'$4'}
backgroundColor={'$background'}
alignItems={'center'}
justifyContent='flex-end'
>
<Animated.View entering={FadeIn} exiting={FadeOut}>
{props.state.routes[props.state.index].name === 'Artists' ? null : (
<XStack
paddingHorizontal={'$4'}
paddingVertical={'$2'}
borderWidth={'$1'}
borderColor={'$borderColor'}
marginTop={'$2'}
marginHorizontal={'$2'}
borderRadius={'$4'}
backgroundColor={'$background'}
alignItems={'center'}
justifyContent='flex-end'
>
{props.state.routes[props.state.index].name === 'Playlists' ? (
<XStack
flex={1}
@@ -66,48 +66,26 @@ export default function LibraryTabBar(props: MaterialTopTabBarProps) {
</Text>
</XStack>
)}
</Animated.View>
{props.state.routes[props.state.index].name === 'Tracks' && (
<XStack
flex={1}
onPress={() => setIsDownloaded(!isDownloaded)}
alignItems={'center'}
justifyContent={'center'}
>
<Icon
name={isDownloaded ? 'download' : 'download-outline'}
color={isDownloaded ? '$success' : '$borderColor'}
/>
{props.state.routes[props.state.index].name === 'Tracks' && (
<XStack
flex={1}
onPress={() => setIsDownloaded(!isDownloaded)}
alignItems={'center'}
justifyContent={'center'}
>
<Icon
name={isDownloaded ? 'download' : 'download-outline'}
color={isDownloaded ? '$success' : '$borderColor'}
/>
<Text color={isDownloaded ? '$success' : '$borderColor'}>
{isDownloaded ? 'Downloaded' : 'All'}
</Text>
</XStack>
)}
<Separator vertical />
<XStack
flex={1}
onPress={() => setSortDescending(!sortDescending)}
alignItems={'center'}
justifyContent={'center'}
>
<Icon
name={
sortDescending
? 'sort-alphabetical-descending'
: 'sort-alphabetical-ascending'
}
color={sortDescending ? '$success' : '$borderColor'}
/>
<Text color={sortDescending ? '$success' : '$borderColor'}>
{sortDescending ? 'Descending' : 'Ascending'}
</Text>
<Text color={isDownloaded ? '$success' : '$borderColor'}>
{isDownloaded ? 'Downloaded' : 'All'}
</Text>
</XStack>
)}
</XStack>
</XStack>
)}
</YStack>
)
}

View File

@@ -45,7 +45,10 @@ export default function ServerAddress({
const api = jellyfin.createApi(`${useHttps ? https : http}${serverAddress}`)
const connectViaHostnamePromise = () =>
new Promise<PublicSystemInfo>((resolve, reject) => {
new Promise<{
publicSystemInfoResponse: PublicSystemInfo
connectionType: 'hostname'
}>((resolve, reject) => {
getSystemApi(api)
.getPublicSystemInfo()
.then((response) => {
@@ -55,7 +58,10 @@ export default function ServerAddress({
'Jellyfin instance did not respond to our hostname request',
),
)
return resolve(response.data)
return resolve({
publicSystemInfoResponse: response.data,
connectionType: 'hostname',
})
})
.catch((error) => {
console.error('An error occurred getting public system info', error)
@@ -69,7 +75,10 @@ export default function ServerAddress({
`${useHttps ? https : http}${ipAddress[0]}:${serverAddress.split(':')[1]}`,
)
const connectViaLocalNetworkPromise = () =>
new Promise<PublicSystemInfo>((resolve, reject) => {
new Promise<{
publicSystemInfoResponse: PublicSystemInfo
connectionType: 'ipAddress'
}>((resolve, reject) => {
getSystemApi(ipAddressApi)
.getPublicSystemInfo()
.then((response) => {
@@ -79,7 +88,10 @@ export default function ServerAddress({
'Jellyfin instance did not respond to our IP Address request',
),
)
return resolve(response.data)
return resolve({
publicSystemInfoResponse: response.data,
connectionType: 'ipAddress',
})
})
.catch((error) => {
console.error('An error occurred getting public system info', error)
@@ -89,14 +101,24 @@ export default function ServerAddress({
return connectViaHostnamePromise().catch(() => connectViaLocalNetworkPromise())
},
onSuccess: (publicSystemInfoResponse) => {
onSuccess: ({
publicSystemInfoResponse,
connectionType,
}: {
publicSystemInfoResponse: PublicSystemInfo
connectionType: 'hostname' | 'ipAddress'
}) => {
if (!publicSystemInfoResponse.Version)
throw new Error('Jellyfin instance did not respond')
console.debug(`Connected to Jellyfin via ${connectionType}`, publicSystemInfoResponse)
console.log(`Connected to Jellyfin ${publicSystemInfoResponse.Version!}`)
const server: JellifyServer = {
url: `${useHttps ? https : http}${serverAddress!}`,
url:
connectionType === 'hostname'
? `${useHttps ? https : http}${serverAddress!}`
: publicSystemInfoResponse.LocalAddress!,
address: serverAddress!,
name: publicSystemInfoResponse.ServerName!,
version: publicSystemInfoResponse.Version!,

View File

@@ -2,8 +2,8 @@ import React, { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import _ from 'lodash'
import { JellyfinCredentials } from '../../../api/types/jellyfin-credentials'
import { getToken, Spacer, Spinner, XStack, YStack } from 'tamagui'
import { H2 } from '../../Global/helpers/text'
import { getToken, H6, Spacer, Spinner, XStack, YStack } from 'tamagui'
import { H2, H5, Text } from '../../Global/helpers/text'
import Button from '../../Global/helpers/button'
import { SafeAreaView } from 'react-native-safe-area-context'
import { JellifyUser } from '../../../types/JellifyUser'
@@ -69,6 +69,9 @@ export default function ServerAuthentication({
<H2 marginHorizontal={'$2'} textAlign='center'>
{`Sign in to ${server?.name ?? 'Jellyfin'}`}
</H2>
<H6 marginHorizontal={'$2'} textAlign='center'>
{server?.version ?? 'Unknown Jellyfin version'}
</H6>
</YStack>
<YStack marginHorizontal={'$4'}>
<Input
@@ -103,7 +106,7 @@ export default function ServerAuthentication({
if (navigation.canGoBack()) navigation.goBack()
else
navigation.navigate('ServerAddress', undefined, {
pop: false,
pop: true,
})
}}
>
@@ -115,6 +118,7 @@ export default function ServerAuthentication({
<Button
marginVertical={0}
disabled={_.isEmpty(username) || useApiMutation.isPending}
icon={() => <Icon name='chevron-right' small />}
onPress={() => {
if (!_.isUndefined(username)) {
console.log(`Signing in...`)

View File

@@ -11,6 +11,7 @@ import { fetchUserViews } from '../../../api/queries/libraries'
import { useQuery } from '@tanstack/react-query'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../../../components/types'
import Icon from '../../Global/components/icon'
export default function ServerLibrary({
navigation,
@@ -83,6 +84,7 @@ export default function ServerLibrary({
<Button
disabled={!libraryId}
icon={() => <Icon name='guitar-electric' small />}
onPress={() => {
setLibrary({
musicLibraryId: libraryId!,
@@ -105,10 +107,11 @@ export default function ServerLibrary({
</Button>
<Button
icon={() => <Icon name='chevron-left' small />}
onPress={() => {
setUser(undefined)
navigation.navigate('ServerAuthentication', undefined, {
pop: false,
pop: true,
})
}}
>

View File

@@ -9,17 +9,19 @@ import PlaybackTab from './components/playback-tab'
import InfoTab from './components/info-tab'
import SettingsTabBar from './components/tab-bar'
import StorageTab from './components/storage-tab'
import { useSettingsContext } from '../../providers/Settings'
const SettingsTabsNavigator = createMaterialTopTabNavigator()
export default function Settings(): React.JSX.Element {
const theme = useTheme()
const { devTools } = useSettingsContext()
return (
<SettingsTabsNavigator.Navigator
screenOptions={{
tabBarScrollEnabled: true,
tabBarGap: getToken('$size.0'),
tabBarScrollEnabled: true,
tabBarItemStyle: {
width: getToken('$size.8'),
},
@@ -69,16 +71,6 @@ export default function Settings(): React.JSX.Element {
}}
/>
<SettingsTabsNavigator.Screen
name='Labs'
component={LabsTab}
options={{
tabBarIcon: ({ focused, color }) => (
<Icon name='flask' color={focused ? '$primary' : '$borderColor'} small />
),
}}
/>
<SettingsTabsNavigator.Screen
name='Account'
component={AccountTab}
@@ -106,6 +98,21 @@ export default function Settings(): React.JSX.Element {
),
}}
/>
{devTools && (
<SettingsTabsNavigator.Screen
name='Labs'
component={LabsTab}
options={{
tabBarIcon: ({ focused, color }) => (
<Icon
name='flask'
color={focused ? '$primary' : '$borderColor'}
small
/>
),
}}
/>
)}
</SettingsTabsNavigator.Navigator>
)
}

View File

@@ -8,6 +8,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useNavigation } from '@react-navigation/native'
import { Text } from '../../Global/helpers/text'
import SettingsListGroup from './settings-list-group'
import { https } from '../../Login/utils/constants'
export default function AccountTab(): React.JSX.Element {
const { user, library, server } = useJellifyContext()
@@ -33,11 +34,11 @@ export default function AccountTab(): React.JSX.Element {
children: <Text>{library?.musicLibraryName ?? 'Unknown Library'}</Text>,
},
{
title: 'Jellyfin Server',
title: server?.name ?? 'Untitled Server',
subTitle: server?.version ?? 'Unknown Jellyfin Version',
iconName: 'server',
iconColor: '$borderColor',
children: <Text>{server?.name ?? 'Unknown Server'}</Text>,
iconName: server?.url.includes(https) ? 'lock' : 'lock-open',
iconColor: server?.url.includes(https) ? '$success' : '$borderColor',
children: <Text>{server?.address ?? 'Unknown Server'}</Text>,
},
]}
/>

View File

@@ -10,15 +10,26 @@ import fetchPatrons from '../../../../api/queries/patrons'
import { FlatList, Linking } from 'react-native'
import { H6, ScrollView, Separator, XStack, YStack } from 'tamagui'
import Icon from '../../../Global/components/icon'
import { useEffect, useState } from 'react'
import { useSettingsContext } from '../../../../providers/Settings'
export default function InfoTabIndex({ navigation }: InfoTabStackNavigationProp) {
const { api } = useJellifyContext()
const { setDevTools } = useSettingsContext()
const [versionNumberPresses, setVersionNumberPresses] = useState(0)
const { data: patrons } = useQuery({
queryKey: [QueryKeys.Patrons],
queryFn: () => fetchPatrons(api),
})
useEffect(() => {
if (versionNumberPresses > 5) {
setDevTools(true)
}
}, [versionNumberPresses])
return (
<ScrollView contentInsetAdjustmentBehavior='automatic'>
<SettingsListGroup
@@ -30,7 +41,13 @@ export default function InfoTabIndex({ navigation }: InfoTabStackNavigationProp)
iconColor: '$borderColor',
children: (
<YStack gap={'$2'}>
<Text>Made with 💜 by Violet Caulfield</Text>
<Text
onPress={() =>
setVersionNumberPresses(versionNumberPresses + 1)
}
>
Made with 💜 by Violet Caulfield
</Text>
<Separator marginBottom={'$2'} />
<XStack gap={'$3'}>

View File

@@ -1,19 +1,33 @@
import { ListItem, View, YGroup } from 'tamagui'
import { Text } from '../../Global/helpers/text'
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
import Icon from '../../Global/components/icon'
import SettingsListGroup from './settings-list-group'
import { useQueryClient } from '@tanstack/react-query'
import { QueryKeys } from '../../../enums/query-keys'
import Button from '../../Global/helpers/button'
import { storage } from '../../../constants/storage'
export default function LabsTab(): React.JSX.Element {
const queryClient = useQueryClient()
return (
<SettingsListGroup
borderColor={'$danger'}
settingsList={[
{
title: 'Nothing to see here...(yet)',
subTitle: 'Come back later to enable experimental features',
title: 'Clear Artists Cache',
subTitle: 'Invalidates the artists in the library',
iconName: 'test-tube-off',
iconColor: '$danger',
children: (
<Button
onPress={() => {
storage.delete(QueryKeys.AllArtistsAlphabetical)
queryClient.invalidateQueries({
queryKey: [QueryKeys.AllArtistsAlphabetical],
})
}}
>
Clear Cache
</Button>
),
},
]}
/>

View File

@@ -3,7 +3,7 @@ import Button from '../../Global/helpers/button'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { SettingsStackParamList } from '../../../screens/Settings/types'
import { Text } from '../../Global/helpers/text'
import Icon from '../../Global/components/icon'
export default function SignOut({
navigation,
}: {
@@ -12,6 +12,7 @@ export default function SignOut({
return (
<Button
color={'$danger'}
icon={() => <Icon name='hand-peace' small color={'$danger'} />}
borderColor={'$danger'}
marginHorizontal={'$6'}
onPress={() => {

View File

@@ -16,13 +16,13 @@ export type StackParamList = {
Home: undefined
AddPlaylist: undefined
RecentArtists: {
artists: InfiniteData<BaseItemDto[], unknown> | undefined
artists: BaseItemDto[] | undefined
fetchNextPage: () => void
hasNextPage: boolean
isPending: boolean
}
MostPlayedArtists: {
artists: InfiniteData<BaseItemDto[], unknown> | undefined
artists: BaseItemDto[] | undefined
fetchNextPage: () => void
hasNextPage: boolean
isPending: boolean
@@ -53,11 +53,12 @@ export type StackParamList = {
Discover: undefined
RecentlyAdded: {
albums: InfiniteData<BaseItemDto[], unknown> | undefined
albums: BaseItemDto[] | undefined
navigation: NativeStackNavigationProp<StackParamList>
fetchNextPage: () => void
hasNextPage: boolean
isPending: boolean
isFetchingNextPage: boolean
}
Library: undefined
@@ -148,32 +149,38 @@ export type LibraryProps = NativeStackScreenProps<StackParamList, 'Library'>
export type TracksProps = NativeStackScreenProps<StackParamList, 'Tracks'>
export type ArtistsProps = {
artists: InfiniteData<BaseItemDto[], unknown> | undefined
artists: (string | number | BaseItemDto)[] | undefined
navigation: NativeStackNavigationProp<StackParamList>
fetchNextPage: () => void
fetchNextPage: (options?: FetchNextPageOptions | undefined) => void
hasNextPage: boolean
isPending: boolean
isFetchingNextPage: boolean
showAlphabeticalSelector: boolean
}
export type AlbumsProps = {
albums: InfiniteData<BaseItemDto[], unknown> | undefined
albums: (string | number | BaseItemDto)[] | undefined
navigation: NativeStackNavigationProp<StackParamList>
fetchNextPage: () => void
fetchNextPage: (options?: FetchNextPageOptions | undefined) => void
hasNextPage: boolean
isPending: boolean
isFetchingNextPage: boolean
showAlphabeticalSelector: boolean
}
export type GenresProps = {
genres: InfiniteData<BaseItemDto[], unknown> | undefined
navigation: NativeStackNavigationProp<StackParamList>
fetchNextPage: () => void
fetchNextPage: (options?: FetchNextPageOptions | undefined) => void
hasNextPage: boolean
isPending: boolean
isFetchingNextPage: boolean
}
export type PlaylistsProps = {
playlists: InfiniteData<BaseItemDto[], unknown> | undefined
navigation: NativeStackNavigationProp<StackParamList>
fetchNextPage: () => void
fetchNextPage: (options?: FetchNextPageOptions | undefined) => void
hasNextPage: boolean
isPending: boolean
isFetchingNextPage: boolean
}
export type DeletePlaylistProps = NativeStackScreenProps<StackParamList, 'DeletePlaylist'>

View File

@@ -15,4 +15,6 @@ export enum MMKVStorageKeys {
SendMetrics = 'SEND_METRICS',
AutoDownload = 'AutoDownload',
LibraryIsDownloaded = 'LibraryIsDownloaded',
DevTools = 'DevTools',
LibraryArtistPageParam = 'LibraryArtistPageParam',
}

View File

@@ -80,4 +80,5 @@ export enum QueryKeys {
AllAlbums = 'AllAlbums',
StorageInUse = 'StorageInUse',
Patrons = 'Patrons',
AllArtistsAlphabetical = 'AllArtistsAlphabetical',
}

View File

@@ -1 +1,10 @@
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
/**
* Sleep for a given number of milliseconds
*
* Name inspired by the album Sleepify by Vulfpeck
* @see https://en.wikipedia.org/wiki/Sleepify
*
* @param ms The number of milliseconds to sleep
* @returns A promise that resolves after the given number of milliseconds
*/
export const sleepify = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

View File

@@ -7,7 +7,7 @@ import { useJellifyContext } from '..'
interface DiscoverContext {
refreshing: boolean
refresh: () => void
recentlyAdded: InfiniteData<BaseItemDto[], unknown> | undefined
recentlyAdded: BaseItemDto[] | undefined
recentlyPlayed: InfiniteData<BaseItemDto[], unknown> | undefined
fetchNextRecentlyAdded: () => void
fetchNextRecentlyPlayed: () => void
@@ -15,6 +15,8 @@ interface DiscoverContext {
hasNextRecentlyPlayed: boolean
isPendingRecentlyAdded: boolean
isPendingRecentlyPlayed: boolean
isFetchingNextRecentlyAdded: boolean
isFetchingNextRecentlyPlayed: boolean
}
const DiscoverContextInitializer = () => {
@@ -27,9 +29,11 @@ const DiscoverContextInitializer = () => {
fetchNextPage: fetchNextRecentlyAdded,
hasNextPage: hasNextRecentlyAdded,
isPending: isPendingRecentlyAdded,
isFetchingNextPage: isFetchingNextRecentlyAdded,
} = useInfiniteQuery({
queryKey: [QueryKeys.RecentlyAdded],
queryFn: ({ pageParam }) => fetchRecentlyAdded(api, library, pageParam),
select: (data) => data.pages.flatMap((page) => page),
getNextPageParam: (lastPage, pages) => (lastPage.length > 0 ? pages.length + 1 : undefined),
initialPageParam: 0,
})
@@ -40,6 +44,7 @@ const DiscoverContextInitializer = () => {
fetchNextPage: fetchNextRecentlyPlayed,
hasNextPage: hasNextRecentlyPlayed,
isPending: isPendingRecentlyPlayed,
isFetchingNextPage: isFetchingNextRecentlyPlayed,
} = useInfiniteQuery({
queryKey: [QueryKeys.RecentlyPlayed],
queryFn: ({ pageParam }) => fetchRecentlyPlayed(api, user, library, pageParam),
@@ -65,6 +70,8 @@ const DiscoverContextInitializer = () => {
hasNextRecentlyPlayed,
isPendingRecentlyAdded,
isPendingRecentlyPlayed,
isFetchingNextRecentlyAdded,
isFetchingNextRecentlyPlayed,
}
}
@@ -79,6 +86,8 @@ const DiscoverContext = createContext<DiscoverContext>({
hasNextRecentlyPlayed: false,
isPendingRecentlyAdded: false,
isPendingRecentlyPlayed: false,
isFetchingNextRecentlyAdded: false,
isFetchingNextRecentlyPlayed: false,
})
export const DiscoverProvider: ({ children }: { children: ReactNode }) => React.JSX.Element = ({

View File

@@ -14,7 +14,7 @@ const DisplayContextInitializer = () => {
const { width } = useSafeAreaFrame()
const [numberOfColumns, setNumberOfColumns] = useState<number>(
Math.floor(width / getTokens().size.$11.val),
Math.floor(width / getTokens().size.$12.val),
)
const [display, setDisplay] = useState<'grid' | 'list'>('grid')

View File

@@ -10,7 +10,7 @@ import { useJellifyContext } from '..'
interface HomeContext {
refreshing: boolean
onRefresh: () => void
recentArtists: InfiniteData<BaseItemDto[], unknown> | undefined
recentArtists: BaseItemDto[] | undefined
recentTracks: InfiniteData<BaseItemDto[], unknown> | undefined
fetchNextRecentTracks: () => void
@@ -25,7 +25,7 @@ interface HomeContext {
fetchNextFrequentlyPlayed: () => void
hasNextFrequentlyPlayed: boolean
frequentArtists: InfiniteData<BaseItemDto[], unknown> | undefined
frequentArtists: BaseItemDto[] | undefined
frequentlyPlayed: InfiniteData<BaseItemDto[], unknown> | undefined
isFetchingRecentTracks: boolean
@@ -65,12 +65,13 @@ const HomeContextInitializer = () => {
} = useInfiniteQuery({
queryKey: [QueryKeys.RecentlyPlayedArtists],
queryFn: ({ pageParam }) => fetchRecentlyPlayedArtists(pageParam),
select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
console.debug('Getting next page for recent artists')
return lastPage.length > 0 ? lastPageParam + 1 : undefined
},
enabled: !isErrorRecentTracks && recentTracks && recentTracks.pages.length > 0,
enabled: !!recentTracks && recentTracks.pages.length > 0,
})
const {
@@ -100,6 +101,7 @@ const HomeContextInitializer = () => {
} = useInfiniteQuery({
queryKey: [QueryKeys.FrequentArtists],
queryFn: ({ pageParam }) => fetchFrequentlyPlayedArtists(api, library, pageParam),
select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
console.debug('Getting next page for frequent artists')

View File

@@ -1,18 +1,27 @@
import { QueryKeys } from '../../enums/query-keys'
import { BaseItemDto, ItemSortBy, SortOrder } from '@jellyfin/sdk/lib/generated-client/models'
import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query'
import {
FetchNextPageOptions,
FetchPreviousPageOptions,
InfiniteData,
InfiniteQueryObserverResult,
QueryObserverResult,
useInfiniteQuery,
} from '@tanstack/react-query'
import { useJellifyContext } from '..'
import { fetchArtists } from '../../api/queries/artist'
import { createContext, useContext, useState } from 'react'
import { createContext, RefObject, useContext, useRef, useState } from 'react'
import { useDisplayContext } from '../Display/display-provider'
import QueryConfig from '../../api/queries/query.config'
import { fetchTracks } from '../../api/queries/tracks'
import { fetchAlbums } from '../../api/queries/album'
import { useLibrarySortAndFilterContext } from './sorting-filtering'
export const alphabet = '#abcdefghijklmnopqrstuvwxyz'.split('')
interface LibraryContext {
artists: InfiniteData<BaseItemDto[], unknown> | undefined
albums: InfiniteData<BaseItemDto[], unknown> | undefined
artists: (string | number | BaseItemDto)[] | undefined
albums: (string | number | BaseItemDto)[] | undefined
tracks: InfiniteData<BaseItemDto[], unknown> | undefined
// genres: BaseItemDto[] | undefined
// playlists: BaseItemDto[] | undefined
@@ -23,18 +32,28 @@ interface LibraryContext {
// refetchGenres: () => void
// refetchPlaylists: () => void
fetchNextArtistsPage: () => void
fetchNextArtistsPage: (
options?: FetchNextPageOptions,
) => Promise<InfiniteQueryObserverResult<(string | number | BaseItemDto)[], Error>>
hasNextArtistsPage: boolean
fetchNextTracksPage: () => void
fetchNextTracksPage: (options?: FetchNextPageOptions | undefined) => void
hasNextTracksPage: boolean
fetchNextAlbumsPage: () => void
fetchNextAlbumsPage: (
options?: FetchNextPageOptions | undefined,
) => Promise<InfiniteQueryObserverResult<(string | number | BaseItemDto)[], Error>>
hasNextAlbumsPage: boolean
isPendingArtists: boolean
isPendingTracks: boolean
isPendingAlbums: boolean
artistPageParams: RefObject<string[]>
albumPageParams: RefObject<string[]>
isFetchingNextArtistsPage: boolean
isFetchingNextTracksPage: boolean
isFetchingNextAlbumsPage: boolean
}
const LibraryContextInitializer = () => {
@@ -44,27 +63,53 @@ const LibraryContextInitializer = () => {
const { sortDescending, isFavorites } = useLibrarySortAndFilterContext()
const artistPageParams = useRef<string[]>([])
const albumPageParams = useRef<string[]>([])
const {
data: artists,
isPending: isPendingArtists,
refetch: refetchArtists,
fetchNextPage: fetchNextArtistsPage,
hasNextPage: hasNextArtistsPage,
isFetchingNextPage: isFetchingNextArtistsPage,
} = useInfiniteQuery({
queryKey: [QueryKeys.AllArtists, isFavorites, sortDescending],
queryKey: [QueryKeys.AllArtistsAlphabetical, isFavorites, sortDescending],
queryFn: ({ pageParam }) =>
fetchArtists(
api,
library,
pageParam,
isFavorites,
false,
[ItemSortBy.SortName],
[sortDescending ? SortOrder.Descending : SortOrder.Ascending],
),
initialPageParam: 0,
select: (data) => data.pages.flatMap((page) => [page.title, ...page.data]),
initialPageParam: alphabet[0],
maxPages: alphabet.length,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
console.debug(`Artists last page length: ${lastPage.length}`)
return lastPage.length === QueryConfig.limits.library ? lastPageParam + 1 : undefined
console.debug(`Fetching next Artists page, last page: ${lastPage.title}`)
console.debug(`fetching artist params: ${allPageParams.join(', ')}`)
if (lastPageParam !== alphabet[alphabet.length - 1]) {
artistPageParams.current = [
...allPageParams,
alphabet[alphabet.indexOf(lastPageParam) + 1],
]
return alphabet[alphabet.indexOf(lastPageParam) + 1]
}
return undefined
},
getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => {
console.debug(`Artists first page: ${firstPage.title}`)
artistPageParams.current = allPageParams
if (firstPageParam !== alphabet[0]) {
artistPageParams.current = allPageParams
return alphabet[alphabet.indexOf(firstPageParam) - 1]
}
return undefined
},
})
@@ -73,6 +118,8 @@ const LibraryContextInitializer = () => {
isPending: isPendingTracks,
refetch: refetchTracks,
fetchNextPage: fetchNextTracksPage,
isFetchingNextPage: isFetchingNextTracksPage,
isError: isFetchingTracksError,
hasNextPage: hasNextTracksPage,
} = useInfiniteQuery({
queryKey: [QueryKeys.AllTracks, isFavorites, sortDescending],
@@ -99,6 +146,7 @@ const LibraryContextInitializer = () => {
isPending: isPendingAlbums,
refetch: refetchAlbums,
fetchNextPage: fetchNextAlbumsPage,
isFetchingNextPage: isFetchingNextAlbumsPage,
hasNextPage: hasNextAlbumsPage,
} = useInfiniteQuery({
queryKey: [QueryKeys.AllAlbums, isFavorites, sortDescending],
@@ -111,10 +159,30 @@ const LibraryContextInitializer = () => {
[ItemSortBy.SortName],
[sortDescending ? SortOrder.Descending : SortOrder.Ascending],
),
initialPageParam: 0,
initialPageParam: alphabet[0],
select: (data) => data.pages.flatMap((page) => [page.title, ...page.data]),
maxPages: alphabet.length,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
console.debug(`Albums last page length: ${lastPage.length}`)
return lastPage.length === QueryConfig.limits.library ? lastPageParam + 1 : undefined
console.debug(`Albums last page length: ${lastPage.data.length}`)
if (lastPageParam !== alphabet[alphabet.length - 1]) {
albumPageParams.current = [
...allPageParams,
alphabet[alphabet.indexOf(lastPageParam) + 1],
]
return alphabet[alphabet.indexOf(lastPageParam) + 1]
}
return undefined
},
getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => {
console.debug(`Albums first page: ${firstPage.title}`)
albumPageParams.current = allPageParams
if (firstPageParam !== alphabet[0]) {
albumPageParams.current = allPageParams
return alphabet[alphabet.indexOf(firstPageParam) - 1]
}
return undefined
},
})
@@ -134,13 +202,59 @@ const LibraryContextInitializer = () => {
isPendingArtists,
isPendingTracks,
isPendingAlbums,
artistPageParams,
albumPageParams,
isFetchingNextArtistsPage,
isFetchingNextTracksPage,
isFetchingNextAlbumsPage,
}
}
const LibraryContext = createContext<LibraryContext>({
artists: undefined,
refetchArtists: () => {},
fetchNextArtistsPage: () => {},
fetchNextArtistsPage: async () => {
return {
data: [],
status: 'success',
fetchStatus: 'idle',
isFetching: false,
isLoading: false,
isSuccess: true,
isError: false,
isStale: false,
error: null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
refetch: async () => Promise.resolve({} as any),
remove: () => {},
dataUpdatedAt: 0,
errorUpdatedAt: 0,
failureCount: 0,
isFetched: true,
isFetchingNextPage: false,
isFetchingPreviousPage: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fetchNextPage: async () => Promise.resolve({} as any),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fetchPreviousPage: async () => Promise.resolve({} as any),
hasNextPage: false,
hasPreviousPage: false,
isPending: false,
isLoadingError: false,
isRefetchError: false,
isPlaceholderData: false,
isFetchNextPageError: false,
isFetchPreviousPageError: false,
failureReason: null,
errorUpdateCount: 0,
isFetchedAfterMount: true,
isInitialLoading: false,
isPaused: false,
isRefetching: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
promise: Promise.resolve({} as any),
}
},
hasNextArtistsPage: false,
tracks: undefined,
refetchTracks: () => {},
@@ -148,11 +262,57 @@ const LibraryContext = createContext<LibraryContext>({
hasNextTracksPage: false,
albums: undefined,
refetchAlbums: () => {},
fetchNextAlbumsPage: () => {},
fetchNextAlbumsPage: async () => {
return {
data: [],
status: 'success',
fetchStatus: 'idle',
isFetching: false,
isLoading: false,
isSuccess: true,
isError: false,
isStale: false,
error: null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
refetch: async () => Promise.resolve({} as any),
remove: () => {},
dataUpdatedAt: 0,
errorUpdatedAt: 0,
failureCount: 0,
isFetched: true,
isFetchingNextPage: false,
isFetchingPreviousPage: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fetchNextPage: async () => Promise.resolve({} as any),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fetchPreviousPage: async () => Promise.resolve({} as any),
hasNextPage: false,
hasPreviousPage: false,
isPending: false,
isLoadingError: false,
isRefetchError: false,
isPlaceholderData: false,
isFetchNextPageError: false,
isFetchPreviousPageError: false,
failureReason: null,
errorUpdateCount: 0,
isFetchedAfterMount: true,
isInitialLoading: false,
isPaused: false,
isRefetching: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
promise: Promise.resolve({} as any),
}
},
hasNextAlbumsPage: false,
isPendingArtists: false,
isPendingTracks: false,
isPendingAlbums: false,
artistPageParams: { current: [] },
albumPageParams: { current: [] },
isFetchingNextArtistsPage: false,
isFetchingNextTracksPage: false,
isFetchingNextAlbumsPage: false,
})
export const LibraryProvider = ({ children }: { children: React.ReactNode }) => {
@@ -162,3 +322,4 @@ export const LibraryProvider = ({ children }: { children: React.ReactNode }) =>
}
export const useLibraryContext = () => useContext(LibraryContext)
export { useLibrarySortAndFilterContext }

View File

@@ -8,6 +8,8 @@ interface SettingsContext {
setSendMetrics: React.Dispatch<React.SetStateAction<boolean>>
autoDownload: boolean
setAutoDownload: React.Dispatch<React.SetStateAction<boolean>>
devTools: boolean
setDevTools: React.Dispatch<React.SetStateAction<boolean>>
}
/**
@@ -26,12 +28,16 @@ const SettingsContextInitializer = () => {
const autoDownloadInit = storage.getBoolean(MMKVStorageKeys.AutoDownload)
const devToolsInit = storage.getBoolean(MMKVStorageKeys.DevTools)
const [sendMetrics, setSendMetrics] = useState(sendMetricsInit ?? false)
const [autoDownload, setAutoDownload] = useState(
autoDownloadInit ?? ['ios', 'android'].includes(Platform.OS),
)
const [devTools, setDevTools] = useState(false)
useEffect(() => {
storage.set(MMKVStorageKeys.SendMetrics, sendMetrics)
}, [sendMetrics])
@@ -40,11 +46,17 @@ const SettingsContextInitializer = () => {
storage.set(MMKVStorageKeys.AutoDownload, autoDownload)
}, [autoDownload])
useEffect(() => {
storage.set(MMKVStorageKeys.DevTools, devTools)
}, [devTools])
return {
sendMetrics,
setSendMetrics,
autoDownload,
setAutoDownload,
devTools,
setDevTools,
}
}
@@ -53,6 +65,8 @@ export const SettingsContext = createContext<SettingsContext>({
setSendMetrics: () => {},
autoDownload: false,
setAutoDownload: () => {},
devTools: false,
setDevTools: () => {},
})
export const SettingsProvider = ({ children }: { children: React.ReactNode }) => {

View File

@@ -12,6 +12,8 @@ export default function RecentlyAdded({
fetchNextPage={route.params.fetchNextPage}
hasNextPage={route.params.hasNextPage}
isPending={route.params.isPending}
isFetchingNextPage={route.params.isFetchingNextPage}
showAlphabeticalSelector={false}
/>
)
}

View File

@@ -26,6 +26,8 @@ export default function HomeArtistsScreen({
fetchNextPage={fetchNextFrequentArtists}
hasNextPage={hasNextFrequentArtists}
isPending={isFetchingFrequentArtists}
isFetchingNextPage={isFetchingFrequentArtists}
showAlphabeticalSelector={false}
/>
)
}
@@ -37,6 +39,8 @@ export default function HomeArtistsScreen({
fetchNextPage={fetchNextRecentArtists}
hasNextPage={hasNextRecentArtists}
isPending={isFetchingRecentArtists}
isFetchingNextPage={isFetchingRecentArtists}
showAlphabeticalSelector={false}
/>
)
}

View File

@@ -25,7 +25,8 @@ export default function SettingsScreen(): React.JSX.Element {
/* https://www.reddit.com/r/reactnative/comments/1dgktbn/comment/lxd23sj/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button */
presentation: 'formSheet',
sheetInitialDetentIndex: 0,
sheetAllowedDetents: [0.2],
sheetAllowedDetents: [0.25],
headerShown: false,
}}
/>
</SettingsStack.Navigator>

View File

@@ -1,15 +1,20 @@
import TrackPlayer from 'react-native-track-player'
import { Button, Spacer, View, XStack, YStack } from 'tamagui'
import { Spacer, View, XStack, YStack } from 'tamagui'
import { SignOutModalProps } from './types'
import { H5, Text } from '../../components/Global/helpers/text'
export default function SignOutModal({ navigation }: SignOutModalProps): React.JSX.Element {
return (
<YStack marginHorizontal={'$6'}>
<H5>Sign out?</H5>
import Button from '../../components/Global/helpers/button'
import Icon from '../../components/Global/components/icon'
import { useJellifyContext } from '../../providers'
<Spacer />
export default function SignOutModal({ navigation }: SignOutModalProps): React.JSX.Element {
const { server } = useJellifyContext()
return (
<YStack margin={'$6'}>
<H5>{`Sign out of ${server?.name ?? 'Jellyfin'}?`}</H5>
<XStack gap={'$2'}>
<Button
icon={() => <Icon name='chevron-left' small color={'$borderColor'} />}
borderWidth={'$1'}
borderColor={'$borderColor'}
flex={1}
@@ -23,6 +28,7 @@ export default function SignOutModal({ navigation }: SignOutModalProps): React.J
</Button>
<Button
flex={1}
icon={() => <Icon name='logout' small color={'$danger'} />}
color={'$danger'}
borderColor={'$danger'}
onPress={() => {

View File

@@ -2182,6 +2182,14 @@
dependencies:
"@sentry/core" "8.54.0"
"@shopify/flash-list@^1.8.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-1.8.0.tgz#d271d3ca49acf9f53812594f0c4d24a994eb95e5"
integrity sha512-APZ48kceCCJobUimmI2594io+HujELK60HFKgzIyIdHGX5ySR5YfvsPy3PKtPwHHDtIMFNaq3U/BY3qZocOhCA==
dependencies:
recyclerlistview "4.2.3"
tslib "2.8.1"
"@sideway/address@^4.1.5":
version "4.1.5"
resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5"
@@ -6970,7 +6978,7 @@ locate-path@^6.0.0:
dependencies:
p-locate "^5.0.0"
lodash.debounce@^4.0.8:
lodash.debounce@4.0.8, lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
@@ -7971,7 +7979,7 @@ prompts@^2.0.1, prompts@^2.4.2:
kleur "^3.0.3"
sisteransi "^1.0.5"
prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@15.8.1, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -8388,6 +8396,15 @@ recast@^0.23.3:
tiny-invariant "^1.3.3"
tslib "^2.0.1"
recyclerlistview@4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/recyclerlistview/-/recyclerlistview-4.2.3.tgz#14032e7ad2f24396e24d5b3060c6ba76b567f000"
integrity sha512-STR/wj/FyT8EMsBzzhZ1l2goYirMkIgfV3gYEPxI3Kf3lOnu6f7Dryhyw7/IkQrgX5xtTcDrZMqytvteH9rL3g==
dependencies:
lodash.debounce "4.0.8"
prop-types "15.8.1"
ts-object-utils "0.0.5"
redent@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f"
@@ -9331,6 +9348,11 @@ ts-api-utils@^1.3.0:
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.3.tgz#bfc2215fe6528fecab2b0fba570a2e8a4263b064"
integrity sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==
ts-object-utils@0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/ts-object-utils/-/ts-object-utils-0.0.5.tgz#95361cdecd7e52167cfc5e634c76345e90a26077"
integrity sha512-iV0GvHqOmilbIKJsfyfJY9/dNHCs969z3so90dQWsO1eMMozvTpnB1MEaUbb3FYtZTGjv5sIy/xmslEz0Rg2TA==
tsconfig-paths@^3.15.0:
version "3.15.0"
resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4"
@@ -9341,16 +9363,16 @@ tsconfig-paths@^3.15.0:
minimist "^1.2.6"
strip-bom "^3.0.0"
tslib@2.8.1, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.8.1:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
tslib@^1.8.1:
version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.8.1:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
tsutils@^3.21.0:
version "3.21.0"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"