rendering fiixes to playlist and albums

bump react native sortables
This commit is contained in:
Violet Caulfield
2025-12-01 15:56:19 -06:00
parent 0f048671e7
commit e088249014
9 changed files with 114 additions and 265 deletions

View File

@@ -51,7 +51,7 @@
"react-native-reanimated": "4.1.5",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "4.18.0",
"react-native-sortables": "^1.9.3",
"react-native-sortables": "1.9.4",
"react-native-text-ticker": "^1.15.0",
"react-native-toast-message": "^2.3.3",
"react-native-track-player": "5.0.0-alpha0",
@@ -1942,7 +1942,7 @@
"react-native-screens": ["react-native-screens@4.18.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ=="],
"react-native-sortables": ["react-native-sortables@1.9.3", "", { "optionalDependencies": { "react-native-haptic-feedback": ">=2.0.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-VLhW9+3AVEaJNwwQSgN+n/Qe+YRB0C0mNWTjHhyzcZ+YjY4BmJao4bZxl5lD6EsfqZ1Ij6B2ZdxjNlSkUXrvow=="],
"react-native-sortables": ["react-native-sortables@1.9.4", "", { "optionalDependencies": { "react-native-haptic-feedback": ">=2.0.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-a6hxT+gl14HA5Sm8UiLXJqF8KMEQVa+mUJd75OnzoVsmrxUDtjAatlMdV0kI9qTQDT/ZSFLPRmdUhOR762IA4g=="],
"react-native-tab-view": ["react-native-tab-view@4.2.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-TUbh7Yr0tE/99t1pJQLbQ+4/Px67xkT7/r3AhfV+93Q3WoUira0Lx7yuKUP2C118doqxub8NCLERwcqsHr29nQ=="],

View File

@@ -83,7 +83,7 @@
"react-native-reanimated": "4.1.5",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "4.18.0",
"react-native-sortables": "^1.9.3",
"react-native-sortables": "1.9.4",
"react-native-text-ticker": "^1.15.0",
"react-native-toast-message": "^2.3.3",
"react-native-track-player": "5.0.0-alpha0",

View File

@@ -17,7 +17,6 @@ import { useNetworkContext } from '../../providers/Network'
import { useNetworkStatus } from '../../stores/network'
import { useLoadNewQueue } from '../../providers/Player/hooks/mutations'
import { QueuingType } from '../../enums/queuing-type'
import { useAlbumContext } from '../../providers/Album'
import { useNavigation } from '@react-navigation/native'
import HomeStackParamList from '../../screens/Home/types'
import LibraryStackParamList from '../../screens/Library/types'
@@ -26,6 +25,9 @@ import { BaseStackParamList } from '../../screens/types'
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../../stores/device-profile'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
import { useApi } from '../../stores'
import { QueryKeys } from '../../enums/query-keys'
import { fetchAlbumDiscs } from '../../api/queries/item'
import { useQuery } from '@tanstack/react-query'
/**
* The screen for an Album's track list
@@ -35,12 +37,16 @@ import { useApi } from '../../stores'
*
* @returns A React component
*/
export function Album(): React.JSX.Element {
export function Album({ album }: { album: BaseItemDto }): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>()
const { album, discs, isPending } = useAlbumContext()
const api = useApi()
const { data: discs, isPending } = useQuery({
queryKey: [QueryKeys.ItemTracks, album.Id],
queryFn: () => fetchAlbumDiscs(api, album),
})
const { addToDownloadQueue, pendingDownloads } = useNetworkContext()
const downloadingDeviceProfile = useDownloadingDeviceProfile()
@@ -92,7 +98,7 @@ export function Album(): React.JSX.Element {
</XStack>
) : null
}}
ListHeaderComponent={AlbumTrackListHeader}
ListHeaderComponent={() => <AlbumTrackListHeader album={album} />}
renderItem={({ item: track, index }) => (
<Track
navigation={navigation}
@@ -102,7 +108,7 @@ export function Album(): React.JSX.Element {
queue={album}
/>
)}
ListFooterComponent={AlbumTrackListFooter}
ListFooterComponent={() => <AlbumTrackListFooter album={album} />}
ListEmptyComponent={() => (
<YStack flex={1} alignContent='center'>
{isPending ? <Spinner color={'$primary'} /> : <Text>No tracks found</Text>}
@@ -120,7 +126,7 @@ export function Album(): React.JSX.Element {
* @param playAlbum The function to call to play the album
* @returns A React component
*/
function AlbumTrackListHeader(): React.JSX.Element {
function AlbumTrackListHeader({ album }: { album: BaseItemDto }): React.JSX.Element {
const api = useApi()
const { width } = useSafeAreaFrame()
@@ -130,7 +136,10 @@ function AlbumTrackListHeader(): React.JSX.Element {
const loadNewQueue = useLoadNewQueue()
const { album, discs } = useAlbumContext()
const { data: discs, isPending } = useQuery({
queryKey: [QueryKeys.ItemTracks, album.Id],
queryFn: () => fetchAlbumDiscs(api, album),
})
const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>()
@@ -235,8 +244,7 @@ function AlbumTrackListHeader(): React.JSX.Element {
)
}
function AlbumTrackListFooter(): React.JSX.Element {
const { album } = useAlbumContext()
function AlbumTrackListFooter({ album }: { album: BaseItemDto }): React.JSX.Element {
const navigation =
useNavigation<
NativeStackNavigationProp<

View File

@@ -3,7 +3,6 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { H5, Spacer, XStack, YStack } from 'tamagui'
import InstantMixButton from '../../Global/components/instant-mix-button'
import Icon from '../../Global/components/icon'
import { usePlaylistContext } from '../../../providers/Playlist'
import { useNetworkStatus } from '../../../../src/stores/network'
import { useNetworkContext } from '../../../../src/providers/Network'
import { ActivityIndicator } from 'react-native'
@@ -19,15 +18,21 @@ import ItemImage from '../../Global/components/image'
import { useApi } from '../../../stores'
import Input from '../../Global/helpers/input'
import Animated, { FadeInDown, FadeOutDown } from 'react-native-reanimated'
import { Dispatch, SetStateAction } from 'react'
export default function PlaylistTracklistHeader({
canEdit,
playlist,
playlistTracks,
editing,
newName,
setNewName,
}: {
canEdit?: boolean
playlist: BaseItemDto
playlistTracks: BaseItemDto[] | undefined
editing: boolean
newName: string
setNewName: Dispatch<SetStateAction<string>>
}): React.JSX.Element {
const { playlist, playlistTracks, editing, setEditing, newName, setNewName } =
usePlaylistContext()
return (
<YStack justifyContent='center' alignItems='center' paddingTop={'$1'} marginBottom={'$2'}>
<YStack justifyContent='center' alignContent='center' padding={'$2'}>
@@ -68,10 +73,8 @@ export default function PlaylistTracklistHeader({
<Animated.View entering={FadeInDown} exiting={FadeOutDown}>
<PlaylistHeaderControls
editing={editing}
setEditing={setEditing}
playlist={playlist}
playlistTracks={playlistTracks ?? []}
canEdit={canEdit}
/>
</Animated.View>
) : (
@@ -83,16 +86,12 @@ export default function PlaylistTracklistHeader({
function PlaylistHeaderControls({
editing,
setEditing,
playlist,
playlistTracks,
canEdit,
}: {
editing: boolean
setEditing: (editing: boolean) => void
playlist: BaseItemDto
playlistTracks: BaseItemDto[]
canEdit: boolean | undefined
}): React.JSX.Element {
const { addToDownloadQueue, pendingDownloads } = useNetworkContext()
const streamingDeviceProfile = useStreamingDeviceProfile()
@@ -133,18 +132,7 @@ function PlaylistHeaderControls({
return (
<XStack justifyContent='center' marginVertical={'$1'} gap={'$2'} flexWrap='wrap'>
<YStack justifyContent='center' alignContent='center'>
{editing && canEdit ? (
<Icon
color={'$danger'}
name='delete-sweep-outline' // otherwise use "delete-circle"
onPress={() => {
navigation.push('DeletePlaylist', { playlist })
}}
small
/>
) : (
<InstantMixButton item={playlist} navigation={navigation} />
)}
<InstantMixButton item={playlist} navigation={navigation} />
</YStack>
<YStack justifyContent='center' alignContent='center'>
@@ -155,16 +143,6 @@ function PlaylistHeaderControls({
<Icon name='shuffle' onPress={() => playPlaylist(true)} small />
</YStack>
{canEdit && (
<YStack justifyContent='center' alignContent='center'>
<Icon
color={'$borderColor'}
name={editing ? 'content-save-outline' : 'pencil'}
onPress={() => setEditing(!editing)}
small
/>
</YStack>
)}
<YStack justifyContent='center' alignContent='center'>
{!isDownloading ? (
<Icon

View File

@@ -2,12 +2,10 @@ import { ScrollView, Spinner, useTheme, XStack } from 'tamagui'
import Track from '../Global/components/track'
import Icon from '../Global/components/icon'
import { PlaylistProps } from './interfaces'
import { usePlaylistContext } from '../../providers/Playlist'
import { StackActions, useNavigation } from '@react-navigation/native'
import { RootStackParamList } from '../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import Sortable from 'react-native-sortables'
import { useLayoutEffect } from 'react'
import { useReducedHapticsSetting } from '../../stores/settings/app'
import { RenderItemInfo } from 'react-native-sortables/dist/typescript/types'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
@@ -19,6 +17,12 @@ import { QueuingType } from '../../enums/queuing-type'
import { useApi } from '../../stores'
import useStreamingDeviceProfile from '../../stores/device-profile'
import { RefreshControl } from 'react-native-gesture-handler'
import { useEffect, useLayoutEffect, useState } from 'react'
import { updatePlaylist } from '../../../src/api/mutations/playlists'
import { usePlaylistTracks } from '../../../src/api/queries/playlist'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
import { useMutation } from '@tanstack/react-query'
import Animated, { SlideInLeft, SlideOutRight } from 'react-native-reanimated'
export default function Playlist({
playlist,
@@ -29,18 +33,62 @@ export default function Playlist({
const theme = useTheme()
const {
playlistTracks,
isPending,
refetch,
editing,
setEditing,
isUpdating,
newName,
setPlaylistTracks,
useUpdatePlaylist,
handleCancel,
} = usePlaylistContext()
const [editing, setEditing] = useState<boolean>(false)
const [newName, setNewName] = useState<string>(playlist.Name ?? '')
const [playlistTracks, setPlaylistTracks] = useState<BaseItemDto[] | undefined>(undefined)
const trigger = useHapticFeedback()
const { data: tracks, isPending, refetch, isSuccess } = usePlaylistTracks(playlist)
const { mutate: useUpdatePlaylist, isPending: isUpdating } = useMutation({
mutationFn: ({
playlist,
tracks,
newName,
}: {
playlist: BaseItemDto
tracks: BaseItemDto[]
newName: string
}) => {
return updatePlaylist(
api,
playlist.Id!,
newName,
tracks.map((track) => track.Id!),
)
},
onSuccess: () => {
trigger('notificationSuccess')
// Refresh playlist component data
refetch()
},
onError: () => {
trigger('notificationError')
setNewName(playlist.Name ?? '')
setPlaylistTracks(tracks ?? [])
},
onSettled: () => {
setEditing(false)
},
})
const handleCancel = () => {
setEditing(false)
setNewName(playlist.Name ?? '')
setPlaylistTracks(tracks)
}
useEffect(() => {
if (!isPending && isSuccess) setPlaylistTracks(tracks)
}, [tracks, isPending, isSuccess])
useEffect(() => {
if (!editing) refetch()
}, [editing])
const loadNewQueue = useLoadNewQueue()
@@ -128,9 +176,11 @@ export default function Playlist({
return (
<XStack alignItems='center' key={`${index}-${track.Id}`} flex={1}>
{editing && (
<Sortable.Handle>
<Icon name='drag' />
</Sortable.Handle>
<Animated.View entering={SlideInLeft} exiting={SlideOutRight}>
<Sortable.Handle>
<Icon name='drag' />
</Sortable.Handle>
</Animated.View>
)}
<Sortable.Touchable
@@ -181,7 +231,13 @@ export default function Playlist({
/>
}
>
<PlaylistTracklistHeader />
<PlaylistTracklistHeader
setNewName={setNewName}
newName={newName}
editing={editing}
playlist={playlist}
playlistTracks={playlistTracks}
/>
<Sortable.Grid
data={playlistTracks ?? []}

View File

@@ -1,47 +0,0 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { QueryKeys } from '../../enums/query-keys'
import { fetchAlbumDiscs } from '../../api/queries/item'
import { useQuery } from '@tanstack/react-query'
import { createContext, ReactNode, useContext } from 'react'
import { useApi } from '../../stores'
interface AlbumContext {
album: BaseItemDto
discs: { title: string; data: BaseItemDto[] }[] | undefined
isPending: boolean
}
function AlbumContextInitializer(album: BaseItemDto): AlbumContext {
const api = useApi()
const { data: discs, isPending } = useQuery({
queryKey: [QueryKeys.ItemTracks, album.Id],
queryFn: () => fetchAlbumDiscs(api, album),
})
return {
album,
discs,
isPending,
}
}
const AlbumContext = createContext<AlbumContext>({
album: {},
discs: undefined,
isPending: false,
})
export const AlbumProvider: ({
album,
children,
}: {
album: BaseItemDto
children: ReactNode
}) => React.JSX.Element = ({ album, children }) => {
const context = AlbumContextInitializer(album)
return <AlbumContext.Provider value={context}>{children}</AlbumContext.Provider>
}
export const useAlbumContext = () => useContext(AlbumContext)

View File

@@ -1,138 +0,0 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { UseMutateFunction, useMutation } from '@tanstack/react-query'
import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
import { updatePlaylist } from '../../api/mutations/playlists'
import { SharedValue, useSharedValue } from 'react-native-reanimated'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
import { useApi } from '../../stores'
import { usePlaylistTracks } from '../../api/queries/playlist'
interface PlaylistContext {
playlist: BaseItemDto
playlistTracks: BaseItemDto[] | undefined
refetch: () => void
isPending: boolean
editing: boolean
setEditing: (editing: boolean) => void
newName: string
setNewName: (name: string) => void
setPlaylistTracks: (tracks: BaseItemDto[]) => void
useUpdatePlaylist: UseMutateFunction<
void,
Error,
{
playlist: BaseItemDto
tracks: BaseItemDto[]
newName: string
},
unknown
>
isUpdating?: boolean
handleCancel: () => void
}
const PlaylistContextInitializer = (playlist: BaseItemDto) => {
const api = useApi()
const canEdit = playlist.CanDelete
const [editing, setEditing] = useState<boolean>(false)
const [newName, setNewName] = useState<string>(playlist.Name ?? '')
const [playlistTracks, setPlaylistTracks] = useState<BaseItemDto[] | undefined>(undefined)
const trigger = useHapticFeedback()
const { data: tracks, isPending, refetch, isSuccess } = usePlaylistTracks(playlist)
const { mutate: useUpdatePlaylist, isPending: isUpdating } = useMutation({
mutationFn: ({
playlist,
tracks,
newName,
}: {
playlist: BaseItemDto
tracks: BaseItemDto[]
newName: string
}) => {
return updatePlaylist(
api,
playlist.Id!,
newName,
tracks.map((track) => track.Id!),
)
},
onSuccess: () => {
trigger('notificationSuccess')
// Refresh playlist component data
refetch()
},
onError: () => {
trigger('notificationError')
setNewName(playlist.Name ?? '')
setPlaylistTracks(tracks ?? [])
},
onSettled: () => {
setEditing(false)
},
})
const handleCancel = () => {
setEditing(false)
setNewName(playlist.Name ?? '')
setPlaylistTracks(tracks)
}
useEffect(() => {
if (!isPending && isSuccess) setPlaylistTracks(tracks)
}, [tracks, isPending, isSuccess])
useEffect(() => {
if (!editing) refetch()
}, [editing])
return {
playlist,
playlistTracks,
refetch,
isPending,
editing,
setEditing,
newName,
setNewName,
setPlaylistTracks,
useUpdatePlaylist,
handleCancel,
isUpdating,
}
}
const PlaylistContext = createContext<PlaylistContext>({
playlist: {},
playlistTracks: undefined,
refetch: () => {},
isPending: false,
editing: false,
setEditing: () => {},
newName: '',
setNewName: () => {},
setPlaylistTracks: () => {},
useUpdatePlaylist: () => {},
handleCancel: () => {},
isUpdating: false,
})
export const PlaylistProvider = ({
playlist,
children,
}: {
playlist: BaseItemDto
children: ReactNode
}) => {
const context = PlaylistContextInitializer(playlist)
return <PlaylistContext.Provider value={context}>{children}</PlaylistContext.Provider>
}
export const usePlaylistContext = () => useContext(PlaylistContext)

View File

@@ -1,13 +1,8 @@
import { Album } from '../../components/Album'
import { AlbumProps } from '../types'
import { AlbumProvider } from '../../providers/Album'
export default function AlbumScreen({ route, navigation }: AlbumProps): React.JSX.Element {
const { album } = route.params
return (
<AlbumProvider album={album}>
<Album />
</AlbumProvider>
)
return <Album album={album} />
}

View File

@@ -1,9 +1,8 @@
import { BaseStackParamList, RootStackParamList } from '../types'
import { BaseStackParamList } from '../types'
import { RouteProp } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import React from 'react'
import Playlist from '../../components/Playlist/index'
import { PlaylistProvider } from '../../providers/Playlist'
export function PlaylistScreen({
route,
@@ -13,12 +12,10 @@ export function PlaylistScreen({
navigation: NativeStackNavigationProp<BaseStackParamList>
}): React.JSX.Element {
return (
<PlaylistProvider playlist={route.params.playlist}>
<Playlist
playlist={route.params.playlist}
navigation={navigation}
canEdit={route.params.canEdit}
/>
</PlaylistProvider>
<Playlist
playlist={route.params.playlist}
navigation={navigation}
canEdit={route.params.canEdit}
/>
)
}