mirror of
https://github.com/Jellify-Music/App.git
synced 2026-01-06 02:50:30 -06:00
song quick action menu (#589)
* Add swipe-to-queue functionality and related tests for track management * Refactor imports to use runOnJS from react-native-worklets for consistency across components * Implement SwipeableRow component for track management with swipe actions * Add haptic feedback and improve swipe action handling in SwipeableRow component * Enhance SwipeableRow component with quick action support for right swipe and refactor left swipe actions in ItemRow and Track components * Update publish-beta.yml * Add SwipeToSkip component for track navigation gestures in PlayerScreen * Implement swipe gesture handling in SongInfo and PlayerScreen for track navigation * Add swipe action settings and enhance SwipeableRow functionality - Introduced left and right swipe action settings in the swipe settings store. - Updated SwipeableRow to support quick action menus for left and right swipes. - Enhanced ItemRow and Track components to utilize new swipe settings. - Added GesturesTab for configuring swipe actions in settings. - Improved PreferencesTab to allow users to select swipe actions. * Enhance SwipeableRow and Track components with improved action handling and UI updates * Refactor SwipeableRow integration and enhance swipe action configuration * Refactor Track component to include prependElement prop for drag icon display in editing mode * fix build git blame violet * Implement swipeable row management with close handlers and integrate into various components * goddammit violet * Add tests for SwipeableRow and swipeable row registry behavior * fix maestro settings tab tests --------- Co-authored-by: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import LibraryStackParamList from '../../screens/Library/types'
|
||||
import DiscoverStackParamList from '../../screens/Discover/types'
|
||||
import { BaseStackParamList } from '../../screens/types'
|
||||
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../../stores/device-profile'
|
||||
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
|
||||
import { useApi } from '../../stores'
|
||||
|
||||
/**
|
||||
@@ -64,6 +65,10 @@ export function Album(): React.JSX.Element {
|
||||
|
||||
const albumTrackList = useMemo(() => discs?.flatMap((disc) => disc.data), [discs])
|
||||
|
||||
const handleScrollBeginDrag = useCallback(() => {
|
||||
closeAllSwipeableRows()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SectionList
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
@@ -117,6 +122,7 @@ export function Album(): React.JSX.Element {
|
||||
)}
|
||||
</YStack>
|
||||
)}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
393
src/components/Global/components/SwipeableRow.tsx
Normal file
393
src/components/Global/components/SwipeableRow.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { XStack, YStack, getToken } from 'tamagui'
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import { runOnJS } from 'react-native-worklets'
|
||||
import Icon from './icon'
|
||||
import { Text } from '../helpers/text'
|
||||
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
|
||||
import {
|
||||
notifySwipeableRowClosed,
|
||||
notifySwipeableRowOpened,
|
||||
registerSwipeableRow,
|
||||
unregisterSwipeableRow,
|
||||
} from './swipeable-row-registry'
|
||||
|
||||
export type SwipeAction = {
|
||||
label: string
|
||||
icon: string
|
||||
color: string // Tamagui token e.g. '$success'
|
||||
onTrigger: () => void
|
||||
}
|
||||
|
||||
export type QuickAction = {
|
||||
icon: string
|
||||
color: string // Tamagui token e.g. '$primary'
|
||||
onPress: () => void
|
||||
}
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
leftAction?: SwipeAction | null // immediate action on right swipe
|
||||
leftActions?: QuickAction[] | null // quick action menu on right swipe
|
||||
rightAction?: SwipeAction | null // legacy immediate action on left swipe
|
||||
rightActions?: QuickAction[] | null // quick action menu on left swipe
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared swipeable row using a Pan gesture. One action allowed per side for simplicity,
|
||||
* consistent thresholds and snap behavior across the app.
|
||||
*/
|
||||
export default function SwipeableRow({
|
||||
children,
|
||||
leftAction,
|
||||
leftActions,
|
||||
rightAction,
|
||||
rightActions,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const triggerHaptic = useHapticFeedback()
|
||||
const tx = useSharedValue(0)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const idRef = useRef<string | undefined>(undefined)
|
||||
const menuOpenRef = useRef(false)
|
||||
const defaultMaxLeft = 120
|
||||
const defaultMaxRight = -120
|
||||
const threshold = 80
|
||||
const [rightActionsWidth, setRightActionsWidth] = useState(0)
|
||||
const [leftActionsWidth, setLeftActionsWidth] = useState(0)
|
||||
|
||||
// Compute how far we allow left swipe. If quick actions exist, use their width; else a sane default.
|
||||
const hasRightSide = !!rightAction || (rightActions && rightActions.length > 0)
|
||||
const maxRight = hasRightSide
|
||||
? rightActions && rightActions.length > 0
|
||||
? -Math.max(0, rightActionsWidth)
|
||||
: defaultMaxRight
|
||||
: 0
|
||||
|
||||
// Compute how far we allow right swipe. If quick actions exist on left side, use their width.
|
||||
const hasLeftSide = !!leftAction || (leftActions && leftActions.length > 0)
|
||||
const maxLeft = hasLeftSide
|
||||
? leftActions && leftActions.length > 0
|
||||
? Math.max(0, leftActionsWidth)
|
||||
: defaultMaxLeft
|
||||
: 0
|
||||
|
||||
if (!idRef.current) {
|
||||
idRef.current = `swipeable-row-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
const syncClosedState = useCallback(() => {
|
||||
menuOpenRef.current = false
|
||||
setMenuOpen(false)
|
||||
notifySwipeableRowClosed(idRef.current!)
|
||||
}, [])
|
||||
|
||||
const close = useCallback(() => {
|
||||
syncClosedState()
|
||||
tx.value = withTiming(0, { duration: 160, easing: Easing.out(Easing.cubic) })
|
||||
}, [syncClosedState, tx])
|
||||
|
||||
const openMenu = useCallback(() => {
|
||||
menuOpenRef.current = true
|
||||
setMenuOpen(true)
|
||||
notifySwipeableRowOpened(idRef.current!)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
registerSwipeableRow(idRef.current!, close)
|
||||
return () => {
|
||||
unregisterSwipeableRow(idRef.current!)
|
||||
}
|
||||
}, [close])
|
||||
|
||||
useEffect(() => {
|
||||
menuOpenRef.current = menuOpen
|
||||
}, [menuOpen])
|
||||
|
||||
const schedule = (fn?: () => void) => {
|
||||
if (!fn) return
|
||||
// Defer JS work so the UI bounce plays smoothly
|
||||
setTimeout(() => fn(), 0)
|
||||
}
|
||||
|
||||
const gesture = useMemo(() => {
|
||||
return Gesture.Pan()
|
||||
.activeOffsetX([-10, 10])
|
||||
.failOffsetY([-10, 10])
|
||||
.onBegin(() => {
|
||||
if (disabled) return
|
||||
runOnJS(setDragging)(true)
|
||||
})
|
||||
.onUpdate((e) => {
|
||||
if (disabled) return
|
||||
const next = Math.max(Math.min(e.translationX, maxLeft), maxRight)
|
||||
tx.value = next
|
||||
})
|
||||
.onEnd(() => {
|
||||
if (disabled) return
|
||||
if (tx.value > threshold) {
|
||||
// Right swipe: show left quick actions if provided; otherwise trigger leftAction
|
||||
if (leftActions && leftActions.length > 0) {
|
||||
runOnJS(triggerHaptic)('impactLight')
|
||||
// Snap open to expose quick actions, do not auto-trigger
|
||||
tx.value = withTiming(maxLeft, {
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
runOnJS(openMenu)()
|
||||
return
|
||||
} else if (leftAction) {
|
||||
runOnJS(triggerHaptic)('impactLight')
|
||||
tx.value = withTiming(
|
||||
maxLeft,
|
||||
{ duration: 140, easing: Easing.out(Easing.cubic) },
|
||||
() => {
|
||||
runOnJS(schedule)(leftAction.onTrigger)
|
||||
tx.value = withTiming(0, {
|
||||
duration: 160,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Left swipe (quick actions)
|
||||
if (tx.value < -Math.min(threshold, Math.abs(maxRight) / 2)) {
|
||||
if (rightActions && rightActions.length > 0) {
|
||||
runOnJS(triggerHaptic)('impactLight')
|
||||
// Snap open to expose quick actions, do not auto-trigger
|
||||
tx.value = withTiming(maxRight, {
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
runOnJS(openMenu)()
|
||||
return
|
||||
} else if (rightAction) {
|
||||
runOnJS(triggerHaptic)('impactLight')
|
||||
tx.value = withTiming(
|
||||
maxRight,
|
||||
{ duration: 140, easing: Easing.out(Easing.cubic) },
|
||||
() => {
|
||||
runOnJS(schedule)(rightAction.onTrigger)
|
||||
tx.value = withTiming(0, {
|
||||
duration: 160,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
tx.value = withTiming(0, { duration: 160, easing: Easing.out(Easing.cubic) })
|
||||
runOnJS(syncClosedState)()
|
||||
})
|
||||
.onFinalize(() => {
|
||||
if (disabled) return
|
||||
runOnJS(setDragging)(false)
|
||||
})
|
||||
}, [
|
||||
disabled,
|
||||
leftAction,
|
||||
leftActions,
|
||||
rightAction,
|
||||
rightActions,
|
||||
maxRight,
|
||||
maxLeft,
|
||||
openMenu,
|
||||
syncClosedState,
|
||||
triggerHaptic,
|
||||
])
|
||||
|
||||
const fgStyle = useAnimatedStyle(() => ({ transform: [{ translateX: tx.value }] }))
|
||||
const leftUnderlayStyle = useAnimatedStyle(() => {
|
||||
// Normalize progress to [0,1] with a monotonic denominator to avoid non-monotonic ranges
|
||||
// when the available swipe distance is smaller than the threshold (e.g., 1 quick action = 48px)
|
||||
const leftMax = maxLeft === 0 ? 1 : maxLeft
|
||||
const denom = Math.max(1, Math.min(threshold, leftMax))
|
||||
const progress = Math.min(1, Math.max(0, tx.value / denom))
|
||||
// Slight ease by capping at 0.9 near threshold and 1.0 when fully open
|
||||
const opacity = progress < 1 ? progress * 0.9 : 1
|
||||
return { opacity }
|
||||
})
|
||||
const rightUnderlayStyle = useAnimatedStyle(() => {
|
||||
const rightMax = maxRight === 0 ? -1 : maxRight // negative value when available
|
||||
const absMax = Math.abs(rightMax)
|
||||
const denom = Math.max(1, Math.min(threshold, absMax))
|
||||
const progress = Math.min(1, Math.max(0, -tx.value / denom))
|
||||
const opacity = progress < 1 ? progress * 0.9 : 1
|
||||
return { opacity }
|
||||
})
|
||||
|
||||
if (disabled) return <>{children}</>
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={gesture}>
|
||||
<YStack position='relative' overflow='hidden'>
|
||||
{/* Left action underlay with colored background (icon-only) */}
|
||||
{leftAction && !leftActions && (
|
||||
<Animated.View
|
||||
style={[
|
||||
{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
|
||||
leftUnderlayStyle,
|
||||
]}
|
||||
pointerEvents='none'
|
||||
>
|
||||
<XStack
|
||||
flex={1}
|
||||
backgroundColor={leftAction.color}
|
||||
alignItems='center'
|
||||
paddingLeft={getToken('$3')}
|
||||
>
|
||||
<XStack alignItems='center'>
|
||||
<Icon name={leftAction.icon} color={'$background'} />
|
||||
</XStack>
|
||||
</XStack>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{leftActions && leftActions.length > 0 && (
|
||||
<Animated.View
|
||||
style={[
|
||||
{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
|
||||
leftUnderlayStyle,
|
||||
]}
|
||||
pointerEvents={menuOpen ? 'auto' : 'none'}
|
||||
>
|
||||
{/* Underlay background matches list background for continuity */}
|
||||
<XStack
|
||||
flex={1}
|
||||
backgroundColor={'$background'}
|
||||
alignItems='center'
|
||||
justifyContent='flex-start'
|
||||
>
|
||||
<XStack
|
||||
gap={0}
|
||||
paddingLeft={0}
|
||||
onLayout={(e) => setLeftActionsWidth(e.nativeEvent.layout.width)}
|
||||
alignItems='center'
|
||||
justifyContent='flex-start'
|
||||
>
|
||||
{leftActions.map((action, idx) => (
|
||||
<XStack
|
||||
key={`left-quick-action-${idx}`}
|
||||
width={48}
|
||||
height={48}
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
backgroundColor={action.color}
|
||||
borderRadius={0}
|
||||
pressStyle={{ opacity: 0.8 }}
|
||||
onPress={() => {
|
||||
action.onPress()
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<Icon name={action.icon} color={'$background'} />
|
||||
</XStack>
|
||||
))}
|
||||
</XStack>
|
||||
</XStack>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Right action underlay or quick actions (left swipe) */}
|
||||
{rightAction && !rightActions && (
|
||||
<Animated.View
|
||||
style={[
|
||||
{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
|
||||
rightUnderlayStyle,
|
||||
]}
|
||||
pointerEvents='none'
|
||||
>
|
||||
<XStack
|
||||
flex={1}
|
||||
backgroundColor={rightAction.color}
|
||||
alignItems='center'
|
||||
justifyContent='flex-end'
|
||||
paddingRight={getToken('$3')}
|
||||
>
|
||||
<XStack alignItems='center'>
|
||||
<Icon name={rightAction.icon} color={'$background'} />
|
||||
</XStack>
|
||||
</XStack>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{rightActions && rightActions.length > 0 && (
|
||||
<Animated.View
|
||||
style={[
|
||||
{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
|
||||
rightUnderlayStyle,
|
||||
]}
|
||||
pointerEvents={menuOpen ? 'auto' : 'none'}
|
||||
>
|
||||
{/* Underlay background matches list background to keep continuity */}
|
||||
<XStack
|
||||
flex={1}
|
||||
backgroundColor={'$background'}
|
||||
alignItems='center'
|
||||
justifyContent='flex-end'
|
||||
>
|
||||
<XStack
|
||||
gap={0}
|
||||
paddingRight={0}
|
||||
onLayout={(e) => setRightActionsWidth(e.nativeEvent.layout.width)}
|
||||
alignItems='center'
|
||||
justifyContent='flex-end'
|
||||
>
|
||||
{rightActions.map((action, idx) => (
|
||||
<XStack
|
||||
key={`quick-action-${idx}`}
|
||||
width={48}
|
||||
height={48}
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
backgroundColor={action.color}
|
||||
borderRadius={0}
|
||||
pressStyle={{ opacity: 0.8 }}
|
||||
onPress={() => {
|
||||
action.onPress()
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<Icon name={action.icon} color={'$background'} />
|
||||
</XStack>
|
||||
))}
|
||||
</XStack>
|
||||
</XStack>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Foreground content */}
|
||||
<Animated.View
|
||||
style={fgStyle}
|
||||
pointerEvents={dragging ? 'none' : 'auto'}
|
||||
accessibilityHint={leftAction || rightAction ? 'Swipe for actions' : undefined}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
|
||||
{/* Tap-capture overlay: when a quick-action menu is open, tapping the row closes it without triggering child onPress */}
|
||||
<XStack
|
||||
position='absolute'
|
||||
left={0}
|
||||
right={0}
|
||||
top={0}
|
||||
bottom={0}
|
||||
pointerEvents={menuOpen ? 'auto' : 'none'}
|
||||
onPress={close}
|
||||
/>
|
||||
</YStack>
|
||||
</GestureDetector>
|
||||
)
|
||||
}
|
||||
@@ -2,12 +2,8 @@ import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { LayoutChangeEvent, View as RNView } from 'react-native'
|
||||
import { getToken, useTheme, View, YStack } from 'tamagui'
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
runOnJS,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'
|
||||
import { runOnJS } from 'react-native-worklets'
|
||||
import { Text } from '../helpers/text'
|
||||
import { useSafeAreaFrame } from 'react-native-safe-area-context'
|
||||
import { UseInfiniteQueryResult, useMutation } from '@tanstack/react-query'
|
||||
|
||||
@@ -9,12 +9,17 @@ import FavoriteIcon from './favorite-icon'
|
||||
import navigationRef from '../../../../navigation'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { BaseStackParamList } from '../../../screens/types'
|
||||
import { useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
|
||||
import { useAddToQueue, useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
|
||||
import { useNetworkStatus } from '../../../stores/network'
|
||||
import useStreamingDeviceProfile from '../../../stores/device-profile'
|
||||
import useItemContext from '../../../hooks/use-item-context'
|
||||
import { RouteProp, useRoute } from '@react-navigation/native'
|
||||
import { useCallback } from 'react'
|
||||
import SwipeableRow from './SwipeableRow'
|
||||
import { useSwipeSettingsStore } from '../../../stores/settings/swipe'
|
||||
import { buildSwipeConfig } from '../helpers/swipe-actions'
|
||||
import { useIsFavorite } from '../../../api/queries/user-data'
|
||||
import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite'
|
||||
import { useApi } from '../../../stores'
|
||||
|
||||
interface ItemRowProps {
|
||||
@@ -49,8 +54,12 @@ export default function ItemRow({
|
||||
const deviceProfile = useStreamingDeviceProfile()
|
||||
|
||||
const loadNewQueue = useLoadNewQueue()
|
||||
const { mutate: addToQueue } = useAddToQueue()
|
||||
const { mutate: addFavorite } = useAddFavorite()
|
||||
const { mutate: removeFavorite } = useRemoveFavorite()
|
||||
|
||||
const warmContext = useItemContext()
|
||||
const { data: isFavorite } = useIsFavorite(item)
|
||||
|
||||
const onPressIn = useCallback(() => warmContext(item), [warmContext, item])
|
||||
|
||||
@@ -103,45 +112,81 @@ export default function ItemRow({
|
||||
|
||||
const renderRunTime = item.Type === BaseItemKind.Audio
|
||||
|
||||
const isAudio = item.Type === 'Audio'
|
||||
|
||||
const leftSettings = useSwipeSettingsStore((s) => s.left)
|
||||
const rightSettings = useSwipeSettingsStore((s) => s.right)
|
||||
|
||||
const swipeHandlers = useCallback(
|
||||
() => ({
|
||||
addToQueue: () =>
|
||||
addToQueue({
|
||||
api,
|
||||
deviceProfile,
|
||||
networkStatus,
|
||||
tracks: [item],
|
||||
queuingType: QueuingType.DirectlyQueued,
|
||||
}),
|
||||
toggleFavorite: () => (isFavorite ? removeFavorite({ item }) : addFavorite({ item })),
|
||||
addToPlaylist: () => navigationRef.navigate('AddToPlaylist', { track: item }),
|
||||
}),
|
||||
[
|
||||
addToQueue,
|
||||
api,
|
||||
deviceProfile,
|
||||
networkStatus,
|
||||
item,
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
isFavorite,
|
||||
],
|
||||
)
|
||||
|
||||
const swipeConfig = isAudio
|
||||
? buildSwipeConfig({ left: leftSettings, right: rightSettings, handlers: swipeHandlers() })
|
||||
: {}
|
||||
|
||||
return (
|
||||
<XStack
|
||||
alignContent='center'
|
||||
minHeight={'$7'}
|
||||
width={'100%'}
|
||||
onPressIn={onPressIn}
|
||||
onPress={onPressCallback}
|
||||
onLongPress={onLongPress}
|
||||
animation={'quick'}
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
paddingVertical={'$2'}
|
||||
paddingRight={'$2'}
|
||||
>
|
||||
<YStack marginHorizontal={'$3'} justifyContent='center'>
|
||||
<ItemImage
|
||||
item={item}
|
||||
height={'$12'}
|
||||
width={'$12'}
|
||||
circular={item.Type === 'MusicArtist' || circular}
|
||||
/>
|
||||
</YStack>
|
||||
<SwipeableRow disabled={!isAudio} {...swipeConfig}>
|
||||
<XStack
|
||||
alignContent='center'
|
||||
minHeight={'$7'}
|
||||
width={'100%'}
|
||||
onPressIn={onPressIn}
|
||||
onPress={onPressCallback}
|
||||
onLongPress={onLongPress}
|
||||
animation={'quick'}
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
paddingVertical={'$2'}
|
||||
paddingRight={'$2'}
|
||||
>
|
||||
<YStack marginHorizontal={'$3'} justifyContent='center'>
|
||||
<ItemImage
|
||||
item={item}
|
||||
height={'$12'}
|
||||
width={'$12'}
|
||||
circular={item.Type === 'MusicArtist' || circular}
|
||||
/>
|
||||
</YStack>
|
||||
|
||||
<ItemRowDetails item={item} />
|
||||
<ItemRowDetails item={item} />
|
||||
|
||||
<XStack justifyContent='flex-end' alignItems='center' flex={2}>
|
||||
{renderRunTime ? (
|
||||
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
|
||||
) : ['Playlist'].includes(item.Type ?? '') ? (
|
||||
<Text
|
||||
color={'$borderColor'}
|
||||
>{`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Track' : 'Tracks'}`}</Text>
|
||||
) : null}
|
||||
<FavoriteIcon item={item} />
|
||||
<XStack justifyContent='flex-end' alignItems='center' flex={2}>
|
||||
{renderRunTime ? (
|
||||
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
|
||||
) : ['Playlist'].includes(item.Type ?? '') ? (
|
||||
<Text
|
||||
color={'$borderColor'}
|
||||
>{`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Track' : 'Tracks'}`}</Text>
|
||||
) : null}
|
||||
<FavoriteIcon item={item} />
|
||||
|
||||
{item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
|
||||
<Icon name='dots-horizontal' onPress={onLongPress} />
|
||||
) : null}
|
||||
{item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
|
||||
<Icon name='dots-horizontal' onPress={onLongPress} />
|
||||
) : null}
|
||||
</XStack>
|
||||
</XStack>
|
||||
</XStack>
|
||||
</SwipeableRow>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
38
src/components/Global/components/swipeable-row-registry.ts
Normal file
38
src/components/Global/components/swipeable-row-registry.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
type CloseHandler = () => void
|
||||
|
||||
const closeHandlers = new Map<string, CloseHandler>()
|
||||
const openRows = new Set<string>()
|
||||
|
||||
export const registerSwipeableRow = (id: string, close: CloseHandler) => {
|
||||
closeHandlers.set(id, close)
|
||||
}
|
||||
|
||||
export const unregisterSwipeableRow = (id: string) => {
|
||||
closeHandlers.delete(id)
|
||||
openRows.delete(id)
|
||||
}
|
||||
|
||||
export const notifySwipeableRowOpened = (id: string) => {
|
||||
openRows.forEach((openId) => {
|
||||
if (openId !== id) {
|
||||
const close = closeHandlers.get(openId)
|
||||
close?.()
|
||||
}
|
||||
})
|
||||
|
||||
openRows.clear()
|
||||
openRows.add(id)
|
||||
}
|
||||
|
||||
export const notifySwipeableRowClosed = (id: string) => {
|
||||
openRows.delete(id)
|
||||
}
|
||||
|
||||
export const closeAllSwipeableRows = () => {
|
||||
openRows.forEach((id) => {
|
||||
const close = closeHandlers.get(id)
|
||||
close?.()
|
||||
})
|
||||
|
||||
openRows.clear()
|
||||
}
|
||||
@@ -14,12 +14,17 @@ import navigationRef from '../../../../navigation'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { BaseStackParamList } from '../../../screens/types'
|
||||
import ItemImage from './image'
|
||||
import { useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
|
||||
import { useAddToQueue, useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
|
||||
import useStreamingDeviceProfile from '../../../stores/device-profile'
|
||||
import useStreamedMediaInfo from '../../../api/queries/media'
|
||||
import { useDownloadedTrack } from '../../../api/queries/download'
|
||||
import SwipeableRow from './SwipeableRow'
|
||||
import { useSwipeSettingsStore } from '../../../stores/settings/swipe'
|
||||
import { buildSwipeConfig } from '../helpers/swipe-actions'
|
||||
import { useIsFavorite } from '../../../api/queries/user-data'
|
||||
import { useApi } from '../../../stores'
|
||||
import { useCurrentTrack, usePlayQueue } from '../../../stores/player/queue'
|
||||
import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite'
|
||||
|
||||
export interface TrackProps {
|
||||
track: BaseItemDto
|
||||
@@ -50,6 +55,7 @@ export default function Track({
|
||||
testID,
|
||||
isNested,
|
||||
invertedColors,
|
||||
prependElement,
|
||||
showRemove,
|
||||
onRemove,
|
||||
}: TrackProps): React.JSX.Element {
|
||||
@@ -62,12 +68,19 @@ export default function Track({
|
||||
const nowPlaying = useCurrentTrack()
|
||||
const playQueue = usePlayQueue()
|
||||
const loadNewQueue = useLoadNewQueue()
|
||||
const { mutate: addToQueue } = useAddToQueue()
|
||||
const [networkStatus] = useNetworkStatus()
|
||||
|
||||
const { data: mediaInfo } = useStreamedMediaInfo(track.Id)
|
||||
|
||||
const offlineAudio = useDownloadedTrack(track.Id)
|
||||
|
||||
const { mutate: addFavorite } = useAddFavorite()
|
||||
const { mutate: removeFavorite } = useRemoveFavorite()
|
||||
const { data: isFavoriteTrack } = useIsFavorite(track)
|
||||
const leftSettings = useSwipeSettingsStore((s) => s.left)
|
||||
const rightSettings = useSwipeSettingsStore((s) => s.right)
|
||||
|
||||
// Memoize expensive computations
|
||||
const isPlaying = useMemo(
|
||||
() => nowPlaying?.item.Id === track.Id,
|
||||
@@ -156,89 +169,129 @@ export default function Track({
|
||||
[showArtwork, track.Artists],
|
||||
)
|
||||
|
||||
const swipeHandlers = useMemo(
|
||||
() => ({
|
||||
addToQueue: () =>
|
||||
addToQueue({
|
||||
api,
|
||||
deviceProfile,
|
||||
networkStatus,
|
||||
tracks: [track],
|
||||
queuingType: QueuingType.DirectlyQueued,
|
||||
}),
|
||||
toggleFavorite: () =>
|
||||
isFavoriteTrack ? removeFavorite({ item: track }) : addFavorite({ item: track }),
|
||||
addToPlaylist: () => navigationRef.navigate('AddToPlaylist', { track }),
|
||||
}),
|
||||
[
|
||||
addToQueue,
|
||||
api,
|
||||
deviceProfile,
|
||||
networkStatus,
|
||||
track,
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
isFavoriteTrack,
|
||||
],
|
||||
)
|
||||
|
||||
const swipeConfig = useMemo(
|
||||
() =>
|
||||
buildSwipeConfig({ left: leftSettings, right: rightSettings, handlers: swipeHandlers }),
|
||||
[leftSettings, rightSettings, swipeHandlers],
|
||||
)
|
||||
|
||||
return (
|
||||
<Theme name={invertedColors ? 'inverted_purple' : undefined}>
|
||||
<XStack
|
||||
alignContent='center'
|
||||
alignItems='center'
|
||||
height={showArtwork ? '$6' : '$5'}
|
||||
flex={1}
|
||||
testID={testID ?? undefined}
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
paddingVertical={'$2'}
|
||||
justifyContent='center'
|
||||
marginRight={'$2'}
|
||||
animation={'quick'}
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
backgroundColor={'$background'}
|
||||
>
|
||||
<SwipeableRow disabled={isNested === true} {...swipeConfig}>
|
||||
<XStack
|
||||
alignContent='center'
|
||||
alignItems='center'
|
||||
height={showArtwork ? '$6' : '$5'}
|
||||
flex={1}
|
||||
testID={testID ?? undefined}
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
paddingVertical={'$2'}
|
||||
justifyContent='center'
|
||||
marginHorizontal={showArtwork ? '$2' : '$1'}
|
||||
marginRight={'$2'}
|
||||
animation={'quick'}
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
backgroundColor={'$background'}
|
||||
>
|
||||
{showArtwork ? (
|
||||
<ItemImage item={track} width={'$12'} height={'$12'} />
|
||||
) : (
|
||||
<Text
|
||||
key={`${track.Id}-number`}
|
||||
color={textColor}
|
||||
width={getToken('$12')}
|
||||
textAlign='center'
|
||||
fontVariant={['tabular-nums']}
|
||||
>
|
||||
{indexNumber}
|
||||
</Text>
|
||||
)}
|
||||
</XStack>
|
||||
{prependElement ? (
|
||||
<XStack marginLeft={'$2'} marginRight={'$1'} alignItems='center'>
|
||||
{prependElement}
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
<YStack alignContent='center' justifyContent='flex-start' flex={6}>
|
||||
<Text
|
||||
key={`${track.Id}-name`}
|
||||
bold
|
||||
color={textColor}
|
||||
lineBreakStrategyIOS='standard'
|
||||
numberOfLines={1}
|
||||
<XStack
|
||||
alignContent='center'
|
||||
justifyContent='center'
|
||||
marginHorizontal={showArtwork ? '$2' : '$1'}
|
||||
>
|
||||
{trackName}
|
||||
</Text>
|
||||
{showArtwork ? (
|
||||
<ItemImage item={track} width={'$12'} height={'$12'} />
|
||||
) : (
|
||||
<Text
|
||||
key={`${track.Id}-number`}
|
||||
color={textColor}
|
||||
width={getToken('$12')}
|
||||
textAlign='center'
|
||||
fontVariant={['tabular-nums']}
|
||||
>
|
||||
{indexNumber}
|
||||
</Text>
|
||||
)}
|
||||
</XStack>
|
||||
|
||||
{shouldShowArtists && (
|
||||
<YStack alignContent='center' justifyContent='flex-start' flex={6}>
|
||||
<Text
|
||||
key={`${track.Id}-artists`}
|
||||
key={`${track.Id}-name`}
|
||||
bold
|
||||
color={textColor}
|
||||
lineBreakStrategyIOS='standard'
|
||||
numberOfLines={1}
|
||||
color={'$borderColor'}
|
||||
>
|
||||
{artistsText}
|
||||
{trackName}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<DownloadedIcon item={track} />
|
||||
{shouldShowArtists && (
|
||||
<Text
|
||||
key={`${track.Id}-artists`}
|
||||
lineBreakStrategyIOS='standard'
|
||||
numberOfLines={1}
|
||||
color={'$borderColor'}
|
||||
>
|
||||
{artistsText}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<FavoriteIcon item={track} />
|
||||
<DownloadedIcon item={track} />
|
||||
|
||||
<RunTimeTicks
|
||||
key={`${track.Id}-runtime`}
|
||||
props={{
|
||||
style: {
|
||||
textAlign: 'center',
|
||||
flex: 1.5,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{track.RunTimeTicks}
|
||||
</RunTimeTicks>
|
||||
<FavoriteIcon item={track} />
|
||||
|
||||
<Icon
|
||||
name={showRemove ? 'close' : 'dots-horizontal'}
|
||||
flex={1}
|
||||
onPress={handleIconPress}
|
||||
/>
|
||||
</XStack>
|
||||
<RunTimeTicks
|
||||
key={`${track.Id}-runtime`}
|
||||
props={{
|
||||
style: {
|
||||
textAlign: 'center',
|
||||
flex: 1.5,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{track.RunTimeTicks}
|
||||
</RunTimeTicks>
|
||||
|
||||
<Icon
|
||||
name={showRemove ? 'close' : 'dots-horizontal'}
|
||||
flex={1}
|
||||
onPress={handleIconPress}
|
||||
/>
|
||||
</XStack>
|
||||
</SwipeableRow>
|
||||
</Theme>
|
||||
)
|
||||
}
|
||||
|
||||
87
src/components/Global/helpers/swipe-actions.ts
Normal file
87
src/components/Global/helpers/swipe-actions.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { QuickAction, SwipeAction } from '../components/SwipeableRow'
|
||||
import { SwipeActionType } from '../../../stores/settings/swipe'
|
||||
|
||||
export type SwipeHandlers = {
|
||||
addToQueue: () => void
|
||||
toggleFavorite: () => void
|
||||
addToPlaylist: () => void
|
||||
}
|
||||
|
||||
export type SwipeConfig = {
|
||||
leftAction?: SwipeAction
|
||||
leftActions?: QuickAction[]
|
||||
rightAction?: SwipeAction
|
||||
rightActions?: QuickAction[]
|
||||
}
|
||||
function toSwipeAction(type: SwipeActionType, handlers: SwipeHandlers): SwipeAction {
|
||||
switch (type) {
|
||||
case 'AddToQueue':
|
||||
return {
|
||||
label: 'Add to queue',
|
||||
icon: 'playlist-plus',
|
||||
color: '$success',
|
||||
onTrigger: handlers.addToQueue,
|
||||
}
|
||||
case 'ToggleFavorite':
|
||||
return {
|
||||
label: 'Toggle favorite',
|
||||
icon: 'heart',
|
||||
color: '$primary',
|
||||
onTrigger: handlers.toggleFavorite,
|
||||
}
|
||||
case 'AddToPlaylist':
|
||||
default:
|
||||
return {
|
||||
label: 'Add to playlist',
|
||||
icon: 'playlist-plus',
|
||||
color: '$color',
|
||||
onTrigger: handlers.addToPlaylist,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toQuickAction(type: SwipeActionType, handlers: SwipeHandlers): QuickAction {
|
||||
switch (type) {
|
||||
case 'AddToQueue':
|
||||
return {
|
||||
icon: 'playlist-plus',
|
||||
color: '$success',
|
||||
onPress: handlers.addToQueue,
|
||||
}
|
||||
case 'ToggleFavorite':
|
||||
return {
|
||||
icon: 'heart',
|
||||
color: '$primary',
|
||||
onPress: handlers.toggleFavorite,
|
||||
}
|
||||
case 'AddToPlaylist':
|
||||
default:
|
||||
return {
|
||||
icon: 'playlist-plus',
|
||||
color: '$color',
|
||||
onPress: handlers.addToPlaylist,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSwipeConfig(params: {
|
||||
left: SwipeActionType[]
|
||||
right: SwipeActionType[]
|
||||
handlers: SwipeHandlers
|
||||
}): SwipeConfig {
|
||||
const { left, right, handlers } = params
|
||||
|
||||
const cfg: SwipeConfig = {}
|
||||
|
||||
if (left && left.length > 0) {
|
||||
if (left.length === 1) cfg.leftAction = toSwipeAction(left[0], handlers)
|
||||
else cfg.leftActions = left.map((t) => toQuickAction(t, handlers))
|
||||
}
|
||||
|
||||
if (right && right.length > 0) {
|
||||
if (right.length === 1) cfg.rightAction = toSwipeAction(right[0], handlers)
|
||||
else cfg.rightActions = right.map((t) => toQuickAction(t, handlers))
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
import { useCallback } from 'react'
|
||||
import { InstantMixProps } from '../../screens/types'
|
||||
import Track from '../Global/components/track'
|
||||
import { Separator } from 'tamagui'
|
||||
import { FlashList } from '@shopify/flash-list'
|
||||
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
|
||||
|
||||
export default function InstantMix({ route, navigation }: InstantMixProps): React.JSX.Element {
|
||||
const { mix } = route.params
|
||||
const handleScrollBeginDrag = useCallback(() => {
|
||||
closeAllSwipeableRows()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
data={mix}
|
||||
ItemSeparatorComponent={() => <Separator />}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
renderItem={({ item, index }) => (
|
||||
<Track
|
||||
showArtwork
|
||||
|
||||
@@ -7,8 +7,8 @@ import Animated, {
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
Easing,
|
||||
runOnJS,
|
||||
} from 'react-native-reanimated'
|
||||
import { runOnJS } from 'react-native-worklets'
|
||||
|
||||
import { Text } from '../Global/helpers/text'
|
||||
import { useNetworkStatus } from '../../stores/network'
|
||||
|
||||
@@ -12,11 +12,64 @@ import navigationRef from '../../../../navigation'
|
||||
import Icon from '../../Global/components/icon'
|
||||
import { getItemName } from '../../../utils/text'
|
||||
import { CommonActions } from '@react-navigation/native'
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import Animated, { useSharedValue, withDelay, withSpring } from 'react-native-reanimated'
|
||||
import type { SharedValue } from 'react-native-reanimated'
|
||||
import { runOnJS } from 'react-native-worklets'
|
||||
import { usePrevious, useSkip } from '../../../providers/Player/hooks/mutations'
|
||||
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
|
||||
import { useCurrentTrack } from '../../../stores/player/queue'
|
||||
import { useApi } from '../../../stores'
|
||||
|
||||
export default function SongInfo(): React.JSX.Element {
|
||||
type SongInfoProps = {
|
||||
// Shared animated value coming from Player to drive overlay icons
|
||||
swipeX?: SharedValue<number>
|
||||
}
|
||||
|
||||
export default function SongInfo({ swipeX }: SongInfoProps = {}): React.JSX.Element {
|
||||
const api = useApi()
|
||||
const skip = useSkip()
|
||||
const previous = usePrevious()
|
||||
const trigger = useHapticFeedback()
|
||||
|
||||
// local fallback if no shared value was provided
|
||||
const localX = useSharedValue(0)
|
||||
const x = swipeX ?? localX
|
||||
|
||||
const albumGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.activeOffsetX([-12, 12])
|
||||
.onUpdate((e) => {
|
||||
if (Math.abs(e.translationY) < 40) {
|
||||
x.value = Math.max(-160, Math.min(160, e.translationX))
|
||||
}
|
||||
})
|
||||
.onEnd((e) => {
|
||||
const threshold = 120
|
||||
const minVelocity = 600
|
||||
const isHorizontal = Math.abs(e.translationY) < 40
|
||||
if (
|
||||
isHorizontal &&
|
||||
(Math.abs(e.translationX) > threshold ||
|
||||
Math.abs(e.velocityX) > minVelocity)
|
||||
) {
|
||||
if (e.translationX > 0) {
|
||||
x.value = withSpring(220)
|
||||
runOnJS(trigger)('notificationSuccess')
|
||||
runOnJS(skip)(undefined)
|
||||
} else {
|
||||
x.value = withSpring(-220)
|
||||
runOnJS(trigger)('notificationSuccess')
|
||||
runOnJS(previous)()
|
||||
}
|
||||
x.value = withDelay(160, withSpring(0))
|
||||
} else {
|
||||
x.value = withSpring(0)
|
||||
}
|
||||
}),
|
||||
[previous, skip, trigger, x],
|
||||
)
|
||||
const nowPlaying = useCurrentTrack()
|
||||
|
||||
const { data: album } = useQuery({
|
||||
@@ -60,9 +113,13 @@ export default function SongInfo(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<XStack>
|
||||
<YStack marginRight={'$2.5'} onPress={handleAlbumPress} justifyContent='center'>
|
||||
<ItemImage item={nowPlaying!.item} width={'$12'} height={'$12'} />
|
||||
</YStack>
|
||||
<GestureDetector gesture={albumGesture}>
|
||||
<Animated.View>
|
||||
<YStack marginRight={'$2.5'} onPress={handleAlbumPress} justifyContent='center'>
|
||||
<ItemImage item={nowPlaying!.item} width={'$12'} height={'$12'} />
|
||||
</YStack>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
|
||||
<YStack justifyContent='flex-start' flex={1} gap={'$0.25'}>
|
||||
<TextTicker {...TextTickerConfig} style={{ height: getToken('$9') }}>
|
||||
|
||||
@@ -12,6 +12,18 @@ import PlayerHeader from './components/header'
|
||||
import SongInfo from './components/song-info'
|
||||
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
|
||||
import { Platform } from 'react-native'
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated'
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import { runOnJS } from 'react-native-worklets'
|
||||
import { usePrevious, useSkip } from '../../providers/Player/hooks/mutations'
|
||||
import useHapticFeedback from '../../hooks/use-haptic-feedback'
|
||||
import Icon from '../Global/components/icon'
|
||||
import { useCurrentTrack } from '../../stores/player/queue'
|
||||
|
||||
export default function PlayerScreen(): React.JSX.Element {
|
||||
@@ -19,6 +31,9 @@ export default function PlayerScreen(): React.JSX.Element {
|
||||
|
||||
const [showToast, setShowToast] = useState(true)
|
||||
|
||||
const skip = useSkip()
|
||||
const previous = usePrevious()
|
||||
const trigger = useHapticFeedback()
|
||||
const nowPlaying = useCurrentTrack()
|
||||
|
||||
const theme = useTheme()
|
||||
@@ -36,6 +51,55 @@ export default function PlayerScreen(): React.JSX.Element {
|
||||
|
||||
const { top, bottom } = useSafeAreaInsets()
|
||||
|
||||
// Shared animated value controlled by the large swipe area
|
||||
const translateX = useSharedValue(0)
|
||||
|
||||
// Edge icon opacity styles
|
||||
const leftIconStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(Math.max(0, -translateX.value), [0, 40, 120], [0, 0.25, 1]),
|
||||
}))
|
||||
const rightIconStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(Math.max(0, translateX.value), [0, 40, 120], [0, 0.25, 1]),
|
||||
}))
|
||||
|
||||
// Gesture logic for central big swipe area
|
||||
const swipeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.activeOffsetX([-12, 12])
|
||||
.onUpdate((e) => {
|
||||
if (Math.abs(e.translationY) < 40) {
|
||||
translateX.value = Math.max(-160, Math.min(160, e.translationX))
|
||||
}
|
||||
})
|
||||
.onEnd((e) => {
|
||||
const threshold = 120
|
||||
const minVelocity = 600
|
||||
const isHorizontal = Math.abs(e.translationY) < 40
|
||||
if (
|
||||
isHorizontal &&
|
||||
(Math.abs(e.translationX) > threshold ||
|
||||
Math.abs(e.velocityX) > minVelocity)
|
||||
) {
|
||||
if (e.translationX > 0) {
|
||||
// Inverted: swipe right = previous
|
||||
translateX.value = withSpring(220)
|
||||
runOnJS(trigger)('notificationSuccess')
|
||||
runOnJS(previous)()
|
||||
} else {
|
||||
// Inverted: swipe left = next
|
||||
translateX.value = withSpring(-220)
|
||||
runOnJS(trigger)('notificationSuccess')
|
||||
runOnJS(skip)(undefined)
|
||||
}
|
||||
translateX.value = withDelay(160, withSpring(0))
|
||||
} else {
|
||||
translateX.value = withSpring(0)
|
||||
}
|
||||
}),
|
||||
[previous, skip, trigger, translateX],
|
||||
)
|
||||
|
||||
/**
|
||||
* Styling for the top layer of Player ZStack
|
||||
*
|
||||
@@ -58,24 +122,70 @@ export default function PlayerScreen(): React.JSX.Element {
|
||||
<ZStack fullscreen>
|
||||
<BlurredBackground width={width} height={height} />
|
||||
|
||||
<YStack
|
||||
justifyContent='center'
|
||||
flex={1}
|
||||
marginHorizontal={'$5'}
|
||||
{...mainContainerStyle}
|
||||
{/* Swipe feedback icons (topmost overlay) */}
|
||||
<Animated.View
|
||||
pointerEvents='none'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
{/* flexGrow 1 */}
|
||||
<PlayerHeader />
|
||||
|
||||
<YStack justifyContent='flex-start' gap={'$5'} flexShrink={1}>
|
||||
<SongInfo />
|
||||
|
||||
<Scrubber />
|
||||
{/* playback progress goes here */}
|
||||
<Controls />
|
||||
<Footer />
|
||||
<YStack flex={1} justifyContent='center'>
|
||||
<Animated.View
|
||||
style={[{ position: 'absolute', left: 12 }, leftIconStyle]}
|
||||
>
|
||||
<Icon name='skip-next' color='$primary' large />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[{ position: 'absolute', right: 12 }, rightIconStyle]}
|
||||
>
|
||||
<Icon name='skip-previous' color='$primary' large />
|
||||
</Animated.View>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</Animated.View>
|
||||
|
||||
{/* Central large swipe area overlay (captures swipe like big album art) */}
|
||||
<GestureDetector gesture={swipeGesture}>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: height * 0.18,
|
||||
left: width * 0.06,
|
||||
right: width * 0.06,
|
||||
height: height * 0.36,
|
||||
zIndex: 9998,
|
||||
}}
|
||||
/>
|
||||
</GestureDetector>
|
||||
|
||||
<Animated.View style={{ flex: 1 }}>
|
||||
<YStack
|
||||
justifyContent='center'
|
||||
flex={1}
|
||||
marginHorizontal={'$5'}
|
||||
{...mainContainerStyle}
|
||||
>
|
||||
{/* flexGrow 1 */}
|
||||
<YStack>
|
||||
<PlayerHeader />
|
||||
<SongInfo />
|
||||
</YStack>
|
||||
|
||||
<YStack justifyContent='flex-start' gap={'$5'} flexShrink={1}>
|
||||
<Scrubber />
|
||||
|
||||
{/* playback progress goes here */}
|
||||
<YStack>
|
||||
<Controls />
|
||||
<Footer />
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</Animated.View>
|
||||
</ZStack>
|
||||
)}
|
||||
{showToast && <Toast config={JellifyToastConfig(theme)} />}
|
||||
|
||||
@@ -11,13 +11,8 @@ import { Progress as TrackPlayerProgress } from 'react-native-track-player'
|
||||
import { useProgress } from '../../providers/Player/hooks/queries'
|
||||
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
runOnJS,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated'
|
||||
import Animated, { FadeIn, FadeOut, useSharedValue, withSpring } from 'react-native-reanimated'
|
||||
import { runOnJS } from 'react-native-worklets'
|
||||
import { RootStackParamList } from '../../screens/types'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import ItemImage from '../Global/components/image'
|
||||
@@ -37,11 +32,11 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
|
||||
const handleSwipe = useCallback(
|
||||
(direction: string) => {
|
||||
if (direction === 'Swiped Left') {
|
||||
// Skip to previous song
|
||||
previous()
|
||||
} else if (direction === 'Swiped Right') {
|
||||
// Skip to next song
|
||||
// Inverted: Swipe left -> next
|
||||
skip(undefined)
|
||||
} else if (direction === 'Swiped Right') {
|
||||
// Inverted: Swipe right -> previous
|
||||
previous()
|
||||
} else if (direction === 'Swiped Up') {
|
||||
// Navigate to the big player
|
||||
navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' })
|
||||
|
||||
@@ -5,12 +5,13 @@ import { RefreshControl } from 'react-native'
|
||||
import { PlaylistProps } from './interfaces'
|
||||
import PlayliistTracklistHeader from './components/header'
|
||||
import { usePlaylistContext } from '../../providers/Playlist'
|
||||
import { useAnimatedScrollHandler } from 'react-native-reanimated'
|
||||
import { runOnJS, useAnimatedScrollHandler } from 'react-native-reanimated'
|
||||
import AnimatedDraggableFlatList from '../Global/components/animated-draggable-flat-list'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { RootStackParamList } from '../../screens/types'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import useHapticFeedback from '../../hooks/use-haptic-feedback'
|
||||
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
|
||||
|
||||
export default function Playlist({
|
||||
playlist,
|
||||
@@ -33,6 +34,10 @@ export default function Playlist({
|
||||
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
|
||||
|
||||
const scrollOffsetHandler = useAnimatedScrollHandler({
|
||||
onBeginDrag: () => {
|
||||
'worklet'
|
||||
runOnJS(closeAllSwipeableRows)()
|
||||
},
|
||||
onScroll: (event) => {
|
||||
'worklet'
|
||||
scroll.value = event.contentOffset.y
|
||||
@@ -74,32 +79,32 @@ export default function Playlist({
|
||||
}}
|
||||
refreshing={isPending}
|
||||
renderItem={({ item: track, getIndex, drag }) => (
|
||||
<XStack alignItems='center'>
|
||||
{editing && canEdit && <Icon name='drag' onPress={drag} />}
|
||||
|
||||
<Track
|
||||
navigation={navigation}
|
||||
track={track}
|
||||
tracklist={playlistTracks ?? []}
|
||||
index={getIndex() ?? 0}
|
||||
queue={playlist}
|
||||
showArtwork
|
||||
onLongPress={() => {
|
||||
if (editing) {
|
||||
drag()
|
||||
} else {
|
||||
rootNavigation.navigate('Context', {
|
||||
item: track,
|
||||
navigation,
|
||||
})
|
||||
}
|
||||
}}
|
||||
showRemove={editing}
|
||||
onRemove={() =>
|
||||
useRemoveFromPlaylist.mutate({ playlist, track, index: getIndex()! })
|
||||
<Track
|
||||
navigation={navigation}
|
||||
track={track}
|
||||
tracklist={playlistTracks ?? []}
|
||||
index={getIndex() ?? 0}
|
||||
queue={playlist}
|
||||
showArtwork
|
||||
onLongPress={() => {
|
||||
if (editing) {
|
||||
drag()
|
||||
} else {
|
||||
rootNavigation.navigate('Context', {
|
||||
item: track,
|
||||
navigation,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</XStack>
|
||||
}}
|
||||
showRemove={editing}
|
||||
onRemove={() =>
|
||||
useRemoveFromPlaylist.mutate({ playlist, track, index: getIndex()! })
|
||||
}
|
||||
prependElement={
|
||||
editing && canEdit ? <Icon name='drag' onPress={drag} /> : undefined
|
||||
}
|
||||
isNested={editing}
|
||||
/>
|
||||
)}
|
||||
style={{
|
||||
marginHorizontal: 2,
|
||||
|
||||
@@ -13,7 +13,9 @@ import { isEmpty } from 'lodash'
|
||||
import HorizontalCardList from '../Global/components/horizontal-list'
|
||||
import { ItemCard } from '../Global/components/item-card'
|
||||
import SearchParamList from '../../screens/Search/types'
|
||||
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
|
||||
import { useApi, useJellifyLibrary, useJellifyUser } from '../../stores'
|
||||
|
||||
export default function Search({
|
||||
navigation,
|
||||
}: {
|
||||
@@ -60,6 +62,10 @@ export default function Search({
|
||||
search()
|
||||
}
|
||||
|
||||
const handleScrollBeginDrag = useCallback(() => {
|
||||
closeAllSwipeableRows()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
@@ -115,6 +121,7 @@ export default function Search({
|
||||
renderItem={({ item }) => (
|
||||
<ItemRow item={item} queueName={searchString ?? 'Search'} navigation={navigation} />
|
||||
)}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
style={{
|
||||
marginHorizontal: getToken('$2'),
|
||||
marginTop: getToken('$4'),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import ItemRow from '../Global/components/item-row'
|
||||
import { Text } from '../Global/helpers/text'
|
||||
import { H3, Separator, YStack } from 'tamagui'
|
||||
@@ -8,6 +9,7 @@ import SearchParamList from '../../screens/Search/types'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
|
||||
|
||||
export default function Suggestions({
|
||||
suggestions,
|
||||
@@ -15,6 +17,9 @@ export default function Suggestions({
|
||||
suggestions: BaseItemDto[] | undefined
|
||||
}): React.JSX.Element {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<SearchParamList>>()
|
||||
const handleScrollBeginDrag = useCallback(() => {
|
||||
closeAllSwipeableRows()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
@@ -51,6 +56,7 @@ export default function Suggestions({
|
||||
Wake now, discover that you are the eyes of the world...
|
||||
</Text>
|
||||
}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
renderItem={({ item }) => {
|
||||
return <ItemRow item={item} queueName={'Suggestions'} navigation={navigation} />
|
||||
}}
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function Settings(): React.JSX.Element {
|
||||
component={PreferencesTab}
|
||||
options={{
|
||||
title: 'App',
|
||||
tabBarIcon: ({ focused, color }) => (
|
||||
tabBarIcon: ({ focused, color }: { focused: boolean; color: string }) => (
|
||||
<Icon
|
||||
name={`jellyfish${!focused ? '-outline' : ''}`}
|
||||
color={focused ? '$primary' : '$borderColor'}
|
||||
@@ -52,7 +52,7 @@ export default function Settings(): React.JSX.Element {
|
||||
component={PlaybackTab}
|
||||
options={{
|
||||
title: 'Player',
|
||||
tabBarIcon: ({ focused, color }) => (
|
||||
tabBarIcon: ({ focused, color }: { focused: boolean; color: string }) => (
|
||||
<Icon
|
||||
name='cassette'
|
||||
color={focused ? '$primary' : '$borderColor'}
|
||||
@@ -67,7 +67,7 @@ export default function Settings(): React.JSX.Element {
|
||||
component={StorageTab}
|
||||
options={{
|
||||
title: 'Usage',
|
||||
tabBarIcon: ({ focused, color }) => (
|
||||
tabBarIcon: ({ focused, color }: { focused: boolean; color: string }) => (
|
||||
<Icon
|
||||
name='harddisk'
|
||||
color={focused ? '$primary' : '$borderColor'}
|
||||
@@ -81,7 +81,7 @@ export default function Settings(): React.JSX.Element {
|
||||
name='User'
|
||||
component={AccountTab}
|
||||
options={{
|
||||
tabBarIcon: ({ focused, color }) => (
|
||||
tabBarIcon: ({ focused, color }: { focused: boolean; color: string }) => (
|
||||
<Icon
|
||||
name='account-music'
|
||||
color={focused ? '$primary' : '$borderColor'}
|
||||
@@ -95,7 +95,7 @@ export default function Settings(): React.JSX.Element {
|
||||
name='About'
|
||||
component={InfoTab}
|
||||
options={{
|
||||
tabBarIcon: ({ focused, color }) => (
|
||||
tabBarIcon: ({ focused, color }: { focused: boolean; color: string }) => (
|
||||
<Icon
|
||||
name={`information${!focused ? '-outline' : ''}`}
|
||||
color={focused ? '$primary' : '$borderColor'}
|
||||
|
||||
78
src/components/Settings/components/gestures-tab.tsx
Normal file
78
src/components/Settings/components/gestures-tab.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { YStack, XStack, Paragraph, Separator } from 'tamagui'
|
||||
import SettingsListGroup from './settings-list-group'
|
||||
import { CheckboxWithLabel } from '../../Global/helpers/checkbox-with-label'
|
||||
import { useSwipeSettingsStore, SwipeActionType } from '../../../stores/settings/swipe'
|
||||
|
||||
const ALL_ACTIONS: { key: SwipeActionType; label: string }[] = [
|
||||
{ key: 'AddToQueue', label: 'Add to queue' },
|
||||
{ key: 'ToggleFavorite', label: 'Toggle favorite' },
|
||||
{ key: 'AddToPlaylist', label: 'Add to playlist' },
|
||||
]
|
||||
|
||||
export default function GesturesTab(): React.JSX.Element {
|
||||
const left = useSwipeSettingsStore((s) => s.left)
|
||||
const right = useSwipeSettingsStore((s) => s.right)
|
||||
const toggleLeft = useSwipeSettingsStore((s) => s.toggleLeft)
|
||||
const toggleRight = useSwipeSettingsStore((s) => s.toggleRight)
|
||||
|
||||
const leftSummary = useMemo(() => (left.length ? left.join(', ') : 'None'), [left])
|
||||
const rightSummary = useMemo(() => (right.length ? right.join(', ') : 'None'), [right])
|
||||
|
||||
return (
|
||||
<SettingsListGroup
|
||||
settingsList={[
|
||||
{
|
||||
title: 'Swipe Left on Track',
|
||||
subTitle: `Selected: ${leftSummary}`,
|
||||
iconName: 'gesture-swipe-left',
|
||||
iconColor: '$borderColor',
|
||||
children: (
|
||||
<YStack gap={'$2'} paddingVertical={'$2'}>
|
||||
<Paragraph color={'$borderColor'}>
|
||||
If one action is selected, it will trigger immediately on reveal. If
|
||||
multiple are selected, swiping left reveals a menu.
|
||||
</Paragraph>
|
||||
<Separator />
|
||||
{ALL_ACTIONS.map((a) => (
|
||||
<XStack key={`left-${a.key}`} paddingVertical={'$1'}>
|
||||
<CheckboxWithLabel
|
||||
checked={left.includes(a.key)}
|
||||
onCheckedChange={() => toggleLeft(a.key)}
|
||||
label={a.label}
|
||||
size={'$2'}
|
||||
/>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Swipe Right on Track',
|
||||
subTitle: `Selected: ${rightSummary}`,
|
||||
iconName: 'gesture-swipe-right',
|
||||
iconColor: '$borderColor',
|
||||
children: (
|
||||
<YStack gap={'$2'} paddingVertical={'$2'}>
|
||||
<Paragraph color={'$borderColor'}>
|
||||
If one action is selected, it will trigger immediately on reveal. If
|
||||
multiple are selected, swiping right reveals a menu.
|
||||
</Paragraph>
|
||||
<Separator />
|
||||
{ALL_ACTIONS.map((a) => (
|
||||
<XStack key={`right-${a.key}`} paddingVertical={'$1'}>
|
||||
<CheckboxWithLabel
|
||||
checked={right.includes(a.key)}
|
||||
onCheckedChange={() => toggleRight(a.key)}
|
||||
label={a.label}
|
||||
size={'$2'}
|
||||
/>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RadioGroup, YStack } from 'tamagui'
|
||||
import { RadioGroup, YStack, XStack, Paragraph, SizableText } from 'tamagui'
|
||||
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
|
||||
import SettingsListGroup from './settings-list-group'
|
||||
import { RadioGroupItemWithLabel } from '../../Global/helpers/radio-group-item-with-label'
|
||||
@@ -8,12 +8,45 @@ import {
|
||||
useSendMetricsSetting,
|
||||
useThemeSetting,
|
||||
} from '../../../stores/settings/app'
|
||||
import { useSwipeSettingsStore } from '../../../stores/settings/swipe'
|
||||
import { useMemo } from 'react'
|
||||
import Button from '../../Global/helpers/button'
|
||||
import Icon from '../../Global/components/icon'
|
||||
|
||||
export default function PreferencesTab(): React.JSX.Element {
|
||||
const [sendMetrics, setSendMetrics] = useSendMetricsSetting()
|
||||
const [reducedHaptics, setReducedHaptics] = useReducedHapticsSetting()
|
||||
const [themeSetting, setThemeSetting] = useThemeSetting()
|
||||
const left = useSwipeSettingsStore((s) => s.left)
|
||||
const right = useSwipeSettingsStore((s) => s.right)
|
||||
const toggleLeft = useSwipeSettingsStore((s) => s.toggleLeft)
|
||||
const toggleRight = useSwipeSettingsStore((s) => s.toggleRight)
|
||||
|
||||
const ActionChip = ({
|
||||
active,
|
||||
label,
|
||||
icon,
|
||||
onPress,
|
||||
}: {
|
||||
active: boolean
|
||||
label: string
|
||||
icon: string
|
||||
onPress: () => void
|
||||
}) => (
|
||||
<Button
|
||||
onPress={onPress}
|
||||
backgroundColor={active ? '$primary' : 'transparent'}
|
||||
borderColor={active ? '$primary' : '$borderColor'}
|
||||
borderWidth={'$0.5'}
|
||||
color={active ? '$background' : '$color'}
|
||||
paddingHorizontal={'$3'}
|
||||
size={'$2'}
|
||||
borderRadius={'$10'}
|
||||
icon={<Icon name={icon} color={active ? '$background' : '$color'} small />}
|
||||
>
|
||||
<SizableText size={'$2'}>{label}</SizableText>
|
||||
</Button>
|
||||
)
|
||||
|
||||
const themeSubtitle = useMemo(() => {
|
||||
switch (themeSetting) {
|
||||
@@ -54,6 +87,73 @@ export default function PreferencesTab(): React.JSX.Element {
|
||||
</YStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Track Swipe Actions',
|
||||
subTitle: 'Choose actions for left/right swipes',
|
||||
iconName: 'gesture-swipe',
|
||||
iconColor: '$borderColor',
|
||||
children: (
|
||||
<YStack gap={'$2'} paddingVertical={'$2'}>
|
||||
<Paragraph color={'$borderColor'}>
|
||||
Single selection triggers on reveal; multiple selections show a
|
||||
menu.
|
||||
</Paragraph>
|
||||
<XStack
|
||||
alignItems='center'
|
||||
justifyContent='space-between'
|
||||
gap={'$3'}
|
||||
paddingTop={'$2'}
|
||||
>
|
||||
<YStack gap={'$2'} flex={1}>
|
||||
<SizableText size={'$3'}>Swipe Left</SizableText>
|
||||
<XStack gap={'$2'} flexWrap='wrap'>
|
||||
<ActionChip
|
||||
active={left.includes('ToggleFavorite')}
|
||||
label='Favorite'
|
||||
icon='heart'
|
||||
onPress={() => toggleLeft('ToggleFavorite')}
|
||||
/>
|
||||
<ActionChip
|
||||
active={left.includes('AddToPlaylist')}
|
||||
label='Add to Playlist'
|
||||
icon='playlist-plus'
|
||||
onPress={() => toggleLeft('AddToPlaylist')}
|
||||
/>
|
||||
<ActionChip
|
||||
active={left.includes('AddToQueue')}
|
||||
label='Add to Queue'
|
||||
icon='playlist-play'
|
||||
onPress={() => toggleLeft('AddToQueue')}
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
<YStack gap={'$2'} flex={1}>
|
||||
<SizableText size={'$3'}>Swipe Right</SizableText>
|
||||
<XStack gap={'$2'} flexWrap='wrap'>
|
||||
<ActionChip
|
||||
active={right.includes('ToggleFavorite')}
|
||||
label='Favorite'
|
||||
icon='heart'
|
||||
onPress={() => toggleRight('ToggleFavorite')}
|
||||
/>
|
||||
<ActionChip
|
||||
active={right.includes('AddToPlaylist')}
|
||||
label='Add to Playlist'
|
||||
icon='playlist-plus'
|
||||
onPress={() => toggleRight('AddToPlaylist')}
|
||||
/>
|
||||
<ActionChip
|
||||
active={right.includes('AddToQueue')}
|
||||
label='Add to Queue'
|
||||
icon='playlist-play'
|
||||
onPress={() => toggleRight('AddToQueue')}
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</YStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Reduce Haptics',
|
||||
iconName: reducedHaptics ? 'vibrate-off' : 'vibrate',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { UseInfiniteQueryResult } from '@tanstack/react-query'
|
||||
import { debounce, isString } from 'lodash'
|
||||
import { RefreshControl } from 'react-native-gesture-handler'
|
||||
import useItemContext from '../../hooks/use-item-context'
|
||||
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
|
||||
|
||||
interface TracksProps {
|
||||
tracksInfiniteQuery: UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>
|
||||
@@ -134,6 +135,10 @@ export default function Tracks({
|
||||
}
|
||||
}, [pendingLetterRef.current, tracksInfiniteQuery.data])
|
||||
|
||||
const handleScrollBeginDrag = useCallback(() => {
|
||||
closeAllSwipeableRows()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<XStack flex={1}>
|
||||
<FlashList
|
||||
@@ -153,6 +158,7 @@ export default function Tracks({
|
||||
onEndReached={() => {
|
||||
if (tracksInfiniteQuery.hasNextPage) tracksInfiniteQuery.fetchNextPage()
|
||||
}}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
stickyHeaderIndices={stickyHeaderIndicies}
|
||||
ListEmptyComponent={
|
||||
<YStack flex={1} justify='center' alignItems='center'>
|
||||
|
||||
69
src/stores/settings/swipe.ts
Normal file
69
src/stores/settings/swipe.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { create } from 'zustand'
|
||||
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
|
||||
import { stateStorage } from '../../constants/storage'
|
||||
|
||||
export type SwipeActionType = 'AddToQueue' | 'ToggleFavorite' | 'AddToPlaylist'
|
||||
|
||||
type SwipeSettingsStore = {
|
||||
left: SwipeActionType[] // actions when swiping LEFT on a track row
|
||||
right: SwipeActionType[] // actions when swiping RIGHT on a track row
|
||||
|
||||
setLeft: (actions: SwipeActionType[]) => void
|
||||
setRight: (actions: SwipeActionType[]) => void
|
||||
toggleLeft: (action: SwipeActionType) => void
|
||||
toggleRight: (action: SwipeActionType) => void
|
||||
}
|
||||
|
||||
const DEFAULT_LEFT: SwipeActionType[] = ['ToggleFavorite', 'AddToPlaylist']
|
||||
const DEFAULT_RIGHT: SwipeActionType[] = ['AddToQueue']
|
||||
|
||||
export const useSwipeSettingsStore = create<SwipeSettingsStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
left: DEFAULT_LEFT,
|
||||
right: DEFAULT_RIGHT,
|
||||
setLeft: (actions) => set({ left: actions }),
|
||||
setRight: (actions) => set({ right: actions }),
|
||||
toggleLeft: (action) => {
|
||||
const cur = get().left
|
||||
set({
|
||||
left: cur.includes(action)
|
||||
? cur.filter((a) => a !== action)
|
||||
: [...cur, action],
|
||||
})
|
||||
},
|
||||
toggleRight: (action) => {
|
||||
const cur = get().right
|
||||
set({
|
||||
right: cur.includes(action)
|
||||
? cur.filter((a) => a !== action)
|
||||
: [...cur, action],
|
||||
})
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'swipe-settings-storage',
|
||||
storage: createJSONStorage(() => stateStorage),
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export const useLeftSwipeActions = (): [
|
||||
SwipeActionType[],
|
||||
(actions: SwipeActionType[]) => void,
|
||||
] => {
|
||||
const left = useSwipeSettingsStore((s) => s.left)
|
||||
const setLeft = useSwipeSettingsStore((s) => s.setLeft)
|
||||
return [left, setLeft]
|
||||
}
|
||||
|
||||
export const useRightSwipeActions = (): [
|
||||
SwipeActionType[],
|
||||
(actions: SwipeActionType[]) => void,
|
||||
] => {
|
||||
const right = useSwipeSettingsStore((s) => s.right)
|
||||
const setRight = useSwipeSettingsStore((s) => s.setRight)
|
||||
return [right, setRight]
|
||||
}
|
||||
@@ -119,10 +119,13 @@ export function mapDtoToTrack(
|
||||
type: TrackType.Default,
|
||||
}
|
||||
|
||||
// Only include headers when we have an API token (streaming cases). For downloaded tracks it's not needed.
|
||||
const headers = (api as Api | undefined)?.accessToken
|
||||
? { 'X-Emby-Token': (api as Api).accessToken }
|
||||
: undefined
|
||||
|
||||
return {
|
||||
headers: {
|
||||
'X-Emby-Token': api.accessToken,
|
||||
},
|
||||
...(headers ? { headers } : {}),
|
||||
...trackMediaInfo,
|
||||
title: item.Name,
|
||||
album: item.Album,
|
||||
|
||||
Reference in New Issue
Block a user