playlist screen redesign (#709)

* playlist screen redesign with improved reordering and removing controls powered by react native sortables

* remove unnecessary packages
This commit is contained in:
Violet Caulfield
2025-11-23 18:26:35 -06:00
committed by GitHub
parent 9d2e10e996
commit c54a056f0b
14 changed files with 320 additions and 321 deletions

View File

@@ -1,5 +1,6 @@
<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" />

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "jellify",
@@ -13,7 +14,7 @@
"@react-navigation/bottom-tabs": "7.8.6",
"@react-navigation/material-top-tabs": "7.4.4",
"@react-navigation/native": "7.1.21",
"@react-navigation/native-stack": "7.6.4",
"@react-navigation/native-stack": "7.7.0",
"@sentry/react-native": "7.6.0",
"@shopify/flash-list": "2.2.0",
"@tamagui/config": "1.137.1",
@@ -37,10 +38,8 @@
"react-native-config": "1.5.6",
"react-native-device-info": "15.0.1",
"react-native-dns-lookup": "^1.0.6",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-flashdrag-list": "^0.2.5",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "^2.28.0",
"react-native-gesture-handler": "2.29.1",
"react-native-google-cast": "^4.9.1",
"react-native-haptic-feedback": "^2.3.3",
"react-native-linear-gradient": "^2.8.3",
@@ -51,7 +50,7 @@
"react-native-reanimated": "4.1.5",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "4.18.0",
"react-native-swipeable-item": "^2.0.9",
"react-native-sortables": "^1.9.3",
"react-native-text-ticker": "^1.15.0",
"react-native-toast-message": "^2.3.3",
"react-native-track-player": "5.0.0-alpha0",
@@ -574,7 +573,7 @@
"@react-navigation/native": ["@react-navigation/native@7.1.21", "", { "dependencies": { "@react-navigation/core": "^7.13.2", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-mhpAewdivBL01ibErr91FUW9bvKhfAF6Xv/yr6UOJtDhv0jU6iUASUcA3i3T8VJCOB/vxmoke7VDp8M+wBFs/Q=="],
"@react-navigation/native-stack": ["@react-navigation/native-stack@7.6.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.3", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.21", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Lj4+m6gVPYOURf/yRAOvvqGL5LCVq9Pg3qdfGUR9SXPU61Kf386WZMOBJAusXQDkszavEusV+ROKYGPXkgIgDQ=="],
"@react-navigation/native-stack": ["@react-navigation/native-stack@7.7.0", "", { "dependencies": { "@react-navigation/elements": "^2.8.3", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.21", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-9RTnXwE6x6mxRYQMj1ZcbslrcBTUQfafNVwiHkHM0nscld/qSP+e+FrUJW561UWXojyui3U3g7prMSooSgUB/g=="],
"@react-navigation/routers": ["@react-navigation/routers@7.5.2", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-kymreY5aeTz843E+iPAukrsOtc7nabAH6novtAPREmmGu77dQpfxPB2ZWpKb5nRErIRowp1kYRoN2Ckl+S6JYw=="],
@@ -1910,10 +1909,6 @@
"react-native-dns-lookup": ["react-native-dns-lookup@1.0.6", "", { "peerDependencies": { "react-native": "*" } }, "sha512-47pdg4h50CEume23HzUs9FwhCqsXGYtXIRRnb0olY62ThifQGVN0eETRenY4YaKv4g9jIt1GVKDTSkH//uXXRg=="],
"react-native-draggable-flatlist": ["react-native-draggable-flatlist@4.0.3", "", { "dependencies": { "@babel/preset-typescript": "^7.17.12" }, "peerDependencies": { "react-native": ">=0.64.0", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=2.8.0" } }, "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw=="],
"react-native-flashdrag-list": ["react-native-flashdrag-list@0.2.5", "", { "peerDependencies": { "@shopify/flash-list": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*" } }, "sha512-SG4Lsgi/Clf/U4MaXyI/c/FoI2j60oxCxBTFqz+weyYDZXXlpuiMcMt3FPovQBBYwNYUP+VlAYeOzODRAXgf6A=="],
"react-native-fs": ["react-native-fs@2.20.0", "", { "dependencies": { "base-64": "^0.1.0", "utf8": "^3.0.0" }, "peerDependencies": { "react-native": "*", "react-native-windows": "*" }, "optionalPeers": ["react-native-windows"] }, "sha512-VkTBzs7fIDUiy/XajOSNk0XazFE9l+QlMAce7lGuebZcag5CnjszB+u4BdqzwaQOdcYb5wsJIsqq4kxInIRpJQ=="],
"react-native-gesture-handler": ["react-native-gesture-handler@2.29.1", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-du3qmv0e3Sm7qsd9SfmHps+AggLiylcBBQ8ztz7WUtd8ZjKs5V3kekAbi9R2W9bRLSg47Ntp4GGMYZOhikQdZA=="],
@@ -1940,7 +1935,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-swipeable-item": ["react-native-swipeable-item@2.0.9", "", { "dependencies": { "@babel/preset-typescript": "^7.17.12" }, "peerDependencies": { "react-native": ">=0.64.0", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=2.8.0" } }, "sha512-NUBX5Xs8cYCU7lWj5O/NtY7kq8I9dIo3eQVtnSbfYU39RXi/n0TpRqpmaQTPM6sE5EQR/BcygD4jwDcrE5h/sQ=="],
"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-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

@@ -2720,7 +2720,7 @@ PODS:
- React
- RNFS (2.20.0):
- React-Core
- RNGestureHandler (2.28.0):
- RNGestureHandler (2.29.1):
- boost
- DoubleConversion
- fast_float
@@ -3471,7 +3471,7 @@ SPEC CHECKSUMS:
RNDeviceInfo: 36d7f232bfe7c9b5c494cb7793230424ed32c388
RNDnsLookup: db4a89381b80ec1a5153088518d2c4f8e51f2521
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
RNGestureHandler: f1dd7f92a0faa2868a919ab53bb9d66eb4ebfcf5
RNGestureHandler: e1cf8ef3f11045536eed6bd4f132b003ef5f9a5f
RNReactNativeHapticFeedback: be4f1b4bf0398c30b59b76ed92ecb0a2ff3a69c6
RNReanimated: ac06da53579693ab451941ef89f5a55afeab0dd9
RNScreens: d821082c6dd1cb397cc0c98b026eeafaa68be479

View File

@@ -46,7 +46,7 @@
"@react-navigation/bottom-tabs": "7.8.6",
"@react-navigation/material-top-tabs": "7.4.4",
"@react-navigation/native": "7.1.21",
"@react-navigation/native-stack": "7.6.4",
"@react-navigation/native-stack": "7.7.0",
"@sentry/react-native": "7.6.0",
"@shopify/flash-list": "2.2.0",
"@tamagui/config": "1.137.1",
@@ -70,10 +70,8 @@
"react-native-config": "1.5.6",
"react-native-device-info": "15.0.1",
"react-native-dns-lookup": "^1.0.6",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-flashdrag-list": "^0.2.5",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "^2.28.0",
"react-native-gesture-handler": "2.29.1",
"react-native-google-cast": "^4.9.1",
"react-native-haptic-feedback": "^2.3.3",
"react-native-linear-gradient": "^2.8.3",
@@ -85,7 +83,6 @@
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "4.18.0",
"react-native-sortables": "^1.9.3",
"react-native-swipeable-item": "^2.0.9",
"react-native-text-ticker": "^1.15.0",
"react-native-toast-message": "^2.3.3",
"react-native-track-player": "5.0.0-alpha0",

View File

@@ -232,6 +232,7 @@ export async function updatePlaylist(
name: string,
trackIds: string[],
) {
console.info('Updating playlist with name:', name, 'and track IDs:', trackIds)
return new Promise<void>((resolve, reject) => {
if (isUndefined(api)) return reject(new Error('No API client available'))

View File

@@ -35,7 +35,7 @@ export type QuickAction = {
type Props = {
children: React.ReactNode
onPress?: () => void | null
onPress?: () => Promise<void> | null
onLongPress?: () => void | null
leftAction?: SwipeAction | null // immediate action on right swipe
leftActions?: QuickAction[] | null // quick action menu on right swipe

View File

@@ -1,7 +0,0 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import Animated from 'react-native-reanimated'
import DraggableFlatList from 'react-native-draggable-flatlist'
const AnimatedDraggableFlatList = Animated.createAnimatedComponent(DraggableFlatList<BaseItemDto>)
export default AnimatedDraggableFlatList

View File

@@ -80,8 +80,8 @@ export default function ItemRow({
[navigationRef, navigation, item],
)
const onPressCallback = useCallback(() => {
if (onPress) onPress()
const onPressCallback = useCallback(async () => {
if (onPress) await onPress()
else
switch (item.Type) {
case 'Audio': {

View File

@@ -42,9 +42,8 @@ export interface TrackProps {
isNested?: boolean | undefined
invertedColors?: boolean | undefined
prependElement?: React.JSX.Element | undefined
showRemove?: boolean | undefined
onRemove?: () => void | undefined
testID?: string | undefined
editing?: boolean | undefined
}
export default function Track({
@@ -60,8 +59,7 @@ export default function Track({
isNested,
invertedColors,
prependElement,
showRemove,
onRemove,
editing,
}: TrackProps): React.JSX.Element {
const theme = useTheme()
const [artworkAreaWidth, setArtworkAreaWidth] = useState(0)
@@ -106,9 +104,9 @@ export default function Track({
)
// Memoize handlers to prevent recreation
const handlePress = useCallback(() => {
const handlePress = useCallback(async () => {
if (onPress) {
onPress()
await onPress()
} else {
loadNewQueue({
api,
@@ -140,19 +138,15 @@ export default function Track({
}, [onLongPress, track, isNested, mediaInfo?.MediaSources, offlineAudio])
const handleIconPress = useCallback(() => {
if (showRemove) {
if (onRemove) onRemove()
} else {
navigationRef.navigate('Context', {
item: track,
navigation,
streamingMediaSourceInfo: mediaInfo?.MediaSources
? mediaInfo!.MediaSources![0]
: undefined,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
})
}
}, [showRemove, onRemove, track, isNested, mediaInfo?.MediaSources, offlineAudio])
navigationRef.navigate('Context', {
item: track,
navigation,
streamingMediaSourceInfo: mediaInfo?.MediaSources
? mediaInfo!.MediaSources![0]
: undefined,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
})
}, [track, isNested, mediaInfo?.MediaSources, offlineAudio])
// Memoize text color to prevent recalculation
const textColor = useMemo(() => {
@@ -317,10 +311,7 @@ export default function Track({
<DownloadedIcon item={track} />
<FavoriteIcon item={track} />
{runtimeComponent}
<Icon
name={showRemove ? 'close' : 'dots-horizontal'}
onPress={handleIconPress}
/>
{!editing && <Icon name={'dots-horizontal'} onPress={handleIconPress} />}
</XStack>
</XStack>
</SwipeableRow>

View File

@@ -63,20 +63,23 @@ export default function Queue({
<Icon name='drag' />
</Sortable.Handle>
<Track
queue={queueRef ?? 'Recently Played'}
track={queueItem.item}
index={index ?? 0}
showArtwork
testID={`queue-item-${index}`}
onPress={() => skip(index)}
isNested
showRemove
onRemove={() => removeFromQueue(index)}
/>
<Sortable.Touchable onTap={() => skip(index)}>
<Track
queue={queueRef ?? 'Recently Played'}
track={queueItem.item}
index={index}
showArtwork
testID={`queue-item-${index}`}
isNested
/>
</Sortable.Touchable>
<Sortable.Touchable onTap={() => removeFromQueue(index)}>
<Icon name='close' />
</Sortable.Touchable>
</XStack>
),
[queueRef, navigation, useSkip, useRemoveFromQueue],
[queueRef, skip, removeFromQueue],
)
return (

View File

@@ -1,12 +1,9 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { getTokens, Separator, View, XStack, YStack } from 'tamagui'
import { AnimatedH5 } from '../../Global/helpers/text'
import { H5, XStack, YStack } from 'tamagui'
import InstantMixButton from '../../Global/components/instant-mix-button'
import Icon from '../../Global/components/icon'
import { usePlaylistContext } from '../../../providers/Playlist'
import Animated, { useAnimatedStyle, withSpring } from 'react-native-reanimated'
import { useNetworkStatus } from '../../../../src/stores/network'
import { useNetworkContext } from '../../../../src/providers/Network'
import { ActivityIndicator } from 'react-native'
@@ -20,98 +17,54 @@ import useStreamingDeviceProfile, {
} from '../../../stores/device-profile'
import ItemImage from '../../Global/components/image'
import { useApi } from '../../../stores'
import Input from '../../Global/helpers/input'
export default function PlayliistTracklistHeader(
playlist: BaseItemDto,
editing: boolean,
playlistTracks: BaseItemDto[],
canEdit: boolean | undefined,
): React.JSX.Element {
const { width } = useSafeAreaFrame()
const { setEditing, scroll } = usePlaylistContext()
const artworkSize = 200
const textSize = getTokens().size['$12'].val
const animatedArtworkStyle = useAnimatedStyle(() => {
'worklet'
return {
height: withSpring(Math.max(0, Math.min(artworkSize, artworkSize - scroll.value * 2)), {
stiffness: 100,
damping: 25,
}),
width: withSpring(Math.max(0, Math.min(artworkSize, artworkSize - scroll.value * 2)), {
stiffness: 100,
damping: 25,
}),
display: scroll.value * 3 > artworkSize ? 'none' : 'flex',
}
})
const animatedNameStyle = useAnimatedStyle(() => {
'worklet'
const clampedWidth = Math.max(
// Prevent the name from getting too small
width / 2.5,
Math.min(
// Prevent the name from getting too large
width / 1.1,
width / 2.25 + scroll.value * 2,
),
)
return {
width: withSpring(clampedWidth, {
stiffness: 100,
damping: 25,
}),
height: withSpring(Math.max(textSize, artworkSize - scroll.value), {
stiffness: 100,
damping: 25,
}),
alignContent: 'center',
justifyContent: 'center',
}
})
export default function PlaylistTracklistHeader({
canEdit,
}: {
canEdit?: boolean
}): React.JSX.Element {
const { playlist, playlistTracks, editing, setEditing, newName, setNewName } =
usePlaylistContext()
return (
<View backgroundColor={'$background'} borderRadius={'$2'}>
<XStack
justifyContent='flex-start'
alignItems='flex-start'
paddingTop={'$1'}
marginBottom={'$2'}
>
<YStack justifyContent='center' alignContent='center' padding={'$2'}>
<Animated.View style={[animatedArtworkStyle]}>
<ItemImage item={playlist} />
</Animated.View>
</YStack>
<YStack justifyContent='center' alignItems='center' paddingTop={'$1'} marginBottom={'$2'}>
<YStack justifyContent='center' alignContent='center' padding={'$2'}>
<ItemImage item={playlist} width={'$20'} height={'$20'} />
</YStack>
<Animated.View style={[animatedNameStyle, { flex: 1 }]}>
<AnimatedH5
lineBreakStrategyIOS='standard'
textAlign='center'
numberOfLines={5}
marginBottom={'$2'}
>
{playlist.Name ?? 'Untitled Playlist'}
</AnimatedH5>
{editing ? (
<Input
value={newName}
onChangeText={setNewName}
placeholder='Playlist Name'
textAlign='center'
fontSize={18}
fontWeight='bold'
clearButtonMode='while-editing'
marginHorizontal={'$4'}
/>
) : (
<H5
lineBreakStrategyIOS='standard'
textAlign='center'
numberOfLines={5}
marginBottom={'$2'}
>
{newName ?? 'Untitled Playlist'}
</H5>
)}
<PlaylistHeaderControls
editing={editing}
setEditing={setEditing}
playlist={playlist}
playlistTracks={playlistTracks}
canEdit={canEdit}
/>
</Animated.View>
</XStack>
<Separator />
</View>
{!editing && (
<PlaylistHeaderControls
editing={editing}
setEditing={setEditing}
playlist={playlist}
playlistTracks={playlistTracks ?? []}
canEdit={canEdit}
/>
)}
</YStack>
)
}

View File

@@ -1,53 +1,192 @@
import { Separator, useTheme, XStack } from 'tamagui'
import { ScrollView, Spinner, useTheme, XStack, YStack } from 'tamagui'
import Track from '../Global/components/track'
import Icon from '../Global/components/icon'
import { RefreshControl } from 'react-native'
import { PlaylistProps } from './interfaces'
import PlayliistTracklistHeader from './components/header'
import { usePlaylistContext } from '../../providers/Playlist'
import { runOnJS, useAnimatedScrollHandler } from 'react-native-reanimated'
import AnimatedDraggableFlatList from '../Global/components/animated-draggable-flat-list'
import { useNavigation } from '@react-navigation/native'
import { StackActions, useNavigation } from '@react-navigation/native'
import { RootStackParamList } from '../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
import Sortable from 'react-native-sortables'
import { useCallback, 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'
import PlaylistTracklistHeader from './components/header'
import navigationRef from '../../../navigation'
import { useLoadNewQueue } from '../../providers/Player/hooks/mutations'
import { useNetworkStatus } from '../../stores/network'
import { QueuingType } from '../../enums/queuing-type'
import { useApi } from '../../stores'
import useStreamingDeviceProfile from '../../stores/device-profile'
import { RefreshControl } from 'react-native-gesture-handler'
export default function Playlist({
playlist,
navigation,
canEdit,
}: PlaylistProps): React.JSX.Element {
const {
scroll,
playlistTracks,
isPending,
editing,
refetch,
setPlaylistTracks,
useUpdatePlaylist,
useRemoveFromPlaylist,
} = usePlaylistContext()
const trigger = useHapticFeedback()
const api = useApi()
const theme = useTheme()
const {
playlistTracks,
isPending,
refetch,
editing,
setEditing,
isUpdating,
newName,
setPlaylistTracks,
useUpdatePlaylist,
handleCancel,
} = usePlaylistContext()
const loadNewQueue = useLoadNewQueue()
const [networkStatus] = useNetworkStatus()
useLayoutEffect(() => {
navigation.setOptions({
headerRight: () =>
canEdit && (
<XStack gap={'$3'}>
{editing && (
<>
<Icon
color={'$danger'}
name='delete-sweep-outline' // otherwise use "delete-circle"
onPress={() => {
navigationRef.dispatch(
StackActions.push('DeletePlaylist', { playlist }),
)
}}
/>
<Icon
color='$neutral'
name='close-circle-outline'
onPress={handleCancel}
/>
</>
)}
{!isUpdating ? (
<Icon
name={editing ? 'floppy' : 'pencil'}
color={editing ? '$success' : '$color'}
onPress={() =>
!editing
? setEditing(true)
: useUpdatePlaylist({
playlist,
tracks: playlistTracks ?? [],
newName,
})
}
/>
) : (
<Spinner color={'$success'} />
)}
</XStack>
),
})
}, [
editing,
navigation,
canEdit,
playlist,
handleCancel,
isUpdating,
useUpdatePlaylist,
playlistTracks,
newName,
setEditing,
])
const [reducedHaptics] = useReducedHapticsSetting()
const streamingDeviceProfile = useStreamingDeviceProfile()
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
const scrollOffsetHandler = useAnimatedScrollHandler({
onBeginDrag: () => {
'worklet'
runOnJS(closeAllSwipeableRows)()
const renderItem = useCallback(
({ item: track, index }: RenderItemInfo<BaseItemDto>) => {
const handlePress = async () => {
await loadNewQueue({
track,
tracklist: playlistTracks ?? [],
api,
networkStatus,
deviceProfile: streamingDeviceProfile,
index,
queue: playlist,
queuingType: QueuingType.FromSelection,
startPlayback: true,
})
}
return (
<XStack alignItems='center' key={`${index}-${track.Id}`} flex={1}>
{editing && (
<Sortable.Handle>
<Icon name='drag' />
</Sortable.Handle>
)}
<Sortable.Touchable
style={{ flexGrow: 1 }}
onTap={handlePress}
onLongPress={() => {
if (!editing)
rootNavigation.navigate('Context', {
item: track,
navigation,
})
}}
>
<Track
navigation={navigation}
track={track}
tracklist={playlistTracks ?? []}
index={index}
queue={playlist}
showArtwork
editing={editing}
/>
</Sortable.Touchable>
{editing && (
<Sortable.Touchable
onTap={() => {
setPlaylistTracks(
(playlistTracks ?? []).filter(({ Id }) => Id !== track.Id),
)
}}
>
<Icon name='close' color={'$danger'} />
</Sortable.Touchable>
)}
</XStack>
)
},
onScroll: (event) => {
'worklet'
scroll.value = event.contentOffset.y
},
})
[
navigation,
playlist,
playlistTracks,
editing,
setPlaylistTracks,
loadNewQueue,
api,
networkStatus,
streamingDeviceProfile,
rootNavigation,
],
)
return (
<AnimatedDraggableFlatList
<ScrollView
flex={1}
refreshControl={
<RefreshControl
refreshing={isPending}
@@ -55,67 +194,23 @@ export default function Playlist({
tintColor={theme.primary.val}
/>
}
contentInsetAdjustmentBehavior='automatic'
data={playlistTracks ?? []}
dragHitSlop={{ left: -50 }} // https://github.com/computerjazz/react-native-draggable-flatlist/issues/336
keyExtractor={(item, index) => {
return `${index}-${item.Id}`
}}
ItemSeparatorComponent={() => <Separator />}
ListHeaderComponent={() =>
PlayliistTracklistHeader(playlist, editing, playlistTracks ?? [], canEdit)
}
stickyHeaderIndices={[0]}
numColumns={1}
onDragBegin={() => {
trigger('impactMedium')
}}
onDragEnd={({ data, from, to }) => {
useUpdatePlaylist.mutate(
{
playlist,
tracks: data,
},
{
onSuccess: () => {
setPlaylistTracks(data)
},
},
)
}}
refreshing={isPending}
renderItem={({ item: track, getIndex, drag }) => (
<Track
navigation={navigation}
track={track}
tracklist={playlistTracks ?? []}
index={getIndex() ?? 0}
queue={playlist}
showArtwork
onLongPress={() => {
if (editing) {
drag()
} else {
rootNavigation.navigate('Context', {
item: track,
navigation,
})
}
}}
showRemove={editing}
onRemove={() =>
useRemoveFromPlaylist.mutate({ playlist, track, index: getIndex()! })
}
prependElement={
editing && canEdit ? <Icon name='drag' onPress={drag} /> : undefined
}
isNested={editing}
/>
)}
style={{
marginHorizontal: 2,
}}
onScroll={scrollOffsetHandler}
/>
>
<PlaylistTracklistHeader />
<Sortable.Grid
data={playlistTracks ?? []}
keyExtractor={(item) => {
return `${item.Id}`
}}
autoScrollEnabled
columns={1}
customHandle
overDrag='vertical'
sortEnabled={canEdit && editing}
onDragEnd={({ data }) => setPlaylistTracks(data)}
renderItem={renderItem}
hapticsEnabled={!reducedHaptics}
/>
</ScrollView>
)
}

View File

@@ -78,6 +78,7 @@ export default function Search({
value={searchString}
marginHorizontal={'$2'}
testID='search-input'
clearButtonMode='while-editing'
/>
{!isEmpty(items) && (

View File

@@ -1,8 +1,7 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { useMutation, UseMutationResult } from '@tanstack/react-query'
import { UseMutateFunction, useMutation } from '@tanstack/react-query'
import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
import { removeFromPlaylist, updatePlaylist } from '../../api/mutations/playlists'
import { RemoveFromPlaylistMutation } from '../../components/Playlist/interfaces'
import { updatePlaylist } from '../../api/mutations/playlists'
import { SharedValue, useSharedValue } from 'react-native-reanimated'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
import { useApi } from '../../stores'
@@ -15,18 +14,21 @@ interface PlaylistContext {
isPending: boolean
editing: boolean
setEditing: (editing: boolean) => void
newName: string
setNewName: (name: string) => void
setPlaylistTracks: (tracks: BaseItemDto[]) => void
useUpdatePlaylist: UseMutationResult<
useUpdatePlaylist: UseMutateFunction<
void,
Error,
{ playlist: BaseItemDto; tracks: BaseItemDto[] }
{
playlist: BaseItemDto
tracks: BaseItemDto[]
newName: string
},
unknown
>
useRemoveFromPlaylist: UseMutationResult<
void,
Error,
{ playlist: BaseItemDto; track: BaseItemDto; index: number }
>
scroll: SharedValue<number>
isUpdating?: boolean
handleCancel: () => void
}
const PlaylistContextInitializer = (playlist: BaseItemDto) => {
@@ -35,20 +37,28 @@ const PlaylistContextInitializer = (playlist: BaseItemDto) => {
const canEdit = playlist.CanDelete
const [editing, setEditing] = useState<boolean>(false)
const [playlistTracks, setPlaylistTracks] = useState<BaseItemDto[] | undefined>(undefined)
const [newName, setNewName] = useState<string>(playlist.Name ?? '')
const scroll = useSharedValue(0)
const [playlistTracks, setPlaylistTracks] = useState<BaseItemDto[] | undefined>(undefined)
const trigger = useHapticFeedback()
const { data: tracks, isPending, refetch, isSuccess } = usePlaylistTracks(playlist)
const useUpdatePlaylist = useMutation({
mutationFn: ({ playlist, tracks }: { playlist: BaseItemDto; tracks: BaseItemDto[] }) => {
const { mutate: useUpdatePlaylist, isPending: isUpdating } = useMutation({
mutationFn: ({
playlist,
tracks,
newName,
}: {
playlist: BaseItemDto
tracks: BaseItemDto[]
newName: string
}) => {
return updatePlaylist(
api,
playlist.Id!,
playlist.Name!,
newName,
tracks.map((track) => track.Id!),
)
},
@@ -60,31 +70,20 @@ const PlaylistContextInitializer = (playlist: BaseItemDto) => {
},
onError: () => {
trigger('notificationError')
setNewName(playlist.Name ?? '')
setPlaylistTracks(tracks ?? [])
},
})
const useRemoveFromPlaylist = useMutation({
mutationFn: ({ playlist, track, index }: RemoveFromPlaylistMutation) => {
return removeFromPlaylist(api, track, playlist)
},
onSuccess: (data, { index }) => {
trigger('notificationSuccess')
if (playlistTracks) {
setPlaylistTracks(
playlistTracks
.slice(0, index)
.concat(playlistTracks.slice(index + 1, playlistTracks.length - 1)),
)
}
},
onError: () => {
trigger('notificationError')
onSettled: () => {
setEditing(false)
},
})
const handleCancel = () => {
setEditing(false)
setNewName(playlist.Name ?? '')
setPlaylistTracks(tracks)
}
useEffect(() => {
if (!isPending && isSuccess) setPlaylistTracks(tracks)
}, [tracks, isPending, isSuccess])
@@ -100,10 +99,12 @@ const PlaylistContextInitializer = (playlist: BaseItemDto) => {
isPending,
editing,
setEditing,
newName,
setNewName,
setPlaylistTracks,
useUpdatePlaylist,
useRemoveFromPlaylist,
scroll,
handleCancel,
isUpdating,
}
}
@@ -114,44 +115,12 @@ const PlaylistContext = createContext<PlaylistContext>({
isPending: false,
editing: false,
setEditing: () => {},
newName: '',
setNewName: () => {},
setPlaylistTracks: () => {},
useUpdatePlaylist: {
mutate: () => {},
mutateAsync: async (variables) => {},
data: undefined,
error: null,
variables: undefined,
isError: false,
isIdle: true,
isPaused: false,
isPending: false,
isSuccess: false,
status: 'idle',
reset: () => {},
context: {},
failureCount: 0,
failureReason: null,
submittedAt: 0,
},
useRemoveFromPlaylist: {
mutate: () => {},
mutateAsync: async (variables) => {},
data: undefined,
error: null,
variables: undefined,
isError: false,
isIdle: true,
isPaused: false,
isPending: false,
isSuccess: false,
status: 'idle',
reset: () => {},
context: {},
failureCount: 0,
failureReason: null,
submittedAt: 0,
},
scroll: { value: 0 } as SharedValue<number>,
useUpdatePlaylist: () => {},
handleCancel: () => {},
isUpdating: false,
})
export const PlaylistProvider = ({