Implement track selection feature across various components and screens

This commit is contained in:
skalthoff
2025-12-07 21:48:02 -08:00
parent 7424ac080e
commit c6a2320e36
11 changed files with 451 additions and 141 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -33,6 +33,7 @@ export default function ArtistTracksTab({
queue={'Artist Tracks'}
showAlphabeticalSelector={false}
trackPageParams={trackPageParams}
selectionContext={`artist-${artist.Id}`}
/>
)
}

View 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>
)
}

View File

@@ -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>

View File

@@ -21,6 +21,7 @@ function TracksTab(): React.JSX.Element {
queue={isFavorites ? 'Favorite Tracks' : isDownloaded ? 'Downloaded Tracks' : 'Library'}
showAlphabeticalSelector={true}
trackPageParams={trackPageParams}
selectionContext={'library-tracks'}
/>
)
}

View File

@@ -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>
)}

View File

@@ -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>
)
}

View File

@@ -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'}
/>
)
}

View File

@@ -7,6 +7,7 @@ export default function TracksScreen({ route, navigation }: TracksProps): React.
navigation={navigation}
tracksInfiniteQuery={route.params.tracksInfiniteQuery}
queue={'Library'}
selectionContext={'tracks-screen'}
/>
)
}

View 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