Merge branch 'main' into Swipe-to-add-to-queue
@@ -2,7 +2,7 @@
|
||||
name: Bug report
|
||||
about: Create a report to help us improve Jellify
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
type: bug
|
||||
assignees: anultravioletaurora
|
||||
|
||||
---
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
name: Feature request
|
||||
about: Suggest an idea for Jellify
|
||||
title: "[FEATURE]"
|
||||
type: feature
|
||||
labels: enhancement
|
||||
assignees: anultravioletaurora
|
||||
|
||||
|
||||
@@ -117,13 +117,15 @@ jobs:
|
||||
|
||||
- name: 🔑 Setup Play Store credentials
|
||||
run: |
|
||||
if [ -n "${{ secrets.PLAY_STORE_SERVICE_ACCOUNT }}" ]; then
|
||||
echo '${{ secrets.PLAY_STORE_SERVICE_ACCOUNT }}' > android/play-store-credentials.json
|
||||
if [ -n "$PLAY_STORE_CREDENTIALS" ]; then
|
||||
echo "$PLAY_STORE_CREDENTIALS" > android/play-store-credentials.json
|
||||
echo "Play Store credentials created"
|
||||
else
|
||||
echo "ERROR: No Play Store credentials secret found!"
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
PLAY_STORE_CREDENTIALS: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: 🤫 Output TelemetryDeck Secrets to TelemetryDeck.json
|
||||
run: |
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
# 🪼 Jellify
|
||||
|
||||
<img alt='Jellify logo' src='assets/icons/teal-icon.svg' width='250' height='250' /><br/>
|
||||
<img alt='Jellify logo' src='assets/transparent-banner.png' width="600" height="300" /><br/>
|
||||
|
||||
[](https://github.com/anultravioletaurora/Jellify/releases)
|
||||
[](https://github.com/anultravioletaurora/Jellify/actions/workflows/publish-beta.yml) [](https://github.com/Jellify-Music/App/actions/workflows/publish-ota-update.yml)
|
||||
@@ -211,6 +209,7 @@ Install via [Altstore](https://altstore.io) or your favorite sideloading utility
|
||||
### 🎨 Frontend
|
||||
|
||||
[Tamagui](https://tamagui.dev/)\
|
||||
[Figtree](https://github.com/erikdkennedy/figtree)\
|
||||
[React Navigation](https://reactnavigation.org/)\
|
||||
[React Native Blurhash](https://github.com/mrousavy/react-native-blurhash)\
|
||||
[React Native CarPlay](https://github.com/birkir/react-native-carplay)\
|
||||
@@ -281,7 +280,7 @@ This allows me to prioritize specific features, acquire additional hardware for
|
||||
- Over-the-Air Updates
|
||||
- Cast Support
|
||||
- The friends we made along the way that have been critical in fostering an amazing community around _Jellify_
|
||||
- [Thalia](https://github.com/PercyGabriel1129)
|
||||
- [Thalia](https://github.com/thaliadavar)
|
||||
- [BotBlake](https://github.com/BotBlake)
|
||||
- [Neptune1987](https://github.com/NeptuneHub)
|
||||
- My long time friends that have heard me talk about _Jellify_ for literally **eons**. Thank you for testing _Jellify_ during it's infancy and for supporting me all the way back at the beginning of this project
|
||||
|
||||
@@ -91,8 +91,8 @@ android {
|
||||
applicationId "com.jellify"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 131
|
||||
versionName "0.18.4"
|
||||
versionCode 132
|
||||
versionName "0.18.5"
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
|
||||
|
After Width: | Height: | Size: 39 KiB |
@@ -541,7 +541,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 243;
|
||||
CURRENT_PROJECT_VERSION = 244;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -552,7 +552,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.18.4;
|
||||
MARKETING_VERSION = 0.18.5;
|
||||
NEW_SETTING = "";
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
@@ -583,7 +583,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 243;
|
||||
CURRENT_PROJECT_VERSION = 244;
|
||||
DEVELOPMENT_TEAM = WAH9CZ8BPG;
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -593,7 +593,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.18.4;
|
||||
MARKETING_VERSION = 0.18.5;
|
||||
NEW_SETTING = "";
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellify",
|
||||
"version": "0.18.4",
|
||||
"version": "0.18.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"init-android": "yarn install --network-concurrency 1",
|
||||
@@ -44,7 +44,7 @@
|
||||
"@react-navigation/bottom-tabs": "7.5.0",
|
||||
"@react-navigation/material-top-tabs": "7.3.9",
|
||||
"@react-navigation/native": "7.1.18",
|
||||
"@react-navigation/native-stack": "7.5.0",
|
||||
"@react-navigation/native-stack": "7.5.1",
|
||||
"@sentry/react-native": "7.1.0",
|
||||
"@shopify/flash-list": "^2.1.0",
|
||||
"@tamagui/config": "1.135.4",
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 1016 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 561 KiB After Width: | Height: | Size: 540 KiB |
|
Before Width: | Height: | Size: 526 KiB After Width: | Height: | Size: 554 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 2.7 MiB |
@@ -1,35 +0,0 @@
|
||||
import { Api } from '@jellyfin/sdk'
|
||||
import { JellyfinInfo } from '../info'
|
||||
import _ from 'lodash'
|
||||
|
||||
export function createApi(
|
||||
serverUrl?: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
accessToken?: string,
|
||||
): Promise<Api> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (_.isUndefined(serverUrl)) {
|
||||
console.info("Server Url doesn't exist yet")
|
||||
return reject("Server Url doesn't exist")
|
||||
}
|
||||
|
||||
if (!_.isUndefined(accessToken)) {
|
||||
console.info('Creating API with accessToken')
|
||||
return resolve(JellyfinInfo.createApi(serverUrl, accessToken))
|
||||
}
|
||||
|
||||
if (_.isUndefined(username) && _.isUndefined(password)) {
|
||||
console.info('Creating public API for server url')
|
||||
return resolve(JellyfinInfo.createApi(serverUrl))
|
||||
}
|
||||
|
||||
JellyfinInfo.createApi(serverUrl)
|
||||
.authenticateUserByName(username!, password)
|
||||
.then(({ data }) => {
|
||||
if (data.AccessToken)
|
||||
return resolve(JellyfinInfo.createApi(serverUrl, data.AccessToken))
|
||||
else return reject('Unable to sign in')
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export const useFrequentlyPlayedArtists = () => {
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: FrequentlyPlayedArtistsQueryKey(user, library),
|
||||
queryFn: ({ pageParam }) => fetchFrequentlyPlayedArtists(api, library, pageParam),
|
||||
queryFn: ({ pageParam }) => fetchFrequentlyPlayedArtists(api, user, library, pageParam),
|
||||
select: (data) => data.pages.flatMap((page) => page),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
|
||||
|
||||
@@ -6,10 +6,14 @@ import {
|
||||
} from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { Api } from '@jellyfin/sdk'
|
||||
import { isUndefined } from 'lodash'
|
||||
import { isEmpty, isNull, isUndefined } from 'lodash'
|
||||
import { JellifyLibrary } from '../../../../types/JellifyLibrary'
|
||||
import { fetchItem } from '../../item'
|
||||
import { ApiLimits } from '../../query.config'
|
||||
import { JellifyUser } from '@/src/types/JellifyUser'
|
||||
import { queryClient } from '../../../../constants/query-client'
|
||||
import { InfiniteData } from '@tanstack/react-query'
|
||||
import { FrequentlyPlayedTracksQueryKey } from '../keys'
|
||||
|
||||
/**
|
||||
* Fetches the 100 most frequently played items from the user's library
|
||||
@@ -58,6 +62,7 @@ export function fetchFrequentlyPlayed(
|
||||
*/
|
||||
export function fetchFrequentlyPlayedArtists(
|
||||
api: Api | undefined,
|
||||
user: JellifyUser | undefined,
|
||||
library: JellifyLibrary | undefined,
|
||||
page: number,
|
||||
): Promise<BaseItemDto[]> {
|
||||
@@ -69,41 +74,49 @@ export function fetchFrequentlyPlayedArtists(
|
||||
if (isUndefined(api)) return reject('Client instance not set')
|
||||
if (isUndefined(library)) return reject('Library instance not set')
|
||||
|
||||
fetchFrequentlyPlayed(api, library, 0)
|
||||
.then((frequentTracks) => {
|
||||
return frequentTracks
|
||||
.filter((track) => !isUndefined(track.AlbumArtists))
|
||||
.map((track) => {
|
||||
return {
|
||||
artistId: track.AlbumArtists![0].Id!,
|
||||
playCount: track.UserData?.PlayCount ?? 0,
|
||||
}
|
||||
})
|
||||
})
|
||||
.then((albumArtistsWithPlayCounts) => {
|
||||
return albumArtistsWithPlayCounts.reduce(
|
||||
(acc, { artistId, playCount }) => {
|
||||
const existing = acc.find((a) => a.artistId === artistId)
|
||||
if (existing) {
|
||||
existing.playCount += playCount
|
||||
} else {
|
||||
acc.push({ artistId, playCount })
|
||||
}
|
||||
return acc
|
||||
},
|
||||
[] as { artistId: string; playCount: number }[],
|
||||
)
|
||||
})
|
||||
.then((artistsWithPlayCounts) => {
|
||||
console.debug('Fetching artists')
|
||||
const artists = artistsWithPlayCounts
|
||||
.sort((a, b) => b.playCount - a.playCount)
|
||||
.map((artist) => {
|
||||
return fetchItem(api, artist.artistId)
|
||||
})
|
||||
const frequentlyPlayed = queryClient.getQueryData<InfiniteData<BaseItemDto[]>>(
|
||||
FrequentlyPlayedTracksQueryKey(user, library),
|
||||
)
|
||||
if (isUndefined(frequentlyPlayed)) {
|
||||
return reject('Frequently played tracks not found in query client')
|
||||
}
|
||||
|
||||
return Promise.all(artists)
|
||||
const artistIdWithPlayCount = frequentlyPlayed.pages[page]
|
||||
.filter(
|
||||
(track) =>
|
||||
!isUndefined(track.AlbumArtists) &&
|
||||
!isNull(track.AlbumArtists) &&
|
||||
!isEmpty(track.AlbumArtists) &&
|
||||
!isUndefined(track.AlbumArtists![0].Id),
|
||||
)
|
||||
.map(({ AlbumArtists, UserData }) => {
|
||||
return {
|
||||
artistId: AlbumArtists![0].Id!,
|
||||
playCount: UserData?.PlayCount ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
console.info('Artist IDs with play count:', artistIdWithPlayCount.length)
|
||||
|
||||
const artistPromises = artistIdWithPlayCount
|
||||
.reduce(
|
||||
(acc, { artistId, playCount }) => {
|
||||
const existing = acc.find((a) => a.artistId === artistId)
|
||||
if (existing) {
|
||||
existing.playCount += playCount
|
||||
} else {
|
||||
acc.push({ artistId, playCount })
|
||||
}
|
||||
return acc
|
||||
},
|
||||
[] as { artistId: string; playCount: number }[],
|
||||
)
|
||||
.sort((a, b) => b.playCount - a.playCount)
|
||||
.map((artist) => {
|
||||
return fetchItem(api, artist.artistId)
|
||||
})
|
||||
|
||||
return Promise.all(artistPromises)
|
||||
.then((artists) => {
|
||||
return resolve(artists.filter((artist) => !isUndefined(artist)))
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function fetchItem(api: Api | undefined, itemId: string): Promise<B
|
||||
getItemsApi(api)
|
||||
.getItems({
|
||||
ids: [itemId],
|
||||
fields: [ItemFields.Tags],
|
||||
fields: [ItemFields.Tags, ItemFields.Genres],
|
||||
enableUserData: true,
|
||||
})
|
||||
.then((response) => {
|
||||
@@ -77,7 +77,7 @@ export async function fetchItems(
|
||||
sortBy,
|
||||
recursive: true,
|
||||
sortOrder,
|
||||
fields: [ItemFields.ChildCount, ItemFields.SortName],
|
||||
fields: [ItemFields.ChildCount, ItemFields.SortName, ItemFields.Genres],
|
||||
startIndex: typeof page === 'number' ? page * QueryConfig.limits.library : 0,
|
||||
limit: QueryConfig.limits.library,
|
||||
isFavorite,
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { ONE_DAY } from '../../constants/query-client'
|
||||
import { ImageFormat } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
|
||||
export enum ApiLimits {
|
||||
Discover = 50,
|
||||
Home = 100,
|
||||
Library = 400,
|
||||
Discover = 50,
|
||||
Similar = 5,
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Create an enumeration for the configuration needed.
|
||||
*/
|
||||
const QueryConfig = {
|
||||
/**
|
||||
* Defines the limits for the number of items returned by a query
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ApiLimits } from '../query.config'
|
||||
import { isUndefined } from 'lodash'
|
||||
|
||||
const RECENTS_QUERY_CONFIG = {
|
||||
maxPages: 2,
|
||||
refetchOnMount: false,
|
||||
staleTime: Infinity,
|
||||
} as const
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import QueryConfig from './query.config'
|
||||
import { ApiLimits } from './query.config'
|
||||
import { Api } from '@jellyfin/sdk'
|
||||
import { isUndefined } from 'lodash'
|
||||
import { JellifyUser } from '../../types/JellifyUser'
|
||||
@@ -10,7 +10,7 @@ export default function fetchSimilar(
|
||||
user: JellifyUser | undefined,
|
||||
libraryId: string | undefined,
|
||||
itemId: string,
|
||||
limit: number = QueryConfig.limits.similar,
|
||||
limit: number = ApiLimits.Similar,
|
||||
startIndex: number = 0,
|
||||
): Promise<BaseItemDto[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function fetchSearchSuggestions(
|
||||
BaseItemKind.MusicAlbum,
|
||||
],
|
||||
sortBy: ['IsFavoriteOrLiked', 'Random'],
|
||||
fields: [ItemFields.ChildCount, ItemFields.SortName, ItemFields.Genres],
|
||||
})
|
||||
.then(({ data }) => {
|
||||
if (data.Items) resolve(data.Items)
|
||||
@@ -60,7 +61,7 @@ export async function fetchArtistSuggestions(
|
||||
userId: user.id,
|
||||
limit: 50,
|
||||
startIndex: page * 50,
|
||||
fields: [ItemFields.ChildCount, ItemFields.SortName],
|
||||
fields: [ItemFields.ChildCount, ItemFields.SortName, ItemFields.Genres],
|
||||
sortBy: ['Random'],
|
||||
})
|
||||
.then(({ data }) => {
|
||||
|
||||
@@ -56,15 +56,13 @@ export default function ArtistNavigation({
|
||||
)
|
||||
|
||||
return (
|
||||
<SafeAreaView edges={['right', 'left']}>
|
||||
<SectionList
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
sections={sections}
|
||||
ListHeaderComponent={ArtistHeader}
|
||||
renderSectionHeader={renderSectionHeader}
|
||||
renderItem={({ item }) => <ItemRow item={item} navigation={navigation} />}
|
||||
ListFooterComponent={SimilarArtists}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
<SectionList
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
sections={sections}
|
||||
ListHeaderComponent={ArtistHeader}
|
||||
renderSectionHeader={renderSectionHeader}
|
||||
renderItem={({ item }) => <ItemRow item={item} navigation={navigation} />}
|
||||
ListFooterComponent={SimilarArtists}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { ItemCard } from '../Global/components/item-card'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { BaseStackParamList } from '../../screens/types'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { Text } from '../Global/helpers/text'
|
||||
import { useArtistContext } from '../../providers/Artist'
|
||||
import { ActivityIndicator } from 'react-native'
|
||||
import navigationRef from '../../../navigation'
|
||||
import HorizontalCardList from '../Global/components/horizontal-list'
|
||||
import { H6, YStack } from 'tamagui'
|
||||
import { YStack } from 'tamagui'
|
||||
import { FlashList } from '@shopify/flash-list'
|
||||
import ItemRow from '../Global/components/item-row'
|
||||
|
||||
export default function SimilarArtists(): React.JSX.Element {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>()
|
||||
@@ -21,24 +20,16 @@ export default function SimilarArtists(): React.JSX.Element {
|
||||
bold
|
||||
>{`Similar to ${artist.Name ?? 'Unknown Artist'}`}</Text>
|
||||
|
||||
<HorizontalCardList
|
||||
<FlashList
|
||||
data={similarArtists}
|
||||
renderItem={({ item: artist }) => (
|
||||
<ItemCard
|
||||
caption={artist.Name ?? 'Unknown Artist'}
|
||||
size={'$8'}
|
||||
<ItemRow
|
||||
item={artist}
|
||||
onPress={() => {
|
||||
navigation.push('Artist', {
|
||||
artist,
|
||||
})
|
||||
}}
|
||||
onLongPress={() => {
|
||||
navigationRef.navigate('Context', {
|
||||
item: artist,
|
||||
navigation,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
@@ -50,7 +41,6 @@ export default function SimilarArtists(): React.JSX.Element {
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
removeClippedSubviews
|
||||
/>
|
||||
</YStack>
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ export default function Index(): React.JSX.Element {
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
marginTop: getToken('$4'),
|
||||
marginHorizontal: getToken('$2'),
|
||||
}}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
removeClippedSubviews
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import HorizontalCardList from '../../../components/Global/components/horizontal-list'
|
||||
import { ItemCard } from '../../../components/Global/components/item-card'
|
||||
import { useDiscoverContext } from '../../../providers/Discover'
|
||||
import { View, XStack } from 'tamagui'
|
||||
import { H5, View, XStack } from 'tamagui'
|
||||
import { H4 } from '../../../components/Global/helpers/text'
|
||||
import Icon from '../../Global/components/icon'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
@@ -25,7 +25,7 @@ export default function RecentlyAdded(): React.JSX.Element {
|
||||
})
|
||||
}}
|
||||
>
|
||||
<H4 marginLeft={'$2'}>Recently Added</H4>
|
||||
<H5 marginLeft={'$2'}>Recently Added</H5>
|
||||
<Icon name='arrow-right' />
|
||||
</XStack>
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function RecentlyAdded(): React.JSX.Element {
|
||||
caption={item.Name}
|
||||
subCaption={`${item.Artists?.join(', ')}`}
|
||||
squared
|
||||
size={'$10'}
|
||||
size={'$11'}
|
||||
item={item}
|
||||
onPress={() => {
|
||||
navigation.navigate('Album', {
|
||||
@@ -49,6 +49,8 @@ export default function RecentlyAdded(): React.JSX.Element {
|
||||
navigation,
|
||||
})
|
||||
}}
|
||||
gap={'$1'}
|
||||
captionAlign='left'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, XStack } from 'tamagui'
|
||||
import { H5, View, XStack } from 'tamagui'
|
||||
import { useDiscoverContext } from '../../../providers/Discover'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import Icon from '../../Global/components/icon'
|
||||
@@ -41,9 +41,9 @@ export default function PublicPlaylists() {
|
||||
})
|
||||
}}
|
||||
>
|
||||
<H4 marginLeft={'$2'} lineBreakStrategyIOS='standard' maxWidth={width * 0.8}>
|
||||
<H5 marginLeft={'$2'} lineBreakStrategyIOS='standard' maxWidth={width * 0.8}>
|
||||
Playlists on {server?.name ?? 'Jellyfin'}
|
||||
</H4>
|
||||
</H5>
|
||||
<Icon name='arrow-right' />
|
||||
</XStack>
|
||||
<HorizontalCardList
|
||||
@@ -64,6 +64,8 @@ export default function PublicPlaylists() {
|
||||
navigation,
|
||||
})
|
||||
}
|
||||
marginHorizontal={'$1'}
|
||||
captionAlign='left'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { View, XStack } from 'tamagui'
|
||||
import { H5, View, XStack } from 'tamagui'
|
||||
import Icon from '../../Global/components/icon'
|
||||
import HorizontalCardList from '../../Global/components/horizontal-list'
|
||||
import { ItemCard } from '../../Global/components/item-card'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { useDiscoverContext } from '../../../providers/Discover'
|
||||
import { H4 } from '../../Global/helpers/text'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import DiscoverStackParamList from '../../../screens/Discover/types'
|
||||
import navigationRef from '../../../../navigation'
|
||||
import { pickFirstGenre } from '../../../utils/genre-formatting'
|
||||
|
||||
export default function SuggestedArtists(): React.JSX.Element {
|
||||
const { suggestedArtistsInfiniteQuery } = useDiscoverContext()
|
||||
@@ -26,7 +26,7 @@ export default function SuggestedArtists(): React.JSX.Element {
|
||||
}}
|
||||
marginLeft={'$2'}
|
||||
>
|
||||
<H4>Suggested Artists</H4>
|
||||
<H5>Suggested Artists</H5>
|
||||
<Icon name='arrow-right' />
|
||||
</XStack>
|
||||
<HorizontalCardList
|
||||
@@ -34,6 +34,7 @@ export default function SuggestedArtists(): React.JSX.Element {
|
||||
renderItem={({ item }) => (
|
||||
<ItemCard
|
||||
caption={item.Name}
|
||||
subCaption={pickFirstGenre(item.Genres)}
|
||||
size={'$10'}
|
||||
item={item}
|
||||
onPress={() => {
|
||||
|
||||
@@ -114,7 +114,7 @@ function Image({
|
||||
? getBorderRadius(circular, width)
|
||||
: circular
|
||||
? getTokenValue('$20') * 10
|
||||
: getTokenValue('$2'),
|
||||
: getTokenValue('$5'),
|
||||
width: !isUndefined(width)
|
||||
? typeof width === 'number'
|
||||
? width
|
||||
@@ -180,8 +180,8 @@ function getBorderRadius(
|
||||
? width / 25
|
||||
: typeof width === 'string' && width.includes('%')
|
||||
? 0
|
||||
: getTokenValue(width as Token) / 15
|
||||
} else borderRadius = getTokenValue('$2')
|
||||
: getTokenValue(width as Token) / 10
|
||||
} else borderRadius = getTokenValue('$10')
|
||||
|
||||
return borderRadius
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ interface CardProps extends TamaguiCardProps {
|
||||
item: BaseItemDto
|
||||
squared?: boolean
|
||||
testId?: string | null | undefined
|
||||
captionAlign?: 'center' | 'left' | 'right'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,6 +30,7 @@ function ItemCardComponent({
|
||||
squared,
|
||||
testId,
|
||||
onPress,
|
||||
captionAlign = 'center',
|
||||
...cardProps
|
||||
}: CardProps) {
|
||||
usePerformanceMonitor('ItemCard', 2)
|
||||
@@ -74,13 +76,24 @@ function ItemCardComponent({
|
||||
</TamaguiCard.Background>
|
||||
</TamaguiCard>
|
||||
{caption && (
|
||||
<YStack alignContent='center' alignItems='center' maxWidth={cardProps.size}>
|
||||
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
<YStack maxWidth={cardProps.size}>
|
||||
<Text
|
||||
bold
|
||||
lineBreakStrategyIOS='standard'
|
||||
width={cardProps.size}
|
||||
numberOfLines={1}
|
||||
textAlign={captionAlign}
|
||||
>
|
||||
{caption}
|
||||
</Text>
|
||||
|
||||
{subCaption && (
|
||||
<Text lineBreakStrategyIOS='standard' numberOfLines={1} textAlign='center'>
|
||||
<Text
|
||||
lineBreakStrategyIOS='standard'
|
||||
width={cardProps.size}
|
||||
numberOfLines={1}
|
||||
textAlign={captionAlign}
|
||||
>
|
||||
{subCaption}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -225,7 +225,7 @@ function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element {
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
{shouldRenderGenres && (
|
||||
{shouldRenderGenres && item.Genres && (
|
||||
<Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
{item.Genres?.join(', ') ?? ''}
|
||||
</Text>
|
||||
|
||||
@@ -2,8 +2,7 @@ import HorizontalCardList from '../../../components/Global/components/horizontal
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import React, { useCallback } from 'react'
|
||||
import { ItemCard } from '../../../components/Global/components/item-card'
|
||||
import { View, XStack } from 'tamagui'
|
||||
import { H4 } from '../../../components/Global/helpers/text'
|
||||
import { H5, View, XStack } from 'tamagui'
|
||||
import Icon from '../../Global/components/icon'
|
||||
import { useDisplayContext } from '../../../providers/Display/display-provider'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
@@ -11,6 +10,7 @@ import HomeStackParamList from '../../../screens/Home/types'
|
||||
import { RootStackParamList } from '../../../screens/types'
|
||||
import { useFrequentlyPlayedArtists } from '../../../api/queries/frequents'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
|
||||
import { pickFirstGenre } from '../../../utils/genre-formatting'
|
||||
|
||||
export default function FrequentArtists(): React.JSX.Element {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList>>()
|
||||
@@ -24,6 +24,7 @@ export default function FrequentArtists(): React.JSX.Element {
|
||||
<ItemCard
|
||||
item={artist}
|
||||
caption={artist.Name ?? 'Unknown Artist'}
|
||||
subCaption={pickFirstGenre(artist.Genres)}
|
||||
onPress={() => {
|
||||
navigation.navigate('Artist', {
|
||||
artist,
|
||||
@@ -51,7 +52,7 @@ export default function FrequentArtists(): React.JSX.Element {
|
||||
})
|
||||
}}
|
||||
>
|
||||
<H4 marginLeft={'$2'}>Most Played</H4>
|
||||
<H5 marginLeft={'$2'}>Most Played</H5>
|
||||
<Icon name='arrow-right' />
|
||||
</XStack>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { View, XStack } from 'tamagui'
|
||||
import { H5, View, XStack } from 'tamagui'
|
||||
import HorizontalCardList from '../../../components/Global/components/horizontal-list'
|
||||
import { ItemCard } from '../../../components/Global/components/item-card'
|
||||
import { QueuingType } from '../../../enums/queuing-type'
|
||||
@@ -41,7 +41,7 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
|
||||
})
|
||||
}}
|
||||
>
|
||||
<H4 marginLeft={'$2'}>On Repeat</H4>
|
||||
<H5 marginLeft={'$2'}>On Repeat</H5>
|
||||
<Icon name='arrow-right' />
|
||||
</XStack>
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
|
||||
renderItem={({ item: track, index }) => (
|
||||
<ItemCard
|
||||
item={track}
|
||||
size={'$10'}
|
||||
size={'$11'}
|
||||
caption={track.Name}
|
||||
subCaption={`${track.Artists?.join(', ')}`}
|
||||
squared
|
||||
@@ -77,6 +77,8 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
|
||||
navigation,
|
||||
})
|
||||
}}
|
||||
marginHorizontal={'$1'}
|
||||
captionAlign='left'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
import { View, XStack } from 'tamagui'
|
||||
import { H4 } from '../../Global/helpers/text'
|
||||
import { H5, View, XStack } from 'tamagui'
|
||||
import { RootStackParamList } from '../../../screens/types'
|
||||
import { ItemCard } from '../../Global/components/item-card'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
@@ -10,6 +9,7 @@ import { useDisplayContext } from '../../../providers/Display/display-provider'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import HomeStackParamList from '../../../screens/Home/types'
|
||||
import { useRecentArtists } from '../../../api/queries/recents'
|
||||
import { pickFirstGenre } from '../../../utils/genre-formatting'
|
||||
|
||||
export default function RecentArtists(): React.JSX.Element {
|
||||
const recentArtistsInfiniteQuery = useRecentArtists()
|
||||
@@ -29,7 +29,7 @@ export default function RecentArtists(): React.JSX.Element {
|
||||
})
|
||||
}}
|
||||
>
|
||||
<H4 marginLeft={'$2'}>Recent Artists</H4>
|
||||
<H5 marginLeft={'$2'}>Recent Artists</H5>
|
||||
<Icon name='arrow-right' />
|
||||
</XStack>
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function RecentArtists(): React.JSX.Element {
|
||||
<ItemCard
|
||||
item={recentArtist}
|
||||
caption={recentArtist.Name ?? 'Unknown Artist'}
|
||||
subCaption={pickFirstGenre(recentArtist.Genres)}
|
||||
onPress={() => {
|
||||
navigation.navigate('Artist', {
|
||||
artist: recentArtist,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { View, XStack } from 'tamagui'
|
||||
import { H5, View, XStack } from 'tamagui'
|
||||
import { H4 } from '../../Global/helpers/text'
|
||||
import { ItemCard } from '../../Global/components/item-card'
|
||||
import { RootStackParamList } from '../../../screens/types'
|
||||
@@ -45,7 +45,7 @@ export default function RecentlyPlayed(): React.JSX.Element {
|
||||
})
|
||||
}}
|
||||
>
|
||||
<H4 marginLeft={'$2'}>Play it again</H4>
|
||||
<H5 marginLeft={'$2'}>Play it again</H5>
|
||||
<Icon name='arrow-right' />
|
||||
</XStack>
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function RecentlyPlayed(): React.JSX.Element {
|
||||
}
|
||||
renderItem={({ index, item: recentlyPlayedTrack }) => (
|
||||
<ItemCard
|
||||
size={'$10'}
|
||||
size={'$11'}
|
||||
caption={recentlyPlayedTrack.Name}
|
||||
subCaption={`${recentlyPlayedTrack.Artists?.join(', ')}`}
|
||||
squared
|
||||
@@ -82,6 +82,8 @@ export default function RecentlyPlayed(): React.JSX.Element {
|
||||
navigation,
|
||||
})
|
||||
}}
|
||||
marginHorizontal={'$1'}
|
||||
captionAlign='left'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ScrollView, RefreshControl } from 'react-native'
|
||||
import { ScrollView, RefreshControl, Platform } from 'react-native'
|
||||
import { YStack, getToken } from 'tamagui'
|
||||
import RecentArtists from './helpers/recent-artists'
|
||||
import RecentlyPlayed from './helpers/recently-played'
|
||||
@@ -22,10 +22,15 @@ export function Home(): React.JSX.Element {
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
marginVertical: getToken('$4'),
|
||||
marginHorizontal: getToken('$2'),
|
||||
}}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} />}
|
||||
>
|
||||
<YStack alignContent='flex-start' gap='$3'>
|
||||
<YStack
|
||||
alignContent='flex-start'
|
||||
gap='$3'
|
||||
marginBottom={Platform.OS === 'android' ? '$4' : undefined}
|
||||
>
|
||||
<RecentArtists />
|
||||
|
||||
<RecentlyPlayed />
|
||||
|
||||
@@ -22,19 +22,18 @@ function LibraryTabBar(props: MaterialTopTabBarProps) {
|
||||
|
||||
{[''].includes(props.state.routes[props.state.index].name) ? null : (
|
||||
<XStack
|
||||
paddingHorizontal={'$4'}
|
||||
borderWidth={'$1'}
|
||||
borderColor={'$borderColor'}
|
||||
marginTop={'$2'}
|
||||
marginHorizontal={'$2'}
|
||||
borderRadius={'$4'}
|
||||
backgroundColor={'$background'}
|
||||
alignItems={'center'}
|
||||
justifyContent='flex-end'
|
||||
justifyContent='flex-start'
|
||||
paddingHorizontal={'$4'}
|
||||
paddingVertical={'$1'}
|
||||
gap={'$4'}
|
||||
maxWidth={'80%'}
|
||||
>
|
||||
{props.state.routes[props.state.index].name === 'Playlists' ? (
|
||||
<XStack
|
||||
flex={1}
|
||||
onPress={() => {
|
||||
trigger('impactLight')
|
||||
props.navigation.navigate('AddPlaylist')
|
||||
@@ -48,7 +47,6 @@ function LibraryTabBar(props: MaterialTopTabBarProps) {
|
||||
</XStack>
|
||||
) : (
|
||||
<XStack
|
||||
flex={1}
|
||||
onPress={() => {
|
||||
trigger('impactLight')
|
||||
setIsFavorites(!isUndefined(isFavorites) ? undefined : true)
|
||||
@@ -69,7 +67,6 @@ function LibraryTabBar(props: MaterialTopTabBarProps) {
|
||||
|
||||
{props.state.routes[props.state.index].name === 'Tracks' && (
|
||||
<XStack
|
||||
flex={1}
|
||||
onPress={() => {
|
||||
trigger('impactLight')
|
||||
setIsDownloaded(!isDownloaded)
|
||||
|
||||
@@ -16,6 +16,8 @@ export default function PlayerHeader(): React.JSX.Element {
|
||||
|
||||
const theme = useTheme()
|
||||
|
||||
const artworkMaxHeight = Platform.OS === 'android' ? '65%' : '70%'
|
||||
|
||||
// If the Queue is a BaseItemDto, display the name of it
|
||||
const playingFrom = useMemo(
|
||||
() =>
|
||||
@@ -56,7 +58,7 @@ export default function PlayerHeader(): React.JSX.Element {
|
||||
flexGrow={1}
|
||||
justifyContent='center'
|
||||
paddingHorizontal={'$2'}
|
||||
maxHeight={'70%'}
|
||||
maxHeight={artworkMaxHeight}
|
||||
marginVertical={'auto'}
|
||||
>
|
||||
<Animated.View
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import axios from 'axios'
|
||||
|
||||
/**
|
||||
* The Axios instance for making HTTP requests.
|
||||
*
|
||||
* Default timeout is set to 15 seconds.
|
||||
*/
|
||||
const AXIOS_INSTANCE = axios.create({
|
||||
timeout: 15 * 1000, // 15 seconds
|
||||
})
|
||||
|
||||
export default AXIOS_INSTANCE
|
||||
@@ -29,9 +29,9 @@ export const queryClient = new QueryClient({
|
||||
gcTime: ONE_DAY,
|
||||
|
||||
/**
|
||||
* Refetch data after 2 hours as a default
|
||||
* Refetch data after 12 hours as a default
|
||||
*/
|
||||
staleTime: ONE_HOUR * 2,
|
||||
staleTime: ONE_HOUR * 12,
|
||||
|
||||
refetchIntervalInBackground: false,
|
||||
|
||||
|
||||
@@ -47,8 +47,6 @@ export const PlayerProvider: () => React.JSX.Element = () => {
|
||||
|
||||
switch (event.type) {
|
||||
case Event.PlaybackActiveTrackChanged:
|
||||
await handleActiveTrackChanged()
|
||||
|
||||
if (event.track) {
|
||||
nowPlaying = event.track as JellifyTrack
|
||||
|
||||
@@ -56,6 +54,8 @@ export const PlayerProvider: () => React.JSX.Element = () => {
|
||||
await TrackPlayer.setVolume(volume)
|
||||
}
|
||||
|
||||
await handleActiveTrackChanged()
|
||||
|
||||
if (event.lastTrack)
|
||||
if (isPlaybackFinished(event.lastPosition, event.lastTrack.duration ?? 1))
|
||||
await reportPlaybackCompleted(api, event.lastTrack as JellifyTrack)
|
||||
|
||||
@@ -16,6 +16,7 @@ import { MMKVStorageKeys } from '../enums/mmkv-storage-keys'
|
||||
import { Api } from '@jellyfin/sdk/lib/api'
|
||||
import { JellyfinInfo } from '../api/info'
|
||||
import { queryClient } from '../constants/query-client'
|
||||
import AXIOS_INSTANCE from '../configs/axios.config'
|
||||
|
||||
/**
|
||||
* The context for the Jellify provider.
|
||||
@@ -99,8 +100,9 @@ const JellifyContextInitializer = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUndefined(server) && !isUndefined(user))
|
||||
setApi(JellyfinInfo.createApi(server.url, user.accessToken))
|
||||
else if (!isUndefined(server)) setApi(JellyfinInfo.createApi(server.url))
|
||||
setApi(JellyfinInfo.createApi(server.url, user.accessToken, AXIOS_INSTANCE))
|
||||
else if (!isUndefined(server))
|
||||
setApi(JellyfinInfo.createApi(server.url, undefined, AXIOS_INSTANCE))
|
||||
else setApi(undefined)
|
||||
|
||||
setLoggedIn(!isUndefined(server) && !isUndefined(user) && !isUndefined(library))
|
||||
|
||||
@@ -10,6 +10,7 @@ import LibraryScreen from '../Library'
|
||||
import TabParamList from './types'
|
||||
import { TabProps } from '../types'
|
||||
import TabBar from './tab-bar'
|
||||
import { Platform } from 'react-native'
|
||||
|
||||
const Tab = createBottomTabNavigator<TabParamList>()
|
||||
|
||||
@@ -18,6 +19,10 @@ export default function Tabs({ route, navigation }: TabProps): React.JSX.Element
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
/*
|
||||
* https://github.com/react-navigation/react-navigation/issues/12755
|
||||
*/
|
||||
detachInactiveScreens={Platform.OS !== 'ios'}
|
||||
initialRouteName={route.params?.screen ?? 'HomeTab'}
|
||||
screenOptions={{
|
||||
animation: 'shift',
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
*
|
||||
* @param genres A list of Genres returned by the Jellyfin API
|
||||
* @returns A the first, singular genre the array
|
||||
* @example Kim Petras - "Dance"
|
||||
*/
|
||||
export function pickFirstGenre(genres: string[] | undefined | null): string {
|
||||
if (!genres || genres.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return genres[0].split(';')[0]
|
||||
}
|
||||
@@ -23,10 +23,10 @@ const tokens = createTokens({
|
||||
white: '#ffffff',
|
||||
neutral: '#77748E',
|
||||
|
||||
darkBackground: 'rgb(17, 16, 20)',
|
||||
darkBackground75: 'rgba(17, 16, 20, 0.75)',
|
||||
darkBackground50: 'rgba(17, 16, 20, 0.5)',
|
||||
darkBackground25: 'rgba(17, 16, 20, 0.25)',
|
||||
darkBackground: 'rgba(25, 24, 28, 1)',
|
||||
darkBackground75: 'rgba(25, 24, 28, 0.75)',
|
||||
darkBackground50: 'rgba(25, 24, 28, 0.5)',
|
||||
darkBackground25: 'rgba(25, 24, 28, 0.25)',
|
||||
|
||||
darkBorder: '#CEAAFF',
|
||||
|
||||
|
||||
@@ -2167,10 +2167,10 @@
|
||||
color "^4.2.3"
|
||||
react-native-tab-view "^4.2.0"
|
||||
|
||||
"@react-navigation/native-stack@7.5.0":
|
||||
version "7.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@react-navigation/native-stack/-/native-stack-7.5.0.tgz#413e586cf5d0615aad35fe48493975b0073dfc81"
|
||||
integrity sha512-boUYXqEsI+plBTfnCOorW7v+lLk/B+0875hZ5qdK6C4cItgGzle2y+tU1dlfQCRszyXlz4l/qwJwTLFwUqUGDg==
|
||||
"@react-navigation/native-stack@7.5.1":
|
||||
version "7.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@react-navigation/native-stack/-/native-stack-7.5.1.tgz#8b184a65c49bcd21d859ac06ae9c033e0df319b7"
|
||||
integrity sha512-OTn+thYqa5z43j8CyVsi/pTAAZHj27ly/fsd9zz8l8ypxoGhYz7Ro0+Gb9MsL+9Yw1QJTXzJ8dk41+Ay1MafKg==
|
||||
dependencies:
|
||||
"@react-navigation/elements" "^2.7.0"
|
||||
color "^4.2.3"
|
||||
|
||||