mirror of
https://github.com/Jellify-Music/App.git
synced 2026-01-08 20:10:22 -06:00
Fix Context Sheet (Android), Context Sheet Styling, Player Styling Fixes, Query Client Optimizations (#481)
* player style fixes * fix content sheet for android * context sheet prefetching in item card, item row, and track components context sheet header styling fix issue where nowplaying was incorrect after the queue was initialized from storage
This commit is contained in:
10
App.tsx
10
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,
|
||||
}}
|
||||
>
|
||||
<GestureHandlerRootView>
|
||||
|
||||
74
android/.run/app.run.xml
Normal file
74
android/.run/app.run.xml
Normal file
@@ -0,0 +1,74 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="app" type="AndroidRunConfigurationType" factoryName="Android App" activateToolWindowBeforeRun="false">
|
||||
<module name="Jellify.app" />
|
||||
<option name="ANDROID_RUN_CONFIGURATION_SCHEMA_VERSION" value="1" />
|
||||
<option name="DEPLOY" value="true" />
|
||||
<option name="DEPLOY_APK_FROM_BUNDLE" value="false" />
|
||||
<option name="DEPLOY_AS_INSTANT" value="false" />
|
||||
<option name="ARTIFACT_NAME" value="" />
|
||||
<option name="PM_INSTALL_OPTIONS" value="" />
|
||||
<option name="ALL_USERS" value="false" />
|
||||
<option name="ALWAYS_INSTALL_WITH_PM" value="false" />
|
||||
<option name="ALLOW_ASSUME_VERIFIED" value="false" />
|
||||
<option name="CLEAR_APP_STORAGE" value="false" />
|
||||
<option name="DYNAMIC_FEATURES_DISABLED_LIST" value="" />
|
||||
<option name="ACTIVITY_EXTRA_FLAGS" value="" />
|
||||
<option name="MODE" value="default_activity" />
|
||||
<option name="RESTORE_ENABLED" value="false" />
|
||||
<option name="RESTORE_FILE" value="" />
|
||||
<option name="RESTORE_FRESH_INSTALL_ONLY" value="false" />
|
||||
<option name="CLEAR_LOGCAT" value="false" />
|
||||
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
|
||||
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
|
||||
<option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" />
|
||||
<option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="" />
|
||||
<option name="DEBUGGER_TYPE" value="Auto" />
|
||||
<Auto>
|
||||
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
|
||||
<option name="SHOW_STATIC_VARS" value="true" />
|
||||
<option name="WORKING_DIR" value="" />
|
||||
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
|
||||
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
|
||||
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
|
||||
<option name="DEBUG_SANDBOX_SDK" value="false" />
|
||||
</Auto>
|
||||
<Hybrid>
|
||||
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
|
||||
<option name="SHOW_STATIC_VARS" value="true" />
|
||||
<option name="WORKING_DIR" value="" />
|
||||
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
|
||||
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
|
||||
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
|
||||
<option name="DEBUG_SANDBOX_SDK" value="false" />
|
||||
</Hybrid>
|
||||
<Java>
|
||||
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
|
||||
<option name="DEBUG_SANDBOX_SDK" value="false" />
|
||||
</Java>
|
||||
<Native>
|
||||
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
|
||||
<option name="SHOW_STATIC_VARS" value="true" />
|
||||
<option name="WORKING_DIR" value="" />
|
||||
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
|
||||
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
|
||||
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
|
||||
<option name="DEBUG_SANDBOX_SDK" value="false" />
|
||||
</Native>
|
||||
<Profilers>
|
||||
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
|
||||
<option name="STARTUP_PROFILING_ENABLED" value="false" />
|
||||
<option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
|
||||
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Java/Kotlin Method Sample (legacy)" />
|
||||
<option name="STARTUP_NATIVE_MEMORY_PROFILING_ENABLED" value="false" />
|
||||
<option name="NATIVE_MEMORY_SAMPLE_RATE_BYTES" value="2048" />
|
||||
</Profilers>
|
||||
<option name="DEEP_LINK" value="" />
|
||||
<option name="ACTIVITY" value="" />
|
||||
<option name="ACTIVITY_CLASS" value="" />
|
||||
<option name="SEARCH_ACTIVITY_IN_GLOBAL_SCOPE" value="false" />
|
||||
<option name="SKIP_ACTIVITY_VALIDATION" value="false" />
|
||||
<method v="2">
|
||||
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -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
|
||||
|
||||
28
jest/functional/Player-Handlers.test.ts
Normal file
28
jest/functional/Player-Handlers.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
})
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -96,6 +96,7 @@ export default function Albums({
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
removeClippedSubviews
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { getToken, ListItem, View, YGroup, ZStack } from 'tamagui'
|
||||
import { getToken, ListItem, ScrollView, View, YGroup } from 'tamagui'
|
||||
import { BaseStackParamList, RootStackParamList } from '../../screens/types'
|
||||
import { Text } from '../Global/helpers/text'
|
||||
import FavoriteContextMenuRow from '../Global/components/favorite-context-menu-row'
|
||||
import { Blurhash } from 'react-native-blurhash'
|
||||
import { getPrimaryBlurhashFromDto } from '../../utils/blurhash'
|
||||
import { useColorScheme } from 'react-native'
|
||||
import { useThemeSettingContext } from '../../providers/Settings'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
@@ -12,13 +10,13 @@ import Icon from '../Global/components/icon'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { QueryKeys } from '../../enums/query-keys'
|
||||
import { fetchItem, fetchItems } from '../../api/queries/item'
|
||||
import { fetchAlbumDiscs, fetchItem } from '../../api/queries/item'
|
||||
import { useJellifyContext } from '../../providers'
|
||||
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { useAddToQueueContext } from '../../providers/Player/queue'
|
||||
import { AddToQueueMutation } from '../../providers/Player/interfaces'
|
||||
import { QueuingType } from '../../enums/queuing-type'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import navigationRef from '../../../navigation'
|
||||
import { goToAlbumFromContextSheet, goToArtistFromContextSheet } from './utils/navigation'
|
||||
import { getItemName } from '../../utils/text'
|
||||
@@ -26,6 +24,8 @@ import ItemImage from '../Global/components/image'
|
||||
import { StackActions } from '@react-navigation/native'
|
||||
import TextTicker from 'react-native-text-ticker'
|
||||
import { TextTickerConfig } from '../Player/component.config'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { trigger } from 'react-native-haptic-feedback'
|
||||
|
||||
type StackNavigation = Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
|
||||
|
||||
@@ -37,43 +37,21 @@ interface ContextProps {
|
||||
}
|
||||
|
||||
export default function ItemContext({ item, stackNavigation }: ContextProps): React.JSX.Element {
|
||||
const { api, user, library } = useJellifyContext()
|
||||
const { api } = useJellifyContext()
|
||||
|
||||
const { bottom } = useSafeAreaInsets()
|
||||
|
||||
const isArtist = item.Type === BaseItemKind.MusicArtist
|
||||
const isAlbum = item.Type === BaseItemKind.MusicAlbum
|
||||
const isTrack = item.Type === BaseItemKind.Audio
|
||||
const isPlaylist = item.Type === BaseItemKind.Playlist
|
||||
|
||||
const itemArtists = item.ArtistItems ?? []
|
||||
|
||||
const { data: album } = useQuery({
|
||||
queryKey: [QueryKeys.Item, item.AlbumId],
|
||||
queryKey: [QueryKeys.Album, item.AlbumId],
|
||||
queryFn: () => fetchItem(api, item.AlbumId!),
|
||||
enabled: isTrack,
|
||||
})
|
||||
|
||||
const { data: artists } = useQuery({
|
||||
queryKey: [
|
||||
QueryKeys.ArtistById,
|
||||
itemArtists.length > 0 ? itemArtists?.map((artist) => artist.Id) : item.Id,
|
||||
],
|
||||
queryFn: () =>
|
||||
fetchItems(
|
||||
api,
|
||||
user,
|
||||
library,
|
||||
[BaseItemKind.MusicArtist],
|
||||
0,
|
||||
[],
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
itemArtists?.map((artist) => artist.Id!),
|
||||
),
|
||||
enabled: (isTrack || isAlbum) && itemArtists.length > 0,
|
||||
select: (data) => data.data,
|
||||
})
|
||||
|
||||
const { data: tracks } = useQuery({
|
||||
queryKey: [QueryKeys.ItemTracks, item.Id],
|
||||
queryFn: () =>
|
||||
@@ -83,20 +61,44 @@ export default function ItemContext({ item, stackNavigation }: ContextProps): Re
|
||||
if (data.Items) return data.Items
|
||||
else return []
|
||||
}),
|
||||
enabled: isPlaylist,
|
||||
})
|
||||
|
||||
const { data: discs } = useQuery({
|
||||
queryKey: [QueryKeys.ItemTracks, item.Id],
|
||||
queryFn: () => fetchAlbumDiscs(api, item),
|
||||
enabled: isAlbum,
|
||||
})
|
||||
|
||||
const renderAddToQueueRow = isTrack || (isAlbum && tracks) || (isPlaylist && tracks)
|
||||
|
||||
const renderAddToPlaylistRow = isTrack
|
||||
|
||||
const renderViewAlbumRow = useMemo(() => isAlbum || (isTrack && album), [album, item])
|
||||
const renderViewAlbumRow = isAlbum || (isTrack && album)
|
||||
|
||||
const artistIds = !isPlaylist
|
||||
? isArtist
|
||||
? [item.Id]
|
||||
: item.ArtistItems
|
||||
? item.ArtistItems.map((item) => item.Id)
|
||||
: []
|
||||
: []
|
||||
|
||||
const itemTracks = useMemo(() => {
|
||||
if (isTrack) return [item]
|
||||
else if (isAlbum && discs) return discs.flatMap((data) => data.data)
|
||||
else if (isPlaylist && tracks) return tracks
|
||||
else return []
|
||||
}, [isTrack, isAlbum, discs, isPlaylist, tracks])
|
||||
|
||||
useEffect(() => trigger('impactLight'), [item?.Id])
|
||||
|
||||
return (
|
||||
<View animation={'quick'}>
|
||||
<YGroup unstyled flex={1} marginTop={'$8'}>
|
||||
<ScrollView>
|
||||
<YGroup unstyled marginBottom={bottom}>
|
||||
<FavoriteContextMenuRow item={item} />
|
||||
|
||||
{renderAddToQueueRow && <AddToQueueMenuRow tracks={isTrack ? [item] : tracks!} />}
|
||||
{renderAddToQueueRow && <AddToQueueMenuRow tracks={itemTracks} />}
|
||||
|
||||
{renderAddToPlaylistRow && <AddToPlaylistRow track={item} />}
|
||||
|
||||
@@ -108,36 +110,10 @@ export default function ItemContext({ item, stackNavigation }: ContextProps): Re
|
||||
)}
|
||||
|
||||
{!isPlaylist && (
|
||||
<ViewArtistMenuRow
|
||||
artists={isArtist ? [item] : artists ? artists : []}
|
||||
stackNavigation={stackNavigation}
|
||||
/>
|
||||
<ArtistMenuRows artistIds={artistIds} stackNavigation={stackNavigation} />
|
||||
)}
|
||||
</YGroup>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemContextBackground({ item }: { item: BaseItemDto }): React.JSX.Element {
|
||||
return (
|
||||
<ZStack flex={1}>
|
||||
<BackgroundBlur item={item} />
|
||||
|
||||
<BackgroundGradient />
|
||||
</ZStack>
|
||||
)
|
||||
}
|
||||
|
||||
function BackgroundBlur({ item }: { item: BaseItemDto }): React.JSX.Element {
|
||||
const blurhash = getPrimaryBlurhashFromDto(item)
|
||||
|
||||
return (
|
||||
<Blurhash
|
||||
blurhash={blurhash!}
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -155,7 +131,7 @@ function AddToPlaylistRow({ track }: { track: BaseItemDto }): React.JSX.Element
|
||||
}}
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
>
|
||||
<Icon color='$primary' name='playlist-plus' />
|
||||
<Icon small color='$primary' name='playlist-plus' />
|
||||
|
||||
<Text bold>Add to Playlist</Text>
|
||||
</ListItem>
|
||||
@@ -182,9 +158,11 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
|
||||
}}
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
>
|
||||
<Icon color='$primary' name='music-note-plus' />
|
||||
<Icon small color='$primary' name='music-note-plus' />
|
||||
|
||||
<Text bold>Add to Queue</Text>
|
||||
<Text bold marginLeft={'$1'}>
|
||||
Add to Queue
|
||||
</Text>
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
@@ -224,7 +202,7 @@ function ViewAlbumMenuRow({ album: album, stackNavigation }: MenuRowProps): Reac
|
||||
onPress={goToAlbum}
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
>
|
||||
<ItemImage item={album} height={'$10'} width={'$10'} />
|
||||
<ItemImage item={album} height={'$9'} width={'$9'} />
|
||||
|
||||
<TextTicker {...TextTickerConfig}>
|
||||
<Text bold>{`Go to ${getItemName(album)}`}</Text>
|
||||
@@ -233,13 +211,37 @@ function ViewAlbumMenuRow({ album: album, stackNavigation }: MenuRowProps): Reac
|
||||
)
|
||||
}
|
||||
|
||||
function ViewArtistMenuRow({
|
||||
artists,
|
||||
function ArtistMenuRows({
|
||||
artistIds,
|
||||
stackNavigation,
|
||||
}: {
|
||||
artists: BaseItemDto[]
|
||||
artistIds: (string | null | undefined)[]
|
||||
stackNavigation: StackNavigation | undefined
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<View>
|
||||
{artistIds.map((id) => (
|
||||
<ViewArtistMenuRow artistId={id} key={id} stackNavigation={stackNavigation} />
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ViewArtistMenuRow({
|
||||
artistId,
|
||||
stackNavigation,
|
||||
}: {
|
||||
artistId: string | null | undefined
|
||||
stackNavigation: StackNavigation | undefined
|
||||
}): React.JSX.Element {
|
||||
const { api } = useJellifyContext()
|
||||
|
||||
const { data: artist } = useQuery({
|
||||
queryKey: [QueryKeys.ArtistById, artistId],
|
||||
queryFn: () => fetchItem(api, artistId!),
|
||||
enabled: !!artistId,
|
||||
})
|
||||
|
||||
const goToArtist = useCallback(
|
||||
(artist: BaseItemDto) => {
|
||||
if (stackNavigation) stackNavigation.navigate('Artist', { artist })
|
||||
@@ -248,23 +250,20 @@ function ViewArtistMenuRow({
|
||||
[stackNavigation, navigationRef],
|
||||
)
|
||||
|
||||
return (
|
||||
<View>
|
||||
{artists.map((artist, index) => (
|
||||
<ListItem
|
||||
animation={'quick'}
|
||||
backgroundColor={'transparent'}
|
||||
gap={'$3'}
|
||||
justifyContent='flex-start'
|
||||
key={index}
|
||||
onPress={() => goToArtist(artist)}
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
>
|
||||
<ItemImage circular item={artist} height={'$10'} width={'$10'} />
|
||||
return artist ? (
|
||||
<ListItem
|
||||
animation={'quick'}
|
||||
backgroundColor={'transparent'}
|
||||
gap={'$3'}
|
||||
justifyContent='flex-start'
|
||||
onPress={() => goToArtist(artist)}
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
>
|
||||
<ItemImage circular item={artist} height={'$9'} width={'$9'} />
|
||||
|
||||
<Text bold>{`Go to ${getItemName(artist)}`}</Text>
|
||||
</ListItem>
|
||||
))}
|
||||
</View>
|
||||
<Text bold>{`Go to ${getItemName(artist)}`}</Text>
|
||||
</ListItem>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React from 'react'
|
||||
import Icon from './icon'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { isUndefined } from 'lodash'
|
||||
import { getTokens, Spinner } from 'tamagui'
|
||||
import { Spinner } from 'tamagui'
|
||||
import { QueryKeys } from '../../../enums/query-keys'
|
||||
import { fetchUserData } from '../../../api/queries/favorites'
|
||||
import { useJellifyUserDataContext } from '../../../providers/UserData'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
|
||||
import { ONE_HOUR } from '../../../constants/query-client'
|
||||
|
||||
interface SetFavoriteMutation {
|
||||
item: BaseItemDto
|
||||
@@ -21,26 +22,21 @@ export default function FavoriteButton({
|
||||
item: BaseItemDto
|
||||
onToggle?: () => void
|
||||
}): React.JSX.Element {
|
||||
const [isFavorite, setFavorite] = useState<boolean>(isFavoriteItem(item))
|
||||
|
||||
const { api, user } = useJellifyContext()
|
||||
const { toggleFavorite } = useJellifyUserDataContext()
|
||||
|
||||
const { data, isFetching, refetch } = useQuery({
|
||||
queryKey: [QueryKeys.UserData, item.Id!],
|
||||
const {
|
||||
data: isFavorite,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: [QueryKeys.UserData, item.Id],
|
||||
queryFn: () => fetchUserData(api, user, item.Id!),
|
||||
staleTime: 1000 * 60 * 60 * 1, // 1 hour,
|
||||
select: (data) => typeof data === 'object' && data.IsFavorite,
|
||||
staleTime: ONE_HOUR,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
refetch()
|
||||
}, [item])
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setFavorite(data.IsFavorite ?? false)
|
||||
}, [data])
|
||||
|
||||
return isFetching && isUndefined(item.UserData) ? (
|
||||
return isFetching ? (
|
||||
<Spinner alignSelf='center' />
|
||||
) : isFavorite ? (
|
||||
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||||
@@ -50,7 +46,6 @@ export default function FavoriteButton({
|
||||
onPress={() =>
|
||||
toggleFavorite(isFavorite, {
|
||||
item,
|
||||
setFavorite,
|
||||
onToggle,
|
||||
})
|
||||
}
|
||||
@@ -62,9 +57,8 @@ export default function FavoriteButton({
|
||||
name={'heart-outline'}
|
||||
color={'$primary'}
|
||||
onPress={() =>
|
||||
toggleFavorite(isFavorite, {
|
||||
toggleFavorite(!!isFavorite, {
|
||||
item,
|
||||
setFavorite,
|
||||
onToggle,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,31 +3,24 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { QueryKeys } from '../../../enums/query-keys'
|
||||
import { fetchUserData } from '../../../api/queries/favorites'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { getToken, ListItem, XStack } from 'tamagui'
|
||||
import { ListItem, XStack } from 'tamagui'
|
||||
import Icon from './icon'
|
||||
import { useJellifyUserDataContext } from '../../../providers/UserData'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Text } from '../helpers/text'
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
|
||||
import { ONE_HOUR } from '../../../constants/query-client'
|
||||
|
||||
export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }): React.JSX.Element {
|
||||
const { api, user } = useJellifyContext()
|
||||
const { toggleFavorite } = useJellifyUserDataContext()
|
||||
|
||||
const { data: userData, refetch } = useQuery({
|
||||
const { data: isFavorite, refetch } = useQuery({
|
||||
queryKey: [QueryKeys.UserData, item.Id],
|
||||
queryFn: () => fetchUserData(api, user, item.Id!),
|
||||
staleTime: 1000 * 60 * 60 * 1, // 1 hour,
|
||||
select: (data) => typeof data === 'object' && data.IsFavorite,
|
||||
staleTime: ONE_HOUR,
|
||||
})
|
||||
|
||||
const [isFavorite, setIsFavorite] = useState<boolean>(
|
||||
userData?.IsFavorite ?? item.UserData?.IsFavorite ?? false,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setIsFavorite(userData?.IsFavorite ?? false)
|
||||
}, [userData])
|
||||
|
||||
return isFavorite ? (
|
||||
<ListItem
|
||||
animation={'quick'}
|
||||
@@ -36,7 +29,6 @@ export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }):
|
||||
onPress={() => {
|
||||
toggleFavorite(isFavorite, {
|
||||
item,
|
||||
setFavorite: setIsFavorite,
|
||||
onToggle: () => refetch(),
|
||||
})
|
||||
}}
|
||||
@@ -47,12 +39,10 @@ export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }):
|
||||
exiting={FadeOut}
|
||||
key={`${item.Id}-remove-favorite-row`}
|
||||
>
|
||||
<XStack alignContent='center' justifyContent='flex-start' gap={'$3'}>
|
||||
<XStack alignItems='center' justifyContent='flex-start' gap={'$2'}>
|
||||
<Icon name={'heart'} small color={'$primary'} />
|
||||
|
||||
<Text marginTop={'$2'} bold>
|
||||
Remove from favorites
|
||||
</Text>
|
||||
<Text bold>Remove from favorites</Text>
|
||||
</XStack>
|
||||
</Animated.View>
|
||||
</ListItem>
|
||||
@@ -63,21 +53,18 @@ export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }):
|
||||
justifyContent='flex-start'
|
||||
gap={'$2'}
|
||||
onPress={() => {
|
||||
toggleFavorite(isFavorite, {
|
||||
toggleFavorite(!!isFavorite, {
|
||||
item,
|
||||
setFavorite: setIsFavorite,
|
||||
onToggle: () => refetch(),
|
||||
})
|
||||
}}
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
>
|
||||
<Animated.View entering={FadeIn} exiting={FadeOut} key={`${item.Id}-favorite-row`}>
|
||||
<XStack alignContent='center' justifyContent='flex-start' gap={'$3'}>
|
||||
<Icon name={'heart-outline'} small color={'$primary'} />
|
||||
<XStack alignItems='center' justifyContent='flex-start' gap={'$2'}>
|
||||
<Icon small name={'heart-outline'} color={'$primary'} />
|
||||
|
||||
<Text marginTop={'$2'} bold>
|
||||
Add to favorites
|
||||
</Text>
|
||||
<Text bold>Add to favorites</Text>
|
||||
</XStack>
|
||||
</Animated.View>
|
||||
</ListItem>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Icon from './icon'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { QueryKeys } from '../../../enums/query-keys'
|
||||
import { fetchUserData } from '../../../api/queries/favorites'
|
||||
import { useEffect, useState, memo } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
|
||||
|
||||
@@ -16,23 +16,15 @@ import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
|
||||
* @returns A React component that displays a favorite icon for a given item.
|
||||
*/
|
||||
function FavoriteIcon({ item }: { item: BaseItemDto }): React.JSX.Element {
|
||||
const [isFavorite, setIsFavorite] = useState<boolean>(item.UserData?.IsFavorite ?? false)
|
||||
|
||||
const { api, user } = useJellifyContext()
|
||||
|
||||
const { data: userData, isPending } = useQuery({
|
||||
queryKey: [QueryKeys.UserData, item.Id!],
|
||||
const { data: isFavorite } = useQuery({
|
||||
queryKey: [QueryKeys.UserData, item.Id],
|
||||
queryFn: () => fetchUserData(api, user, item.Id!),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes,
|
||||
select: (data) => typeof data === 'object' && data.IsFavorite,
|
||||
enabled: !!api && !!user && !!item.Id, // Only run if we have the required data
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && userData !== undefined) {
|
||||
setIsFavorite(userData?.IsFavorite ?? false)
|
||||
}
|
||||
}, [userData, isPending])
|
||||
|
||||
return isFavorite ? (
|
||||
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||||
<Icon small name='heart' color={'$primary'} flex={1} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ColorTokens,
|
||||
getToken,
|
||||
getTokens,
|
||||
getTokenValue,
|
||||
themeable,
|
||||
ThemeTokens,
|
||||
Tokens,
|
||||
@@ -11,13 +12,11 @@ import {
|
||||
} from 'tamagui'
|
||||
import MaterialDesignIcon from '@react-native-vector-icons/material-design-icons'
|
||||
|
||||
const smallSize = 30
|
||||
const smallSize = 28
|
||||
|
||||
const regularSize = 36
|
||||
const regularSize = 34
|
||||
|
||||
const largeSize = 48
|
||||
|
||||
const extraLargeSize = 96
|
||||
const largeSize = 44
|
||||
|
||||
export default function Icon({
|
||||
name,
|
||||
@@ -25,7 +24,6 @@ export default function Icon({
|
||||
onPressIn,
|
||||
small,
|
||||
large,
|
||||
extraLarge,
|
||||
disabled,
|
||||
color,
|
||||
flex,
|
||||
@@ -37,13 +35,12 @@ export default function Icon({
|
||||
small?: boolean
|
||||
large?: boolean
|
||||
disabled?: boolean
|
||||
extraLarge?: boolean
|
||||
color?: ThemeTokens | undefined
|
||||
flex?: number | undefined
|
||||
testID?: string | undefined
|
||||
}): React.JSX.Element {
|
||||
const theme = useTheme()
|
||||
const size = extraLarge ? extraLargeSize : large ? largeSize : small ? smallSize : regularSize
|
||||
const size = large ? largeSize : small ? smallSize : regularSize
|
||||
|
||||
return (
|
||||
<YStack
|
||||
@@ -51,9 +48,10 @@ export default function Icon({
|
||||
justifyContent='center'
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
paddingHorizontal={'$0.5'}
|
||||
width={size + getToken('$1')}
|
||||
height={size + getToken('$1')}
|
||||
hitSlop={getTokenValue('$2.5')}
|
||||
marginHorizontal={'$1'}
|
||||
width={size}
|
||||
height={size}
|
||||
flex={flex}
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
|
||||
@@ -43,6 +43,7 @@ export default function ItemImage({
|
||||
<FastImage
|
||||
source={{ uri: imageUrl }}
|
||||
testID={testID}
|
||||
resizeMode='cover'
|
||||
style={{
|
||||
shadowRadius: getTokenValue('$4'),
|
||||
shadowOffset: {
|
||||
@@ -55,12 +56,12 @@ export default function ItemImage({
|
||||
? typeof width === 'number'
|
||||
? width
|
||||
: getTokenValue(width)
|
||||
: getTokenValue('$12') + getTokenValue('$5'),
|
||||
: '100%',
|
||||
height: !isUndefined(height)
|
||||
? typeof height === 'number'
|
||||
? height
|
||||
: getTokenValue(height)
|
||||
: getTokenValue('$12') + getTokenValue('$5'),
|
||||
: '100%',
|
||||
alignSelf: 'center',
|
||||
backgroundColor: theme.borderColor.val,
|
||||
}}
|
||||
@@ -80,14 +81,10 @@ function getBorderRadius(circular: boolean | undefined, width: Token | number |
|
||||
let borderRadius
|
||||
|
||||
if (circular) {
|
||||
borderRadius = width
|
||||
? typeof width === 'number'
|
||||
? width
|
||||
: getTokenValue(width)
|
||||
: getTokenValue('$12') + getTokenValue('$5')
|
||||
borderRadius = width ? (typeof width === 'number' ? width : getTokenValue(width)) : '100%'
|
||||
} else if (!isUndefined(width)) {
|
||||
borderRadius = typeof width === 'number' ? width / 10 : getTokenValue(width) / 10
|
||||
}
|
||||
borderRadius = typeof width === 'number' ? width / 25 : getTokenValue(width) / 15
|
||||
} else borderRadius = '5%'
|
||||
|
||||
return borderRadius
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ export default function InstantMixButton({
|
||||
const { data, isFetching, refetch } = useQuery({
|
||||
queryKey: [QueryKeys.InstantMix, item.Id!],
|
||||
queryFn: () => fetchInstantMixFromItem(api, user, item),
|
||||
staleTime: 1000 * 60 * 60 * 24, // 24 hours
|
||||
})
|
||||
|
||||
return data ? (
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import React from 'react'
|
||||
import { CardProps as TamaguiCardProps } from 'tamagui'
|
||||
import { getToken, Card as TamaguiCard, View, YStack } from 'tamagui'
|
||||
import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { Text } from '../helpers/text'
|
||||
import FastImage from 'react-native-fast-image'
|
||||
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { fetchMediaInfo } from '../../../api/queries/media'
|
||||
import { QueryKeys } from '../../../enums/query-keys'
|
||||
import { getQualityParams } from '../../../utils/mappings'
|
||||
import { useStreamingQualityContext } from '../../../providers/Settings'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ItemProvider } from '../../../providers/Item'
|
||||
import ItemImage from './image'
|
||||
|
||||
interface CardProps extends TamaguiCardProps {
|
||||
caption?: string | null | undefined
|
||||
@@ -28,80 +22,56 @@ interface CardProps extends TamaguiCardProps {
|
||||
* @param props
|
||||
*/
|
||||
export function ItemCard(props: CardProps) {
|
||||
const { api, user } = useJellifyContext()
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
|
||||
useQuery({
|
||||
queryKey: [QueryKeys.MediaSources, streamingQuality, props.item.Id],
|
||||
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), props.item),
|
||||
staleTime: Infinity, // Don't refetch media info unless the user changes the quality
|
||||
enabled: props.item.Type === 'Audio',
|
||||
})
|
||||
|
||||
return (
|
||||
<View alignItems='center' margin={'$1.5'}>
|
||||
<TamaguiCard
|
||||
size={'$12'}
|
||||
height={props.size}
|
||||
width={props.size}
|
||||
testID={props.testId ?? undefined}
|
||||
backgroundColor={getToken('$color.amethyst')}
|
||||
circular={!props.squared}
|
||||
borderRadius={props.squared ? 5 : 'unset'}
|
||||
animation='bouncy'
|
||||
hoverStyle={props.onPress ? { scale: 0.925 } : {}}
|
||||
pressStyle={props.onPress ? { scale: 0.875 } : {}}
|
||||
{...props}
|
||||
>
|
||||
<TamaguiCard.Header></TamaguiCard.Header>
|
||||
<TamaguiCard.Footer padded>
|
||||
{/* { props.item.Type === 'MusicArtist' && (
|
||||
<BlurhashedImage
|
||||
cornered
|
||||
item={props.item}
|
||||
type={ImageType.Logo}
|
||||
width={logoDimensions.width}
|
||||
height={logoDimensions.height}
|
||||
/>
|
||||
)} */}
|
||||
</TamaguiCard.Footer>
|
||||
<TamaguiCard.Background>
|
||||
<FastImage
|
||||
source={{
|
||||
uri:
|
||||
getImageApi(api!).getItemImageUrlById(
|
||||
props.item.Type === 'Audio'
|
||||
? props.item.AlbumId! || props.item.Id!
|
||||
: props.item.Id!,
|
||||
ImageType.Primary,
|
||||
{
|
||||
tag: props.item.AlbumId
|
||||
? props.item.AlbumPrimaryImageTag!
|
||||
: props.item.ImageTags?.Primary,
|
||||
},
|
||||
) || '',
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: props.squared ? 2 : 100,
|
||||
}}
|
||||
/>
|
||||
</TamaguiCard.Background>
|
||||
</TamaguiCard>
|
||||
{props.caption && (
|
||||
<YStack alignContent='center' alignItems='center' maxWidth={props.size}>
|
||||
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
{props.caption}
|
||||
</Text>
|
||||
|
||||
{props.subCaption && (
|
||||
<Text lineBreakStrategyIOS='standard' numberOfLines={1} textAlign='center'>
|
||||
{props.subCaption}
|
||||
<ItemProvider item={props.item}>
|
||||
<View alignItems='center' margin={'$1.5'}>
|
||||
<TamaguiCard
|
||||
size={'$12'}
|
||||
height={props.size}
|
||||
width={props.size}
|
||||
testID={props.testId ?? undefined}
|
||||
backgroundColor={getToken('$color.amethyst')}
|
||||
circular={!props.squared}
|
||||
borderRadius={props.squared ? '$5' : 'unset'}
|
||||
animation='bouncy'
|
||||
hoverStyle={props.onPress ? { scale: 0.925 } : {}}
|
||||
pressStyle={props.onPress ? { scale: 0.875 } : {}}
|
||||
{...props}
|
||||
>
|
||||
<TamaguiCard.Header></TamaguiCard.Header>
|
||||
<TamaguiCard.Footer padded>
|
||||
{/* { props.item.Type === 'MusicArtist' && (
|
||||
<BlurhashedImage
|
||||
cornered
|
||||
item={props.item}
|
||||
type={ImageType.Logo}
|
||||
width={logoDimensions.width}
|
||||
height={logoDimensions.height}
|
||||
/>
|
||||
)} */}
|
||||
</TamaguiCard.Footer>
|
||||
<TamaguiCard.Background>
|
||||
<ItemImage item={props.item} circular={!props.squared} />
|
||||
</TamaguiCard.Background>
|
||||
</TamaguiCard>
|
||||
{props.caption && (
|
||||
<YStack alignContent='center' alignItems='center' maxWidth={props.size}>
|
||||
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
{props.caption}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{props.subCaption && (
|
||||
<Text
|
||||
lineBreakStrategyIOS='standard'
|
||||
numberOfLines={1}
|
||||
textAlign='center'
|
||||
>
|
||||
{props.subCaption}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
)}
|
||||
</View>
|
||||
</ItemProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,15 +9,10 @@ import ItemImage from './image'
|
||||
import FavoriteIcon from './favorite-icon'
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import { runOnJS } from 'react-native-reanimated'
|
||||
import { getQualityParams } from '../../../utils/mappings'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchMediaInfo } from '../../../api/queries/media'
|
||||
import { QueryKeys } from '../../../enums/query-keys'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { useStreamingQualityContext } from '../../../providers/Settings'
|
||||
import navigationRef from '../../../../navigation'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { BaseStackParamList } from '../../../screens/types'
|
||||
import { ItemProvider } from '../../../providers/Item'
|
||||
|
||||
interface ItemRowProps {
|
||||
item: BaseItemDto
|
||||
@@ -41,20 +36,10 @@ interface ItemRowProps {
|
||||
export default function ItemRow({
|
||||
item,
|
||||
navigation,
|
||||
queueName,
|
||||
onPress,
|
||||
circular,
|
||||
}: ItemRowProps): React.JSX.Element {
|
||||
const useLoadNewQueue = useLoadQueueContext()
|
||||
const { api, user } = useJellifyContext()
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
|
||||
useQuery({
|
||||
queryKey: [QueryKeys.MediaSources, streamingQuality, item.Id],
|
||||
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), item),
|
||||
staleTime: Infinity, // Don't refetch media info unless the user changes the quality
|
||||
enabled: item.Type === 'Audio',
|
||||
})
|
||||
|
||||
const gestureCallback = () => {
|
||||
switch (item.Type) {
|
||||
@@ -81,97 +66,99 @@ export default function ItemRow({
|
||||
})
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={gesture}>
|
||||
<XStack
|
||||
alignContent='center'
|
||||
minHeight={'$7'}
|
||||
width={'100%'}
|
||||
onLongPress={() => {
|
||||
navigationRef.navigate('Context', {
|
||||
item,
|
||||
navigation,
|
||||
})
|
||||
}}
|
||||
onPress={() => {
|
||||
if (onPress) {
|
||||
onPress()
|
||||
return
|
||||
}
|
||||
|
||||
switch (item.Type) {
|
||||
case 'MusicArtist': {
|
||||
navigation?.navigate('Artist', { artist: item })
|
||||
break
|
||||
}
|
||||
|
||||
case 'MusicAlbum': {
|
||||
navigation?.navigate('Album', { album: item })
|
||||
break
|
||||
}
|
||||
}
|
||||
}}
|
||||
paddingVertical={'$2'}
|
||||
paddingRight={'$2'}
|
||||
>
|
||||
<YStack marginHorizontal={'$3'} justifyContent='center'>
|
||||
<ItemImage
|
||||
item={item}
|
||||
height={'$12'}
|
||||
width={'$12'}
|
||||
circular={item.Type === 'MusicArtist' || circular}
|
||||
/>
|
||||
</YStack>
|
||||
|
||||
<YStack alignContent='center' justifyContent='center' flex={4}>
|
||||
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
{item.Name ?? ''}
|
||||
</Text>
|
||||
{item.Type === 'MusicArtist' && (
|
||||
<Text lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
{`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Album' : 'Albums'}`}
|
||||
</Text>
|
||||
)}
|
||||
{(item.Type === 'Audio' || item.Type === 'MusicAlbum') && (
|
||||
<Text lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
{item.AlbumArtist ?? 'Untitled Artist'}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{item.Type === 'Playlist' && (
|
||||
<Text lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
{item.Genres?.join(', ') ?? ''}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<ItemProvider item={item}>
|
||||
<GestureDetector gesture={gesture}>
|
||||
<XStack
|
||||
justifyContent='flex-end'
|
||||
alignItems='center'
|
||||
flex={['Audio', 'MusicAlbum'].includes(item.Type ?? '') ? 2 : 1}
|
||||
>
|
||||
<FavoriteIcon item={item} />
|
||||
{/* Runtime ticks for Songs */}
|
||||
{['Audio', 'MusicAlbum'].includes(item.Type ?? '') ? (
|
||||
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
|
||||
) : ['Playlist'].includes(item.Type ?? '') ? (
|
||||
<Text
|
||||
color={'$borderColor'}
|
||||
>{`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Track' : 'Tracks'}`}</Text>
|
||||
) : null}
|
||||
alignContent='center'
|
||||
minHeight={'$7'}
|
||||
width={'100%'}
|
||||
onLongPress={() => {
|
||||
navigationRef.navigate('Context', {
|
||||
item,
|
||||
navigation,
|
||||
})
|
||||
}}
|
||||
onPress={() => {
|
||||
if (onPress) {
|
||||
onPress()
|
||||
return
|
||||
}
|
||||
|
||||
{item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
|
||||
<Icon
|
||||
name='dots-horizontal'
|
||||
onPress={() => {
|
||||
navigationRef.navigate('Context', {
|
||||
item,
|
||||
navigation,
|
||||
})
|
||||
}}
|
||||
switch (item.Type) {
|
||||
case 'MusicArtist': {
|
||||
navigation?.navigate('Artist', { artist: item })
|
||||
break
|
||||
}
|
||||
|
||||
case 'MusicAlbum': {
|
||||
navigation?.navigate('Album', { album: item })
|
||||
break
|
||||
}
|
||||
}
|
||||
}}
|
||||
paddingVertical={'$2'}
|
||||
paddingRight={'$2'}
|
||||
>
|
||||
<YStack marginHorizontal={'$3'} justifyContent='center'>
|
||||
<ItemImage
|
||||
item={item}
|
||||
height={'$12'}
|
||||
width={'$12'}
|
||||
circular={item.Type === 'MusicArtist' || circular}
|
||||
/>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
<YStack alignContent='center' justifyContent='center' flex={4}>
|
||||
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
{item.Name ?? ''}
|
||||
</Text>
|
||||
{item.Type === 'MusicArtist' && (
|
||||
<Text lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
{`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Album' : 'Albums'}`}
|
||||
</Text>
|
||||
)}
|
||||
{(item.Type === 'Audio' || item.Type === 'MusicAlbum') && (
|
||||
<Text lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
{item.AlbumArtist ?? 'Untitled Artist'}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{item.Type === 'Playlist' && (
|
||||
<Text lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
{item.Genres?.join(', ') ?? ''}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<XStack
|
||||
justifyContent='flex-end'
|
||||
alignItems='center'
|
||||
flex={['Audio', 'MusicAlbum'].includes(item.Type ?? '') ? 2 : 1}
|
||||
>
|
||||
<FavoriteIcon item={item} />
|
||||
{/* Runtime ticks for Songs */}
|
||||
{['Audio', 'MusicAlbum'].includes(item.Type ?? '') ? (
|
||||
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
|
||||
) : ['Playlist'].includes(item.Type ?? '') ? (
|
||||
<Text
|
||||
color={'$borderColor'}
|
||||
>{`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Track' : 'Tracks'}`}</Text>
|
||||
) : null}
|
||||
|
||||
{item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
|
||||
<Icon
|
||||
name='dots-horizontal'
|
||||
onPress={() => {
|
||||
navigationRef.navigate('Context', {
|
||||
item,
|
||||
navigation,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</XStack>
|
||||
</XStack>
|
||||
</XStack>
|
||||
</GestureDetector>
|
||||
</GestureDetector>
|
||||
</ItemProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,27 +2,21 @@ import React, { useMemo, useCallback } from 'react'
|
||||
import { getToken, Theme, useTheme, XStack, YStack } from 'tamagui'
|
||||
import { Text } from '../helpers/text'
|
||||
import { RunTimeTicks } from '../helpers/time-codes'
|
||||
import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import Icon from './icon'
|
||||
import { QueuingType } from '../../../enums/queuing-type'
|
||||
import { Queue } from '../../../player/types/queue-item'
|
||||
import FavoriteIcon from './favorite-icon'
|
||||
import FastImage from 'react-native-fast-image'
|
||||
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { networkStatusTypes } from '../../../components/Network/internetConnectionWatcher'
|
||||
import { useNetworkContext } from '../../../providers/Network'
|
||||
import { useLoadQueueContext, usePlayQueueContext } from '../../../providers/Player/queue'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import DownloadedIcon from './downloaded-icon'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { QueryKeys } from '../../../enums/query-keys'
|
||||
import { fetchMediaInfo } from '../../../api/queries/media'
|
||||
import { useStreamingQualityContext } from '../../../providers/Settings'
|
||||
import { getQualityParams } from '../../../utils/mappings'
|
||||
import { useNowPlayingContext } from '../../../providers/Player'
|
||||
import navigationRef from '../../../../navigation'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { BaseStackParamList } from '@/src/screens/types'
|
||||
import { BaseStackParamList } from '../../../screens/types'
|
||||
import ItemImage from './image'
|
||||
import { ItemProvider } from '../../../providers/Item'
|
||||
|
||||
export interface TrackProps {
|
||||
track: BaseItemDto
|
||||
@@ -58,12 +52,10 @@ export default function Track({
|
||||
}: TrackProps): React.JSX.Element {
|
||||
const theme = useTheme()
|
||||
|
||||
const { api, user } = useJellifyContext()
|
||||
const nowPlaying = useNowPlayingContext()
|
||||
const playQueue = usePlayQueueContext()
|
||||
const useLoadNewQueue = useLoadQueueContext()
|
||||
const { downloadedTracks, networkStatus } = useNetworkContext()
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
|
||||
// Memoize expensive computations
|
||||
const isPlaying = useMemo(
|
||||
@@ -83,21 +75,6 @@ export default function Track({
|
||||
[networkStatus],
|
||||
)
|
||||
|
||||
// Memoize image source to prevent recreation
|
||||
const imageSource = useMemo(
|
||||
() => ({
|
||||
uri:
|
||||
getImageApi(api!).getItemImageUrlById(
|
||||
track.AlbumId! || track.Id!,
|
||||
ImageType.Primary,
|
||||
{
|
||||
tag: track.ImageTags?.Primary,
|
||||
},
|
||||
) || '',
|
||||
}),
|
||||
[api, track.AlbumId, track.Id, track.ImageTags?.Primary],
|
||||
)
|
||||
|
||||
// Memoize tracklist for queue loading
|
||||
const memoizedTracklist = useMemo(
|
||||
() => tracklist ?? playQueue.map((track) => track.item),
|
||||
@@ -126,9 +103,10 @@ export default function Track({
|
||||
} else {
|
||||
navigationRef.navigate('Context', {
|
||||
item: track,
|
||||
navigation,
|
||||
})
|
||||
}
|
||||
}, [onLongPress, navigation, track, isNested])
|
||||
}, [onLongPress, track, isNested])
|
||||
|
||||
const handleIconPress = useCallback(() => {
|
||||
if (showRemove) {
|
||||
@@ -138,15 +116,7 @@ export default function Track({
|
||||
item: track,
|
||||
})
|
||||
}
|
||||
}, [showRemove, onRemove, navigation, track, isNested])
|
||||
|
||||
// Only fetch media info if needed (for streaming)
|
||||
useQuery({
|
||||
queryKey: [QueryKeys.MediaSources, streamingQuality, track.Id],
|
||||
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), track),
|
||||
staleTime: Infinity, // Don't refetch media info unless the user changes the quality
|
||||
enabled: !isDownloaded, // Only fetch if not downloaded
|
||||
})
|
||||
}, [showRemove, onRemove, track, isNested])
|
||||
|
||||
// Memoize text color to prevent recalculation
|
||||
const textColor = useMemo(() => {
|
||||
@@ -171,91 +141,85 @@ export default function Track({
|
||||
)
|
||||
|
||||
return (
|
||||
<Theme name={invertedColors ? 'inverted_purple' : undefined}>
|
||||
<XStack
|
||||
alignContent='center'
|
||||
alignItems='center'
|
||||
height={showArtwork ? '$6' : '$5'}
|
||||
flex={1}
|
||||
testID={testID ?? undefined}
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
paddingVertical={'$2'}
|
||||
justifyContent='center'
|
||||
marginRight={'$2'}
|
||||
>
|
||||
<ItemProvider item={track}>
|
||||
<Theme name={invertedColors ? 'inverted_purple' : undefined}>
|
||||
<XStack
|
||||
alignContent='center'
|
||||
alignItems='center'
|
||||
height={showArtwork ? '$6' : '$5'}
|
||||
flex={1}
|
||||
testID={testID ?? undefined}
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
paddingVertical={'$2'}
|
||||
justifyContent='center'
|
||||
marginHorizontal={showArtwork ? '$2' : '$1'}
|
||||
marginRight={'$2'}
|
||||
>
|
||||
{showArtwork ? (
|
||||
<FastImage
|
||||
key={`${track.Id}-${track.AlbumId || track.Id}`}
|
||||
source={imageSource}
|
||||
style={{
|
||||
width: getToken('$12'),
|
||||
height: getToken('$12'),
|
||||
borderRadius: getToken('$1'),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
key={`${track.Id}-number`}
|
||||
color={textColor}
|
||||
width={getToken('$12')}
|
||||
textAlign='center'
|
||||
>
|
||||
{indexNumber}
|
||||
</Text>
|
||||
)}
|
||||
</XStack>
|
||||
|
||||
<YStack alignContent='center' justifyContent='flex-start' flex={6}>
|
||||
<Text
|
||||
key={`${track.Id}-name`}
|
||||
bold
|
||||
color={textColor}
|
||||
lineBreakStrategyIOS='standard'
|
||||
numberOfLines={1}
|
||||
<XStack
|
||||
alignContent='center'
|
||||
justifyContent='center'
|
||||
marginHorizontal={showArtwork ? '$2' : '$1'}
|
||||
>
|
||||
{trackName}
|
||||
</Text>
|
||||
{showArtwork ? (
|
||||
<ItemImage item={track} width={'$12'} height={'$12'} />
|
||||
) : (
|
||||
<Text
|
||||
key={`${track.Id}-number`}
|
||||
color={textColor}
|
||||
width={getToken('$12')}
|
||||
textAlign='center'
|
||||
>
|
||||
{indexNumber}
|
||||
</Text>
|
||||
)}
|
||||
</XStack>
|
||||
|
||||
{shouldShowArtists && (
|
||||
<YStack alignContent='center' justifyContent='flex-start' flex={6}>
|
||||
<Text
|
||||
key={`${track.Id}-artists`}
|
||||
key={`${track.Id}-name`}
|
||||
bold
|
||||
color={textColor}
|
||||
lineBreakStrategyIOS='standard'
|
||||
numberOfLines={1}
|
||||
>
|
||||
{artistsText}
|
||||
{trackName}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<DownloadedIcon item={track} />
|
||||
{shouldShowArtists && (
|
||||
<Text
|
||||
key={`${track.Id}-artists`}
|
||||
lineBreakStrategyIOS='standard'
|
||||
numberOfLines={1}
|
||||
>
|
||||
{artistsText}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<FavoriteIcon item={track} />
|
||||
<DownloadedIcon item={track} />
|
||||
|
||||
<RunTimeTicks
|
||||
key={`${track.Id}-runtime`}
|
||||
props={{
|
||||
style: {
|
||||
textAlign: 'center',
|
||||
flex: 1.5,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{track.RunTimeTicks}
|
||||
</RunTimeTicks>
|
||||
<FavoriteIcon item={track} />
|
||||
|
||||
<Icon
|
||||
name={showRemove ? 'close' : 'dots-horizontal'}
|
||||
flex={1}
|
||||
onPress={handleIconPress}
|
||||
/>
|
||||
</XStack>
|
||||
</Theme>
|
||||
<RunTimeTicks
|
||||
key={`${track.Id}-runtime`}
|
||||
props={{
|
||||
style: {
|
||||
textAlign: 'center',
|
||||
flex: 1.5,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{track.RunTimeTicks}
|
||||
</RunTimeTicks>
|
||||
|
||||
<Icon
|
||||
name={showRemove ? 'close' : 'dots-horizontal'}
|
||||
flex={1}
|
||||
onPress={handleIconPress}
|
||||
/>
|
||||
</XStack>
|
||||
</Theme>
|
||||
</ItemProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function HorizontalSlider({ value, max, width, props }: SliderProps): Rea
|
||||
<JellifySliderThumb
|
||||
circular
|
||||
index={0}
|
||||
size={14} // Anything larger than 14 causes the thumb to be clipped
|
||||
size={'$0.75'} // Anything larger than 14 causes the thumb to be clipped
|
||||
// Increase hit slop for better touch handling
|
||||
hitSlop={{
|
||||
top: 25,
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { InstantMixProps } from '../../screens/types'
|
||||
import { FlatList } from 'react-native'
|
||||
import Track from '../Global/components/track'
|
||||
import { Separator } from 'tamagui'
|
||||
import { FlashList } from '@shopify/flash-list'
|
||||
|
||||
export default function InstantMix({ route, navigation }: InstantMixProps): React.JSX.Element {
|
||||
const { mix } = route.params
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
<FlashList
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
data={mix}
|
||||
ItemSeparatorComponent={() => <Separator />}
|
||||
renderItem={({ item, index }) => (
|
||||
<Track
|
||||
navigation={navigation}
|
||||
showArtwork
|
||||
track={item}
|
||||
index={index}
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function Controls(): React.JSX.Element {
|
||||
const shuffled = useShuffledContext()
|
||||
|
||||
return (
|
||||
<XStack alignItems='center' justifyContent='space-evenly' flex={2} marginHorizontal={'$2'}>
|
||||
<XStack alignItems='center' justifyContent='space-between'>
|
||||
<Icon
|
||||
small
|
||||
color={shuffled ? '$primary' : '$color'}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { XStack } from 'tamagui'
|
||||
import { Spacer, XStack } from 'tamagui'
|
||||
|
||||
import Icon from '../../Global/components/icon'
|
||||
|
||||
@@ -11,10 +11,8 @@ export default function Footer(): React.JSX.Element {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<PlayerParamList>>()
|
||||
|
||||
return (
|
||||
<XStack justifyContent='flex-end' alignItems='center' marginHorizontal={'$5'} flex={1}>
|
||||
<XStack alignItems='center' justifyContent='flex-start' flex={1}>
|
||||
<Icon small name='cast-audio' disabled />
|
||||
</XStack>
|
||||
<XStack justifyContent='center' alignItems='center'>
|
||||
<Spacer flex={1} />
|
||||
|
||||
<XStack alignItems='center' justifyContent='flex-end' flex={1}>
|
||||
<Icon
|
||||
|
||||
@@ -1,71 +1,68 @@
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { useNowPlayingContext, usePlaybackStateContext } from '../../../providers/Player'
|
||||
import { useNowPlayingContext } from '../../../providers/Player'
|
||||
import { useQueueRefContext } from '../../../providers/Player/queue'
|
||||
import { getToken, useWindowDimensions, XStack, YStack, useTheme, Spacer } from 'tamagui'
|
||||
import { XStack, YStack, Spacer, useTheme } from 'tamagui'
|
||||
import { Text } from '../../Global/helpers/text'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import Icon from '../../Global/components/icon'
|
||||
import { RootStackParamList } from '../../../screens/types'
|
||||
import React from 'react'
|
||||
import { State } from 'react-native-track-player'
|
||||
import React, { useMemo } from 'react'
|
||||
import ItemImage from '../../Global/components/image'
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { Platform } from 'react-native'
|
||||
import MaterialDesignIcons from '@react-native-vector-icons/material-design-icons'
|
||||
import navigationRef from '../../../../navigation'
|
||||
|
||||
export default function PlayerHeader(): React.JSX.Element {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
|
||||
|
||||
const nowPlaying = useNowPlayingContext()
|
||||
const playbackState = usePlaybackStateContext()
|
||||
|
||||
const isPlaying = playbackState === State.Playing
|
||||
|
||||
const queueRef = useQueueRefContext()
|
||||
|
||||
const { width } = useWindowDimensions()
|
||||
|
||||
const theme = useTheme()
|
||||
|
||||
return (
|
||||
<YStack flexShrink={1} marginTop={'$2'}>
|
||||
<XStack justifyContent='center' marginBottom={'$2'} marginHorizontal={'$2'}>
|
||||
<YStack alignContent='center' flex={1} justifyContent='center'>
|
||||
<Icon
|
||||
name='chevron-down'
|
||||
onPress={() => {
|
||||
navigation.goBack()
|
||||
}}
|
||||
small
|
||||
/>
|
||||
</YStack>
|
||||
// If the Queue is a BaseItemDto, display the name of it
|
||||
const playingFrom = useMemo(
|
||||
() => (typeof queueRef === 'object' ? (queueRef.Name ?? 'Untitled') : queueRef),
|
||||
[queueRef],
|
||||
)
|
||||
|
||||
<YStack alignItems='center' alignContent='center' flex={2}>
|
||||
return (
|
||||
<YStack flexGrow={1} justifyContent='flex-start' maxHeight={'80%'}>
|
||||
<XStack
|
||||
alignContent='flex-start'
|
||||
flexShrink={1}
|
||||
justifyContent='center'
|
||||
onPress={() => navigationRef.goBack()}
|
||||
>
|
||||
<MaterialDesignIcons
|
||||
color={theme.color.val}
|
||||
name={Platform.OS === 'android' ? 'chevron-left' : 'chevron-down'}
|
||||
size={22}
|
||||
style={{ flex: 1, margin: 'auto' }}
|
||||
/>
|
||||
|
||||
<YStack alignItems='center' flex={1}>
|
||||
<Text>Playing from</Text>
|
||||
<Text bold numberOfLines={1} lineBreakStrategyIOS='standard'>
|
||||
{
|
||||
// If the Queue is a BaseItemDto, display the name of it
|
||||
typeof queueRef === 'object' ? (queueRef.Name ?? 'Untitled') : queueRef
|
||||
}
|
||||
{playingFrom}
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<Spacer flex={1} />
|
||||
</XStack>
|
||||
|
||||
<XStack justifyContent='center' alignContent='center' paddingVertical={'$8'}>
|
||||
<YStack
|
||||
flexGrow={1}
|
||||
justifyContent='center'
|
||||
paddingHorizontal={'$2'}
|
||||
maxHeight={'70%'}
|
||||
marginVertical={'auto'}
|
||||
paddingVertical={Platform.OS === 'android' ? '$4' : '$2'}
|
||||
>
|
||||
<Animated.View
|
||||
entering={FadeIn}
|
||||
exiting={FadeOut}
|
||||
key={`${nowPlaying!.item.AlbumId}-item-image`}
|
||||
>
|
||||
<ItemImage
|
||||
item={nowPlaying!.item}
|
||||
testID='player-image-test-id'
|
||||
width={getToken('$20') * 2}
|
||||
height={getToken('$20') * 2}
|
||||
/>
|
||||
<ItemImage item={nowPlaying!.item} testID='player-image-test-id' />
|
||||
</Animated.View>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -135,11 +135,15 @@ export default function Scrubber(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={scrubGesture}>
|
||||
<YStack>
|
||||
<YStack alignItems='center'>
|
||||
<HorizontalSlider
|
||||
value={displayPosition}
|
||||
max={maxDuration ? maxDuration : 1 * ProgressMultiplier}
|
||||
width={getToken('$20') + getToken('$20')}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// I'm sorry for this, pikachu. this was the only way I could make the scrubber
|
||||
// the correct width
|
||||
width={'100%'}
|
||||
props={sliderProps}
|
||||
/>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PlayerParamList } from '../../../screens/Player/types'
|
||||
import { useNowPlayingContext } from '../../../providers/Player'
|
||||
import navigationRef from '../../../../navigation'
|
||||
import Icon from '../../Global/components/icon'
|
||||
import { getItemName } from '../../../utils/text'
|
||||
|
||||
interface SongInfoProps {
|
||||
navigation: NativeStackNavigationProp<PlayerParamList>
|
||||
@@ -32,9 +33,12 @@ function SongInfo({ navigation }: SongInfoProps): React.JSX.Element {
|
||||
// Memoize expensive computations
|
||||
const trackTitle = useMemo(() => nowPlaying!.title ?? 'Untitled Track', [nowPlaying?.title])
|
||||
|
||||
const artistName = useMemo(() => nowPlaying?.artist ?? 'Unknown Artist', [nowPlaying?.artist])
|
||||
|
||||
const artistItems = useMemo(() => nowPlaying!.item.ArtistItems, [nowPlaying?.item.ArtistItems])
|
||||
const { artistItems, artists } = useMemo(() => {
|
||||
return {
|
||||
artistItems: nowPlaying!.item.ArtistItems,
|
||||
artists: nowPlaying!.item.ArtistItems?.map((artist) => getItemName(artist)).join(' • '),
|
||||
}
|
||||
}, [nowPlaying?.item.ArtistItems])
|
||||
|
||||
// Memoize navigation handlers
|
||||
const handleAlbumPress = useCallback(() => {
|
||||
@@ -74,9 +78,9 @@ function SongInfo({ navigation }: SongInfoProps): React.JSX.Element {
|
||||
}, [artistItems, navigation])
|
||||
|
||||
return (
|
||||
<XStack flex={1}>
|
||||
<YStack marginHorizontal={'$1.5'} onPress={handleAlbumPress} justifyContent='center'>
|
||||
<ItemImage item={nowPlaying!.item} width={'$11'} height={'$11'} />
|
||||
<XStack>
|
||||
<YStack marginRight={'$2.5'} onPress={handleAlbumPress} justifyContent='center'>
|
||||
<ItemImage item={nowPlaying!.item} width={'$12'} height={'$12'} />
|
||||
</YStack>
|
||||
|
||||
<YStack justifyContent='flex-start' flex={1} gap={'$0.25'}>
|
||||
@@ -88,7 +92,7 @@ function SongInfo({ navigation }: SongInfoProps): React.JSX.Element {
|
||||
|
||||
<TextTicker {...TextTickerConfig} style={{ height: getToken('$8') }}>
|
||||
<Text fontSize={'$6'} color={'$color'} onPress={handleArtistPress}>
|
||||
{artistName}
|
||||
{artists ?? 'Unknown Artist'}
|
||||
</Text>
|
||||
</TextTicker>
|
||||
</YStack>
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { useNowPlayingContext } from '../../providers/Player'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { YStack, XStack, getToken, useTheme, ZStack, useWindowDimensions, View } from 'tamagui'
|
||||
import {
|
||||
YStack,
|
||||
XStack,
|
||||
getToken,
|
||||
useTheme,
|
||||
ZStack,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
getTokenValue,
|
||||
} from 'tamagui'
|
||||
import Scrubber from './components/scrubber'
|
||||
import Controls from './components/controls'
|
||||
import Toast from 'react-native-toast-message'
|
||||
@@ -14,6 +23,7 @@ import SongInfo from './components/song-info'
|
||||
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { PlayerParamList } from '../../screens/Player/types'
|
||||
import { Platform } from 'react-native'
|
||||
|
||||
export default function PlayerScreen({
|
||||
navigation,
|
||||
@@ -36,66 +46,55 @@ export default function PlayerScreen({
|
||||
}, []),
|
||||
)
|
||||
|
||||
const isAndroid = Platform.OS === 'android'
|
||||
|
||||
const { width, height } = useWindowDimensions()
|
||||
|
||||
const { bottom } = useSafeAreaInsets()
|
||||
|
||||
// Memoize expensive calculations
|
||||
const songInfoContainerStyle = useMemo(
|
||||
() => ({
|
||||
justifyContent: 'center' as const,
|
||||
alignItems: 'center' as const,
|
||||
marginHorizontal: 'auto' as const,
|
||||
width: getToken('$20') + getToken('$20') + getToken('$5'),
|
||||
maxWidth: width / 1.1,
|
||||
flex: 2,
|
||||
}),
|
||||
[width],
|
||||
)
|
||||
|
||||
const scrubberContainerStyle = useMemo(
|
||||
() => ({
|
||||
justifyContent: 'center' as const,
|
||||
flex: 1,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const { top, bottom } = useSafeAreaInsets()
|
||||
|
||||
/**
|
||||
* Styling for the top layer of Player ZStack
|
||||
*
|
||||
* Android Modals extend into the safe area, so we
|
||||
* need to account for that
|
||||
*
|
||||
* Apple devices get a small amount of margin
|
||||
*/
|
||||
const mainContainerStyle = useMemo(
|
||||
() => ({
|
||||
flex: 1,
|
||||
marginBottom: bottom,
|
||||
marginTop: isAndroid ? top : getTokenValue('$4'),
|
||||
marginBottom: bottom * 2,
|
||||
}),
|
||||
[bottom],
|
||||
[top, bottom, isAndroid],
|
||||
)
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }} edges={['top']}>
|
||||
<View flex={1}>
|
||||
{nowPlaying && (
|
||||
<ZStack fullscreen>
|
||||
<BlurredBackground width={width} height={height} />
|
||||
<View flex={1}>
|
||||
{nowPlaying && (
|
||||
<ZStack fullscreen>
|
||||
<BlurredBackground width={width} height={height} />
|
||||
|
||||
<YStack flex={1} marginBottom={bottom} style={mainContainerStyle}>
|
||||
<PlayerHeader />
|
||||
<YStack
|
||||
justifyContent='center'
|
||||
flex={1}
|
||||
marginHorizontal={'$5'}
|
||||
{...mainContainerStyle}
|
||||
>
|
||||
{/* flexGrow 1 */}
|
||||
<PlayerHeader />
|
||||
|
||||
<XStack style={songInfoContainerStyle}>
|
||||
<SongInfo navigation={navigation} />
|
||||
</XStack>
|
||||
|
||||
<XStack style={scrubberContainerStyle}>
|
||||
{/* playback progress goes here */}
|
||||
<Scrubber />
|
||||
</XStack>
|
||||
<YStack justifyContent='flex-start' gap={'$4'} flexShrink={1}>
|
||||
<SongInfo navigation={navigation} />
|
||||
|
||||
<Scrubber />
|
||||
{/* playback progress goes here */}
|
||||
<Controls />
|
||||
|
||||
<Footer />
|
||||
</YStack>
|
||||
</ZStack>
|
||||
)}
|
||||
{showToast && <Toast config={JellifyToastConfig(theme)} />}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</YStack>
|
||||
</ZStack>
|
||||
)}
|
||||
{showToast && <Toast config={JellifyToastConfig(theme)} />}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
export const ONE_MINUTE = 1000 * 60
|
||||
export const ONE_HOUR = ONE_MINUTE * 60
|
||||
export const ONE_DAY = ONE_HOUR * 24
|
||||
|
||||
/**
|
||||
* A global instance of the Tanstack React Query client
|
||||
*
|
||||
@@ -16,18 +20,19 @@ export const queryClient = new QueryClient({
|
||||
/**
|
||||
* This needs to be set equal to or higher than the `maxAge` set in `../App.tsx`
|
||||
*
|
||||
* Because data can remain on the server forever, the `maxAge` is set to `Infinity`
|
||||
* Because we want to preserve hybrid network functionality, the `maxAge` is set to {@link ONE_DAY}
|
||||
*
|
||||
* Therefore, this also needs to be set to `Infinity`, disabling garbage collection
|
||||
* Therefore, this also needs to be set to {@link ONE_DAY}
|
||||
*
|
||||
* @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#how-it-works
|
||||
*/
|
||||
gcTime: Infinity,
|
||||
gcTime: ONE_DAY,
|
||||
|
||||
/**
|
||||
* 1 hour as a default - reduced from 2 hours for better battery usage
|
||||
* Refetch data after 2 hours as a default
|
||||
*/
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
staleTime: ONE_HOUR * 2,
|
||||
|
||||
retry(failureCount: number, error: Error) {
|
||||
if (failureCount > 2) return false
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import React, {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import React, { createContext, ReactNode, useCallback, useContext, useState } from 'react'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import {
|
||||
InfiniteData,
|
||||
InfiniteQueryObserverResult,
|
||||
useInfiniteQuery,
|
||||
UseInfiniteQueryResult,
|
||||
@@ -19,7 +11,6 @@ import { queryClient } from '../../constants/query-client'
|
||||
import QueryConfig from '../../api/queries/query.config'
|
||||
import { fetchFrequentlyPlayed, fetchFrequentlyPlayedArtists } from '../../api/queries/frequents'
|
||||
import { useJellifyContext } from '..'
|
||||
import { useIsFocused } from '@react-navigation/native'
|
||||
interface HomeContext {
|
||||
refreshing: boolean
|
||||
onRefresh: () => void
|
||||
@@ -44,12 +35,6 @@ const HomeContextInitializer = () => {
|
||||
const { api, library, user } = useJellifyContext()
|
||||
const [refreshing, setRefreshing] = useState<boolean>(false)
|
||||
|
||||
const isFocused = useIsFocused()
|
||||
|
||||
useEffect(() => {
|
||||
console.debug(`Home focused: ${isFocused}`)
|
||||
}, [isFocused])
|
||||
|
||||
const {
|
||||
data: recentTracks,
|
||||
isFetching: isFetchingRecentTracks,
|
||||
|
||||
134
src/providers/Item/index.tsx
Normal file
134
src/providers/Item/index.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { createContext, ReactNode, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { QueryKeys } from '../../enums/query-keys'
|
||||
import { fetchMediaInfo } from '../../api/queries/media'
|
||||
import { useJellifyContext } from '..'
|
||||
import { useStreamingQualityContext } from '../Settings'
|
||||
import { getQualityParams } from '../../utils/mappings'
|
||||
import { fetchAlbumDiscs, fetchItem } from '../../api/queries/item'
|
||||
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { ItemArtistProvider } from './item-artists'
|
||||
import { queryClient } from '../../constants/query-client'
|
||||
import { fetchUserData } from '../../api/queries/favorites'
|
||||
|
||||
interface ItemContext {
|
||||
item: BaseItemDto
|
||||
}
|
||||
|
||||
const ItemContext = createContext<ItemContext>({
|
||||
item: {},
|
||||
})
|
||||
|
||||
interface ItemProviderProps {
|
||||
item: BaseItemDto
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a series of {@link useQuery} functions that store additional context
|
||||
* around the item being browsed, including
|
||||
*
|
||||
* - Artist(s)
|
||||
* - Album
|
||||
* - Track(s)
|
||||
*
|
||||
* This data is used throughout Jellify as additional {@link useQuery} hooks to
|
||||
* tap into this cache
|
||||
*
|
||||
* @param param0 Object containing the {@link BaseItemDto} and the child {@link ReactNode} to render
|
||||
* @returns
|
||||
*/
|
||||
export const ItemProvider: ({ item, children }: ItemProviderProps) => React.JSX.Element = ({
|
||||
item,
|
||||
children,
|
||||
}) => {
|
||||
const { api, user } = useJellifyContext()
|
||||
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
|
||||
const { Id, Type, AlbumId, ArtistItems, UserData } = item
|
||||
|
||||
const artistIds = ArtistItems?.map(({ Id }) => Id) ?? []
|
||||
|
||||
useEffect(() => {
|
||||
// Fail fast if we don't have an Item ID to work with
|
||||
if (!Id) return
|
||||
/**
|
||||
* Fetch and cache the media sources if this item is a track
|
||||
*/
|
||||
if (Type === BaseItemKind.Audio)
|
||||
queryClient.ensureQueryData({
|
||||
queryKey: [QueryKeys.MediaSources, streamingQuality, Id],
|
||||
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), Id!),
|
||||
})
|
||||
|
||||
/**
|
||||
* ...or store it as a queryable if the item is an artist
|
||||
*
|
||||
* Referenced later in the context sheet
|
||||
*/
|
||||
if (Type === BaseItemKind.MusicArtist)
|
||||
queryClient.setQueryData([QueryKeys.ArtistById, Id], item)
|
||||
|
||||
/**
|
||||
* Fire query for a track's album...
|
||||
*
|
||||
* Referenced later in the context sheet
|
||||
*/
|
||||
if (!!AlbumId && Type === BaseItemKind.Audio)
|
||||
queryClient.ensureQueryData({
|
||||
queryKey: [QueryKeys.Album, AlbumId],
|
||||
queryFn: () => fetchItem(api, item.AlbumId!),
|
||||
})
|
||||
|
||||
/**
|
||||
* ...or store it if it is an album
|
||||
*
|
||||
* Referenced later in the context sheet
|
||||
*/
|
||||
if (Type === BaseItemKind.MusicAlbum) queryClient.setQueryData([QueryKeys.Album, Id], item)
|
||||
|
||||
/**
|
||||
* Prefetch for an album's tracks
|
||||
*
|
||||
* Referenced later in the context sheet
|
||||
*/
|
||||
if (Type === BaseItemKind.MusicAlbum)
|
||||
queryClient.ensureQueryData({
|
||||
queryKey: [QueryKeys.ItemTracks, Id],
|
||||
queryFn: () => fetchAlbumDiscs(api, item),
|
||||
})
|
||||
|
||||
/**
|
||||
* Prefetch query for a playlist's tracks
|
||||
*
|
||||
* Referenced later in the context sheet
|
||||
*/
|
||||
if (Type === BaseItemKind.Playlist)
|
||||
queryClient.ensureQueryData({
|
||||
queryKey: [QueryKeys.ItemTracks, Id],
|
||||
queryFn: () =>
|
||||
getItemsApi(api!)
|
||||
.getItems({ parentId: Id! })
|
||||
.then(({ data }) => {
|
||||
if (data.Items) return data.Items
|
||||
else return []
|
||||
}),
|
||||
})
|
||||
|
||||
if (UserData) queryClient.setQueryData([QueryKeys.UserData, Id], UserData)
|
||||
else
|
||||
queryClient.ensureQueryData({
|
||||
queryKey: [QueryKeys.UserData, Id],
|
||||
queryFn: () => fetchUserData(api, user, Id),
|
||||
})
|
||||
}, [queryClient, api, user, Id, Type, AlbumId, UserData, item, streamingQuality])
|
||||
|
||||
return (
|
||||
<ItemContext.Provider value={{ item }}>
|
||||
{artistIds.map((Id) => Id && <ItemArtistProvider artistId={Id} key={Id} />)}
|
||||
{children}
|
||||
</ItemContext.Provider>
|
||||
)
|
||||
}
|
||||
34
src/providers/Item/item-artists.tsx
Normal file
34
src/providers/Item/item-artists.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createContext, useEffect } from 'react'
|
||||
import { useJellifyContext } from '..'
|
||||
import { QueryKeys } from '../../enums/query-keys'
|
||||
import { fetchItem } from '../../api/queries/item'
|
||||
import { queryClient } from '../../constants/query-client'
|
||||
|
||||
interface ItemArtistContext {
|
||||
artistId: string | undefined
|
||||
}
|
||||
|
||||
const ItemArtistContext = createContext<ItemArtistContext>({
|
||||
artistId: undefined,
|
||||
})
|
||||
|
||||
export const ItemArtistProvider: ({
|
||||
artistId,
|
||||
}: {
|
||||
artistId: string | undefined
|
||||
}) => React.JSX.Element = ({ artistId }) => {
|
||||
const { api } = useJellifyContext()
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Store queryable of artist item
|
||||
*/
|
||||
if (artistId)
|
||||
queryClient.ensureQueryData({
|
||||
queryKey: [QueryKeys.ArtistById, artistId],
|
||||
queryFn: () => fetchItem(api, artistId!),
|
||||
})
|
||||
})
|
||||
|
||||
return <ItemArtistContext.Provider value={{ artistId }} />
|
||||
}
|
||||
@@ -100,8 +100,6 @@ const LibraryContextInitializer = () => {
|
||||
),
|
||||
select: selectArtists,
|
||||
initialPageParam: 0,
|
||||
staleTime: QueryConfig.staleTime.oneDay, // Cache for 1 day to reduce network requests
|
||||
gcTime: QueryConfig.staleTime.oneWeek, // Keep in memory for 1 week
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
|
||||
return lastPage.length === QueryConfig.limits.library ? lastPageParam + 1 : undefined
|
||||
},
|
||||
@@ -123,8 +121,6 @@ const LibraryContextInitializer = () => {
|
||||
sortDescending ? SortOrder.Descending : SortOrder.Ascending,
|
||||
),
|
||||
initialPageParam: 0,
|
||||
staleTime: QueryConfig.staleTime.oneDay, // Cache for 1 day to reduce network requests
|
||||
gcTime: QueryConfig.staleTime.oneWeek, // Keep in memory for 1 week
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
|
||||
console.debug(`Tracks last page length: ${lastPage.length}`)
|
||||
return lastPage.length === QueryConfig.limits.library * 2
|
||||
@@ -149,8 +145,6 @@ const LibraryContextInitializer = () => {
|
||||
initialPageParam: alphabet[0],
|
||||
select: (data) => data.pages.flatMap((page) => [page.title, ...page.data]),
|
||||
maxPages: alphabet.length,
|
||||
staleTime: QueryConfig.staleTime.oneDay, // Cache for 1 day to reduce network requests
|
||||
gcTime: QueryConfig.staleTime.oneWeek, // Keep in memory for 1 week
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
|
||||
console.debug(`Albums last page length: ${lastPage.data.length}`)
|
||||
if (lastPageParam !== alphabet[alphabet.length - 1]) {
|
||||
@@ -180,8 +174,6 @@ const LibraryContextInitializer = () => {
|
||||
queryFn: () => fetchUserPlaylists(api, user, library),
|
||||
select: (data) => data.pages.flatMap((page) => page),
|
||||
initialPageParam: 0,
|
||||
staleTime: QueryConfig.staleTime.oneDay, // Cache for 1 day to reduce network requests
|
||||
gcTime: QueryConfig.staleTime.oneWeek, // Keep in memory for 1 week
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
|
||||
return lastPage.length === QueryConfig.limits.library ? lastPageParam + 1 : undefined
|
||||
},
|
||||
|
||||
@@ -115,7 +115,6 @@ const NetworkContextInitializer = () => {
|
||||
const { data: storageUsage } = useQuery({
|
||||
queryKey: [QueryKeys.StorageInUse],
|
||||
queryFn: () => fetchStorageInUse(),
|
||||
staleTime: 1000 * 60 * 60 * 1, // 1 hour
|
||||
})
|
||||
|
||||
const { mutate: clearDownloads } = useMutation({
|
||||
|
||||
@@ -90,7 +90,6 @@ const PlayerContextInitializer = () => {
|
||||
nowPlayingJson ? JSON.parse(nowPlayingJson) : undefined,
|
||||
)
|
||||
|
||||
const [initialized, setInitialized] = useState<boolean>(false)
|
||||
const [repeatMode, setRepeatMode] = useState<RepeatMode>(
|
||||
repeatModeJson ? JSON.parse(repeatModeJson) : RepeatMode.Off,
|
||||
)
|
||||
@@ -579,26 +578,6 @@ const PlayerContextInitializer = () => {
|
||||
}
|
||||
}, [currentIndex, playQueue])
|
||||
|
||||
/**
|
||||
* Initialize the player. This is used to load the queue from the {@link QueueProvider}
|
||||
* and set it to the player if we have already completed the onboarding process
|
||||
* and the user has a valid queue in storage
|
||||
*/
|
||||
useEffect(() => {
|
||||
console.debug('Initialized', initialized)
|
||||
console.debug('Play queue length', playQueue.length)
|
||||
console.debug('Current index', currentIndex)
|
||||
if (playQueue.length > 0 && currentIndex > -1 && !initialized) {
|
||||
TrackPlayer.setQueue(playQueue)
|
||||
TrackPlayer.skip(currentIndex)
|
||||
console.debug('Loaded queue from storage')
|
||||
setInitialized(true)
|
||||
} else if (queueRef === 'Recently Played' && currentIndex === -1) {
|
||||
console.debug('Not loading queue as it is empty')
|
||||
setInitialized(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Clean up prefetched track IDs when the current index changes significantly
|
||||
*/
|
||||
|
||||
@@ -151,16 +151,20 @@ const QueueContextInitailizer = () => {
|
||||
const shuffledInit = storage.getBoolean(MMKVStorageKeys.Shuffled)
|
||||
|
||||
//#region State
|
||||
const [currentIndex, setCurrentIndex] = useState<number>(currentIndexValue ?? -1)
|
||||
const [playQueue, setPlayQueue] = useState<JellifyTrack[]>(playQueueInit)
|
||||
const [queueRef, setQueueRef] = useState<Queue>(queueRefInit)
|
||||
const [unshuffledQueue, setUnshuffledQueue] = useState<JellifyTrack[]>(unshuffledQueueInit)
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState<number>(currentIndexValue ?? -1)
|
||||
|
||||
/**
|
||||
* Handles whether we are loading in a new queue, in which case we will temporarily ignore
|
||||
* {@link Event.PlaybackActiveTrackChanged} events until that mutation has settled
|
||||
*/
|
||||
const [skipping, setSkipping] = useState<boolean>(false)
|
||||
|
||||
const [shuffled, setShuffled] = useState<boolean>(shuffledInit ?? false)
|
||||
|
||||
const [initialized, setInitialized] = useState<boolean>(false)
|
||||
//#endregion State
|
||||
|
||||
//#region Context
|
||||
@@ -174,8 +178,10 @@ const QueueContextInitailizer = () => {
|
||||
useTrackPlayerEvents(
|
||||
[Event.PlaybackActiveTrackChanged],
|
||||
async ({ index, track }: { index?: number | undefined; track?: Track | undefined }) => {
|
||||
console.debug(`Active Track Changed to: ${index}. Skipping: ${skipping}`)
|
||||
if (skipping) return
|
||||
console.debug(
|
||||
`Active Track Changed to: ${index}. Skipping: ${skipping}, Initialized: ${initialized}`,
|
||||
)
|
||||
if (skipping || !initialized) return
|
||||
|
||||
let newIndex = -1
|
||||
|
||||
@@ -262,10 +268,6 @@ const QueueContextInitailizer = () => {
|
||||
startIndex: number = 0,
|
||||
shuffleQueue: boolean = false,
|
||||
) => {
|
||||
trigger('impactLight')
|
||||
console.debug(`Queuing ${audioItems.length} items`)
|
||||
|
||||
setSkipping(true)
|
||||
setShuffled(shuffleQueue)
|
||||
|
||||
// Get the item at the start index
|
||||
@@ -535,6 +537,12 @@ const QueueContextInitailizer = () => {
|
||||
queue,
|
||||
shuffled,
|
||||
}: QueueMutation) => loadQueue(tracklist, queue, index, shuffled),
|
||||
onMutate: async ({ tracklist }) => {
|
||||
trigger('impactLight')
|
||||
console.debug(`Queuing ${tracklist.length} items`)
|
||||
|
||||
setSkipping(true)
|
||||
},
|
||||
onSuccess: async (data: void, { startPlayback }: QueueMutation) => {
|
||||
trigger('notificationSuccess')
|
||||
console.debug(`Loaded new queue`)
|
||||
@@ -705,6 +713,23 @@ const QueueContextInitailizer = () => {
|
||||
|
||||
//#region useEffect(s)
|
||||
|
||||
/**
|
||||
* Initialization
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (playQueue.length > 0 && currentIndex > -1 && !initialized) {
|
||||
TrackPlayer.setQueue(playQueue)
|
||||
TrackPlayer.skip(currentIndex)
|
||||
|
||||
// Set Initialized after a timeout to ignore events emitted
|
||||
// while the queue is setting up
|
||||
setTimeout(() => setInitialized(true), 500)
|
||||
} else {
|
||||
console.debug(`No queue to initialize from`)
|
||||
setInitialized(true)
|
||||
}
|
||||
}, [initialized])
|
||||
|
||||
/**
|
||||
* Store play queue in storage when it changes
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ const PlaylistContextInitializer = (playlist: BaseItemDto) => {
|
||||
return response.data.Items ? response.data.Items! : []
|
||||
})
|
||||
},
|
||||
staleTime: 1000 * 60 * 60 * 2, // 2 hours, since these are mutable
|
||||
})
|
||||
|
||||
const useUpdatePlaylist = useMutation({
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useJellifyContext } from '..'
|
||||
|
||||
interface SetFavoriteMutation {
|
||||
item: BaseItemDto
|
||||
setFavorite: React.Dispatch<SetStateAction<boolean>>
|
||||
onToggle?: () => void
|
||||
}
|
||||
|
||||
@@ -27,7 +26,7 @@ const JellifyUserDataContextInitializer = () => {
|
||||
itemId: mutation.item.Id!,
|
||||
})
|
||||
},
|
||||
onSuccess: ({ data }, { item, setFavorite, onToggle }) => {
|
||||
onSuccess: ({ data }, { item, onToggle }) => {
|
||||
// Burnt.alert({
|
||||
// title: `Added favorite`,
|
||||
// duration: 1,
|
||||
@@ -40,7 +39,6 @@ const JellifyUserDataContextInitializer = () => {
|
||||
|
||||
trigger('notificationSuccess')
|
||||
|
||||
setFavorite(true)
|
||||
if (onToggle) onToggle()
|
||||
|
||||
// Force refresh of track user data
|
||||
@@ -54,7 +52,7 @@ const JellifyUserDataContextInitializer = () => {
|
||||
itemId: mutation.item.Id!,
|
||||
})
|
||||
},
|
||||
onSuccess: ({ data }, { item, setFavorite, onToggle }) => {
|
||||
onSuccess: ({ data }, { item, onToggle }) => {
|
||||
// Burnt.alert({
|
||||
// title: `Removed favorite`,
|
||||
// duration: 1,
|
||||
@@ -65,7 +63,6 @@ const JellifyUserDataContextInitializer = () => {
|
||||
type: 'error',
|
||||
})
|
||||
trigger('notificationSuccess')
|
||||
setFavorite(false)
|
||||
|
||||
if (onToggle) onToggle()
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||
import { BaseStackParamList } from '../types'
|
||||
import { Queue } from '../../player/types/queue-item'
|
||||
import { NavigatorScreenParams } from '@react-navigation/native'
|
||||
|
||||
type LibraryStackParamList = BaseStackParamList & {
|
||||
|
||||
@@ -116,8 +116,7 @@ export default function ServerAuthentication({
|
||||
icon={() => <Icon name='chevron-left' small />}
|
||||
bordered={0}
|
||||
onPress={() => {
|
||||
if (navigation.canGoBack()) navigation.goBack()
|
||||
else navigation.navigate('ServerAddress', undefined, { pop: true })
|
||||
navigation.navigate('ServerAddress', undefined, { pop: true })
|
||||
}}
|
||||
>
|
||||
Switch Server
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import Player from './Player'
|
||||
import Tabs from './Tabs'
|
||||
import { RootStackParamList } from './types'
|
||||
import { getToken, useTheme } from 'tamagui'
|
||||
import { getToken, useTheme, YStack } from 'tamagui'
|
||||
import { useJellifyContext } from '../providers'
|
||||
import Login from './Login'
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack'
|
||||
import { createNativeStackNavigator, NativeStackHeaderProps } from '@react-navigation/native-stack'
|
||||
import Context from './Context'
|
||||
import { getItemName } from '../utils/text'
|
||||
import AddToPlaylistSheet from './AddToPlaylist'
|
||||
import { Platform } from 'react-native'
|
||||
import TextTicker from 'react-native-text-ticker'
|
||||
import { TextTickerConfig } from '../components/Player/component.config'
|
||||
import { Text } from '../components/Global/helpers/text'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
|
||||
const RootStack = createNativeStackNavigator<RootStackParamList>()
|
||||
|
||||
@@ -17,6 +21,8 @@ export default function Root(): React.JSX.Element {
|
||||
|
||||
const { api, library } = useJellifyContext()
|
||||
|
||||
const isApple = ['ios', 'macos'].includes(Platform.OS)
|
||||
|
||||
return (
|
||||
<RootStack.Navigator
|
||||
initialRouteName={api && library ? 'Tabs' : 'Login'}
|
||||
@@ -54,11 +60,10 @@ export default function Root(): React.JSX.Element {
|
||||
name='Context'
|
||||
component={Context}
|
||||
options={({ route }) => ({
|
||||
headerTitle: getItemName(route.params.item),
|
||||
header: () => ContextSheetHeader(route.params.item),
|
||||
presentation: 'formSheet',
|
||||
sheetAllowedDetents: 'fitToContents',
|
||||
sheetGrabberVisible: true,
|
||||
headerTransparent: true,
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -67,7 +72,7 @@ export default function Root(): React.JSX.Element {
|
||||
component={AddToPlaylistSheet}
|
||||
options={{
|
||||
headerTitle: 'Add to Playlist',
|
||||
presentation: Platform.OS === 'ios' ? 'formSheet' : 'modal',
|
||||
presentation: 'formSheet',
|
||||
sheetAllowedDetents: 'fitToContents',
|
||||
sheetGrabberVisible: true,
|
||||
}}
|
||||
@@ -75,3 +80,23 @@ export default function Root(): React.JSX.Element {
|
||||
</RootStack.Navigator>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextSheetHeader(item: BaseItemDto): React.JSX.Element {
|
||||
return (
|
||||
<YStack gap={'$1'} marginTop={'$4'} alignItems='center'>
|
||||
<TextTicker {...TextTickerConfig}>
|
||||
<Text bold fontSize={'$6'}>
|
||||
{getItemName(item)}
|
||||
</Text>
|
||||
</TextTicker>
|
||||
|
||||
{(item.ArtistItems?.length ?? 0) > 0 && (
|
||||
<TextTicker {...TextTickerConfig}>
|
||||
<Text bold fontSize={'$4'}>
|
||||
{`${item.ArtistItems?.map((artist) => getItemName(artist)).join(' • ')}`}
|
||||
</Text>
|
||||
</TextTicker>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user