Fix Context Sheet (Android), Context Sheet Styling, Player Styling Fixes, Query Client Optimizations (#481)

* player style fixes

* fix content sheet for android

* context sheet prefetching in item card, item row, and track components

context sheet header styling

fix issue where nowplaying was incorrect after the queue was initialized from storage
This commit is contained in:
Violet Caulfield
2025-08-21 02:17:30 -05:00
committed by GitHub
48 changed files with 864 additions and 715 deletions

10
App.tsx
View File

@@ -7,7 +7,7 @@ import { TamaguiProvider } from 'tamagui'
import { Platform, useColorScheme } from 'react-native'
import jellifyConfig from './tamagui.config'
import { clientPersister } from './src/constants/storage'
import { queryClient } from './src/constants/query-client'
import { ONE_DAY, queryClient } from './src/constants/query-client'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import TrackPlayer, {
AndroidAudioContentType,
@@ -24,6 +24,7 @@ import OTAUpdateScreen from './src/components/OtaUpdates'
import { usePerformanceMonitor } from './src/hooks/use-performance-monitor'
import { SettingsProvider, useThemeSettingContext } from './src/providers/Settings'
import navigationRef from './navigation'
import { PROGRESS_UPDATE_EVENT_INTERVAL } from './src/player/config'
export default function App(): React.JSX.Element {
// Add performance monitoring to track app-level re-renders
@@ -59,7 +60,7 @@ export default function App(): React.JSX.Element {
capabilities: CAPABILITIES,
notificationCapabilities: CAPABILITIES,
// Reduced interval for smoother progress tracking and earlier prefetch detection
progressUpdateEventInterval: 5,
progressUpdateEventInterval: PROGRESS_UPDATE_EVENT_INTERVAL,
}),
)
.finally(() => {
@@ -109,10 +110,9 @@ function Container({ playerIsReady }: { playerIsReady: boolean }): React.JSX.Ele
persister: clientPersister,
/**
* Infinity, since data can remain the
* same forever on the server
* Maximum query data age of one day
*/
maxAge: Infinity,
maxAge: ONE_DAY,
}}
>
<GestureHandlerRootView>

74
android/.run/app.run.xml Normal file
View File

@@ -0,0 +1,74 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="app" type="AndroidRunConfigurationType" factoryName="Android App" activateToolWindowBeforeRun="false">
<module name="Jellify.app" />
<option name="ANDROID_RUN_CONFIGURATION_SCHEMA_VERSION" value="1" />
<option name="DEPLOY" value="true" />
<option name="DEPLOY_APK_FROM_BUNDLE" value="false" />
<option name="DEPLOY_AS_INSTANT" value="false" />
<option name="ARTIFACT_NAME" value="" />
<option name="PM_INSTALL_OPTIONS" value="" />
<option name="ALL_USERS" value="false" />
<option name="ALWAYS_INSTALL_WITH_PM" value="false" />
<option name="ALLOW_ASSUME_VERIFIED" value="false" />
<option name="CLEAR_APP_STORAGE" value="false" />
<option name="DYNAMIC_FEATURES_DISABLED_LIST" value="" />
<option name="ACTIVITY_EXTRA_FLAGS" value="" />
<option name="MODE" value="default_activity" />
<option name="RESTORE_ENABLED" value="false" />
<option name="RESTORE_FILE" value="" />
<option name="RESTORE_FRESH_INSTALL_ONLY" value="false" />
<option name="CLEAR_LOGCAT" value="false" />
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
<option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" />
<option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="" />
<option name="DEBUGGER_TYPE" value="Auto" />
<Auto>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
<option name="DEBUG_SANDBOX_SDK" value="false" />
</Auto>
<Hybrid>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
<option name="DEBUG_SANDBOX_SDK" value="false" />
</Hybrid>
<Java>
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
<option name="DEBUG_SANDBOX_SDK" value="false" />
</Java>
<Native>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
<option name="DEBUG_SANDBOX_SDK" value="false" />
</Native>
<Profilers>
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
<option name="STARTUP_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Java/Kotlin Method Sample (legacy)" />
<option name="STARTUP_NATIVE_MEMORY_PROFILING_ENABLED" value="false" />
<option name="NATIVE_MEMORY_SAMPLE_RATE_BYTES" value="2048" />
</Profilers>
<option name="DEEP_LINK" value="" />
<option name="ACTIVITY" value="" />
<option name="ACTIVITY_CLASS" value="" />
<option name="SEARCH_ACTIVITY_IN_GLOBAL_SCOPE" value="false" />
<option name="SKIP_ACTIVITY_VALIDATION" value="false" />
<method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>

View File

@@ -2719,7 +2719,7 @@ PODS:
- RNWorklets
- SocketRocket
- Yoga
- RNScreens (4.14.1):
- RNScreens (4.15.0):
- boost
- DoubleConversion
- fast_float
@@ -2746,10 +2746,10 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- RNScreens/common (= 4.14.1)
- RNScreens/common (= 4.15.0)
- SocketRocket
- Yoga
- RNScreens/common (4.14.1):
- RNScreens/common (4.15.0):
- boost
- DoubleConversion
- fast_float
@@ -3306,7 +3306,7 @@ SPEC CHECKSUMS:
RNGestureHandler: 3a73f098d74712952870e948b3d9cf7b6cae9961
RNReactNativeHapticFeedback: be4f1b4bf0398c30b59b76ed92ecb0a2ff3a69c6
RNReanimated: ee96d03fe3713993a30cc205522792b4cb08e4f9
RNScreens: 6ced6ae8a526512a6eef6e28c2286e1fc2d378c3
RNScreens: 48bbaca97a5f9aedc3e52bd48673efd2b6aac4f6
RNSentry: 95e1ed0ede28a4af58aaafedeac9fcfaba0e89ce
RNWorklets: e8335dff9d27004709f58316985769040cd1e8f2
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d

View File

@@ -0,0 +1,28 @@
import { Progress } from 'react-native-track-player'
import { shouldMarkPlaybackFinished } from '../../src/providers/Player/utils/handlers'
describe('Playback Event Handlers', () => {
it('should determine that the track has finished', () => {
const progress: Progress = {
position: 95.23423453,
duration: 98.23557854,
buffered: 98.2345568679345,
}
const playbackFinished = shouldMarkPlaybackFinished(progress)
expect(playbackFinished).toBeTruthy()
})
it('should determine the track is still playing', () => {
const progress: Progress = {
position: 85.23423453,
duration: 98.23557854,
buffered: 98.2345568679345,
}
const playbackFinished = shouldMarkPlaybackFinished(progress)
expect(playbackFinished).toBeFalsy()
})
})

View File

@@ -82,7 +82,7 @@
"react-native-pager-view": "^7.0.0",
"react-native-reanimated": "4.0.2",
"react-native-safe-area-context": "^5.6.0",
"react-native-screens": "4.14.1",
"react-native-screens": "4.15.0",
"react-native-swipeable-item": "^2.0.9",
"react-native-text-ticker": "^1.15.0",
"react-native-toast-message": "^2.3.3",

View File

@@ -122,7 +122,6 @@ export async function fetchAlbumDiscs(
const discs = data.Items
? Object.keys(groupBy(data.Items, (track) => track.ParentIndexNumber)).map(
(discNumber) => {
console.debug(discNumber)
return {
title: discNumber,
data: data.Items!.filter((track: BaseItemDto) =>

View File

@@ -1,6 +1,6 @@
import { Api } from '@jellyfin/sdk'
import { BaseItemDto, PlaybackInfoResponse } from '@jellyfin/sdk/lib/generated-client/models'
import { getAudioApi, getMediaInfoApi } from '@jellyfin/sdk/lib/utils/api'
import { PlaybackInfoResponse } from '@jellyfin/sdk/lib/generated-client/models'
import { getMediaInfoApi } from '@jellyfin/sdk/lib/utils/api'
import { isUndefined } from 'lodash'
import { JellifyUser } from '../../types/JellifyUser'
import { AudioQuality } from '../../types/AudioQuality'
@@ -9,7 +9,7 @@ export async function fetchMediaInfo(
api: Api | undefined,
user: JellifyUser | undefined,
bitrate: AudioQuality | undefined,
item: BaseItemDto,
itemId: string,
): Promise<PlaybackInfoResponse> {
console.debug(`Fetching media info of quality ${JSON.stringify(bitrate)}`)
@@ -19,7 +19,7 @@ export async function fetchMediaInfo(
getMediaInfoApi(api)
.getPostedPlaybackInfo({
itemId: item.Id!,
itemId: itemId!,
userId: user.id,
playbackInfoDto: {
MaxAudioChannels: bitrate?.MaxAudioBitDepth

View File

@@ -50,11 +50,6 @@ const QueryConfig = {
width: 1000,
format: ImageFormat.Jpg,
},
staleTime: {
oneDay: 1000 * 60 * 60 * 24, // 1 Day
oneWeek: 1000 * 60 * 60 * 24 * 7, // 7 Days
oneFortnight: 1000 * 60 * 60 * 24 * 7 * 14, // 14 Days
},
}
export default QueryConfig

View File

@@ -1,4 +1,4 @@
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { getArtistsApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { BaseItemDto, BaseItemKind, ItemFields } from '@jellyfin/sdk/lib/generated-client/models'
import { Api } from '@jellyfin/sdk'
import { isUndefined } from 'lodash'
@@ -54,14 +54,12 @@ export async function fetchArtistSuggestions(
if (isUndefined(user)) return reject('User has not been set')
if (isUndefined(libraryId)) return reject('Library has not been set')
getItemsApi(api)
.getItems({
getArtistsApi(api)
.getAlbumArtists({
parentId: libraryId,
userId: user.id,
recursive: true,
limit: 50,
startIndex: page * 50,
includeItemTypes: [BaseItemKind.MusicArtist],
fields: [ItemFields.ChildCount, ItemFields.SortName],
sortBy: ['Random'],
})

View File

@@ -120,11 +120,13 @@ export default function AddToPlaylist({ track }: { track: BaseItemDto }): React.
return (
<ScrollView>
<XStack gap={'$2'} margin={'$4'}>
<ItemImage item={track} />
<ItemImage item={track} width={'$12'} height={'$12'} />
<YStack gap={'$2'} margin={'$2'}>
<TextTicker {...TextTickerConfig}>
<Text bold>{getItemName(track)}</Text>
<Text bold fontSize={'$6'}>
{getItemName(track)}
</Text>
</TextTicker>
<TextTicker {...TextTickerConfig}>

View File

@@ -96,6 +96,7 @@ export default function Albums({
</Text>
)
}
removeClippedSubviews
/>
)
}

View File

@@ -1,10 +1,8 @@
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
import { getToken, ListItem, View, YGroup, ZStack } from 'tamagui'
import { getToken, ListItem, ScrollView, View, YGroup } from 'tamagui'
import { BaseStackParamList, RootStackParamList } from '../../screens/types'
import { Text } from '../Global/helpers/text'
import FavoriteContextMenuRow from '../Global/components/favorite-context-menu-row'
import { Blurhash } from 'react-native-blurhash'
import { getPrimaryBlurhashFromDto } from '../../utils/blurhash'
import { useColorScheme } from 'react-native'
import { useThemeSettingContext } from '../../providers/Settings'
import LinearGradient from 'react-native-linear-gradient'
@@ -12,13 +10,13 @@ import Icon from '../Global/components/icon'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../enums/query-keys'
import { fetchItem, fetchItems } from '../../api/queries/item'
import { fetchAlbumDiscs, fetchItem } from '../../api/queries/item'
import { useJellifyContext } from '../../providers'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { useAddToQueueContext } from '../../providers/Player/queue'
import { AddToQueueMutation } from '../../providers/Player/interfaces'
import { QueuingType } from '../../enums/queuing-type'
import { useCallback, useMemo } from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import navigationRef from '../../../navigation'
import { goToAlbumFromContextSheet, goToArtistFromContextSheet } from './utils/navigation'
import { getItemName } from '../../utils/text'
@@ -26,6 +24,8 @@ import ItemImage from '../Global/components/image'
import { StackActions } from '@react-navigation/native'
import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../Player/component.config'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { trigger } from 'react-native-haptic-feedback'
type StackNavigation = Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
@@ -37,43 +37,21 @@ interface ContextProps {
}
export default function ItemContext({ item, stackNavigation }: ContextProps): React.JSX.Element {
const { api, user, library } = useJellifyContext()
const { api } = useJellifyContext()
const { bottom } = useSafeAreaInsets()
const isArtist = item.Type === BaseItemKind.MusicArtist
const isAlbum = item.Type === BaseItemKind.MusicAlbum
const isTrack = item.Type === BaseItemKind.Audio
const isPlaylist = item.Type === BaseItemKind.Playlist
const itemArtists = item.ArtistItems ?? []
const { data: album } = useQuery({
queryKey: [QueryKeys.Item, item.AlbumId],
queryKey: [QueryKeys.Album, item.AlbumId],
queryFn: () => fetchItem(api, item.AlbumId!),
enabled: isTrack,
})
const { data: artists } = useQuery({
queryKey: [
QueryKeys.ArtistById,
itemArtists.length > 0 ? itemArtists?.map((artist) => artist.Id) : item.Id,
],
queryFn: () =>
fetchItems(
api,
user,
library,
[BaseItemKind.MusicArtist],
0,
[],
[],
undefined,
undefined,
itemArtists?.map((artist) => artist.Id!),
),
enabled: (isTrack || isAlbum) && itemArtists.length > 0,
select: (data) => data.data,
})
const { data: tracks } = useQuery({
queryKey: [QueryKeys.ItemTracks, item.Id],
queryFn: () =>
@@ -83,20 +61,44 @@ export default function ItemContext({ item, stackNavigation }: ContextProps): Re
if (data.Items) return data.Items
else return []
}),
enabled: isPlaylist,
})
const { data: discs } = useQuery({
queryKey: [QueryKeys.ItemTracks, item.Id],
queryFn: () => fetchAlbumDiscs(api, item),
enabled: isAlbum,
})
const renderAddToQueueRow = isTrack || (isAlbum && tracks) || (isPlaylist && tracks)
const renderAddToPlaylistRow = isTrack
const renderViewAlbumRow = useMemo(() => isAlbum || (isTrack && album), [album, item])
const renderViewAlbumRow = isAlbum || (isTrack && album)
const artistIds = !isPlaylist
? isArtist
? [item.Id]
: item.ArtistItems
? item.ArtistItems.map((item) => item.Id)
: []
: []
const itemTracks = useMemo(() => {
if (isTrack) return [item]
else if (isAlbum && discs) return discs.flatMap((data) => data.data)
else if (isPlaylist && tracks) return tracks
else return []
}, [isTrack, isAlbum, discs, isPlaylist, tracks])
useEffect(() => trigger('impactLight'), [item?.Id])
return (
<View animation={'quick'}>
<YGroup unstyled flex={1} marginTop={'$8'}>
<ScrollView>
<YGroup unstyled marginBottom={bottom}>
<FavoriteContextMenuRow item={item} />
{renderAddToQueueRow && <AddToQueueMenuRow tracks={isTrack ? [item] : tracks!} />}
{renderAddToQueueRow && <AddToQueueMenuRow tracks={itemTracks} />}
{renderAddToPlaylistRow && <AddToPlaylistRow track={item} />}
@@ -108,36 +110,10 @@ export default function ItemContext({ item, stackNavigation }: ContextProps): Re
)}
{!isPlaylist && (
<ViewArtistMenuRow
artists={isArtist ? [item] : artists ? artists : []}
stackNavigation={stackNavigation}
/>
<ArtistMenuRows artistIds={artistIds} stackNavigation={stackNavigation} />
)}
</YGroup>
</View>
)
}
function ItemContextBackground({ item }: { item: BaseItemDto }): React.JSX.Element {
return (
<ZStack flex={1}>
<BackgroundBlur item={item} />
<BackgroundGradient />
</ZStack>
)
}
function BackgroundBlur({ item }: { item: BaseItemDto }): React.JSX.Element {
const blurhash = getPrimaryBlurhashFromDto(item)
return (
<Blurhash
blurhash={blurhash!}
style={{
flex: 1,
}}
/>
</ScrollView>
)
}
@@ -155,7 +131,7 @@ function AddToPlaylistRow({ track }: { track: BaseItemDto }): React.JSX.Element
}}
pressStyle={{ opacity: 0.5 }}
>
<Icon color='$primary' name='playlist-plus' />
<Icon small color='$primary' name='playlist-plus' />
<Text bold>Add to Playlist</Text>
</ListItem>
@@ -182,9 +158,11 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
}}
pressStyle={{ opacity: 0.5 }}
>
<Icon color='$primary' name='music-note-plus' />
<Icon small color='$primary' name='music-note-plus' />
<Text bold>Add to Queue</Text>
<Text bold marginLeft={'$1'}>
Add to Queue
</Text>
</ListItem>
)
}
@@ -224,7 +202,7 @@ function ViewAlbumMenuRow({ album: album, stackNavigation }: MenuRowProps): Reac
onPress={goToAlbum}
pressStyle={{ opacity: 0.5 }}
>
<ItemImage item={album} height={'$10'} width={'$10'} />
<ItemImage item={album} height={'$9'} width={'$9'} />
<TextTicker {...TextTickerConfig}>
<Text bold>{`Go to ${getItemName(album)}`}</Text>
@@ -233,13 +211,37 @@ function ViewAlbumMenuRow({ album: album, stackNavigation }: MenuRowProps): Reac
)
}
function ViewArtistMenuRow({
artists,
function ArtistMenuRows({
artistIds,
stackNavigation,
}: {
artists: BaseItemDto[]
artistIds: (string | null | undefined)[]
stackNavigation: StackNavigation | undefined
}): React.JSX.Element {
return (
<View>
{artistIds.map((id) => (
<ViewArtistMenuRow artistId={id} key={id} stackNavigation={stackNavigation} />
))}
</View>
)
}
function ViewArtistMenuRow({
artistId,
stackNavigation,
}: {
artistId: string | null | undefined
stackNavigation: StackNavigation | undefined
}): React.JSX.Element {
const { api } = useJellifyContext()
const { data: artist } = useQuery({
queryKey: [QueryKeys.ArtistById, artistId],
queryFn: () => fetchItem(api, artistId!),
enabled: !!artistId,
})
const goToArtist = useCallback(
(artist: BaseItemDto) => {
if (stackNavigation) stackNavigation.navigate('Artist', { artist })
@@ -248,23 +250,20 @@ function ViewArtistMenuRow({
[stackNavigation, navigationRef],
)
return (
<View>
{artists.map((artist, index) => (
<ListItem
animation={'quick'}
backgroundColor={'transparent'}
gap={'$3'}
justifyContent='flex-start'
key={index}
onPress={() => goToArtist(artist)}
pressStyle={{ opacity: 0.5 }}
>
<ItemImage circular item={artist} height={'$10'} width={'$10'} />
return artist ? (
<ListItem
animation={'quick'}
backgroundColor={'transparent'}
gap={'$3'}
justifyContent='flex-start'
onPress={() => goToArtist(artist)}
pressStyle={{ opacity: 0.5 }}
>
<ItemImage circular item={artist} height={'$9'} width={'$9'} />
<Text bold>{`Go to ${getItemName(artist)}`}</Text>
</ListItem>
))}
</View>
<Text bold>{`Go to ${getItemName(artist)}`}</Text>
</ListItem>
) : (
<></>
)
}

View File

@@ -6,10 +6,17 @@ import { InteractionManager } from 'react-native'
export function goToAlbumFromContextSheet(album: BaseItemDto | undefined) {
if (!navigationRef.isReady() || !album) return
// Pop Context Sheet and Player Modal
navigationRef.dispatch(StackActions.popTo('Tabs'))
// Pop Context Sheet
navigationRef.dispatch(StackActions.pop())
const route = navigationRef.current?.getCurrentRoute()
let route = navigationRef.current?.getCurrentRoute()
// If we've popped into the player, pop that as well
if (route?.name.includes('Player')) {
navigationRef.dispatch(StackActions.pop())
route = navigationRef.current?.getCurrentRoute()
}
if (route?.name.includes('Settings')) {
navigationRef.dispatch(TabActions.jumpTo('LibraryTab'))
@@ -22,10 +29,17 @@ export function goToAlbumFromContextSheet(album: BaseItemDto | undefined) {
export function goToArtistFromContextSheet(artist: BaseItemDto | undefined) {
if (!navigationRef.isReady() || !artist) return
// Pop Context Sheet and Player Modal
navigationRef.dispatch(StackActions.popTo('Tabs'))
// Pop Context Sheet
navigationRef.dispatch(StackActions.pop())
const route = navigationRef.current?.getCurrentRoute()
let route = navigationRef.current?.getCurrentRoute()
// If we've popped into the player, pop that as well
if (route?.name.includes('Player')) {
navigationRef.dispatch(StackActions.pop())
route = navigationRef.current?.getCurrentRoute()
}
if (route?.name.includes('Settings')) {
navigationRef.dispatch(TabActions.jumpTo('LibraryTab'))

View File

@@ -1,14 +1,15 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import React, { useEffect, useState } from 'react'
import React from 'react'
import Icon from './icon'
import { useQuery } from '@tanstack/react-query'
import { isUndefined } from 'lodash'
import { getTokens, Spinner } from 'tamagui'
import { Spinner } from 'tamagui'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchUserData } from '../../../api/queries/favorites'
import { useJellifyUserDataContext } from '../../../providers/UserData'
import { useJellifyContext } from '../../../providers'
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
import { ONE_HOUR } from '../../../constants/query-client'
interface SetFavoriteMutation {
item: BaseItemDto
@@ -21,26 +22,21 @@ export default function FavoriteButton({
item: BaseItemDto
onToggle?: () => void
}): React.JSX.Element {
const [isFavorite, setFavorite] = useState<boolean>(isFavoriteItem(item))
const { api, user } = useJellifyContext()
const { toggleFavorite } = useJellifyUserDataContext()
const { data, isFetching, refetch } = useQuery({
queryKey: [QueryKeys.UserData, item.Id!],
const {
data: isFavorite,
isFetching,
refetch,
} = useQuery({
queryKey: [QueryKeys.UserData, item.Id],
queryFn: () => fetchUserData(api, user, item.Id!),
staleTime: 1000 * 60 * 60 * 1, // 1 hour,
select: (data) => typeof data === 'object' && data.IsFavorite,
staleTime: ONE_HOUR,
})
useEffect(() => {
refetch()
}, [item])
useEffect(() => {
if (data) setFavorite(data.IsFavorite ?? false)
}, [data])
return isFetching && isUndefined(item.UserData) ? (
return isFetching ? (
<Spinner alignSelf='center' />
) : isFavorite ? (
<Animated.View entering={FadeIn} exiting={FadeOut}>
@@ -50,7 +46,6 @@ export default function FavoriteButton({
onPress={() =>
toggleFavorite(isFavorite, {
item,
setFavorite,
onToggle,
})
}
@@ -62,9 +57,8 @@ export default function FavoriteButton({
name={'heart-outline'}
color={'$primary'}
onPress={() =>
toggleFavorite(isFavorite, {
toggleFavorite(!!isFavorite, {
item,
setFavorite,
onToggle,
})
}

View File

@@ -3,31 +3,24 @@ import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchUserData } from '../../../api/queries/favorites'
import { useJellifyContext } from '../../../providers'
import { getToken, ListItem, XStack } from 'tamagui'
import { ListItem, XStack } from 'tamagui'
import Icon from './icon'
import { useJellifyUserDataContext } from '../../../providers/UserData'
import { useEffect, useState } from 'react'
import { Text } from '../helpers/text'
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
import { ONE_HOUR } from '../../../constants/query-client'
export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }): React.JSX.Element {
const { api, user } = useJellifyContext()
const { toggleFavorite } = useJellifyUserDataContext()
const { data: userData, refetch } = useQuery({
const { data: isFavorite, refetch } = useQuery({
queryKey: [QueryKeys.UserData, item.Id],
queryFn: () => fetchUserData(api, user, item.Id!),
staleTime: 1000 * 60 * 60 * 1, // 1 hour,
select: (data) => typeof data === 'object' && data.IsFavorite,
staleTime: ONE_HOUR,
})
const [isFavorite, setIsFavorite] = useState<boolean>(
userData?.IsFavorite ?? item.UserData?.IsFavorite ?? false,
)
useEffect(() => {
setIsFavorite(userData?.IsFavorite ?? false)
}, [userData])
return isFavorite ? (
<ListItem
animation={'quick'}
@@ -36,7 +29,6 @@ export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }):
onPress={() => {
toggleFavorite(isFavorite, {
item,
setFavorite: setIsFavorite,
onToggle: () => refetch(),
})
}}
@@ -47,12 +39,10 @@ export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }):
exiting={FadeOut}
key={`${item.Id}-remove-favorite-row`}
>
<XStack alignContent='center' justifyContent='flex-start' gap={'$3'}>
<XStack alignItems='center' justifyContent='flex-start' gap={'$2'}>
<Icon name={'heart'} small color={'$primary'} />
<Text marginTop={'$2'} bold>
Remove from favorites
</Text>
<Text bold>Remove from favorites</Text>
</XStack>
</Animated.View>
</ListItem>
@@ -63,21 +53,18 @@ export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }):
justifyContent='flex-start'
gap={'$2'}
onPress={() => {
toggleFavorite(isFavorite, {
toggleFavorite(!!isFavorite, {
item,
setFavorite: setIsFavorite,
onToggle: () => refetch(),
})
}}
pressStyle={{ opacity: 0.5 }}
>
<Animated.View entering={FadeIn} exiting={FadeOut} key={`${item.Id}-favorite-row`}>
<XStack alignContent='center' justifyContent='flex-start' gap={'$3'}>
<Icon name={'heart-outline'} small color={'$primary'} />
<XStack alignItems='center' justifyContent='flex-start' gap={'$2'}>
<Icon small name={'heart-outline'} color={'$primary'} />
<Text marginTop={'$2'} bold>
Add to favorites
</Text>
<Text bold>Add to favorites</Text>
</XStack>
</Animated.View>
</ListItem>

View File

@@ -4,7 +4,7 @@ import Icon from './icon'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchUserData } from '../../../api/queries/favorites'
import { useEffect, useState, memo } from 'react'
import { memo } from 'react'
import { useJellifyContext } from '../../../providers'
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
@@ -16,23 +16,15 @@ import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
* @returns A React component that displays a favorite icon for a given item.
*/
function FavoriteIcon({ item }: { item: BaseItemDto }): React.JSX.Element {
const [isFavorite, setIsFavorite] = useState<boolean>(item.UserData?.IsFavorite ?? false)
const { api, user } = useJellifyContext()
const { data: userData, isPending } = useQuery({
queryKey: [QueryKeys.UserData, item.Id!],
const { data: isFavorite } = useQuery({
queryKey: [QueryKeys.UserData, item.Id],
queryFn: () => fetchUserData(api, user, item.Id!),
staleTime: 1000 * 60 * 5, // 5 minutes,
select: (data) => typeof data === 'object' && data.IsFavorite,
enabled: !!api && !!user && !!item.Id, // Only run if we have the required data
})
useEffect(() => {
if (!isPending && userData !== undefined) {
setIsFavorite(userData?.IsFavorite ?? false)
}
}, [userData, isPending])
return isFavorite ? (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<Icon small name='heart' color={'$primary'} flex={1} />

View File

@@ -3,6 +3,7 @@ import {
ColorTokens,
getToken,
getTokens,
getTokenValue,
themeable,
ThemeTokens,
Tokens,
@@ -11,13 +12,11 @@ import {
} from 'tamagui'
import MaterialDesignIcon from '@react-native-vector-icons/material-design-icons'
const smallSize = 30
const smallSize = 28
const regularSize = 36
const regularSize = 34
const largeSize = 48
const extraLargeSize = 96
const largeSize = 44
export default function Icon({
name,
@@ -25,7 +24,6 @@ export default function Icon({
onPressIn,
small,
large,
extraLarge,
disabled,
color,
flex,
@@ -37,13 +35,12 @@ export default function Icon({
small?: boolean
large?: boolean
disabled?: boolean
extraLarge?: boolean
color?: ThemeTokens | undefined
flex?: number | undefined
testID?: string | undefined
}): React.JSX.Element {
const theme = useTheme()
const size = extraLarge ? extraLargeSize : large ? largeSize : small ? smallSize : regularSize
const size = large ? largeSize : small ? smallSize : regularSize
return (
<YStack
@@ -51,9 +48,10 @@ export default function Icon({
justifyContent='center'
onPress={onPress}
onPressIn={onPressIn}
paddingHorizontal={'$0.5'}
width={size + getToken('$1')}
height={size + getToken('$1')}
hitSlop={getTokenValue('$2.5')}
marginHorizontal={'$1'}
width={size}
height={size}
flex={flex}
>
<MaterialDesignIcon

View File

@@ -43,6 +43,7 @@ export default function ItemImage({
<FastImage
source={{ uri: imageUrl }}
testID={testID}
resizeMode='cover'
style={{
shadowRadius: getTokenValue('$4'),
shadowOffset: {
@@ -55,12 +56,12 @@ export default function ItemImage({
? typeof width === 'number'
? width
: getTokenValue(width)
: getTokenValue('$12') + getTokenValue('$5'),
: '100%',
height: !isUndefined(height)
? typeof height === 'number'
? height
: getTokenValue(height)
: getTokenValue('$12') + getTokenValue('$5'),
: '100%',
alignSelf: 'center',
backgroundColor: theme.borderColor.val,
}}
@@ -80,14 +81,10 @@ function getBorderRadius(circular: boolean | undefined, width: Token | number |
let borderRadius
if (circular) {
borderRadius = width
? typeof width === 'number'
? width
: getTokenValue(width)
: getTokenValue('$12') + getTokenValue('$5')
borderRadius = width ? (typeof width === 'number' ? width : getTokenValue(width)) : '100%'
} else if (!isUndefined(width)) {
borderRadius = typeof width === 'number' ? width / 10 : getTokenValue(width) / 10
}
borderRadius = typeof width === 'number' ? width / 25 : getTokenValue(width) / 15
} else borderRadius = '5%'
return borderRadius
}

View File

@@ -20,7 +20,6 @@ export default function InstantMixButton({
const { data, isFetching, refetch } = useQuery({
queryKey: [QueryKeys.InstantMix, item.Id!],
queryFn: () => fetchInstantMixFromItem(api, user, item),
staleTime: 1000 * 60 * 60 * 24, // 24 hours
})
return data ? (

View File

@@ -1,16 +1,10 @@
import React from 'react'
import { CardProps as TamaguiCardProps } from 'tamagui'
import { getToken, Card as TamaguiCard, View, YStack } from 'tamagui'
import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { Text } from '../helpers/text'
import FastImage from 'react-native-fast-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import { useJellifyContext } from '../../../providers'
import { fetchMediaInfo } from '../../../api/queries/media'
import { QueryKeys } from '../../../enums/query-keys'
import { getQualityParams } from '../../../utils/mappings'
import { useStreamingQualityContext } from '../../../providers/Settings'
import { useQuery } from '@tanstack/react-query'
import { ItemProvider } from '../../../providers/Item'
import ItemImage from './image'
interface CardProps extends TamaguiCardProps {
caption?: string | null | undefined
@@ -28,80 +22,56 @@ interface CardProps extends TamaguiCardProps {
* @param props
*/
export function ItemCard(props: CardProps) {
const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
useQuery({
queryKey: [QueryKeys.MediaSources, streamingQuality, props.item.Id],
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), props.item),
staleTime: Infinity, // Don't refetch media info unless the user changes the quality
enabled: props.item.Type === 'Audio',
})
return (
<View alignItems='center' margin={'$1.5'}>
<TamaguiCard
size={'$12'}
height={props.size}
width={props.size}
testID={props.testId ?? undefined}
backgroundColor={getToken('$color.amethyst')}
circular={!props.squared}
borderRadius={props.squared ? 5 : 'unset'}
animation='bouncy'
hoverStyle={props.onPress ? { scale: 0.925 } : {}}
pressStyle={props.onPress ? { scale: 0.875 } : {}}
{...props}
>
<TamaguiCard.Header></TamaguiCard.Header>
<TamaguiCard.Footer padded>
{/* { props.item.Type === 'MusicArtist' && (
<BlurhashedImage
cornered
item={props.item}
type={ImageType.Logo}
width={logoDimensions.width}
height={logoDimensions.height}
/>
)} */}
</TamaguiCard.Footer>
<TamaguiCard.Background>
<FastImage
source={{
uri:
getImageApi(api!).getItemImageUrlById(
props.item.Type === 'Audio'
? props.item.AlbumId! || props.item.Id!
: props.item.Id!,
ImageType.Primary,
{
tag: props.item.AlbumId
? props.item.AlbumPrimaryImageTag!
: props.item.ImageTags?.Primary,
},
) || '',
}}
style={{
width: '100%',
height: '100%',
borderRadius: props.squared ? 2 : 100,
}}
/>
</TamaguiCard.Background>
</TamaguiCard>
{props.caption && (
<YStack alignContent='center' alignItems='center' maxWidth={props.size}>
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
{props.caption}
</Text>
{props.subCaption && (
<Text lineBreakStrategyIOS='standard' numberOfLines={1} textAlign='center'>
{props.subCaption}
<ItemProvider item={props.item}>
<View alignItems='center' margin={'$1.5'}>
<TamaguiCard
size={'$12'}
height={props.size}
width={props.size}
testID={props.testId ?? undefined}
backgroundColor={getToken('$color.amethyst')}
circular={!props.squared}
borderRadius={props.squared ? '$5' : 'unset'}
animation='bouncy'
hoverStyle={props.onPress ? { scale: 0.925 } : {}}
pressStyle={props.onPress ? { scale: 0.875 } : {}}
{...props}
>
<TamaguiCard.Header></TamaguiCard.Header>
<TamaguiCard.Footer padded>
{/* { props.item.Type === 'MusicArtist' && (
<BlurhashedImage
cornered
item={props.item}
type={ImageType.Logo}
width={logoDimensions.width}
height={logoDimensions.height}
/>
)} */}
</TamaguiCard.Footer>
<TamaguiCard.Background>
<ItemImage item={props.item} circular={!props.squared} />
</TamaguiCard.Background>
</TamaguiCard>
{props.caption && (
<YStack alignContent='center' alignItems='center' maxWidth={props.size}>
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
{props.caption}
</Text>
)}
</YStack>
)}
</View>
{props.subCaption && (
<Text
lineBreakStrategyIOS='standard'
numberOfLines={1}
textAlign='center'
>
{props.subCaption}
</Text>
)}
</YStack>
)}
</View>
</ItemProvider>
)
}

View File

@@ -9,15 +9,10 @@ import ItemImage from './image'
import FavoriteIcon from './favorite-icon'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { runOnJS } from 'react-native-reanimated'
import { getQualityParams } from '../../../utils/mappings'
import { useQuery } from '@tanstack/react-query'
import { fetchMediaInfo } from '../../../api/queries/media'
import { QueryKeys } from '../../../enums/query-keys'
import { useJellifyContext } from '../../../providers'
import { useStreamingQualityContext } from '../../../providers/Settings'
import navigationRef from '../../../../navigation'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '../../../screens/types'
import { ItemProvider } from '../../../providers/Item'
interface ItemRowProps {
item: BaseItemDto
@@ -41,20 +36,10 @@ interface ItemRowProps {
export default function ItemRow({
item,
navigation,
queueName,
onPress,
circular,
}: ItemRowProps): React.JSX.Element {
const useLoadNewQueue = useLoadQueueContext()
const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
useQuery({
queryKey: [QueryKeys.MediaSources, streamingQuality, item.Id],
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), item),
staleTime: Infinity, // Don't refetch media info unless the user changes the quality
enabled: item.Type === 'Audio',
})
const gestureCallback = () => {
switch (item.Type) {
@@ -81,97 +66,99 @@ export default function ItemRow({
})
return (
<GestureDetector gesture={gesture}>
<XStack
alignContent='center'
minHeight={'$7'}
width={'100%'}
onLongPress={() => {
navigationRef.navigate('Context', {
item,
navigation,
})
}}
onPress={() => {
if (onPress) {
onPress()
return
}
switch (item.Type) {
case 'MusicArtist': {
navigation?.navigate('Artist', { artist: item })
break
}
case 'MusicAlbum': {
navigation?.navigate('Album', { album: item })
break
}
}
}}
paddingVertical={'$2'}
paddingRight={'$2'}
>
<YStack marginHorizontal={'$3'} justifyContent='center'>
<ItemImage
item={item}
height={'$12'}
width={'$12'}
circular={item.Type === 'MusicArtist' || circular}
/>
</YStack>
<YStack alignContent='center' justifyContent='center' flex={4}>
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.Name ?? ''}
</Text>
{item.Type === 'MusicArtist' && (
<Text lineBreakStrategyIOS='standard' numberOfLines={1}>
{`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Album' : 'Albums'}`}
</Text>
)}
{(item.Type === 'Audio' || item.Type === 'MusicAlbum') && (
<Text lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.AlbumArtist ?? 'Untitled Artist'}
</Text>
)}
{item.Type === 'Playlist' && (
<Text lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.Genres?.join(', ') ?? ''}
</Text>
)}
</YStack>
<ItemProvider item={item}>
<GestureDetector gesture={gesture}>
<XStack
justifyContent='flex-end'
alignItems='center'
flex={['Audio', 'MusicAlbum'].includes(item.Type ?? '') ? 2 : 1}
>
<FavoriteIcon item={item} />
{/* Runtime ticks for Songs */}
{['Audio', 'MusicAlbum'].includes(item.Type ?? '') ? (
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
) : ['Playlist'].includes(item.Type ?? '') ? (
<Text
color={'$borderColor'}
>{`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Track' : 'Tracks'}`}</Text>
) : null}
alignContent='center'
minHeight={'$7'}
width={'100%'}
onLongPress={() => {
navigationRef.navigate('Context', {
item,
navigation,
})
}}
onPress={() => {
if (onPress) {
onPress()
return
}
{item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
<Icon
name='dots-horizontal'
onPress={() => {
navigationRef.navigate('Context', {
item,
navigation,
})
}}
switch (item.Type) {
case 'MusicArtist': {
navigation?.navigate('Artist', { artist: item })
break
}
case 'MusicAlbum': {
navigation?.navigate('Album', { album: item })
break
}
}
}}
paddingVertical={'$2'}
paddingRight={'$2'}
>
<YStack marginHorizontal={'$3'} justifyContent='center'>
<ItemImage
item={item}
height={'$12'}
width={'$12'}
circular={item.Type === 'MusicArtist' || circular}
/>
) : null}
</YStack>
<YStack alignContent='center' justifyContent='center' flex={4}>
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.Name ?? ''}
</Text>
{item.Type === 'MusicArtist' && (
<Text lineBreakStrategyIOS='standard' numberOfLines={1}>
{`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Album' : 'Albums'}`}
</Text>
)}
{(item.Type === 'Audio' || item.Type === 'MusicAlbum') && (
<Text lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.AlbumArtist ?? 'Untitled Artist'}
</Text>
)}
{item.Type === 'Playlist' && (
<Text lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.Genres?.join(', ') ?? ''}
</Text>
)}
</YStack>
<XStack
justifyContent='flex-end'
alignItems='center'
flex={['Audio', 'MusicAlbum'].includes(item.Type ?? '') ? 2 : 1}
>
<FavoriteIcon item={item} />
{/* Runtime ticks for Songs */}
{['Audio', 'MusicAlbum'].includes(item.Type ?? '') ? (
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
) : ['Playlist'].includes(item.Type ?? '') ? (
<Text
color={'$borderColor'}
>{`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Track' : 'Tracks'}`}</Text>
) : null}
{item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
<Icon
name='dots-horizontal'
onPress={() => {
navigationRef.navigate('Context', {
item,
navigation,
})
}}
/>
) : null}
</XStack>
</XStack>
</XStack>
</GestureDetector>
</GestureDetector>
</ItemProvider>
)
}

View File

@@ -2,27 +2,21 @@ import React, { useMemo, useCallback } from 'react'
import { getToken, Theme, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../helpers/text'
import { RunTimeTicks } from '../helpers/time-codes'
import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import Icon from './icon'
import { QueuingType } from '../../../enums/queuing-type'
import { Queue } from '../../../player/types/queue-item'
import FavoriteIcon from './favorite-icon'
import FastImage from 'react-native-fast-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import { networkStatusTypes } from '../../../components/Network/internetConnectionWatcher'
import { useNetworkContext } from '../../../providers/Network'
import { useLoadQueueContext, usePlayQueueContext } from '../../../providers/Player/queue'
import { useJellifyContext } from '../../../providers'
import DownloadedIcon from './downloaded-icon'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchMediaInfo } from '../../../api/queries/media'
import { useStreamingQualityContext } from '../../../providers/Settings'
import { getQualityParams } from '../../../utils/mappings'
import { useNowPlayingContext } from '../../../providers/Player'
import navigationRef from '../../../../navigation'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '@/src/screens/types'
import { BaseStackParamList } from '../../../screens/types'
import ItemImage from './image'
import { ItemProvider } from '../../../providers/Item'
export interface TrackProps {
track: BaseItemDto
@@ -58,12 +52,10 @@ export default function Track({
}: TrackProps): React.JSX.Element {
const theme = useTheme()
const { api, user } = useJellifyContext()
const nowPlaying = useNowPlayingContext()
const playQueue = usePlayQueueContext()
const useLoadNewQueue = useLoadQueueContext()
const { downloadedTracks, networkStatus } = useNetworkContext()
const streamingQuality = useStreamingQualityContext()
// Memoize expensive computations
const isPlaying = useMemo(
@@ -83,21 +75,6 @@ export default function Track({
[networkStatus],
)
// Memoize image source to prevent recreation
const imageSource = useMemo(
() => ({
uri:
getImageApi(api!).getItemImageUrlById(
track.AlbumId! || track.Id!,
ImageType.Primary,
{
tag: track.ImageTags?.Primary,
},
) || '',
}),
[api, track.AlbumId, track.Id, track.ImageTags?.Primary],
)
// Memoize tracklist for queue loading
const memoizedTracklist = useMemo(
() => tracklist ?? playQueue.map((track) => track.item),
@@ -126,9 +103,10 @@ export default function Track({
} else {
navigationRef.navigate('Context', {
item: track,
navigation,
})
}
}, [onLongPress, navigation, track, isNested])
}, [onLongPress, track, isNested])
const handleIconPress = useCallback(() => {
if (showRemove) {
@@ -138,15 +116,7 @@ export default function Track({
item: track,
})
}
}, [showRemove, onRemove, navigation, track, isNested])
// Only fetch media info if needed (for streaming)
useQuery({
queryKey: [QueryKeys.MediaSources, streamingQuality, track.Id],
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), track),
staleTime: Infinity, // Don't refetch media info unless the user changes the quality
enabled: !isDownloaded, // Only fetch if not downloaded
})
}, [showRemove, onRemove, track, isNested])
// Memoize text color to prevent recalculation
const textColor = useMemo(() => {
@@ -171,91 +141,85 @@ export default function Track({
)
return (
<Theme name={invertedColors ? 'inverted_purple' : undefined}>
<XStack
alignContent='center'
alignItems='center'
height={showArtwork ? '$6' : '$5'}
flex={1}
testID={testID ?? undefined}
onPress={handlePress}
onLongPress={handleLongPress}
paddingVertical={'$2'}
justifyContent='center'
marginRight={'$2'}
>
<ItemProvider item={track}>
<Theme name={invertedColors ? 'inverted_purple' : undefined}>
<XStack
alignContent='center'
alignItems='center'
height={showArtwork ? '$6' : '$5'}
flex={1}
testID={testID ?? undefined}
onPress={handlePress}
onLongPress={handleLongPress}
paddingVertical={'$2'}
justifyContent='center'
marginHorizontal={showArtwork ? '$2' : '$1'}
marginRight={'$2'}
>
{showArtwork ? (
<FastImage
key={`${track.Id}-${track.AlbumId || track.Id}`}
source={imageSource}
style={{
width: getToken('$12'),
height: getToken('$12'),
borderRadius: getToken('$1'),
}}
/>
) : (
<Text
key={`${track.Id}-number`}
color={textColor}
width={getToken('$12')}
textAlign='center'
>
{indexNumber}
</Text>
)}
</XStack>
<YStack alignContent='center' justifyContent='flex-start' flex={6}>
<Text
key={`${track.Id}-name`}
bold
color={textColor}
lineBreakStrategyIOS='standard'
numberOfLines={1}
<XStack
alignContent='center'
justifyContent='center'
marginHorizontal={showArtwork ? '$2' : '$1'}
>
{trackName}
</Text>
{showArtwork ? (
<ItemImage item={track} width={'$12'} height={'$12'} />
) : (
<Text
key={`${track.Id}-number`}
color={textColor}
width={getToken('$12')}
textAlign='center'
>
{indexNumber}
</Text>
)}
</XStack>
{shouldShowArtists && (
<YStack alignContent='center' justifyContent='flex-start' flex={6}>
<Text
key={`${track.Id}-artists`}
key={`${track.Id}-name`}
bold
color={textColor}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>
{artistsText}
{trackName}
</Text>
)}
</YStack>
<DownloadedIcon item={track} />
{shouldShowArtists && (
<Text
key={`${track.Id}-artists`}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>
{artistsText}
</Text>
)}
</YStack>
<FavoriteIcon item={track} />
<DownloadedIcon item={track} />
<RunTimeTicks
key={`${track.Id}-runtime`}
props={{
style: {
textAlign: 'center',
flex: 1.5,
alignSelf: 'center',
},
}}
>
{track.RunTimeTicks}
</RunTimeTicks>
<FavoriteIcon item={track} />
<Icon
name={showRemove ? 'close' : 'dots-horizontal'}
flex={1}
onPress={handleIconPress}
/>
</XStack>
</Theme>
<RunTimeTicks
key={`${track.Id}-runtime`}
props={{
style: {
textAlign: 'center',
flex: 1.5,
alignSelf: 'center',
},
}}
>
{track.RunTimeTicks}
</RunTimeTicks>
<Icon
name={showRemove ? 'close' : 'dots-horizontal'}
flex={1}
onPress={handleIconPress}
/>
</XStack>
</Theme>
</ItemProvider>
)
}

View File

@@ -56,7 +56,7 @@ export function HorizontalSlider({ value, max, width, props }: SliderProps): Rea
<JellifySliderThumb
circular
index={0}
size={14} // Anything larger than 14 causes the thumb to be clipped
size={'$0.75'} // Anything larger than 14 causes the thumb to be clipped
// Increase hit slop for better touch handling
hitSlop={{
top: 25,

View File

@@ -1,19 +1,18 @@
import { InstantMixProps } from '../../screens/types'
import { FlatList } from 'react-native'
import Track from '../Global/components/track'
import { Separator } from 'tamagui'
import { FlashList } from '@shopify/flash-list'
export default function InstantMix({ route, navigation }: InstantMixProps): React.JSX.Element {
const { mix } = route.params
return (
<FlatList
<FlashList
contentInsetAdjustmentBehavior='automatic'
data={mix}
ItemSeparatorComponent={() => <Separator />}
renderItem={({ item, index }) => (
<Track
navigation={navigation}
showArtwork
track={item}
index={index}

View File

@@ -24,7 +24,7 @@ export default function Controls(): React.JSX.Element {
const shuffled = useShuffledContext()
return (
<XStack alignItems='center' justifyContent='space-evenly' flex={2} marginHorizontal={'$2'}>
<XStack alignItems='center' justifyContent='space-between'>
<Icon
small
color={shuffled ? '$primary' : '$color'}

View File

@@ -1,4 +1,4 @@
import { XStack } from 'tamagui'
import { Spacer, XStack } from 'tamagui'
import Icon from '../../Global/components/icon'
@@ -11,10 +11,8 @@ export default function Footer(): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<PlayerParamList>>()
return (
<XStack justifyContent='flex-end' alignItems='center' marginHorizontal={'$5'} flex={1}>
<XStack alignItems='center' justifyContent='flex-start' flex={1}>
<Icon small name='cast-audio' disabled />
</XStack>
<XStack justifyContent='center' alignItems='center'>
<Spacer flex={1} />
<XStack alignItems='center' justifyContent='flex-end' flex={1}>
<Icon

View File

@@ -1,71 +1,68 @@
import { useJellifyContext } from '../../../providers'
import { useNowPlayingContext, usePlaybackStateContext } from '../../../providers/Player'
import { useNowPlayingContext } from '../../../providers/Player'
import { useQueueRefContext } from '../../../providers/Player/queue'
import { getToken, useWindowDimensions, XStack, YStack, useTheme, Spacer } from 'tamagui'
import { XStack, YStack, Spacer, useTheme } from 'tamagui'
import { Text } from '../../Global/helpers/text'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import Icon from '../../Global/components/icon'
import { RootStackParamList } from '../../../screens/types'
import React from 'react'
import { State } from 'react-native-track-player'
import React, { useMemo } from 'react'
import ItemImage from '../../Global/components/image'
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
import { useNavigation } from '@react-navigation/native'
import { Platform } from 'react-native'
import MaterialDesignIcons from '@react-native-vector-icons/material-design-icons'
import navigationRef from '../../../../navigation'
export default function PlayerHeader(): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
const nowPlaying = useNowPlayingContext()
const playbackState = usePlaybackStateContext()
const isPlaying = playbackState === State.Playing
const queueRef = useQueueRefContext()
const { width } = useWindowDimensions()
const theme = useTheme()
return (
<YStack flexShrink={1} marginTop={'$2'}>
<XStack justifyContent='center' marginBottom={'$2'} marginHorizontal={'$2'}>
<YStack alignContent='center' flex={1} justifyContent='center'>
<Icon
name='chevron-down'
onPress={() => {
navigation.goBack()
}}
small
/>
</YStack>
// If the Queue is a BaseItemDto, display the name of it
const playingFrom = useMemo(
() => (typeof queueRef === 'object' ? (queueRef.Name ?? 'Untitled') : queueRef),
[queueRef],
)
<YStack alignItems='center' alignContent='center' flex={2}>
return (
<YStack flexGrow={1} justifyContent='flex-start' maxHeight={'80%'}>
<XStack
alignContent='flex-start'
flexShrink={1}
justifyContent='center'
onPress={() => navigationRef.goBack()}
>
<MaterialDesignIcons
color={theme.color.val}
name={Platform.OS === 'android' ? 'chevron-left' : 'chevron-down'}
size={22}
style={{ flex: 1, margin: 'auto' }}
/>
<YStack alignItems='center' flex={1}>
<Text>Playing from</Text>
<Text bold numberOfLines={1} lineBreakStrategyIOS='standard'>
{
// If the Queue is a BaseItemDto, display the name of it
typeof queueRef === 'object' ? (queueRef.Name ?? 'Untitled') : queueRef
}
{playingFrom}
</Text>
</YStack>
<Spacer flex={1} />
</XStack>
<XStack justifyContent='center' alignContent='center' paddingVertical={'$8'}>
<YStack
flexGrow={1}
justifyContent='center'
paddingHorizontal={'$2'}
maxHeight={'70%'}
marginVertical={'auto'}
paddingVertical={Platform.OS === 'android' ? '$4' : '$2'}
>
<Animated.View
entering={FadeIn}
exiting={FadeOut}
key={`${nowPlaying!.item.AlbumId}-item-image`}
>
<ItemImage
item={nowPlaying!.item}
testID='player-image-test-id'
width={getToken('$20') * 2}
height={getToken('$20') * 2}
/>
<ItemImage item={nowPlaying!.item} testID='player-image-test-id' />
</Animated.View>
</XStack>
</YStack>
</YStack>
)
}

View File

@@ -135,11 +135,15 @@ export default function Scrubber(): React.JSX.Element {
return (
<GestureDetector gesture={scrubGesture}>
<YStack>
<YStack alignItems='center'>
<HorizontalSlider
value={displayPosition}
max={maxDuration ? maxDuration : 1 * ProgressMultiplier}
width={getToken('$20') + getToken('$20')}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// I'm sorry for this, pikachu. this was the only way I could make the scrubber
// the correct width
width={'100%'}
props={sliderProps}
/>

View File

@@ -14,6 +14,7 @@ import { PlayerParamList } from '../../../screens/Player/types'
import { useNowPlayingContext } from '../../../providers/Player'
import navigationRef from '../../../../navigation'
import Icon from '../../Global/components/icon'
import { getItemName } from '../../../utils/text'
interface SongInfoProps {
navigation: NativeStackNavigationProp<PlayerParamList>
@@ -32,9 +33,12 @@ function SongInfo({ navigation }: SongInfoProps): React.JSX.Element {
// Memoize expensive computations
const trackTitle = useMemo(() => nowPlaying!.title ?? 'Untitled Track', [nowPlaying?.title])
const artistName = useMemo(() => nowPlaying?.artist ?? 'Unknown Artist', [nowPlaying?.artist])
const artistItems = useMemo(() => nowPlaying!.item.ArtistItems, [nowPlaying?.item.ArtistItems])
const { artistItems, artists } = useMemo(() => {
return {
artistItems: nowPlaying!.item.ArtistItems,
artists: nowPlaying!.item.ArtistItems?.map((artist) => getItemName(artist)).join(' • '),
}
}, [nowPlaying?.item.ArtistItems])
// Memoize navigation handlers
const handleAlbumPress = useCallback(() => {
@@ -74,9 +78,9 @@ function SongInfo({ navigation }: SongInfoProps): React.JSX.Element {
}, [artistItems, navigation])
return (
<XStack flex={1}>
<YStack marginHorizontal={'$1.5'} onPress={handleAlbumPress} justifyContent='center'>
<ItemImage item={nowPlaying!.item} width={'$11'} height={'$11'} />
<XStack>
<YStack marginRight={'$2.5'} onPress={handleAlbumPress} justifyContent='center'>
<ItemImage item={nowPlaying!.item} width={'$12'} height={'$12'} />
</YStack>
<YStack justifyContent='flex-start' flex={1} gap={'$0.25'}>
@@ -88,7 +92,7 @@ function SongInfo({ navigation }: SongInfoProps): React.JSX.Element {
<TextTicker {...TextTickerConfig} style={{ height: getToken('$8') }}>
<Text fontSize={'$6'} color={'$color'} onPress={handleArtistPress}>
{artistName}
{artists ?? 'Unknown Artist'}
</Text>
</TextTicker>
</YStack>

View File

@@ -1,7 +1,16 @@
import { useNowPlayingContext } from '../../providers/Player'
import React, { useCallback, useMemo, useState } from 'react'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { YStack, XStack, getToken, useTheme, ZStack, useWindowDimensions, View } from 'tamagui'
import {
YStack,
XStack,
getToken,
useTheme,
ZStack,
useWindowDimensions,
View,
getTokenValue,
} from 'tamagui'
import Scrubber from './components/scrubber'
import Controls from './components/controls'
import Toast from 'react-native-toast-message'
@@ -14,6 +23,7 @@ import SongInfo from './components/song-info'
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { PlayerParamList } from '../../screens/Player/types'
import { Platform } from 'react-native'
export default function PlayerScreen({
navigation,
@@ -36,66 +46,55 @@ export default function PlayerScreen({
}, []),
)
const isAndroid = Platform.OS === 'android'
const { width, height } = useWindowDimensions()
const { bottom } = useSafeAreaInsets()
// Memoize expensive calculations
const songInfoContainerStyle = useMemo(
() => ({
justifyContent: 'center' as const,
alignItems: 'center' as const,
marginHorizontal: 'auto' as const,
width: getToken('$20') + getToken('$20') + getToken('$5'),
maxWidth: width / 1.1,
flex: 2,
}),
[width],
)
const scrubberContainerStyle = useMemo(
() => ({
justifyContent: 'center' as const,
flex: 1,
}),
[],
)
const { top, bottom } = useSafeAreaInsets()
/**
* Styling for the top layer of Player ZStack
*
* Android Modals extend into the safe area, so we
* need to account for that
*
* Apple devices get a small amount of margin
*/
const mainContainerStyle = useMemo(
() => ({
flex: 1,
marginBottom: bottom,
marginTop: isAndroid ? top : getTokenValue('$4'),
marginBottom: bottom * 2,
}),
[bottom],
[top, bottom, isAndroid],
)
return (
<SafeAreaView style={{ flex: 1 }} edges={['top']}>
<View flex={1}>
{nowPlaying && (
<ZStack fullscreen>
<BlurredBackground width={width} height={height} />
<View flex={1}>
{nowPlaying && (
<ZStack fullscreen>
<BlurredBackground width={width} height={height} />
<YStack flex={1} marginBottom={bottom} style={mainContainerStyle}>
<PlayerHeader />
<YStack
justifyContent='center'
flex={1}
marginHorizontal={'$5'}
{...mainContainerStyle}
>
{/* flexGrow 1 */}
<PlayerHeader />
<XStack style={songInfoContainerStyle}>
<SongInfo navigation={navigation} />
</XStack>
<XStack style={scrubberContainerStyle}>
{/* playback progress goes here */}
<Scrubber />
</XStack>
<YStack justifyContent='flex-start' gap={'$4'} flexShrink={1}>
<SongInfo navigation={navigation} />
<Scrubber />
{/* playback progress goes here */}
<Controls />
<Footer />
</YStack>
</ZStack>
)}
{showToast && <Toast config={JellifyToastConfig(theme)} />}
</View>
</SafeAreaView>
</YStack>
</ZStack>
)}
{showToast && <Toast config={JellifyToastConfig(theme)} />}
</View>
)
}

View File

@@ -1,23 +1,11 @@
import React, { useMemo, useCallback } from 'react'
import {
getToken,
Progress,
Spacer,
useWindowDimensions,
View,
XStack,
YStack,
ZStack,
} from 'tamagui'
import { getToken, Progress, View, XStack, YStack, ZStack } from 'tamagui'
import { useNowPlayingContext } from '../../providers/Player'
import { BottomTabNavigationEventMap } from '@react-navigation/bottom-tabs'
import { NavigationHelpers, ParamListBase, useNavigation } from '@react-navigation/native'
import { useNavigation } from '@react-navigation/native'
import { Text } from '../Global/helpers/text'
import TextTicker from 'react-native-text-ticker'
import PlayPauseButton from './components/buttons'
import { ProgressMultiplier, TextTickerConfig } from './component.config'
import FastImage from 'react-native-fast-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import { usePreviousContext, useSkipContext } from '../../providers/Player/queue'
import { useJellifyContext } from '../../providers'
import { RunTimeSeconds } from '../Global/helpers/time-codes'
@@ -31,9 +19,9 @@ import Animated, {
useSharedValue,
withSpring,
} from 'react-native-reanimated'
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { RootStackParamList } from '../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import ItemImage from '../Global/components/image'
export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
const { api } = useJellifyContext()
@@ -119,30 +107,10 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
exiting={FadeOut}
key={`${nowPlaying!.item.AlbumId}-album-image`}
>
<FastImage
source={{
uri:
getImageApi(api)?.getItemImageUrlById(
nowPlaying!.item.AlbumId! ||
nowPlaying!.item.Id!,
ImageType.Primary,
{
tag: nowPlaying!.item.ImageTags
?.Primary,
},
) || '',
}}
style={{
width: getToken('$12'),
height: getToken('$12'),
borderRadius: getToken('$2'),
backgroundColor: '$borderColor',
shadowRadius: getToken('$2'),
shadowOffset: {
width: 0,
height: -getToken('$2'),
},
}}
<ItemImage
item={nowPlaying!.item}
width={'$12'}
height={'$12'}
/>
</Animated.View>
)}

View File

@@ -1,5 +1,9 @@
import { QueryClient } from '@tanstack/react-query'
export const ONE_MINUTE = 1000 * 60
export const ONE_HOUR = ONE_MINUTE * 60
export const ONE_DAY = ONE_HOUR * 24
/**
* A global instance of the Tanstack React Query client
*
@@ -16,18 +20,19 @@ export const queryClient = new QueryClient({
/**
* This needs to be set equal to or higher than the `maxAge` set in `../App.tsx`
*
* Because data can remain on the server forever, the `maxAge` is set to `Infinity`
* Because we want to preserve hybrid network functionality, the `maxAge` is set to {@link ONE_DAY}
*
* Therefore, this also needs to be set to `Infinity`, disabling garbage collection
* Therefore, this also needs to be set to {@link ONE_DAY}
*
* @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#how-it-works
*/
gcTime: Infinity,
gcTime: ONE_DAY,
/**
* 1 hour as a default - reduced from 2 hours for better battery usage
* Refetch data after 2 hours as a default
*/
staleTime: 1000 * 60 * 60, // 1 hour
staleTime: ONE_HOUR * 2,
retry(failureCount: number, error: Error) {
if (failureCount > 2) return false

View File

@@ -10,3 +10,9 @@ export const UPDATE_INTERVAL: number = 250
* less than in order to do a skip to the previous
*/
export const SKIP_TO_PREVIOUS_THRESHOLD: number = 4
/**
* Indicates the number of seconds the progress update
* event will be emitted from the track player
*/
export const PROGRESS_UPDATE_EVENT_INTERVAL: number = 5

View File

@@ -1,14 +1,6 @@
import React, {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useState,
} from 'react'
import React, { createContext, ReactNode, useCallback, useContext, useState } from 'react'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import {
InfiniteData,
InfiniteQueryObserverResult,
useInfiniteQuery,
UseInfiniteQueryResult,
@@ -19,7 +11,6 @@ import { queryClient } from '../../constants/query-client'
import QueryConfig from '../../api/queries/query.config'
import { fetchFrequentlyPlayed, fetchFrequentlyPlayedArtists } from '../../api/queries/frequents'
import { useJellifyContext } from '..'
import { useIsFocused } from '@react-navigation/native'
interface HomeContext {
refreshing: boolean
onRefresh: () => void
@@ -44,12 +35,6 @@ const HomeContextInitializer = () => {
const { api, library, user } = useJellifyContext()
const [refreshing, setRefreshing] = useState<boolean>(false)
const isFocused = useIsFocused()
useEffect(() => {
console.debug(`Home focused: ${isFocused}`)
}, [isFocused])
const {
data: recentTracks,
isFetching: isFetchingRecentTracks,

View File

@@ -0,0 +1,134 @@
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
import { createContext, ReactNode, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../enums/query-keys'
import { fetchMediaInfo } from '../../api/queries/media'
import { useJellifyContext } from '..'
import { useStreamingQualityContext } from '../Settings'
import { getQualityParams } from '../../utils/mappings'
import { fetchAlbumDiscs, fetchItem } from '../../api/queries/item'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { ItemArtistProvider } from './item-artists'
import { queryClient } from '../../constants/query-client'
import { fetchUserData } from '../../api/queries/favorites'
interface ItemContext {
item: BaseItemDto
}
const ItemContext = createContext<ItemContext>({
item: {},
})
interface ItemProviderProps {
item: BaseItemDto
children: ReactNode
}
/**
* Performs a series of {@link useQuery} functions that store additional context
* around the item being browsed, including
*
* - Artist(s)
* - Album
* - Track(s)
*
* This data is used throughout Jellify as additional {@link useQuery} hooks to
* tap into this cache
*
* @param param0 Object containing the {@link BaseItemDto} and the child {@link ReactNode} to render
* @returns
*/
export const ItemProvider: ({ item, children }: ItemProviderProps) => React.JSX.Element = ({
item,
children,
}) => {
const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
const { Id, Type, AlbumId, ArtistItems, UserData } = item
const artistIds = ArtistItems?.map(({ Id }) => Id) ?? []
useEffect(() => {
// Fail fast if we don't have an Item ID to work with
if (!Id) return
/**
* Fetch and cache the media sources if this item is a track
*/
if (Type === BaseItemKind.Audio)
queryClient.ensureQueryData({
queryKey: [QueryKeys.MediaSources, streamingQuality, Id],
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), Id!),
})
/**
* ...or store it as a queryable if the item is an artist
*
* Referenced later in the context sheet
*/
if (Type === BaseItemKind.MusicArtist)
queryClient.setQueryData([QueryKeys.ArtistById, Id], item)
/**
* Fire query for a track's album...
*
* Referenced later in the context sheet
*/
if (!!AlbumId && Type === BaseItemKind.Audio)
queryClient.ensureQueryData({
queryKey: [QueryKeys.Album, AlbumId],
queryFn: () => fetchItem(api, item.AlbumId!),
})
/**
* ...or store it if it is an album
*
* Referenced later in the context sheet
*/
if (Type === BaseItemKind.MusicAlbum) queryClient.setQueryData([QueryKeys.Album, Id], item)
/**
* Prefetch for an album's tracks
*
* Referenced later in the context sheet
*/
if (Type === BaseItemKind.MusicAlbum)
queryClient.ensureQueryData({
queryKey: [QueryKeys.ItemTracks, Id],
queryFn: () => fetchAlbumDiscs(api, item),
})
/**
* Prefetch query for a playlist's tracks
*
* Referenced later in the context sheet
*/
if (Type === BaseItemKind.Playlist)
queryClient.ensureQueryData({
queryKey: [QueryKeys.ItemTracks, Id],
queryFn: () =>
getItemsApi(api!)
.getItems({ parentId: Id! })
.then(({ data }) => {
if (data.Items) return data.Items
else return []
}),
})
if (UserData) queryClient.setQueryData([QueryKeys.UserData, Id], UserData)
else
queryClient.ensureQueryData({
queryKey: [QueryKeys.UserData, Id],
queryFn: () => fetchUserData(api, user, Id),
})
}, [queryClient, api, user, Id, Type, AlbumId, UserData, item, streamingQuality])
return (
<ItemContext.Provider value={{ item }}>
{artistIds.map((Id) => Id && <ItemArtistProvider artistId={Id} key={Id} />)}
{children}
</ItemContext.Provider>
)
}

View File

@@ -0,0 +1,34 @@
import { createContext, useEffect } from 'react'
import { useJellifyContext } from '..'
import { QueryKeys } from '../../enums/query-keys'
import { fetchItem } from '../../api/queries/item'
import { queryClient } from '../../constants/query-client'
interface ItemArtistContext {
artistId: string | undefined
}
const ItemArtistContext = createContext<ItemArtistContext>({
artistId: undefined,
})
export const ItemArtistProvider: ({
artistId,
}: {
artistId: string | undefined
}) => React.JSX.Element = ({ artistId }) => {
const { api } = useJellifyContext()
useEffect(() => {
/**
* Store queryable of artist item
*/
if (artistId)
queryClient.ensureQueryData({
queryKey: [QueryKeys.ArtistById, artistId],
queryFn: () => fetchItem(api, artistId!),
})
})
return <ItemArtistContext.Provider value={{ artistId }} />
}

View File

@@ -100,8 +100,6 @@ const LibraryContextInitializer = () => {
),
select: selectArtists,
initialPageParam: 0,
staleTime: QueryConfig.staleTime.oneDay, // Cache for 1 day to reduce network requests
gcTime: QueryConfig.staleTime.oneWeek, // Keep in memory for 1 week
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
return lastPage.length === QueryConfig.limits.library ? lastPageParam + 1 : undefined
},
@@ -123,8 +121,6 @@ const LibraryContextInitializer = () => {
sortDescending ? SortOrder.Descending : SortOrder.Ascending,
),
initialPageParam: 0,
staleTime: QueryConfig.staleTime.oneDay, // Cache for 1 day to reduce network requests
gcTime: QueryConfig.staleTime.oneWeek, // Keep in memory for 1 week
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
console.debug(`Tracks last page length: ${lastPage.length}`)
return lastPage.length === QueryConfig.limits.library * 2
@@ -149,8 +145,6 @@ const LibraryContextInitializer = () => {
initialPageParam: alphabet[0],
select: (data) => data.pages.flatMap((page) => [page.title, ...page.data]),
maxPages: alphabet.length,
staleTime: QueryConfig.staleTime.oneDay, // Cache for 1 day to reduce network requests
gcTime: QueryConfig.staleTime.oneWeek, // Keep in memory for 1 week
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
console.debug(`Albums last page length: ${lastPage.data.length}`)
if (lastPageParam !== alphabet[alphabet.length - 1]) {
@@ -180,8 +174,6 @@ const LibraryContextInitializer = () => {
queryFn: () => fetchUserPlaylists(api, user, library),
select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0,
staleTime: QueryConfig.staleTime.oneDay, // Cache for 1 day to reduce network requests
gcTime: QueryConfig.staleTime.oneWeek, // Keep in memory for 1 week
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
return lastPage.length === QueryConfig.limits.library ? lastPageParam + 1 : undefined
},

View File

@@ -115,7 +115,6 @@ const NetworkContextInitializer = () => {
const { data: storageUsage } = useQuery({
queryKey: [QueryKeys.StorageInUse],
queryFn: () => fetchStorageInUse(),
staleTime: 1000 * 60 * 60 * 1, // 1 hour
})
const { mutate: clearDownloads } = useMutation({

View File

@@ -90,7 +90,6 @@ const PlayerContextInitializer = () => {
nowPlayingJson ? JSON.parse(nowPlayingJson) : undefined,
)
const [initialized, setInitialized] = useState<boolean>(false)
const [repeatMode, setRepeatMode] = useState<RepeatMode>(
repeatModeJson ? JSON.parse(repeatModeJson) : RepeatMode.Off,
)
@@ -579,26 +578,6 @@ const PlayerContextInitializer = () => {
}
}, [currentIndex, playQueue])
/**
* Initialize the player. This is used to load the queue from the {@link QueueProvider}
* and set it to the player if we have already completed the onboarding process
* and the user has a valid queue in storage
*/
useEffect(() => {
console.debug('Initialized', initialized)
console.debug('Play queue length', playQueue.length)
console.debug('Current index', currentIndex)
if (playQueue.length > 0 && currentIndex > -1 && !initialized) {
TrackPlayer.setQueue(playQueue)
TrackPlayer.skip(currentIndex)
console.debug('Loaded queue from storage')
setInitialized(true)
} else if (queueRef === 'Recently Played' && currentIndex === -1) {
console.debug('Not loading queue as it is empty')
setInitialized(true)
}
}, [])
/**
* Clean up prefetched track IDs when the current index changes significantly
*/

View File

@@ -151,16 +151,20 @@ const QueueContextInitailizer = () => {
const shuffledInit = storage.getBoolean(MMKVStorageKeys.Shuffled)
//#region State
const [currentIndex, setCurrentIndex] = useState<number>(currentIndexValue ?? -1)
const [playQueue, setPlayQueue] = useState<JellifyTrack[]>(playQueueInit)
const [queueRef, setQueueRef] = useState<Queue>(queueRefInit)
const [unshuffledQueue, setUnshuffledQueue] = useState<JellifyTrack[]>(unshuffledQueueInit)
const [currentIndex, setCurrentIndex] = useState<number>(currentIndexValue ?? -1)
/**
* Handles whether we are loading in a new queue, in which case we will temporarily ignore
* {@link Event.PlaybackActiveTrackChanged} events until that mutation has settled
*/
const [skipping, setSkipping] = useState<boolean>(false)
const [shuffled, setShuffled] = useState<boolean>(shuffledInit ?? false)
const [initialized, setInitialized] = useState<boolean>(false)
//#endregion State
//#region Context
@@ -174,8 +178,10 @@ const QueueContextInitailizer = () => {
useTrackPlayerEvents(
[Event.PlaybackActiveTrackChanged],
async ({ index, track }: { index?: number | undefined; track?: Track | undefined }) => {
console.debug(`Active Track Changed to: ${index}. Skipping: ${skipping}`)
if (skipping) return
console.debug(
`Active Track Changed to: ${index}. Skipping: ${skipping}, Initialized: ${initialized}`,
)
if (skipping || !initialized) return
let newIndex = -1
@@ -262,10 +268,6 @@ const QueueContextInitailizer = () => {
startIndex: number = 0,
shuffleQueue: boolean = false,
) => {
trigger('impactLight')
console.debug(`Queuing ${audioItems.length} items`)
setSkipping(true)
setShuffled(shuffleQueue)
// Get the item at the start index
@@ -535,6 +537,12 @@ const QueueContextInitailizer = () => {
queue,
shuffled,
}: QueueMutation) => loadQueue(tracklist, queue, index, shuffled),
onMutate: async ({ tracklist }) => {
trigger('impactLight')
console.debug(`Queuing ${tracklist.length} items`)
setSkipping(true)
},
onSuccess: async (data: void, { startPlayback }: QueueMutation) => {
trigger('notificationSuccess')
console.debug(`Loaded new queue`)
@@ -705,6 +713,23 @@ const QueueContextInitailizer = () => {
//#region useEffect(s)
/**
* Initialization
*/
useEffect(() => {
if (playQueue.length > 0 && currentIndex > -1 && !initialized) {
TrackPlayer.setQueue(playQueue)
TrackPlayer.skip(currentIndex)
// Set Initialized after a timeout to ignore events emitted
// while the queue is setting up
setTimeout(() => setInitialized(true), 500)
} else {
console.debug(`No queue to initialize from`)
setInitialized(true)
}
}, [initialized])
/**
* Store play queue in storage when it changes
*/

View File

@@ -2,6 +2,7 @@ import { Progress, State } from 'react-native-track-player'
import JellifyTrack from '../../../types/JellifyTrack'
import { PlaystateApi } from '@jellyfin/sdk/lib/generated-client/api/playstate-api'
import { convertSecondsToRunTimeTicks } from '../../../utils/runtimeticks'
import { PROGRESS_UPDATE_EVENT_INTERVAL } from '../../../player/config'
export async function handlePlaybackState(
sessionId: string,
@@ -49,7 +50,7 @@ export async function handlePlaybackProgress(
) {
if (playstateApi) {
console.debug('Playback progress updated')
if (Math.floor(progress.duration) - Math.floor(progress.position) <= 10) {
if (shouldMarkPlaybackFinished(progress)) {
console.debug(`Track finished. ${playstateApi ? 'scrobbling...' : ''}`)
if (playstateApi)
@@ -75,3 +76,7 @@ export async function handlePlaybackProgress(
}
}
}
export function shouldMarkPlaybackFinished({ duration, position }: Progress): boolean {
return Math.floor(duration) - Math.floor(position) <= PROGRESS_UPDATE_EVENT_INTERVAL
}

View File

@@ -56,7 +56,6 @@ const PlaylistContextInitializer = (playlist: BaseItemDto) => {
return response.data.Items ? response.data.Items! : []
})
},
staleTime: 1000 * 60 * 60 * 2, // 2 hours, since these are mutable
})
const useUpdatePlaylist = useMutation({

View File

@@ -11,7 +11,6 @@ import { useJellifyContext } from '..'
interface SetFavoriteMutation {
item: BaseItemDto
setFavorite: React.Dispatch<SetStateAction<boolean>>
onToggle?: () => void
}
@@ -27,7 +26,7 @@ const JellifyUserDataContextInitializer = () => {
itemId: mutation.item.Id!,
})
},
onSuccess: ({ data }, { item, setFavorite, onToggle }) => {
onSuccess: ({ data }, { item, onToggle }) => {
// Burnt.alert({
// title: `Added favorite`,
// duration: 1,
@@ -40,7 +39,6 @@ const JellifyUserDataContextInitializer = () => {
trigger('notificationSuccess')
setFavorite(true)
if (onToggle) onToggle()
// Force refresh of track user data
@@ -54,7 +52,7 @@ const JellifyUserDataContextInitializer = () => {
itemId: mutation.item.Id!,
})
},
onSuccess: ({ data }, { item, setFavorite, onToggle }) => {
onSuccess: ({ data }, { item, onToggle }) => {
// Burnt.alert({
// title: `Removed favorite`,
// duration: 1,
@@ -65,7 +63,6 @@ const JellifyUserDataContextInitializer = () => {
type: 'error',
})
trigger('notificationSuccess')
setFavorite(false)
if (onToggle) onToggle()

View File

@@ -1,7 +1,6 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { NativeStackScreenProps } from '@react-navigation/native-stack'
import { BaseStackParamList } from '../types'
import { Queue } from '../../player/types/queue-item'
import { NavigatorScreenParams } from '@react-navigation/native'
type LibraryStackParamList = BaseStackParamList & {

View File

@@ -116,8 +116,7 @@ export default function ServerAuthentication({
icon={() => <Icon name='chevron-left' small />}
bordered={0}
onPress={() => {
if (navigation.canGoBack()) navigation.goBack()
else navigation.navigate('ServerAddress', undefined, { pop: true })
navigation.navigate('ServerAddress', undefined, { pop: true })
}}
>
Switch Server

View File

@@ -22,7 +22,6 @@ export default function Tabs({ route, navigation }: TabProps): React.JSX.Element
return (
<Tab.Navigator
detachInactiveScreens={Platform.OS !== 'ios'} // Temp fix for iOS where screens are detaching
initialRouteName={route.params?.screen ?? 'HomeTab'}
screenOptions={{
animation: 'shift',

View File

@@ -1,14 +1,18 @@
import Player from './Player'
import Tabs from './Tabs'
import { RootStackParamList } from './types'
import { getToken, useTheme } from 'tamagui'
import { getToken, useTheme, YStack } from 'tamagui'
import { useJellifyContext } from '../providers'
import Login from './Login'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { createNativeStackNavigator, NativeStackHeaderProps } from '@react-navigation/native-stack'
import Context from './Context'
import { getItemName } from '../utils/text'
import AddToPlaylistSheet from './AddToPlaylist'
import { Platform } from 'react-native'
import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../components/Player/component.config'
import { Text } from '../components/Global/helpers/text'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
const RootStack = createNativeStackNavigator<RootStackParamList>()
@@ -17,6 +21,8 @@ export default function Root(): React.JSX.Element {
const { api, library } = useJellifyContext()
const isApple = ['ios', 'macos'].includes(Platform.OS)
return (
<RootStack.Navigator
initialRouteName={api && library ? 'Tabs' : 'Login'}
@@ -54,11 +60,10 @@ export default function Root(): React.JSX.Element {
name='Context'
component={Context}
options={({ route }) => ({
headerTitle: getItemName(route.params.item),
header: () => ContextSheetHeader(route.params.item),
presentation: 'formSheet',
sheetAllowedDetents: 'fitToContents',
sheetGrabberVisible: true,
headerTransparent: true,
})}
/>
@@ -67,7 +72,7 @@ export default function Root(): React.JSX.Element {
component={AddToPlaylistSheet}
options={{
headerTitle: 'Add to Playlist',
presentation: Platform.OS === 'ios' ? 'formSheet' : 'modal',
presentation: 'formSheet',
sheetAllowedDetents: 'fitToContents',
sheetGrabberVisible: true,
}}
@@ -75,3 +80,23 @@ export default function Root(): React.JSX.Element {
</RootStack.Navigator>
)
}
function ContextSheetHeader(item: BaseItemDto): React.JSX.Element {
return (
<YStack gap={'$1'} marginTop={'$4'} alignItems='center'>
<TextTicker {...TextTickerConfig}>
<Text bold fontSize={'$6'}>
{getItemName(item)}
</Text>
</TextTicker>
{(item.ArtistItems?.length ?? 0) > 0 && (
<TextTicker {...TextTickerConfig}>
<Text bold fontSize={'$4'}>
{`${item.ArtistItems?.map((artist) => getItemName(artist)).join(' • ')}`}
</Text>
</TextTicker>
)}
</YStack>
)
}

View File

@@ -8523,10 +8523,10 @@ react-native-safe-area-context@^5.6.0:
resolved "https://registry.yarnpkg.com/react-native-safe-area-context/-/react-native-safe-area-context-5.6.0.tgz#0ab284c291bb57d59330abf7dfe65156d6340e78"
integrity sha512-tJas3YOdsuCg3kepCTGF3LWZp9onMbb9Agju2xfs2kRX8d/5TMUPmupBpjerk/B7Tv/zeJnk+qp5neA96Y0otQ==
react-native-screens@4.14.1:
version "4.14.1"
resolved "https://registry.yarnpkg.com/react-native-screens/-/react-native-screens-4.14.1.tgz#7d440cb19752dc8dd46c07cca46222df10e4e38d"
integrity sha512-/7zxVdk2H4BH/dvqpQQh45VCA05UeC+LCE8TPtGfjn5A+9/UJfKPB8LHhAcWxciLYfMCyW8J2u5dGLGQJH/Ecg==
react-native-screens@4.15.0:
version "4.15.0"
resolved "https://registry.yarnpkg.com/react-native-screens/-/react-native-screens-4.15.0.tgz#8b34056b72a2c92da4de2f77419a116154c88e81"
integrity sha512-LPz+9qWDfwY3pPKojrWPcQnb2sAq9cy/qA8ZAS14ksSdzqFkTTUbs1as2WGBo7xBtdx5Ht78bF9nNh8libX1Xw==
dependencies:
react-freeze "^1.0.0"
react-native-is-edge-to-edge "^1.2.1"