diff --git a/src/components/Album/index.tsx b/src/components/Album/index.tsx index 2b96edc1..99aa0e1a 100644 --- a/src/components/Album/index.tsx +++ b/src/components/Album/index.tsx @@ -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>() + 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 ( - item.Id! + index} - ItemSeparatorComponent={Separator} - renderSectionHeader={({ section }) => { - return !isPending && hasMultipleSections ? ( - - {`Disc ${section.title}`} - { - if (pendingDownloads.length) { - return - } - downloadAlbum(section.data) - }} - /> - - ) : null - }} - ListHeaderComponent={() => } - renderItem={({ item: track, index }) => ( - + item.Id! + index} + ItemSeparatorComponent={Separator} + renderSectionHeader={({ section }) => { + return !isPending && hasMultipleSections ? ( + + {`Disc ${section.title}`} + { + if (pendingDownloads.length) { + return + } + downloadAlbum(section.data) + }} + /> + + ) : null + }} + ListHeaderComponent={() => ( + + )} + renderItem={({ item: track, index }) => ( + handleToggleTrackSelection(track)} + onLongPress={() => handleToggleTrackSelection(track)} + /> + )} + ListFooterComponent={() => } + ListEmptyComponent={() => ( + + {isPending ? : No tracks found} + + )} + onScrollBeginDrag={closeAllSwipeableRows} + /> + + {selectionActive && selectedCount > 0 && ( + )} - ListFooterComponent={() => } - ListEmptyComponent={() => ( - - {isPending ? : No tracks found} - - )} - onScrollBeginDrag={closeAllSwipeableRows} - /> + ) } @@ -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 playAlbum(false)} small /> playAlbum(true)} small /> + + { + if (isSelectionActive) { + endSelection() + clearSelection() + } else { + beginSelection(selectionKey) + } + }} + /> diff --git a/src/components/Artist/TabBar.tsx b/src/components/Artist/TabBar.tsx index f4612f12..e94d970d 100644 --- a/src/components/Artist/TabBar.tsx +++ b/src/components/Artist/TabBar.tsx @@ -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 ( - {props.state.routes[props.state.index].name === 'Tracks' && ( + {isOnTracksTab && ( + + { + trigger('impactLight') + if (isSelectionActive) { + endSelection() + clearSelection() + } else { + beginSelection(selectionKey) + } + }} + alignItems={'center'} + justifyContent={'center'} + > + + + {isSelectionActive ? 'Cancel' : 'Select'} + + )} diff --git a/src/components/Artist/TracksTab.tsx b/src/components/Artist/TracksTab.tsx index 18f289b7..8cf7bdd9 100644 --- a/src/components/Artist/TracksTab.tsx +++ b/src/components/Artist/TracksTab.tsx @@ -33,6 +33,7 @@ export default function ArtistTracksTab({ queue={'Artist Tracks'} showAlphabeticalSelector={false} trackPageParams={trackPageParams} + selectionContext={`artist-${artist.Id}`} /> ) } diff --git a/src/components/Global/components/selection-action-bar.tsx b/src/components/Global/components/selection-action-bar.tsx new file mode 100644 index 00000000..cb66588f --- /dev/null +++ b/src/components/Global/components/selection-action-bar.tsx @@ -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 ( + + + {`${selectedCount} selected`} + Add selected tracks to a playlist + + + + + + + + ) +} diff --git a/src/components/Global/components/track.tsx b/src/components/Global/components/track.tsx index 8ef7f4c2..902fdd2a 100644 --- a/src/components/Global/components/track.tsx +++ b/src/components/Global/components/track.tsx @@ -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 ( + {selectionEnabled && ( + + )} + {runtimeComponent} - {!editing && } + {!editing && !selectionEnabled && ( + + )} diff --git a/src/components/Library/components/tracks-tab.tsx b/src/components/Library/components/tracks-tab.tsx index 3fcee498..6dc2bf9f 100644 --- a/src/components/Library/components/tracks-tab.tsx +++ b/src/components/Library/components/tracks-tab.tsx @@ -21,6 +21,7 @@ function TracksTab(): React.JSX.Element { queue={isFavorites ? 'Favorite Tracks' : isDownloaded ? 'Downloaded Tracks' : 'Library'} showAlphabeticalSelector={true} trackPageParams={trackPageParams} + selectionContext={'library-tracks'} /> ) } diff --git a/src/components/Library/tab-bar.tsx b/src/components/Library/tab-bar.tsx index b2ef5ab4..ea3729e1 100644 --- a/src/components/Library/tab-bar.tsx +++ b/src/components/Library/tab-bar.tsx @@ -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 ( @@ -68,26 +80,57 @@ function LibraryTabBar(props: MaterialTopTabBarProps) { )} - {props.state.routes[props.state.index].name === 'Tracks' && ( - { - trigger('impactLight') - setIsDownloaded(!isDownloaded) - }} - pressStyle={{ opacity: 0.6 }} - animation='quick' - alignItems={'center'} - justifyContent={'center'} - > - + {isOnTracksTab && ( + <> + { + trigger('impactLight') + setIsDownloaded(!isDownloaded) + }} + pressStyle={{ opacity: 0.6 }} + animation='quick' + alignItems={'center'} + justifyContent={'center'} + > + - - {isDownloaded ? 'Downloaded' : 'All'} - - + + {isDownloaded ? 'Downloaded' : 'All'} + + + + { + trigger('impactLight') + if (isTrackSelectionActive) { + endSelection() + clearSelection() + } else { + beginSelection('library-tracks') + } + }} + pressStyle={{ opacity: 0.6 }} + animation='quick' + alignItems={'center'} + justifyContent={'center'} + > + + + + {isTrackSelectionActive ? 'Cancel' : 'Select'} + + + )} )} diff --git a/src/components/Tracks/component.tsx b/src/components/Tracks/component.tsx index b9c86d05..3a816bcf 100644 --- a/src/components/Tracks/component.tsx +++ b/src/components/Tracks/component.tsx @@ -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, '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>(null) - const pendingLetterRef = useRef(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 : - // 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 ( - + - - No tracks - - + ListEmptyComponent={() => No tracks found.} + ListFooterComponent={ + showAlphabeticalSelector ? ( + + ) : undefined } - removeClippedSubviews + getItemType={(item) => (typeof item === 'string' ? 'section' : 'row')} /> - {showAlphabeticalSelector && trackPageParams && ( - - alphabetSelectorMutate({ - letter, - infiniteQuery: tracksInfiniteQuery, - pageParams: trackPageParams, - }) - } + {selectionActive && selectedCount > 0 && ( + )} - + ) } diff --git a/src/screens/Home/tracks.tsx b/src/screens/Home/tracks.tsx index d2d4683c..c1bb022c 100644 --- a/src/screens/Home/tracks.tsx +++ b/src/screens/Home/tracks.tsx @@ -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'} /> ) } diff --git a/src/screens/Tracks/index.tsx b/src/screens/Tracks/index.tsx index 5cf40370..396d90f4 100644 --- a/src/screens/Tracks/index.tsx +++ b/src/screens/Tracks/index.tsx @@ -7,6 +7,7 @@ export default function TracksScreen({ route, navigation }: TracksProps): React. navigation={navigation} tracksInfiniteQuery={route.params.tracksInfiniteQuery} queue={'Library'} + selectionContext={'tracks-screen'} /> ) } diff --git a/src/stores/selection/tracks.ts b/src/stores/selection/tracks.ts new file mode 100644 index 00000000..f5815093 --- /dev/null +++ b/src/stores/selection/tracks.ts @@ -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 + beginSelection: (context?: string | null) => void + endSelection: () => void + clearSelection: () => void + toggleTrack: (track: BaseItemDto) => void +} + +const useTrackSelectionStore = create()( + 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