context sheet prefetching

unit tests

update rn screens
This commit is contained in:
Violet Caulfield
2025-08-20 15:02:52 -05:00
parent 24d3aa475c
commit 8a191b91ab
24 changed files with 486 additions and 389 deletions

View File

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

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

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

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

@@ -79,6 +79,14 @@ export default function ItemContext({ item, stackNavigation }: ContextProps): Re
const renderViewAlbumRow = useMemo(() => isAlbum || (isTrack && album), [album, item])
const artistIds = !isPlaylist
? isArtist
? [item.Id]
: item.ArtistItems
? item.ArtistItems.map((item) => item.Id)
: []
: []
return (
<ScrollView>
<YGroup unstyled marginBottom={bottom}>
@@ -108,10 +116,7 @@ export default function ItemContext({ item, stackNavigation }: ContextProps): Re
)}
{!isPlaylist && (
<ViewArtistMenuRow
artists={isArtist ? [item] : itemArtists}
stackNavigation={stackNavigation}
/>
<ArtistMenuRows artistIds={artistIds} stackNavigation={stackNavigation} />
)}
</YGroup>
</ScrollView>
@@ -210,13 +215,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 })
@@ -225,23 +254,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={'$10'} width={'$10'} />
<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

@@ -83,10 +83,7 @@ function getBorderRadius(circular: boolean | undefined, width: Token | number |
if (circular) {
borderRadius = width ? (typeof width === 'number' ? width : getTokenValue(width)) : '100%'
} else if (!isUndefined(width)) {
borderRadius =
typeof width === 'number'
? width / 25
: getTokenValue(width) / (getTokenValue(width) / 6)
borderRadius = typeof width === 'number' ? width / 25 : getTokenValue(width) / 15
} else borderRadius = '5%'
return borderRadius

View File

@@ -1,17 +1,10 @@
import React from 'react'
import { CardProps as TamaguiCardProps } from 'tamagui'
import { getToken, Card as TamaguiCard, View, YStack } from 'tamagui'
import { BaseItemDto, BaseItemKind, 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, getItemsApi } 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 { fetchAlbumDiscs, fetchItem } from '../../../api/queries/item'
import { ItemProvider } from '../../../providers/Item'
import ItemImage from './image'
interface CardProps extends TamaguiCardProps {
caption?: string | null | undefined
@@ -29,119 +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 === BaseItemKind.Audio,
})
/**
* Fire query for a track's album
*
* Referenced later in the context sheet
*/
useQuery({
queryKey: [QueryKeys.Album, props.item.AlbumId],
queryFn: () => fetchItem(api, props.item.AlbumId!),
enabled: props.item.Type === BaseItemKind.Audio && !!props.item.AlbumId,
})
/**
* Fire query for an album's tracks
*
* Referenced later in the context sheet
*/
useQuery({
queryKey: [QueryKeys.ItemTracks, props.item.Id],
queryFn: () => fetchAlbumDiscs(api, props.item),
enabled: !!props.item.Id && props.item.Type === BaseItemKind.MusicAlbum,
})
/**
* Fire query for an playlist's tracks
*
* Referenced later in the context sheet
*/
useQuery({
queryKey: [QueryKeys.ItemTracks, props.item.Id],
queryFn: () =>
getItemsApi(api!)
.getItems({ parentId: props.item.Id! })
.then(({ data }) => {
if (data.Items) return data.Items
else return []
}),
enabled: !!props.item.Id && props.item.Type === BaseItemKind.Playlist,
})
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

@@ -1,4 +1,4 @@
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { XStack, YStack } from 'tamagui'
import { Text } from '../helpers/text'
import Icon from './icon'
@@ -9,17 +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 { fetchAlbumDiscs, fetchItem } from '../../../api/queries/item'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { ItemProvider } from '../../../providers/Item'
interface ItemRowProps {
item: BaseItemDto
@@ -43,64 +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()
/**
* Fire a query for fetching a track's media sources
*
* Referenced later when queuing tracks
*/
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',
})
/**
* Fire a query for fetching an album for a given track
*
* Referenced later in the context sheet
*/
useQuery({
queryKey: [QueryKeys.Album, item.AlbumId],
queryFn: () => fetchItem(api, item.AlbumId!),
enabled: item.Type === BaseItemKind.Audio && !!item.AlbumId,
})
/**
* Fire a query for fetching an album's tracks
*
* Referenced later in the context sheet
*/
useQuery({
queryKey: [QueryKeys.ItemTracks, item.Id],
queryFn: () => fetchAlbumDiscs(api, item),
enabled: !!item.Id && item.Type === BaseItemKind.MusicAlbum,
})
/**
* Fire query for an playlist's tracks
*
* Referenced later in the context sheet
*/
useQuery({
queryKey: [QueryKeys.ItemTracks, item.Id],
queryFn: () =>
getItemsApi(api!)
.getItems({ parentId: item.Id! })
.then(({ data }) => {
if (data.Items) return data.Items
else return []
}),
enabled: !!item.Id && item.Type === BaseItemKind.Playlist,
})
const gestureCallback = () => {
switch (item.Type) {
@@ -127,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

@@ -127,6 +127,7 @@ export default function Track({
} else {
navigationRef.navigate('Context', {
item: track,
navigation,
})
}
}, [onLongPress, track, isNested])
@@ -144,7 +145,7 @@ export default function Track({
// Only fetch media info if needed (for streaming)
useQuery({
queryKey: [QueryKeys.MediaSources, streamingQuality, track.Id],
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), track),
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), track.Id!),
staleTime: Infinity, // Don't refetch media info unless the user changes the quality
enabled: !isDownloaded, // Only fetch if not downloaded
})
@@ -152,7 +153,7 @@ export default function Track({
// Fire query for fetching the track's media sources
useQuery({
queryKey: [QueryKeys.MediaSources, streamingQuality, track.Id],
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), track),
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), track.Id!),
staleTime: Infinity, // Don't refetch media info unless the user changes the quality
enabled: track.Type === 'Audio',
})

