diff --git a/App.tsx b/App.tsx index 1e3f6b53..3c970101 100644 --- a/App.tsx +++ b/App.tsx @@ -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, }} > diff --git a/android/.run/app.run.xml b/android/.run/app.run.xml new file mode 100644 index 00000000..c25e1059 --- /dev/null +++ b/android/.run/app.run.xml @@ -0,0 +1,74 @@ + + + + + \ No newline at end of file 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/item.ts b/src/api/queries/item.ts index 32d263e9..6c9c8dd1 100644 --- a/src/api/queries/item.ts +++ b/src/api/queries/item.ts @@ -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) => 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/query.config.ts b/src/api/queries/query.config.ts index 50150d73..46aff7df 100644 --- a/src/api/queries/query.config.ts +++ b/src/api/queries/query.config.ts @@ -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 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/Artist/albums.tsx b/src/components/Artist/albums.tsx index 7c4f82ee..1f4c8018 100644 --- a/src/components/Artist/albums.tsx +++ b/src/components/Artist/albums.tsx @@ -96,6 +96,7 @@ export default function Albums({ ) } + removeClippedSubviews /> ) } diff --git a/src/components/Context/index.tsx b/src/components/Context/index.tsx index a1717bbe..1977c20b 100644 --- a/src/components/Context/index.tsx +++ b/src/components/Context/index.tsx @@ -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, '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 ( - - + + - {renderAddToQueueRow && } + {renderAddToQueueRow && } {renderAddToPlaylistRow && } @@ -108,36 +110,10 @@ export default function ItemContext({ item, stackNavigation }: ContextProps): Re )} {!isPlaylist && ( - + )} - - ) -} - -function ItemContextBackground({ item }: { item: BaseItemDto }): React.JSX.Element { - return ( - - - - - - ) -} - -function BackgroundBlur({ item }: { item: BaseItemDto }): React.JSX.Element { - const blurhash = getPrimaryBlurhashFromDto(item) - - return ( - + ) } @@ -155,7 +131,7 @@ function AddToPlaylistRow({ track }: { track: BaseItemDto }): React.JSX.Element }} pressStyle={{ opacity: 0.5 }} > - + Add to Playlist @@ -182,9 +158,11 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele }} pressStyle={{ opacity: 0.5 }} > - + - Add to Queue + + Add to Queue + ) } @@ -224,7 +202,7 @@ function ViewAlbumMenuRow({ album: album, stackNavigation }: MenuRowProps): Reac onPress={goToAlbum} pressStyle={{ opacity: 0.5 }} > - + {`Go to ${getItemName(album)}`} @@ -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 ( + + {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 }) @@ -248,23 +250,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/favorite-button.tsx b/src/components/Global/components/favorite-button.tsx index 3ae5eaae..1001bd2a 100644 --- a/src/components/Global/components/favorite-button.tsx +++ b/src/components/Global/components/favorite-button.tsx @@ -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(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 ? ( ) : isFavorite ? ( @@ -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, }) } diff --git a/src/components/Global/components/favorite-context-menu-row.tsx b/src/components/Global/components/favorite-context-menu-row.tsx index ae9d7c78..2a7f9e9d 100644 --- a/src/components/Global/components/favorite-context-menu-row.tsx +++ b/src/components/Global/components/favorite-context-menu-row.tsx @@ -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( - userData?.IsFavorite ?? item.UserData?.IsFavorite ?? false, - ) - - useEffect(() => { - setIsFavorite(userData?.IsFavorite ?? false) - }, [userData]) - return isFavorite ? ( { 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`} > - + - - Remove from favorites - + Remove from favorites @@ -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 }} > - - + + - - Add to favorites - + Add to favorites diff --git a/src/components/Global/components/favorite-icon.tsx b/src/components/Global/components/favorite-icon.tsx index 011bc199..a24ac224 100644 --- a/src/components/Global/components/favorite-icon.tsx +++ b/src/components/Global/components/favorite-icon.tsx @@ -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(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 ? ( diff --git a/src/components/Global/components/icon.tsx b/src/components/Global/components/icon.tsx index 3f698daa..6c1bfffc 100644 --- a/src/components/Global/components/icon.tsx +++ b/src/components/Global/components/icon.tsx @@ -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 ( fetchInstantMixFromItem(api, user, item), - staleTime: 1000 * 60 * 60 * 24, // 24 hours }) return data ? ( diff --git a/src/components/Global/components/item-card.tsx b/src/components/Global/components/item-card.tsx index b300b7ad..138220ce 100644 --- a/src/components/Global/components/item-card.tsx +++ b/src/components/Global/components/item-card.tsx @@ -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 ( - - - - - {/* { 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 552a22b6..83372f93 100644 --- a/src/components/Global/components/item-row.tsx +++ b/src/components/Global/components/item-row.tsx @@ -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 ( - - { - 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 4ee106f7..1af66f64 100644 --- a/src/components/Global/components/track.tsx +++ b/src/components/Global/components/track.tsx @@ -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 ( - - + + - {showArtwork ? ( - - ) : ( - - {indexNumber} - - )} - - - - - {trackName} - + {showArtwork ? ( + + ) : ( + + {indexNumber} + + )} + - {shouldShowArtists && ( + - {artistsText} + {trackName} - )} - - + {shouldShowArtists && ( + + {artistsText} + + )} + - + - - {track.RunTimeTicks} - + - - - + + {track.RunTimeTicks} + + + + + + ) } diff --git a/src/components/Global/helpers/slider.tsx b/src/components/Global/helpers/slider.tsx index fdeffabd..2b43b9d4 100644 --- a/src/components/Global/helpers/slider.tsx +++ b/src/components/Global/helpers/slider.tsx @@ -56,7 +56,7 @@ export function HorizontalSlider({ value, max, width, props }: SliderProps): Rea } renderItem={({ item, index }) => ( + >() return ( - - - - + + >() - const nowPlaying = useNowPlayingContext() - const playbackState = usePlaybackStateContext() - - const isPlaying = playbackState === State.Playing const queueRef = useQueueRefContext() - const { width } = useWindowDimensions() - const theme = useTheme() - return ( - - - - { - navigation.goBack() - }} - small - /> - + // If the Queue is a BaseItemDto, display the name of it + const playingFrom = useMemo( + () => (typeof queueRef === 'object' ? (queueRef.Name ?? 'Untitled') : queueRef), + [queueRef], + ) - + return ( + + navigationRef.goBack()} + > + + + Playing from - { - // If the Queue is a BaseItemDto, display the name of it - typeof queueRef === 'object' ? (queueRef.Name ?? 'Untitled') : queueRef - } + {playingFrom} - + - + - + ) } diff --git a/src/components/Player/components/scrubber.tsx b/src/components/Player/components/scrubber.tsx index 25815f4e..385646fd 100644 --- a/src/components/Player/components/scrubber.tsx +++ b/src/components/Player/components/scrubber.tsx @@ -135,11 +135,15 @@ export default function Scrubber(): React.JSX.Element { return ( - + diff --git a/src/components/Player/components/song-info.tsx b/src/components/Player/components/song-info.tsx index ced0b1de..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(() => { @@ -74,9 +78,9 @@ function SongInfo({ navigation }: SongInfoProps): React.JSX.Element { }, [artistItems, navigation]) return ( - - - + + + @@ -88,7 +92,7 @@ function SongInfo({ navigation }: SongInfoProps): React.JSX.Element { - {artistName} + {artists ?? 'Unknown Artist'} diff --git a/src/components/Player/index.tsx b/src/components/Player/index.tsx index d4783989..56248de8 100644 --- a/src/components/Player/index.tsx +++ b/src/components/Player/index.tsx @@ -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 ( - - - {nowPlaying && ( - - + + {nowPlaying && ( + + - - + + {/* flexGrow 1 */} + - - - - - - {/* playback progress goes here */} - - + + + + {/* playback progress goes here */} -