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:
skalthoff
2025-11-05 10:39:21 -08:00
committed by GitHub
parent 1cc6a568d3
commit 657e07d835
25 changed files with 1535 additions and 182 deletions

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'),

View File

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

View File

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

View 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>
),
},
]}
/>
)
}

View File

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

View File

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

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

View File

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