793 bug cant delete playlist inside jellify (#806)

* Ad ability to long press and delete a playlist

also can delete a playlist within the playlist screen itself

adds some more fun loading messages and "pull to refresh" text
This commit is contained in:
Violet Caulfield
2025-12-09 07:09:14 -06:00
committed by GitHub
parent 2194053247
commit bdf8a1eb6c
22 changed files with 174 additions and 91 deletions

View File

@@ -17,6 +17,7 @@ const useDiscoverQueries = () => {
refetchPublicPlaylists(),
refetchArtistSuggestions(),
]),
networkMode: 'online',
})
}

View File

@@ -24,6 +24,7 @@ const useHomeQueries = () => {
await Promise.allSettled([refetchFrequentArtists(), refetchRecentArtists()])
return true
},
networkMode: 'online',
})
}

View File

@@ -20,4 +20,4 @@ export const useDownloadedTrack = (itemId: string | null | undefined) =>
useDownloadedTracks([itemId])?.at(0)
export const useIsDownloaded = (itemIds: (string | null | undefined)[]) =>
useDownloadedTracks(itemIds)?.length === itemIds.length
useDownloadedTracks(itemIds)?.length === itemIds.length && itemIds.length > 0

View File

@@ -1,4 +1,4 @@
import { RefreshControl } from 'react-native'
import RefreshControl from '../Global/components/refresh-control'
import { Separator, useTheme, XStack, YStack } from 'tamagui'
import React, { RefObject, useEffect, useRef } from 'react'
import { Text } from '../Global/helpers/text'
@@ -51,8 +51,7 @@ export default function Albums({
const refreshControl = (
<RefreshControl
refreshing={albumsInfiniteQuery.isFetching && !isAlphabetSelectorPending}
onRefresh={albumsInfiniteQuery.refetch}
tintColor={theme.primary.val}
refresh={albumsInfiniteQuery.refetch}
/>
)

View File

@@ -1,7 +1,7 @@
import React, { RefObject, useEffect, useRef } from 'react'
import { Separator, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../Global/helpers/text'
import { RefreshControl } from 'react-native'
import RefreshControl from '../Global/components/refresh-control'
import ItemRow from '../Global/components/item-row'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { FlashList, FlashListRef } from '@shopify/flash-list'
@@ -142,8 +142,7 @@ export default function Artists({
refreshControl={
<RefreshControl
refreshing={artistsInfiniteQuery.isPending && !isAlphabetSelectorPending}
onRefresh={() => artistsInfiniteQuery.refetch()}
tintColor={theme.primary.val}
refresh={artistsInfiniteQuery.refetch}
/>
}
renderItem={renderItem}

View File

@@ -0,0 +1,35 @@
import navigationRef from '../../../../navigation'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { StackActions, TabActions, useNavigation } from '@react-navigation/native'
import { ListItem } from 'tamagui'
import Icon from '../../Global/components/icon'
import { Text } from '../../Global/helpers/text'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import LibraryStackParamList from '@/src/screens/Library/types'
export default function DeletePlaylistRow({
playlist,
}: {
playlist: BaseItemDto
}): React.JSX.Element {
return (
<ListItem
backgroundColor={'transparent'}
gap={'$2.5'}
justifyContent='flex-start'
onPress={() => {
navigationRef.dispatch(
StackActions.push('DeletePlaylist', {
playlist,
onDelete: navigationRef.goBack,
}),
)
}}
pressStyle={{ opacity: 0.5 }}
>
<Icon small name='delete' color='$danger' />
<Text bold>Delete Playlist</Text>
</ListItem>
)
}

View File

@@ -31,11 +31,8 @@ import { useDeleteDownloads } from '../../api/mutations/download'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
import { Platform } from 'react-native'
import { useApi } from '../../stores'
import useAddToPendingDownloads, {
useIsDownloading,
usePendingDownloads,
} from '../../stores/network/downloads'
import { networkStatusTypes } from '../Network/internetConnectionWatcher'
import useAddToPendingDownloads, { useIsDownloading } from '../../stores/network/downloads'
import DeletePlaylistRow from './components/delete-playlist-row'
type StackNavigation = Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
@@ -58,8 +55,6 @@ export default function ItemContext({
const trigger = useHapticFeedback()
const [networkStatus] = useNetworkStatus()
const isArtist = item.Type === BaseItemKind.MusicArtist
const isAlbum = item.Type === BaseItemKind.MusicAlbum
const isTrack = item.Type === BaseItemKind.Audio
@@ -95,6 +90,8 @@ export default function ItemContext({
const renderViewAlbumRow = isAlbum || (isTrack && album)
const renderDeletePlaylistRow = isPlaylist && item.CanDelete
const artistIds = !isPlaylist
? isArtist
? [item.Id]
@@ -116,6 +113,8 @@ export default function ItemContext({
<YGroup scrollable={Platform.OS === 'android'} marginBottom={'$8'}>
<FavoriteContextMenuRow item={item} />
{renderDeletePlaylistRow && <DeletePlaylistRow playlist={item} />}
{renderAddToQueueRow && <AddToQueueMenuRow tracks={itemTracks} />}
{renderAddToQueueRow && <DownloadMenuRow items={itemTracks} />}

View File

@@ -1,16 +1,14 @@
import React from 'react'
import { getToken, ScrollView, useTheme, YStack } from 'tamagui'
import { getToken, ScrollView, YStack } from 'tamagui'
import RecentlyAdded from './helpers/just-added'
import { RefreshControl } from 'react-native'
import PublicPlaylists from './helpers/public-playlists'
import SuggestedArtists from './helpers/suggested-artists'
import useDiscoverQueries from '../../api/mutations/discover'
import { useIsRestoring } from '@tanstack/react-query'
import { useRecentlyAddedAlbums } from '../../api/queries/album'
import RefreshControl from '../Global/components/refresh-control'
export default function Index(): React.JSX.Element {
const theme = useTheme()
const { mutateAsync: refreshAsync, isPending: refreshing } = useDiscoverQueries()
const isRestoring = useIsRestoring()
@@ -28,9 +26,8 @@ export default function Index(): React.JSX.Element {
removeClippedSubviews
refreshControl={
<RefreshControl
refresh={refreshAsync}
refreshing={refreshing || isRestoring || loadingInitialData}
onRefresh={refreshAsync}
tintColor={theme.primary.val}
/>
}
>

View File

@@ -0,0 +1,30 @@
import { useEffect } from 'react'
import { useLoadingCaption } from '../../../hooks/use-caption'
import { RefreshControl as RNRefreshControl } from 'react-native'
import { useTheme } from 'tamagui'
export default function RefreshControl({
refresh,
refreshing,
}: {
refresh: () => void | Promise<unknown>
refreshing: boolean
}): React.JSX.Element {
const theme = useTheme()
const { data: loadingCaption, refetch } = useLoadingCaption()
useEffect(() => {
if (!refreshing) refetch()
}, [refreshing])
return (
<RNRefreshControl
refreshing={refreshing}
onRefresh={refresh}
tintColor={theme.primary.val}
title={refreshing ? loadingCaption : 'Pull to refresh'}
titleColor={theme.primary.val}
/>
)
}

View File

@@ -1,4 +1,4 @@
import { ScrollView, RefreshControl, Platform } from 'react-native'
import { ScrollView, Platform } from 'react-native'
import { YStack, getToken, useTheme } from 'tamagui'
import RecentArtists from './helpers/recent-artists'
import RecentlyPlayed from './helpers/recently-played'
@@ -9,6 +9,8 @@ import useHomeQueries from '../../api/mutations/home'
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
import { useIsRestoring } from '@tanstack/react-query'
import { useRecentlyPlayedTracks } from '../../api/queries/recents'
import { useLoadingCaption } from '../../hooks/use-caption'
import RefreshControl from '../Global/components/refresh-control'
const COMPONENT_NAME = 'Home'
@@ -25,6 +27,8 @@ export function Home(): React.JSX.Element {
const isRestoring = useIsRestoring()
const { data: loadingCaption } = useLoadingCaption()
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
@@ -34,8 +38,7 @@ export function Home(): React.JSX.Element {
refreshControl={
<RefreshControl
refreshing={refreshing || isRestoring || loadingInitialData}
onRefresh={refresh}
tintColor={theme.primary.val}
refresh={refresh}
/>
}
>

View File

@@ -17,7 +17,7 @@ import { QueuingType } from '../../enums/queuing-type'
import { useApi } from '../../stores'
import useStreamingDeviceProfile from '../../stores/device-profile'
import { useCallback, useEffect, useLayoutEffect, useState } from 'react'
import { RefreshControl } from 'react-native'
import RefreshControl from '../Global/components/refresh-control'
import { updatePlaylist } from '../../../src/api/mutations/playlists'
import { usePlaylistTracks } from '../../../src/api/queries/playlist'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
@@ -140,7 +140,10 @@ export default function Playlist({
name='delete-sweep-outline' // otherwise use "delete-circle"
onPress={() => {
navigationRef.dispatch(
StackActions.push('DeletePlaylist', { playlist }),
StackActions.push('DeletePlaylist', {
playlist,
onDelete: navigation.goBack,
}),
)
}}
/>
@@ -292,13 +295,7 @@ export default function Playlist({
return (
<ScrollView
flex={1}
refreshControl={
<RefreshControl
refreshing={isPending}
onRefresh={refetch}
tintColor={theme.primary.val}
/>
}
refreshControl={<RefreshControl refreshing={isPending} refresh={refetch} />}
>
<PlaylistTracklistHeader
setNewName={setNewName}
@@ -334,13 +331,7 @@ export default function Playlist({
estimatedItemSize={72}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
refreshControl={
<RefreshControl
refreshing={isPending}
onRefresh={refetch}
tintColor={theme.primary.val}
/>
}
refreshControl={<RefreshControl refreshing={isPending} refresh={refetch} />}
ItemSeparatorComponent={() => <Separator />}
ListHeaderComponent={
<PlaylistTracklistHeader

View File

@@ -1,6 +1,6 @@
import React, { useCallback } from 'react'
import { RefreshControl } from 'react-native'
import { Separator, useTheme } from 'tamagui'
import RefreshControl from '../Global/components/refresh-control'
import { Separator } from 'tamagui'
import { FlashList } from '@shopify/flash-list'
import ItemRow from '../Global/components/item-row'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
@@ -34,8 +34,6 @@ export default function Playlists({
isFetchingNextPage,
canEdit,
}: PlaylistsProps): React.JSX.Element {
const theme = useTheme()
const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>()
// Memoized key extractor to prevent recreation on each render
@@ -62,16 +60,13 @@ export default function Playlists({
data={playlists}
keyExtractor={keyExtractor}
refreshControl={
<RefreshControl
refreshing={isPending || isFetchingNextPage}
onRefresh={refetch}
tintColor={theme.primary.val}
/>
<RefreshControl refreshing={isPending || isFetchingNextPage} refresh={refetch} />
}
ItemSeparatorComponent={ListSeparator}
renderItem={renderItem}
onEndReached={handleEndReached}
removeClippedSubviews
onScrollBeginDrag={closeAllSwipeableRows}
/>
)
}

View File

@@ -5,12 +5,9 @@ import { Linking } from 'react-native'
import { ScrollView, XStack, YStack } from 'tamagui'
import Icon from '../../Global/components/icon'
import usePatrons from '../../../api/queries/patrons'
import { useQuery } from '@tanstack/react-query'
import { INFO_CAPTIONS } from '../../../configs/info.config'
import { ONE_HOUR } from '../../../constants/query-client'
import { pickRandomItemFromArray } from '../../../utils/random'
import { getStoredOtaVersion } from 'react-native-nitro-ota'
import { downloadUpdate } from '../../OtaUpdates'
import { useInfoCaption } from '../../../hooks/use-caption'
function PatronsList({ patrons }: { patrons: { fullName: string }[] | undefined }) {
if (!patrons?.length) return null
@@ -31,14 +28,7 @@ function PatronsList({ patrons }: { patrons: { fullName: string }[] | undefined
export default function InfoTab() {
const patrons = usePatrons()
const { data: caption } = useQuery({
queryKey: ['Info_Caption'],
queryFn: () => `${pickRandomItemFromArray(INFO_CAPTIONS)}`,
staleTime: ONE_HOUR,
initialData: 'Live and in stereo',
refetchOnMount: 'always',
refetchOnWindowFocus: 'always',
})
const { data: caption } = useInfoCaption()
const otaVersion = getStoredOtaVersion()
const otaVersionText = otaVersion ? `OTA Version: ${otaVersion}` : ''
return (

View File

@@ -10,7 +10,7 @@ import { Text } from '../Global/helpers/text'
import AZScroller, { useAlphabetSelector } from '../Global/components/alphabetical-selector'
import { UseInfiniteQueryResult } from '@tanstack/react-query'
import { isString } from 'lodash'
import { RefreshControl } from 'react-native'
import RefreshControl from '../Global/components/refresh-control'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
@@ -146,8 +146,7 @@ export default function Tracks({
refreshControl={
<RefreshControl
refreshing={tracksInfiniteQuery.isFetching && !isAlphabetSelectorPending}
onRefresh={tracksInfiniteQuery.refetch}
tintColor={theme.primary.val}
refresh={tracksInfiniteQuery.refetch}
/>
}
onEndReached={() => {

30
src/hooks/use-caption.ts Normal file
View File

@@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query'
import { INFO_CAPTIONS } from '../configs/info.config'
import { ONE_HOUR } from '../constants/query-client'
import { pickRandomItemFromArray } from '../utils/random'
import { LOADING_CAPTIONS } from '../configs/loading.config'
enum CaptionQueryKeys {
InfoCaption,
LoadingCaption,
}
export const useInfoCaption = () =>
useQuery({
queryKey: [CaptionQueryKeys.InfoCaption],
queryFn: () => `${pickRandomItemFromArray(INFO_CAPTIONS)}`,
staleTime: ONE_HOUR,
initialData: 'Live and in stereo',
refetchOnMount: 'always',
refetchOnWindowFocus: 'always',
})
export const useLoadingCaption = () =>
useQuery({
queryKey: [CaptionQueryKeys.LoadingCaption],
queryFn: () => `${pickRandomItemFromArray(LOADING_CAPTIONS)}`,
staleTime: 0,
initialData: 'Reticulating splines',
refetchOnMount: 'always',
refetchOnWindowFocus: 'always',
})

View File

@@ -1,4 +1,4 @@
import { Spinner, View, XStack } from 'tamagui'
import { Spinner, XStack, YStack } from 'tamagui'
import Button from '../../components/Global/helpers/button'
import { Text } from '../../components/Global/helpers/text'
import { useMutation } from '@tanstack/react-query'
@@ -6,15 +6,16 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { deletePlaylist } from '../../api/mutations/playlists'
import { queryClient } from '../../constants/query-client'
import Icon from '../../components/Global/components/icon'
import { LibraryDeletePlaylistProps } from './types'
import { DeletePlaylistProps } from '../types'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
import { useApi, useJellifyLibrary } from '../../stores'
import { UserPlaylistsQueryKey } from '../../api/queries/playlist/keys'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
export default function DeletePlaylist({
navigation,
route,
}: LibraryDeletePlaylistProps): React.JSX.Element {
}: DeletePlaylistProps): React.JSX.Element {
const api = useApi()
const [library] = useJellifyLibrary()
@@ -26,8 +27,9 @@ export default function DeletePlaylist({
onSuccess: (data: void, playlist: BaseItemDto) => {
trigger('notificationSuccess')
navigation.goBack()
navigation.goBack()
navigation.goBack() // Dismiss modal
route.params.onDelete()
// Refresh favorite playlists component in library
queryClient.invalidateQueries({
@@ -39,11 +41,13 @@ export default function DeletePlaylist({
},
})
const { bottom } = useSafeAreaInsets()
return (
<View margin={'$4'}>
<Text bold textAlign='center'>{`Delete playlist ${
route.params.playlist.Name ?? 'Untitled Playlist'
}?`}</Text>
<YStack margin={'$4'} gap={'$4'} justifyContent='space-between' marginBottom={bottom}>
<Text bold textAlign='center'>
{`Delete playlist ${route.params.playlist.Name ?? 'Untitled Playlist'}?`}
</Text>
<XStack justifyContent='space-evenly' gap={'$2'}>
<Button
onPress={() => navigation.goBack()}
@@ -77,6 +81,6 @@ export default function DeletePlaylist({
)}
</Button>
</XStack>
</View>
</YStack>
)
}

View File

@@ -86,16 +86,6 @@ export default function LibraryScreen({ route, navigation }: LibraryTabProps): R
title: 'Add Playlist',
}}
/>
<LibraryStack.Screen
name='DeletePlaylist'
component={DeletePlaylist}
options={{
title: 'Delete Playlist',
headerShown: false,
sheetGrabberVisible: true,
}}
/>
</LibraryStack.Group>
</LibraryStack.Navigator>
)

View File

@@ -4,7 +4,7 @@ import { BaseStackParamList } from '../types'
import { NavigatorScreenParams } from '@react-navigation/native'
type LibraryStackParamList = BaseStackParamList & {
LibraryScreen: NavigatorScreenParams<BaseStackParamList>
LibraryScreen: NavigatorScreenParams<BaseStackParamList> | undefined
AddPlaylist: undefined
DeletePlaylist: {
@@ -14,7 +14,7 @@ type LibraryStackParamList = BaseStackParamList & {
export default LibraryStackParamList
export type LibraryScreenProps = NativeStackScreenProps<LibraryScreenParamList, 'LibraryScreen'>
export type LibraryScreenProps = NativeStackScreenProps<LibraryStackParamList, 'LibraryScreen'>
export type LibraryArtistProps = NativeStackScreenProps<LibraryStackParamList, 'Artist'>
export type LibraryAlbumProps = NativeStackScreenProps<LibraryStackParamList, 'Album'>
@@ -23,7 +23,3 @@ export type LibraryDeletePlaylistProps = NativeStackScreenProps<
LibraryStackParamList,
'DeletePlaylist'
>
type LibraryScreenParamList = {
LibraryScreen: NavigatorScreenParams<LibraryStackParamList>
}

View File

@@ -4,7 +4,7 @@ import LibraryStackParamList from '../Library/types'
type TabParamList = {
HomeTab: undefined
LibraryTab: NavigatorScreenParams<LibraryStackParamList>
LibraryTab: undefined | NavigatorScreenParams<LibraryStackParamList>
SearchTab: undefined
DiscoverTab: undefined
SettingsTab: undefined

View File

@@ -13,6 +13,7 @@ import { Text } from '../components/Global/helpers/text'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import AudioSpecsSheet from './Stats'
import { useApi, useJellifyLibrary } from '../stores'
import DeletePlaylist from './Library/delete-playlist'
const RootStack = createNativeStackNavigator<RootStackParamList>()
@@ -82,6 +83,18 @@ export default function Root(): React.JSX.Element {
sheetGrabberVisible: true,
})}
/>
<RootStack.Screen
name='DeletePlaylist'
component={DeletePlaylist}
options={{
title: 'Delete Playlist',
presentation: 'formSheet',
headerShown: false,
sheetGrabberVisible: true,
sheetAllowedDetents: 'fitToContents',
}}
/>
</RootStack.Navigator>
)
}

View File

@@ -72,6 +72,11 @@ export type RootStackParamList = {
streamingMediaSourceInfo?: MediaSourceInfo
downloadedMediaSourceInfo?: MediaSourceInfo
}
DeletePlaylist: {
playlist: BaseItemDto
onDelete: () => void
}
}
export type LoginProps = NativeStackNavigationProp<RootStackParamList, 'Login'>
@@ -81,6 +86,8 @@ export type ContextProps = NativeStackScreenProps<RootStackParamList, 'Context'>
export type AddToPlaylistProps = NativeStackScreenProps<RootStackParamList, 'AddToPlaylist'>
export type AudioSpecsProps = NativeStackScreenProps<RootStackParamList, 'AudioSpecs'>
export type DeletePlaylistProps = NativeStackScreenProps<RootStackParamList, 'DeletePlaylist'>
export type GenresProps = {
genres: InfiniteData<BaseItemDto[], unknown> | undefined
fetchNextPage: (options?: FetchNextPageOptions | undefined) => void

View File

@@ -69,7 +69,11 @@ export const useIsDownloading = (items: BaseItemDto[]) => {
const itemIds = items.map((item) => item.Id)
return itemIds.filter((id) => downloadQueue.has(id)).length === items.length
return (
itemIds.length !== 0 &&
downloadQueue.values.length !== 0 &&
itemIds.filter((id) => downloadQueue.has(id)).length === items.length
)
}
export const useAddToCompletedDownloads = () => {