View File

@@ -54,7 +54,7 @@ function BlurredBackground({
const gradientStyle2 = {
width,
height,
flex: 2,
flex: 3,
}
const backgroundStyle = {

View File

@@ -49,18 +49,19 @@ export default function PlayerHeader(): React.JSX.Element {
<Spacer flex={1} />
</XStack>
<YStack flexGrow={1} justifyContent='center'>
<YStack
flexGrow={1}
justifyContent='center'
paddingHorizontal={'$2'}
maxHeight={'70%'}
marginVertical={'auto'}
>
<Animated.View
entering={FadeIn}
exiting={FadeOut}
key={`${nowPlaying!.item.AlbumId}-item-image`}
>
<ItemImage
item={nowPlaying!.item}
testID='player-image-test-id'
width={360}
height={360}
/>
<ItemImage item={nowPlaying!.item} testID='player-image-test-id' />
</Animated.View>
</YStack>
</YStack>

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

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

@@ -0,0 +1,132 @@
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
import { createContext, ReactNode, useMemo } 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'
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 } = item
const artistIds = ArtistItems?.map(({ Id }) => Id) ?? []
/**
* Fetch and cache the media sources if this item is a track
*/
useQuery({
queryKey: [QueryKeys.MediaSources, streamingQuality, Id],
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), Id!),
staleTime: Infinity, // Don't refetch media info unless the user changes the quality
enabled: !!Id && Type === BaseItemKind.Audio,
})
/**
* ...or store it as a queryable if the item is an artist
*
* Referenced later in the context sheet
*/
useQuery({
queryKey: [QueryKeys.ArtistById, Id],
queryFn: () => item,
enabled: !!Id && Type === BaseItemKind.MusicArtist,
})
/**
* Fire query for a track's album...
*
* Referenced later in the context sheet
*/
useQuery({
queryKey: [QueryKeys.Album, AlbumId],
queryFn: () => fetchItem(api, item.AlbumId!),
enabled: !!AlbumId && Type === BaseItemKind.Audio,
})
/**
* ...or store it if it is an album
*
* Referenced later in the context sheet
*/
useQuery({
queryKey: [QueryKeys.Album, Id],
queryFn: () => item,
enabled: !!Id && Type === BaseItemKind.MusicAlbum,
})
/**
* Prefetch for an album's tracks
*
* Referenced later in the context sheet
*/
useQuery({
queryKey: [QueryKeys.ItemTracks, Id],
queryFn: () => fetchAlbumDiscs(api, item),
enabled: !!Id && item.Type === BaseItemKind.MusicAlbum,
})
/**
* Fire query for a playlist's tracks
*
* Referenced later in the context sheet
*/
useQuery({
queryKey: [QueryKeys.ItemTracks, Id],
queryFn: () =>
getItemsApi(api!)
.getItems({ parentId: Id! })
.then(({ data }) => {
if (data.Items) return data.Items
else return []
}),
enabled: !!Id && Type === BaseItemKind.Playlist,
})
return useMemo(
() => (
<ItemContext.Provider value={{ item }}>
{artistIds.map((Id) => Id && <ItemArtistProvider artistId={Id} key={Id} />)}
{children}
</ItemContext.Provider>
),
[item],
)
}

View File

@@ -0,0 +1,32 @@
import { createContext } from 'react'
import { useJellifyContext } from '..'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../enums/query-keys'
import { fetchItem } from '../../api/queries/item'
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()
/**
* Store queryable of artist item
*/
useQuery({
queryKey: [QueryKeys.ArtistById, artistId],
queryFn: () => fetchItem(api, artistId!),
enabled: !!artistId,
})
return <ItemArtistContext.Provider value={{ artistId }} />
}

View File

@@ -151,13 +151,17 @@ 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 [initialized, setInitialized] = useState<boolean>(false)
const [shuffled, setShuffled] = useState<boolean>(shuffledInit ?? false)
@@ -177,6 +181,11 @@ const QueueContextInitailizer = () => {
console.debug(`Active Track Changed to: ${index}. Skipping: ${skipping}`)
if (skipping) return
// We get an event emitted when the queue is loaded from storage for the first time
// This is the most convenient place I could find to flip this boolean and start
// listening to emitted updates
if (!initialized) return setInitialized(true)
let newIndex = -1
if (!isUndefined(track)) {
@@ -262,10 +271,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,7 +540,14 @@ 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) => {
setInitialized(true)
trigger('notificationSuccess')
console.debug(`Loaded new queue`)

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

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

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