From 8a191b91ab752cbc7928878c34e19efc5b45cc24 Mon Sep 17 00:00:00 2001 From: Violet Caulfield Date: Wed, 20 Aug 2025 15:02:52 -0500 Subject: [PATCH] context sheet prefetching unit tests update rn screens --- App.tsx | 3 +- ios/Podfile.lock | 8 +- jest/functional/Player-Handlers.test.ts | 28 ++ package.json | 2 +- src/api/queries/media.ts | 8 +- src/api/queries/suggestions.ts | 8 +- src/components/AddToPlaylist/index.tsx | 6 +- src/components/Context/index.tsx | 74 ++++-- src/components/Context/utils/navigation.ts | 26 +- src/components/Global/components/image.tsx | 5 +- .../Global/components/item-card.tsx | 174 ++++--------- src/components/Global/components/item-row.tsx | 243 +++++++----------- src/components/Global/components/track.tsx | 5 +- .../Player/components/blurred-background.tsx | 2 +- src/components/Player/components/header.tsx | 15 +- .../Player/components/song-info.tsx | 12 +- src/components/Player/mini-player.tsx | 46 +--- src/player/config.ts | 6 + src/providers/Item/index.tsx | 132 ++++++++++ src/providers/Item/item-artists.tsx | 32 +++ src/providers/Player/queue.tsx | 24 +- src/providers/Player/utils/handlers.ts | 7 +- src/screens/Tabs/index.tsx | 1 - yarn.lock | 8 +- 24 files changed, 486 insertions(+), 389 deletions(-) create mode 100644 jest/functional/Player-Handlers.test.ts create mode 100644 src/providers/Item/index.tsx create mode 100644 src/providers/Item/item-artists.tsx diff --git a/App.tsx b/App.tsx index 1e3f6b53..6a2d1745 100644 --- a/App.tsx +++ b/App.tsx @@ -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(() => { diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 65baeca5..64d9c174 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -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 diff --git a/jest/functional/Player-Handlers.test.ts b/jest/functional/Player-Handlers.test.ts new file mode 100644 index 00000000..61e85fad --- /dev/null +++ b/jest/functional/Player-Handlers.test.ts @@ -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() + }) +}) diff --git a/package.json b/package.json index b5c0f700..2a4b7d83 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/queries/media.ts b/src/api/queries/media.ts index e7d395b4..2c4b170d 100644 --- a/src/api/queries/media.ts +++ b/src/api/queries/media.ts @@ -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 { 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 diff --git a/src/api/queries/suggestions.ts b/src/api/queries/suggestions.ts index 4b7306a9..b98be2e8 100644 --- a/src/api/queries/suggestions.ts +++ b/src/api/queries/suggestions.ts @@ -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'], }) diff --git a/src/components/AddToPlaylist/index.tsx b/src/components/AddToPlaylist/index.tsx index 23e4165f..b789f634 100644 --- a/src/components/AddToPlaylist/index.tsx +++ b/src/components/AddToPlaylist/index.tsx @@ -120,11 +120,13 @@ export default function AddToPlaylist({ track }: { track: BaseItemDto }): React. return ( - + - {getItemName(track)} + + {getItemName(track)} + diff --git a/src/components/Context/index.tsx b/src/components/Context/index.tsx index 3b4d6514..1aaa0971 100644 --- a/src/components/Context/index.tsx +++ b/src/components/Context/index.tsx @@ -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 ( @@ -108,10 +116,7 @@ export default function ItemContext({ item, stackNavigation }: ContextProps): Re )} {!isPlaylist && ( - + )} @@ -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 ( + + {artistIds.map((id) => ( + + ))} + + ) +} + +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 ( - - {artists.map((artist, index) => ( - goToArtist(artist)} - pressStyle={{ opacity: 0.5 }} - > - + return artist ? ( + goToArtist(artist)} + pressStyle={{ opacity: 0.5 }} + > + - {`Go to ${getItemName(artist)}`} - - ))} - + {`Go to ${getItemName(artist)}`} + + ) : ( + <> ) } diff --git a/src/components/Context/utils/navigation.ts b/src/components/Context/utils/navigation.ts index 96e67d74..17c77e10 100644 --- a/src/components/Context/utils/navigation.ts +++ b/src/components/Context/utils/navigation.ts @@ -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')) diff --git a/src/components/Global/components/image.tsx b/src/components/Global/components/image.tsx index 23afc808..f77cb571 100644 --- a/src/components/Global/components/image.tsx +++ b/src/components/Global/components/image.tsx @@ -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 diff --git a/src/components/Global/components/item-card.tsx b/src/components/Global/components/item-card.tsx index 61861274..138220ce 100644 --- a/src/components/Global/components/item-card.tsx +++ b/src/components/Global/components/item-card.tsx @@ -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 ( - - - - - {/* { props.item.Type === 'MusicArtist' && ( - - )} */} - - - - - - {props.caption && ( - - - {props.caption} - - - {props.subCaption && ( - - {props.subCaption} + + + + + + {/* { props.item.Type === 'MusicArtist' && ( + + )} */} + + + + + + {props.caption && ( + + + {props.caption} - )} - - )} - + + {props.subCaption && ( + + {props.subCaption} + + )} + + )} + + ) } diff --git a/src/components/Global/components/item-row.tsx b/src/components/Global/components/item-row.tsx index baceb1c9..83372f93 100644 --- a/src/components/Global/components/item-row.tsx +++ b/src/components/Global/components/item-row.tsx @@ -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 ( - - { - 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'} - > - - - - - - - {item.Name ?? ''} - - {item.Type === 'MusicArtist' && ( - - {`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Album' : 'Albums'}`} - - )} - {(item.Type === 'Audio' || item.Type === 'MusicAlbum') && ( - - {item.AlbumArtist ?? 'Untitled Artist'} - - )} - - {item.Type === 'Playlist' && ( - - {item.Genres?.join(', ') ?? ''} - - )} - - + + - - {/* Runtime ticks for Songs */} - {['Audio', 'MusicAlbum'].includes(item.Type ?? '') ? ( - {item.RunTimeTicks} - ) : ['Playlist'].includes(item.Type ?? '') ? ( - {`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Track' : 'Tracks'}`} - ) : null} + alignContent='center' + minHeight={'$7'} + width={'100%'} + onLongPress={() => { + navigationRef.navigate('Context', { + item, + navigation, + }) + }} + onPress={() => { + if (onPress) { + onPress() + return + } - {item.Type === 'Audio' || item.Type === 'MusicAlbum' ? ( - { - 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'} + > + + - ) : null} + + + + + {item.Name ?? ''} + + {item.Type === 'MusicArtist' && ( + + {`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Album' : 'Albums'}`} + + )} + {(item.Type === 'Audio' || item.Type === 'MusicAlbum') && ( + + {item.AlbumArtist ?? 'Untitled Artist'} + + )} + + {item.Type === 'Playlist' && ( + + {item.Genres?.join(', ') ?? ''} + + )} + + + + + {/* Runtime ticks for Songs */} + {['Audio', 'MusicAlbum'].includes(item.Type ?? '') ? ( + {item.RunTimeTicks} + ) : ['Playlist'].includes(item.Type ?? '') ? ( + {`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Track' : 'Tracks'}`} + ) : null} + + {item.Type === 'Audio' || item.Type === 'MusicAlbum' ? ( + { + navigationRef.navigate('Context', { + item, + navigation, + }) + }} + /> + ) : null} + - - + + ) } diff --git a/src/components/Global/components/track.tsx b/src/components/Global/components/track.tsx index 040257d9..18fdf935 100644 --- a/src/components/Global/components/track.tsx +++ b/src/components/Global/components/track.tsx @@ -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', }) diff --git a/src/components/Player/components/blurred-background.tsx b/src/components/Player/components/blurred-background.tsx index 6dcef149..9520366e 100644 --- a/src/components/Player/components/blurred-background.tsx +++ b/src/components/Player/components/blurred-background.tsx @@ -54,7 +54,7 @@ function BlurredBackground({ const gradientStyle2 = { width, height, - flex: 2, + flex: 3, } const backgroundStyle = { diff --git a/src/components/Player/components/header.tsx b/src/components/Player/components/header.tsx index bd2bfce9..bbb97e5c 100644 --- a/src/components/Player/components/header.tsx +++ b/src/components/Player/components/header.tsx @@ -49,18 +49,19 @@ export default function PlayerHeader(): React.JSX.Element { - + - + diff --git a/src/components/Player/components/song-info.tsx b/src/components/Player/components/song-info.tsx index b1d3ec01..cdeb6488 100644 --- a/src/components/Player/components/song-info.tsx +++ b/src/components/Player/components/song-info.tsx @@ -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 @@ -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 { - {artistName} + {artists ?? 'Unknown Artist'} diff --git a/src/components/Player/mini-player.tsx b/src/components/Player/mini-player.tsx index 07b6122b..e236c59f 100644 --- a/src/components/Player/mini-player.tsx +++ b/src/components/Player/mini-player.tsx @@ -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`} > - )} diff --git a/src/player/config.ts b/src/player/config.ts index 4bade43a..019708d4 100644 --- a/src/player/config.ts +++ b/src/player/config.ts @@ -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 diff --git a/src/providers/Item/index.tsx b/src/providers/Item/index.tsx new file mode 100644 index 00000000..8a6cdf78 --- /dev/null +++ b/src/providers/Item/index.tsx @@ -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({ + 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( + () => ( + + {artistIds.map((Id) => Id && )} + {children} + + ), + [item], + ) +} diff --git a/src/providers/Item/item-artists.tsx b/src/providers/Item/item-artists.tsx new file mode 100644 index 00000000..70618597 --- /dev/null +++ b/src/providers/Item/item-artists.tsx @@ -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({ + 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 +} diff --git a/src/providers/Player/queue.tsx b/src/providers/Player/queue.tsx index 82e1f143..27ee6678 100644 --- a/src/providers/Player/queue.tsx +++ b/src/providers/Player/queue.tsx @@ -151,13 +151,17 @@ const QueueContextInitailizer = () => { const shuffledInit = storage.getBoolean(MMKVStorageKeys.Shuffled) //#region State + const [currentIndex, setCurrentIndex] = useState(currentIndexValue ?? -1) const [playQueue, setPlayQueue] = useState(playQueueInit) const [queueRef, setQueueRef] = useState(queueRefInit) const [unshuffledQueue, setUnshuffledQueue] = useState(unshuffledQueueInit) - const [currentIndex, setCurrentIndex] = useState(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(false) + const [initialized, setInitialized] = useState(false) const [shuffled, setShuffled] = useState(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`) diff --git a/src/providers/Player/utils/handlers.ts b/src/providers/Player/utils/handlers.ts index c11c7232..0722ebfd 100644 --- a/src/providers/Player/utils/handlers.ts +++ b/src/providers/Player/utils/handlers.ts @@ -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 +} diff --git a/src/screens/Tabs/index.tsx b/src/screens/Tabs/index.tsx index 3be95706..dba2aac6 100644 --- a/src/screens/Tabs/index.tsx +++ b/src/screens/Tabs/index.tsx @@ -22,7 +22,6 @@ export default function Tabs({ route, navigation }: TabProps): React.JSX.Element return (