mirror of
https://github.com/anultravioletaurora/Jellify.git
synced 2025-12-21 05:20:06 -06:00
Implement track selection feature across various components and screens
This commit is contained in:
@@ -9,13 +9,13 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import InstantMixButton from '../Global/components/instant-mix-button'
|
||||
import ItemImage from '../Global/components/image'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useSafeAreaFrame } from 'react-native-safe-area-context'
|
||||
import React, { useCallback, useMemo, useEffect } from 'react'
|
||||
import { useSafeAreaFrame, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import Icon from '../Global/components/icon'
|
||||
import { useNetworkStatus } from '../../stores/network'
|
||||
import { useLoadNewQueue } from '../../providers/Player/hooks/mutations'
|
||||
import { QueuingType } from '../../enums/queuing-type'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { StackActions, useNavigation } from '@react-navigation/native'
|
||||
import HomeStackParamList from '../../screens/Home/types'
|
||||
import LibraryStackParamList from '../../screens/Library/types'
|
||||
import DiscoverStackParamList from '../../screens/Discover/types'
|
||||
@@ -27,6 +27,8 @@ import { QueryKeys } from '../../enums/query-keys'
|
||||
import { fetchAlbumDiscs } from '../../api/queries/item'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import useAddToPendingDownloads, { usePendingDownloads } from '../../stores/network/downloads'
|
||||
import useTrackSelectionStore from '../../stores/selection/tracks'
|
||||
import SelectionActionBar from '../Global/components/selection-action-bar'
|
||||
|
||||
/**
|
||||
* The screen for an Album's track list
|
||||
@@ -38,6 +40,57 @@ import useAddToPendingDownloads, { usePendingDownloads } from '../../stores/netw
|
||||
*/
|
||||
export function Album({ album }: { album: BaseItemDto }): React.JSX.Element {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>()
|
||||
const { bottom } = useSafeAreaInsets()
|
||||
|
||||
const selectionKey = `album-${album.Id}`
|
||||
const isSelecting = useTrackSelectionStore((state) => state.isSelecting)
|
||||
const activeContext = useTrackSelectionStore((state) => state.activeContext)
|
||||
const selection = useTrackSelectionStore((state) => state.selection)
|
||||
const toggleTrackSelection = useTrackSelectionStore((state) => state.toggleTrack)
|
||||
const beginSelection = useTrackSelectionStore((state) => state.beginSelection)
|
||||
const endSelection = useTrackSelectionStore((state) => state.endSelection)
|
||||
const clearSelection = useTrackSelectionStore((state) => state.clearSelection)
|
||||
|
||||
const selectionActive = isSelecting && activeContext === selectionKey
|
||||
const selectedTracks = useMemo(
|
||||
() => (selectionActive ? Object.values(selection) : []),
|
||||
[selectionActive, selection],
|
||||
)
|
||||
const selectedCount = selectedTracks.length
|
||||
|
||||
const listPaddingBottom = useMemo(() => {
|
||||
if (selectionActive && selectedCount > 0) return bottom + 96
|
||||
return bottom + 32
|
||||
}, [bottom, selectedCount, selectionActive])
|
||||
|
||||
const handleToggleTrackSelection = useCallback(
|
||||
(track: BaseItemDto) => {
|
||||
if (!selectionActive) beginSelection(selectionKey)
|
||||
toggleTrackSelection(track)
|
||||
},
|
||||
[beginSelection, selectionActive, selectionKey, toggleTrackSelection],
|
||||
)
|
||||
|
||||
const handleAddToPlaylist = useCallback(() => {
|
||||
if (!selectedCount) return
|
||||
navigation.dispatch(
|
||||
StackActions.push('AddToPlaylist', { tracks: selectedTracks, source: album }),
|
||||
)
|
||||
}, [navigation, selectedCount, selectedTracks, album])
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
endSelection()
|
||||
clearSelection()
|
||||
}, [endSelection, clearSelection])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (selectionActive) {
|
||||
endSelection()
|
||||
clearSelection()
|
||||
}
|
||||
}
|
||||
}, [selectionActive, endSelection, clearSelection])
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -62,52 +115,70 @@ export function Album({ album }: { album: BaseItemDto }): React.JSX.Element {
|
||||
const albumTrackList = discs?.flatMap((disc) => disc.data)
|
||||
|
||||
return (
|
||||
<SectionList
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
sections={sections}
|
||||
keyExtractor={(item, index) => item.Id! + index}
|
||||
ItemSeparatorComponent={Separator}
|
||||
renderSectionHeader={({ section }) => {
|
||||
return !isPending && hasMultipleSections ? (
|
||||
<XStack
|
||||
width='100%'
|
||||
justifyContent={hasMultipleSections ? 'space-between' : 'flex-end'}
|
||||
alignItems='center'
|
||||
backgroundColor={'$background'}
|
||||
paddingHorizontal={'$2'}
|
||||
>
|
||||
<Text padding={'$2'} bold>{`Disc ${section.title}`}</Text>
|
||||
<Icon
|
||||
name={pendingDownloads.length ? 'progress-download' : 'download'}
|
||||
small
|
||||
onPress={() => {
|
||||
if (pendingDownloads.length) {
|
||||
return
|
||||
}
|
||||
downloadAlbum(section.data)
|
||||
}}
|
||||
/>
|
||||
</XStack>
|
||||
) : null
|
||||
}}
|
||||
ListHeaderComponent={() => <AlbumTrackListHeader album={album} />}
|
||||
renderItem={({ item: track, index }) => (
|
||||
<Track
|
||||
navigation={navigation}
|
||||
track={track}
|
||||
tracklist={albumTrackList}
|
||||
index={albumTrackList?.indexOf(track) ?? index}
|
||||
queue={album}
|
||||
<YStack flex={1} position='relative'>
|
||||
<SectionList
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{ paddingBottom: listPaddingBottom }}
|
||||
sections={sections}
|
||||
keyExtractor={(item, index) => item.Id! + index}
|
||||
ItemSeparatorComponent={Separator}
|
||||
renderSectionHeader={({ section }) => {
|
||||
return !isPending && hasMultipleSections ? (
|
||||
<XStack
|
||||
width='100%'
|
||||
justifyContent={hasMultipleSections ? 'space-between' : 'flex-end'}
|
||||
alignItems='center'
|
||||
backgroundColor={'$background'}
|
||||
paddingHorizontal={'$2'}
|
||||
>
|
||||
<Text padding={'$2'} bold>{`Disc ${section.title}`}</Text>
|
||||
<Icon
|
||||
name={pendingDownloads.length ? 'progress-download' : 'download'}
|
||||
small
|
||||
onPress={() => {
|
||||
if (pendingDownloads.length) {
|
||||
return
|
||||
}
|
||||
downloadAlbum(section.data)
|
||||
}}
|
||||
/>
|
||||
</XStack>
|
||||
) : null
|
||||
}}
|
||||
ListHeaderComponent={() => (
|
||||
<AlbumTrackListHeader album={album} selectionKey={selectionKey} />
|
||||
)}
|
||||
renderItem={({ item: track, index }) => (
|
||||
<Track
|
||||
navigation={navigation}
|
||||
track={track}
|
||||
tracklist={albumTrackList}
|
||||
index={albumTrackList?.indexOf(track) ?? index}
|
||||
queue={album}
|
||||
selectionEnabled={selectionActive}
|
||||
selected={Boolean(selection[track.Id!])}
|
||||
onToggleSelection={() => handleToggleTrackSelection(track)}
|
||||
onLongPress={() => handleToggleTrackSelection(track)}
|
||||
/>
|
||||
)}
|
||||
ListFooterComponent={() => <AlbumTrackListFooter album={album} />}
|
||||
ListEmptyComponent={() => (
|
||||
<YStack flex={1} alignContent='center'>
|
||||
{isPending ? <Spinner color={'$primary'} /> : <Text>No tracks found</Text>}
|
||||
</YStack>
|
||||
)}
|
||||
onScrollBeginDrag={closeAllSwipeableRows}
|
||||
/>
|
||||
|
||||
{selectionActive && selectedCount > 0 && (
|
||||
<SelectionActionBar
|
||||
selectedCount={selectedCount}
|
||||
onAddToPlaylist={handleAddToPlaylist}
|
||||
onClear={handleClearSelection}
|
||||
bottomInset={bottom}
|
||||
/>
|
||||
)}
|
||||
ListFooterComponent={() => <AlbumTrackListFooter album={album} />}
|
||||
ListEmptyComponent={() => (
|
||||
<YStack flex={1} alignContent='center'>
|
||||
{isPending ? <Spinner color={'$primary'} /> : <Text>No tracks found</Text>}
|
||||
</YStack>
|
||||
)}
|
||||
onScrollBeginDrag={closeAllSwipeableRows}
|
||||
/>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -118,7 +189,13 @@ export function Album({ album }: { album: BaseItemDto }): React.JSX.Element {
|
||||
* @param playAlbum The function to call to play the album
|
||||
* @returns A React component
|
||||
*/
|
||||
function AlbumTrackListHeader({ album }: { album: BaseItemDto }): React.JSX.Element {
|
||||
function AlbumTrackListHeader({
|
||||
album,
|
||||
selectionKey,
|
||||
}: {
|
||||
album: BaseItemDto
|
||||
selectionKey: string
|
||||
}): React.JSX.Element {
|
||||
const api = useApi()
|
||||
|
||||
const { width } = useSafeAreaFrame()
|
||||
@@ -127,6 +204,9 @@ function AlbumTrackListHeader({ album }: { album: BaseItemDto }): React.JSX.Elem
|
||||
const streamingDeviceProfile = useStreamingDeviceProfile()
|
||||
|
||||
const loadNewQueue = useLoadNewQueue()
|
||||
const { isSelecting, activeContext, beginSelection, endSelection, clearSelection } =
|
||||
useTrackSelectionStore()
|
||||
const isSelectionActive = isSelecting && activeContext === selectionKey
|
||||
|
||||
const { data: discs, isPending } = useQuery({
|
||||
queryKey: [QueryKeys.ItemTracks, album.Id],
|
||||
@@ -205,6 +285,19 @@ function AlbumTrackListHeader({ album }: { album: BaseItemDto }): React.JSX.Elem
|
||||
<Icon name='play' onPress={() => playAlbum(false)} small />
|
||||
|
||||
<Icon name='shuffle' onPress={() => playAlbum(true)} small />
|
||||
|
||||
<Icon
|
||||
name={isSelectionActive ? 'close-circle-outline' : 'checkbox-outline'}
|
||||
small
|
||||
onPress={() => {
|
||||
if (isSelectionActive) {
|
||||
endSelection()
|
||||
clearSelection()
|
||||
} else {
|
||||
beginSelection(selectionKey)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</XStack>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { MaterialTopTabBarProps } from '@react-navigation/material-top-tabs'
|
||||
import React from 'react'
|
||||
import { Square, XStack, YStack } from 'tamagui'
|
||||
import React, { useEffect } from 'react'
|
||||
import { XStack, YStack } from 'tamagui'
|
||||
import Icon from '../Global/components/icon'
|
||||
import { Text } from '../Global/helpers/text'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import useHapticFeedback from '../../hooks/use-haptic-feedback'
|
||||
import { ItemSortBy, SortOrder } from '@jellyfin/sdk/lib/generated-client'
|
||||
import { MaterialTopTabBar } from '@react-navigation/material-top-tabs'
|
||||
import useTrackSelectionStore from '../../stores/selection/tracks'
|
||||
import { useArtistContext } from '../../providers/Artist'
|
||||
|
||||
interface ArtistTabBarProps extends MaterialTopTabBarProps {
|
||||
isFavorites: boolean
|
||||
@@ -27,13 +28,25 @@ export default function ArtistTabBar({
|
||||
...props
|
||||
}: ArtistTabBarProps) {
|
||||
const trigger = useHapticFeedback()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { artist } = useArtistContext()
|
||||
const selectionKey = `artist-${artist.Id}`
|
||||
const { isSelecting, activeContext, beginSelection, endSelection, clearSelection } =
|
||||
useTrackSelectionStore()
|
||||
const isSelectionActive = isSelecting && activeContext === selectionKey
|
||||
const isOnTracksTab = props.state.routes[props.state.index].name === 'Tracks'
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOnTracksTab && isSelectionActive) {
|
||||
endSelection()
|
||||
clearSelection()
|
||||
}
|
||||
}, [isOnTracksTab, isSelectionActive, endSelection, clearSelection])
|
||||
|
||||
return (
|
||||
<YStack>
|
||||
<MaterialTopTabBar {...props} />
|
||||
|
||||
{props.state.routes[props.state.index].name === 'Tracks' && (
|
||||
{isOnTracksTab && (
|
||||
<XStack
|
||||
borderColor={'$borderColor'}
|
||||
alignContent={'flex-start'}
|
||||
@@ -87,6 +100,28 @@ export default function ArtistTabBar({
|
||||
{sortBy === ItemSortBy.DateCreated ? 'Date Added' : 'A-Z'}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
<XStack
|
||||
onPress={() => {
|
||||
trigger('impactLight')
|
||||
if (isSelectionActive) {
|
||||
endSelection()
|
||||
clearSelection()
|
||||
} else {
|
||||
beginSelection(selectionKey)
|
||||
}
|
||||
}}
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
>
|
||||
<Icon
|
||||
name={isSelectionActive ? 'close-circle-outline' : 'checkbox-outline'}
|
||||
color={isSelectionActive ? '$primary' : '$borderColor'}
|
||||
/>
|
||||
<Text color={isSelectionActive ? '$primary' : '$borderColor'}>
|
||||
{isSelectionActive ? 'Cancel' : 'Select'}
|
||||
</Text>
|
||||
</XStack>
|
||||
</XStack>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function ArtistTracksTab({
|
||||
queue={'Artist Tracks'}
|
||||
showAlphabeticalSelector={false}
|
||||
trackPageParams={trackPageParams}
|
||||
selectionContext={`artist-${artist.Id}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
52
src/components/Global/components/selection-action-bar.tsx
Normal file
52
src/components/Global/components/selection-action-bar.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { Button, XStack, YStack } from 'tamagui'
|
||||
import Icon from './icon'
|
||||
import { Text } from '../helpers/text'
|
||||
|
||||
export default function SelectionActionBar({
|
||||
selectedCount,
|
||||
onAddToPlaylist,
|
||||
onClear,
|
||||
bottomInset,
|
||||
}: {
|
||||
selectedCount: number
|
||||
onAddToPlaylist: () => void
|
||||
onClear: () => void
|
||||
bottomInset: number
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<XStack
|
||||
paddingHorizontal={'$4'}
|
||||
paddingVertical={'$3'}
|
||||
borderTopColor={'$borderColor'}
|
||||
borderTopWidth={1}
|
||||
backgroundColor={'$background'}
|
||||
gap={'$3'}
|
||||
alignItems='center'
|
||||
position='absolute'
|
||||
bottom={bottomInset + 8}
|
||||
left={0}
|
||||
right={0}
|
||||
elevation={4}
|
||||
>
|
||||
<YStack flex={1}>
|
||||
<Text bold>{`${selectedCount} selected`}</Text>
|
||||
<Text color={'$borderColor'}>Add selected tracks to a playlist</Text>
|
||||
</YStack>
|
||||
|
||||
<XStack gap={'$2'}>
|
||||
<Button variant='outlined' onPress={onClear}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
backgroundColor={'$primary'}
|
||||
color={'$background'}
|
||||
onPress={onAddToPlaylist}
|
||||
icon={<Icon name='playlist-plus' color={'$background'} />}
|
||||
>
|
||||
Add to Playlist
|
||||
</Button>
|
||||
</XStack>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -43,6 +43,9 @@ export interface TrackProps {
|
||||
invertedColors?: boolean | undefined
|
||||
testID?: string | undefined
|
||||
editing?: boolean | undefined
|
||||
selectionEnabled?: boolean | undefined
|
||||
selected?: boolean | undefined
|
||||
onToggleSelection?: (() => void) | undefined
|
||||
}
|
||||
|
||||
export default function Track({
|
||||
@@ -58,6 +61,9 @@ export default function Track({
|
||||
isNested,
|
||||
invertedColors,
|
||||
editing,
|
||||
selectionEnabled,
|
||||
selected,
|
||||
onToggleSelection,
|
||||
}: TrackProps): React.JSX.Element {
|
||||
const theme = useTheme()
|
||||
const [artworkAreaWidth, setArtworkAreaWidth] = useState(0)
|
||||
@@ -94,6 +100,11 @@ export default function Track({
|
||||
|
||||
// Memoize handlers to prevent recreation
|
||||
const handlePress = async () => {
|
||||
if (selectionEnabled && onToggleSelection) {
|
||||
onToggleSelection()
|
||||
return
|
||||
}
|
||||
|
||||
if (onPress) {
|
||||
await onPress()
|
||||
} else {
|
||||
@@ -112,6 +123,11 @@ export default function Track({
|
||||
}
|
||||
|
||||
const handleLongPress = () => {
|
||||
if (selectionEnabled && onToggleSelection) {
|
||||
onToggleSelection()
|
||||
return
|
||||
}
|
||||
|
||||
if (onLongPress) {
|
||||
onLongPress()
|
||||
} else {
|
||||
@@ -206,7 +222,7 @@ export default function Track({
|
||||
return (
|
||||
<Theme name={invertedColors ? 'inverted_purple' : undefined}>
|
||||
<SwipeableRow
|
||||
disabled={isNested === true}
|
||||
disabled={selectionEnabled === true || isNested === true}
|
||||
{...swipeConfig}
|
||||
onLongPress={handleLongPress}
|
||||
onPress={handlePress}
|
||||
@@ -223,6 +239,14 @@ export default function Track({
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
backgroundColor={'$background'}
|
||||
>
|
||||
{selectionEnabled && (
|
||||
<Icon
|
||||
name={selected ? 'check-circle-outline' : 'circle-outline'}
|
||||
color={selected ? '$primary' : '$borderColor'}
|
||||
onPress={onToggleSelection}
|
||||
/>
|
||||
)}
|
||||
|
||||
<XStack
|
||||
flex={0}
|
||||
alignContent='center'
|
||||
@@ -281,7 +305,9 @@ export default function Track({
|
||||
<DownloadedIcon item={track} />
|
||||
<FavoriteIcon item={track} />
|
||||
{runtimeComponent}
|
||||
{!editing && <Icon name={'dots-horizontal'} onPress={handleIconPress} />}
|
||||
{!editing && !selectionEnabled && (
|
||||
<Icon name={'dots-horizontal'} onPress={handleIconPress} />
|
||||
)}
|
||||
</XStack>
|
||||
</XStack>
|
||||
</SwipeableRow>
|
||||
|
||||
@@ -21,6 +21,7 @@ function TracksTab(): React.JSX.Element {
|
||||
queue={isFavorites ? 'Favorite Tracks' : isDownloaded ? 'Downloaded Tracks' : 'Library'}
|
||||
showAlphabeticalSelector={true}
|
||||
trackPageParams={trackPageParams}
|
||||
selectionContext={'library-tracks'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MaterialTopTabBar, MaterialTopTabBarProps } from '@react-navigation/material-top-tabs'
|
||||
import React from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Square, XStack, YStack } from 'tamagui'
|
||||
import Icon from '../Global/components/icon'
|
||||
import { Text } from '../Global/helpers/text'
|
||||
@@ -7,13 +7,25 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import useHapticFeedback from '../../hooks/use-haptic-feedback'
|
||||
import StatusBar from '../Global/helpers/status-bar'
|
||||
import useLibraryStore from '../../stores/library'
|
||||
import useTrackSelectionStore from '../../stores/selection/tracks'
|
||||
|
||||
function LibraryTabBar(props: MaterialTopTabBarProps) {
|
||||
const { isFavorites, setIsFavorites, isDownloaded, setIsDownloaded } = useLibraryStore()
|
||||
const { isSelecting, activeContext, beginSelection, endSelection, clearSelection } =
|
||||
useTrackSelectionStore()
|
||||
|
||||
const trigger = useHapticFeedback()
|
||||
|
||||
const insets = useSafeAreaInsets()
|
||||
const isOnTracksTab = props.state.routes[props.state.index].name === 'Tracks'
|
||||
const isTrackSelectionActive = isSelecting && activeContext === 'library-tracks'
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOnTracksTab && isTrackSelectionActive) {
|
||||
endSelection()
|
||||
clearSelection()
|
||||
}
|
||||
}, [isOnTracksTab, isTrackSelectionActive, endSelection, clearSelection])
|
||||
|
||||
return (
|
||||
<YStack>
|
||||
@@ -68,26 +80,57 @@ function LibraryTabBar(props: MaterialTopTabBarProps) {
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
{props.state.routes[props.state.index].name === 'Tracks' && (
|
||||
<XStack
|
||||
onPress={() => {
|
||||
trigger('impactLight')
|
||||
setIsDownloaded(!isDownloaded)
|
||||
}}
|
||||
pressStyle={{ opacity: 0.6 }}
|
||||
animation='quick'
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
>
|
||||
<Icon
|
||||
name={isDownloaded ? 'download' : 'download-outline'}
|
||||
color={isDownloaded ? '$success' : '$borderColor'}
|
||||
/>
|
||||
{isOnTracksTab && (
|
||||
<>
|
||||
<XStack
|
||||
onPress={() => {
|
||||
trigger('impactLight')
|
||||
setIsDownloaded(!isDownloaded)
|
||||
}}
|
||||
pressStyle={{ opacity: 0.6 }}
|
||||
animation='quick'
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
>
|
||||
<Icon
|
||||
name={isDownloaded ? 'download' : 'download-outline'}
|
||||
color={isDownloaded ? '$success' : '$borderColor'}
|
||||
/>
|
||||
|
||||
<Text color={isDownloaded ? '$success' : '$borderColor'}>
|
||||
{isDownloaded ? 'Downloaded' : 'All'}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text color={isDownloaded ? '$success' : '$borderColor'}>
|
||||
{isDownloaded ? 'Downloaded' : 'All'}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
<XStack
|
||||
onPress={() => {
|
||||
trigger('impactLight')
|
||||
if (isTrackSelectionActive) {
|
||||
endSelection()
|
||||
clearSelection()
|
||||
} else {
|
||||
beginSelection('library-tracks')
|
||||
}
|
||||
}}
|
||||
pressStyle={{ opacity: 0.6 }}
|
||||
animation='quick'
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
isTrackSelectionActive
|
||||
? 'close-circle-outline'
|
||||
: 'checkbox-outline'
|
||||
}
|
||||
color={isTrackSelectionActive ? '$primary' : '$borderColor'}
|
||||
/>
|
||||
|
||||
<Text color={isTrackSelectionActive ? '$primary' : '$borderColor'}>
|
||||
{isTrackSelectionActive ? 'Cancel' : 'Select'}
|
||||
</Text>
|
||||
</XStack>
|
||||
</>
|
||||
)}
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { RefObject, useRef, useEffect } from 'react'
|
||||
import React, { RefObject, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||
import Track from '../Global/components/track'
|
||||
import { Separator, useTheme, XStack, YStack } from 'tamagui'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
@@ -13,6 +13,10 @@ import { isString } from 'lodash'
|
||||
import { RefreshControl } from 'react-native'
|
||||
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
|
||||
import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { StackActions } from '@react-navigation/native'
|
||||
import useTrackSelectionStore from '../../stores/selection/tracks'
|
||||
import SelectionActionBar from '../Global/components/selection-action-bar'
|
||||
|
||||
interface TracksProps {
|
||||
tracksInfiniteQuery: UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>
|
||||
@@ -20,6 +24,7 @@ interface TracksProps {
|
||||
showAlphabeticalSelector?: boolean
|
||||
navigation: Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
|
||||
queue: Queue
|
||||
selectionContext?: string
|
||||
}
|
||||
|
||||
export default function Tracks({
|
||||
@@ -28,13 +33,13 @@ export default function Tracks({
|
||||
showAlphabeticalSelector,
|
||||
navigation,
|
||||
queue,
|
||||
selectionContext,
|
||||
}: TracksProps): React.JSX.Element {
|
||||
const theme = useTheme()
|
||||
const { bottom } = useSafeAreaInsets()
|
||||
|
||||
const sectionListRef = useRef<FlashListRef<string | number | BaseItemDto>>(null)
|
||||
|
||||
const pendingLetterRef = useRef<string | null>(null)
|
||||
|
||||
const stickyHeaderIndicies = (() => {
|
||||
if (!showAlphabeticalSelector || !tracksInfiniteQuery.data) return []
|
||||
|
||||
@@ -44,11 +49,62 @@ export default function Tracks({
|
||||
})()
|
||||
|
||||
const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } =
|
||||
useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase()))
|
||||
useAlphabetSelector(() => {})
|
||||
|
||||
const tracksToDisplay =
|
||||
tracksInfiniteQuery.data?.filter((track) => typeof track === 'object') ?? []
|
||||
|
||||
const selectionContextKey = selectionContext ?? 'tracks'
|
||||
const isSelecting = useTrackSelectionStore((state) => state.isSelecting)
|
||||
const activeContext = useTrackSelectionStore((state) => state.activeContext)
|
||||
const selection = useTrackSelectionStore((state) => state.selection)
|
||||
const toggleTrackSelection = useTrackSelectionStore((state) => state.toggleTrack)
|
||||
const beginSelection = useTrackSelectionStore((state) => state.beginSelection)
|
||||
const endSelection = useTrackSelectionStore((state) => state.endSelection)
|
||||
|
||||
const selectionActive = isSelecting && activeContext === selectionContextKey
|
||||
const selectedTracks = useMemo(
|
||||
() => (selectionActive ? Object.values(selection) : []),
|
||||
[selectionActive, selection],
|
||||
)
|
||||
const selectedCount = selectedTracks.length
|
||||
|
||||
const contentPaddingBottom = useMemo(() => {
|
||||
if (selectionActive && selectedCount > 0) return bottom + 96
|
||||
return bottom + 32
|
||||
}, [bottom, selectedCount, selectionActive])
|
||||
|
||||
const toggleSelectionForTrack = useCallback(
|
||||
(track: BaseItemDto) => {
|
||||
if (!selectionActive) beginSelection(selectionContextKey)
|
||||
toggleTrackSelection(track)
|
||||
},
|
||||
[beginSelection, selectionActive, selectionContextKey, toggleTrackSelection],
|
||||
)
|
||||
|
||||
const handleAddToPlaylist = useCallback(() => {
|
||||
if (!selectedCount) return
|
||||
navigation.dispatch(StackActions.push('AddToPlaylist', { tracks: selectedTracks }))
|
||||
}, [navigation, selectedCount, selectedTracks])
|
||||
|
||||
const handleLetterSelect = useCallback(
|
||||
(letter: string) => {
|
||||
if (!trackPageParams) return Promise.resolve()
|
||||
return alphabetSelectorMutate({
|
||||
letter,
|
||||
pageParams: trackPageParams,
|
||||
infiniteQuery: tracksInfiniteQuery,
|
||||
})
|
||||
},
|
||||
[alphabetSelectorMutate, trackPageParams, tracksInfiniteQuery],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (selectionActive) endSelection()
|
||||
}
|
||||
}, [selectionActive, endSelection])
|
||||
|
||||
const keyExtractor = (item: string | number | BaseItemDto) =>
|
||||
typeof item === 'object' ? item.Id! : item.toString()
|
||||
|
||||
@@ -80,6 +136,10 @@ export default function Tracks({
|
||||
tracksToDisplay.indexOf(track) + 50,
|
||||
)}
|
||||
queue={queue}
|
||||
selectionEnabled={selectionActive}
|
||||
selected={Boolean(selection[track.Id!])}
|
||||
onToggleSelection={() => toggleSelectionForTrack(track)}
|
||||
onLongPress={() => toggleSelectionForTrack(track)}
|
||||
/>
|
||||
) : null
|
||||
|
||||
@@ -92,49 +152,10 @@ export default function Tracks({
|
||||
}) =>
|
||||
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : <Separator />
|
||||
|
||||
// Effect for handling the pending alphabet selector letter
|
||||
useEffect(() => {
|
||||
if (isString(pendingLetterRef.current) && tracksInfiniteQuery.data) {
|
||||
const upperLetters = tracksInfiniteQuery.data
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((letter) => letter.toUpperCase())
|
||||
.sort()
|
||||
|
||||
const index = upperLetters.findIndex((letter) => letter >= pendingLetterRef.current!)
|
||||
|
||||
if (index !== -1) {
|
||||
const letterToScroll = upperLetters[index]
|
||||
const scrollIndex = tracksInfiniteQuery.data.indexOf(letterToScroll)
|
||||
if (scrollIndex !== -1) {
|
||||
sectionListRef.current?.scrollToIndex({
|
||||
index: scrollIndex,
|
||||
viewPosition: 0.1,
|
||||
animated: true,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// fallback: scroll to last section
|
||||
const lastLetter = upperLetters[upperLetters.length - 1]
|
||||
const scrollIndex = tracksInfiniteQuery.data.indexOf(lastLetter)
|
||||
if (scrollIndex !== -1) {
|
||||
sectionListRef.current?.scrollToIndex({
|
||||
index: scrollIndex,
|
||||
viewPosition: 0.1,
|
||||
animated: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pendingLetterRef.current = null
|
||||
}
|
||||
}, [pendingLetterRef.current, tracksInfiniteQuery.data])
|
||||
|
||||
const handleScrollBeginDrag = () => {
|
||||
closeAllSwipeableRows()
|
||||
}
|
||||
const handleScrollBeginDrag = () => closeAllSwipeableRows()
|
||||
|
||||
return (
|
||||
<XStack flex={1}>
|
||||
<YStack flex={1} position='relative'>
|
||||
<FlashList
|
||||
ref={sectionListRef}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
@@ -143,6 +164,7 @@ export default function Tracks({
|
||||
data={tracksInfiniteQuery.data}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={{ paddingBottom: contentPaddingBottom }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={tracksInfiniteQuery.isFetching && !isAlphabetSelectorPending}
|
||||
@@ -155,31 +177,23 @@ export default function Tracks({
|
||||
}}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
stickyHeaderIndices={stickyHeaderIndicies}
|
||||
stickyHeaderConfig={{
|
||||
// The list likes to flicker without this
|
||||
useNativeDriver: false,
|
||||
}}
|
||||
ListEmptyComponent={
|
||||
<YStack flex={1} justify='center' alignItems='center'>
|
||||
<Text marginVertical='auto' color={'$borderColor'}>
|
||||
No tracks
|
||||
</Text>
|
||||
</YStack>
|
||||
ListEmptyComponent={() => <Text margin={'$6'}>No tracks found.</Text>}
|
||||
ListFooterComponent={
|
||||
showAlphabeticalSelector ? (
|
||||
<AZScroller onLetterSelect={handleLetterSelect} />
|
||||
) : undefined
|
||||
}
|
||||
removeClippedSubviews
|
||||
getItemType={(item) => (typeof item === 'string' ? 'section' : 'row')}
|
||||
/>
|
||||
|
||||
{showAlphabeticalSelector && trackPageParams && (
|
||||
<AZScroller
|
||||
onLetterSelect={(letter) =>
|
||||
alphabetSelectorMutate({
|
||||
letter,
|
||||
infiniteQuery: tracksInfiniteQuery,
|
||||
pageParams: trackPageParams,
|
||||
})
|
||||
}
|
||||
{selectionActive && selectedCount > 0 && (
|
||||
<SelectionActionBar
|
||||
selectedCount={selectedCount}
|
||||
onAddToPlaylist={handleAddToPlaylist}
|
||||
onClear={endSelection}
|
||||
bottomInset={bottom}
|
||||
/>
|
||||
)}
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function HomeTracksScreen({
|
||||
navigation={navigation}
|
||||
tracksInfiniteQuery={frequentlyPlayedTracks}
|
||||
queue={'On Repeat'}
|
||||
selectionContext={'home-most-played'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -26,6 +27,7 @@ export default function HomeTracksScreen({
|
||||
navigation={navigation}
|
||||
tracksInfiniteQuery={recentlyPlayedTracks}
|
||||
queue={'Recently Played'}
|
||||
selectionContext={'home-recently-played'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export default function TracksScreen({ route, navigation }: TracksProps): React.
|
||||
navigation={navigation}
|
||||
tracksInfiniteQuery={route.params.tracksInfiniteQuery}
|
||||
queue={'Library'}
|
||||
selectionContext={'tracks-screen'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
42
src/stores/selection/tracks.ts
Normal file
42
src/stores/selection/tracks.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { create } from 'zustand'
|
||||
|
||||
type TrackSelectionState = {
|
||||
isSelecting: boolean
|
||||
activeContext: string | null
|
||||
selection: Record<string, BaseItemDto>
|
||||
beginSelection: (context?: string | null) => void
|
||||
endSelection: () => void
|
||||
clearSelection: () => void
|
||||
toggleTrack: (track: BaseItemDto) => void
|
||||
}
|
||||
|
||||
const useTrackSelectionStore = create<TrackSelectionState>()(
|
||||
devtools((set) => ({
|
||||
isSelecting: false,
|
||||
activeContext: null,
|
||||
selection: {},
|
||||
beginSelection: (context) =>
|
||||
set((state) => ({
|
||||
isSelecting: true,
|
||||
activeContext: context ?? null,
|
||||
selection:
|
||||
state.activeContext && state.activeContext === (context ?? null)
|
||||
? state.selection
|
||||
: {},
|
||||
})),
|
||||
endSelection: () => set({ isSelecting: false, activeContext: null, selection: {} }),
|
||||
clearSelection: () => set({ selection: {} }),
|
||||
toggleTrack: (track) =>
|
||||
set((state) => {
|
||||
if (!track.Id || !state.isSelecting) return state
|
||||
const selection = { ...state.selection }
|
||||
if (selection[track.Id]) delete selection[track.Id]
|
||||
else selection[track.Id] = track
|
||||
return { selection }
|
||||
}),
|
||||
})),
|
||||
)
|
||||
|
||||
export default useTrackSelectionStore
|
||||
Reference in New Issue
Block a user