mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
custom linear haptics
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { Switch, type SwitchProps } from 'react-native';
|
||||
import { hapticForToggle } from '@/lib/hapticCatalog';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
|
||||
export interface HapticSwitchProps
|
||||
extends Omit<SwitchProps, 'value' | 'onValueChange'> {
|
||||
value: boolean;
|
||||
onValueChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
/** Switch feedback fires only for a user-requested value transition. */
|
||||
export function HapticSwitch({
|
||||
value,
|
||||
onValueChange,
|
||||
...props
|
||||
}: HapticSwitchProps) {
|
||||
const handleValueChange = (nextValue: boolean) => {
|
||||
if (nextValue !== value) playHaptic(hapticForToggle(nextValue));
|
||||
onValueChange(nextValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
{...props}
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default HapticSwitch;
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@/theme';
|
||||
import { createThemedStyles } from '@/theme/themed';
|
||||
import { formatDuration } from '@/lib/format';
|
||||
import { tickHaptic } from '@/lib/haptics';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
|
||||
const THUMB_SIZE = 12;
|
||||
|
||||
@@ -59,7 +59,7 @@ export function SeekBar({ currentTime, duration, onSeek, trackKey }: SeekBarProp
|
||||
const fraction = clamp(event.nativeEvent.locationX / Math.max(1, widthRef.current));
|
||||
grantRef.current = { fraction, pageX: event.nativeEvent.pageX };
|
||||
setScrub(fraction);
|
||||
tickHaptic();
|
||||
playHaptic('threshold');
|
||||
};
|
||||
|
||||
const handleMove = (event: GestureResponderEvent) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
import { motion } from '@/theme/motion';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
|
||||
const THUMB_INSET = 3;
|
||||
|
||||
@@ -106,11 +107,17 @@ function SegmentButton({
|
||||
color: interpolateColor(progress.value, [0, 1], [inactiveColor, activeColor]),
|
||||
}));
|
||||
|
||||
const handlePress = () => {
|
||||
if (focused) return;
|
||||
playHaptic('selection');
|
||||
onPress();
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
style={styles.segment}
|
||||
onPress={onPress}
|
||||
onPress={handlePress}
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected: focused }}
|
||||
>
|
||||
|
||||
@@ -18,7 +18,7 @@ import Animated, {
|
||||
} from 'react-native-reanimated';
|
||||
import { useColors } from '@/theme/themed';
|
||||
import { motion } from '@/theme/motion';
|
||||
import { commitHaptic, tickHaptic } from '@/lib/haptics';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
|
||||
type IconName = keyof typeof Ionicons.glyphMap;
|
||||
|
||||
@@ -79,7 +79,7 @@ export function SwipeableRow({
|
||||
const onCommit = (direction: 'right' | 'left') => {
|
||||
if (direction === 'right') swipeRight?.onCommit();
|
||||
else swipeLeft?.onCommit();
|
||||
commitHaptic();
|
||||
playHaptic('confirm');
|
||||
};
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
@@ -95,7 +95,7 @@ export function SwipeableRow({
|
||||
const nowArmed = Math.abs(t) >= arm;
|
||||
if (nowArmed !== armed.value) {
|
||||
armed.value = nowArmed;
|
||||
runOnJS(tickHaptic)();
|
||||
runOnJS(playHaptic)('threshold');
|
||||
}
|
||||
})
|
||||
.onEnd(() => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { motion } from '@/theme/motion';
|
||||
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
||||
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
|
||||
type IconName = keyof typeof Ionicons.glyphMap;
|
||||
type MiniPlayerPhase = 'hidden' | 'reserved' | 'visible';
|
||||
@@ -184,11 +185,16 @@ function TabButton({ meta, focused, onPress }: TabButtonProps) {
|
||||
color: interpolateColor(progress.value, [0, 1], [inactiveColor, activeColor]),
|
||||
}));
|
||||
|
||||
const handlePress = () => {
|
||||
if (!focused) playHaptic('selection');
|
||||
onPress();
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
android_ripple={ripple.icon(26)}
|
||||
style={styles.tab}
|
||||
onPress={onPress}
|
||||
onPress={handlePress}
|
||||
onPressIn={() => {
|
||||
press.value = withTiming(1, motion.quick);
|
||||
}}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { formatDuration } from '@/lib/format';
|
||||
import { downsampleWaveform, getWaveform } from '@/scope/waveform';
|
||||
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { tickHaptic } from '@/lib/haptics';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
|
||||
const CANVAS_HEIGHT = 58;
|
||||
const BAR_WIDTH = 3;
|
||||
@@ -125,7 +125,7 @@ export function WaveformSeekBar({
|
||||
const fraction = clamp(event.nativeEvent.locationX / Math.max(1, widthRef.current));
|
||||
grantRef.current = { fraction, pageX: event.nativeEvent.pageX };
|
||||
setScrub(fraction);
|
||||
tickHaptic();
|
||||
playHaptic('threshold');
|
||||
};
|
||||
|
||||
const handleMove = (event: GestureResponderEvent) => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '@/components/Text';
|
||||
import { HapticSwitch } from '@/components/HapticSwitch';
|
||||
import {
|
||||
radius,
|
||||
spacing,
|
||||
@@ -71,7 +71,7 @@ export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEdit
|
||||
</Pressable>
|
||||
<View style={styles.toggle}>
|
||||
<Text variant="label">{band.enabled ? 'On' : 'Off'}</Text>
|
||||
<Switch
|
||||
<HapticSwitch
|
||||
value={band.enabled}
|
||||
onValueChange={(enabled) => onUpdate({ enabled })}
|
||||
trackColor={{ false: colors.glassBorder, true: colors.accent }}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Text } from '@/components/Text';
|
||||
import { radius, spacing } from '@/theme';
|
||||
import { createThemedStyles } from '@/theme/themed';
|
||||
import { rgbaFromHex } from '@/theme/colorUtils';
|
||||
import { tickHaptic } from '@/lib/haptics';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
import { usePullSearchGestureRef } from '@/components/search/PullSearchGesture';
|
||||
import { RAIL_LETTERS } from '@/lib/letterIndex';
|
||||
|
||||
@@ -60,7 +60,7 @@ export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProp
|
||||
);
|
||||
const letter = RAIL_LETTERS[index];
|
||||
lastLetter.value = letter;
|
||||
runOnJS(tickHaptic)();
|
||||
runOnJS(playHaptic)('frequentStep');
|
||||
runOnJS(scrubTo)(letter);
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
@@ -76,7 +76,7 @@ export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProp
|
||||
const letter = RAIL_LETTERS[index];
|
||||
if (letter === lastLetter.value) return;
|
||||
lastLetter.value = letter;
|
||||
runOnJS(tickHaptic)();
|
||||
runOnJS(playHaptic)('frequentStep');
|
||||
runOnJS(scrubTo)(letter);
|
||||
})
|
||||
.onFinalize(() => {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
type FolderTreeNode
|
||||
} from '@/library/folderTree';
|
||||
import { formatDuration } from '@/lib/format';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
import {
|
||||
radius,
|
||||
spacing,
|
||||
@@ -78,7 +79,10 @@ function FolderRow({
|
||||
android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
style={styles.folderRow}
|
||||
onPress={() => onToggle(node.id)}
|
||||
onLongPress={() => onOpenActions(node)}
|
||||
onLongPress={() => {
|
||||
playHaptic('holdAccepted');
|
||||
onOpenActions(node);
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: isExpanded }}
|
||||
>
|
||||
@@ -157,7 +161,10 @@ function FolderTrackRow({
|
||||
android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
style={[styles.trackRow, active && styles.trackRowActive]}
|
||||
onPress={playFolderTrack}
|
||||
onLongPress={onOpenActions}
|
||||
onLongPress={() => {
|
||||
playHaptic('holdAccepted');
|
||||
onOpenActions();
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<View style={[styles.indent, { width: row.depth * 18 + 16 }]} />
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
||||
import { artworkUri } from '@/library/artwork';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
|
||||
export function PlaylistRow({
|
||||
name,
|
||||
@@ -46,7 +47,14 @@ export function PlaylistRow({
|
||||
android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
style={styles.row}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
onLongPress={
|
||||
onLongPress
|
||||
? () => {
|
||||
playHaptic('holdAccepted');
|
||||
onLongPress();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${name}, ${dynamic ? 'dynamic playlist, ' : ''}${trackCount} ${trackCount === 1 ? 'track' : 'tracks'}`}
|
||||
>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
||||
import { formatDuration } from '@/lib/format';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
import { trackArtworkThumbSource } from '@/library/artwork';
|
||||
import { dbTrackToTrack } from '@/library/trackAdapter';
|
||||
import { enqueueEnd, enqueueTop } from '@/audio/playbackController';
|
||||
@@ -69,6 +70,13 @@ export function TrackRow({
|
||||
|
||||
const thumbUri = failedArtKey !== artKey ? trackArtworkThumbSource(track) : null;
|
||||
const secondaryText = subtitle ?? (showArtist ? track.artist : null);
|
||||
const longPressAction = selectionMode ? onToggleSelect : (onLongPress ?? onOpenActions);
|
||||
const handleLongPress = longPressAction
|
||||
? () => {
|
||||
playHaptic('holdAccepted');
|
||||
longPressAction();
|
||||
}
|
||||
: undefined;
|
||||
const openActions = (event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
onOpenActions?.();
|
||||
@@ -79,7 +87,7 @@ export function TrackRow({
|
||||
android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
style={[styles.row, selectionMode && selected && styles.rowSelected]}
|
||||
onPress={selectionMode ? onToggleSelect : onPress}
|
||||
onLongPress={selectionMode ? onToggleSelect : (onLongPress ?? onOpenActions)}
|
||||
onLongPress={handleLongPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={selectionMode ? { selected } : undefined}
|
||||
>
|
||||
|
||||
@@ -96,7 +96,7 @@ export function LyricsView({
|
||||
|
||||
<TactilePressable android_ripple={ripple.bounded}
|
||||
onPress={onToggleFavorite}
|
||||
haptic="light"
|
||||
haptic={isFavorite ? 'toggleOff' : 'toggleOn'}
|
||||
confirmationScale={1.08}
|
||||
hitSlop={10}
|
||||
style={styles.stripBtn}
|
||||
@@ -133,17 +133,17 @@ export function LyricsView({
|
||||
<View style={styles.controls}>
|
||||
<SeekBar currentTime={currentTime} duration={duration} trackKey={track.id} onSeek={onSeek} />
|
||||
<View style={styles.transport}>
|
||||
<TactilePressable android_ripple={ripple.bounded} onPress={onPrev} haptic="light" hitSlop={12} style={styles.transportBtn} accessibilityLabel="Previous">
|
||||
<TactilePressable android_ripple={ripple.bounded} onPress={onPrev} haptic="action" hitSlop={12} style={styles.transportBtn} accessibilityLabel="Previous">
|
||||
<Ionicons name="play-skip-back" size={28} color={colors.textPrimary} />
|
||||
</TactilePressable>
|
||||
<TactilePressable android_ripple={ripple.bounded} onPress={onPlayPause} haptic="light" pressedScale={0.97} hitSlop={12} style={styles.playButton} accessibilityLabel={isPlaying ? 'Pause' : 'Play'}>
|
||||
<TactilePressable android_ripple={ripple.bounded} onPress={onPlayPause} haptic="action" pressedScale={0.97} hitSlop={12} style={styles.playButton} accessibilityLabel={isPlaying ? 'Pause' : 'Play'}>
|
||||
<Ionicons
|
||||
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
||||
size={28}
|
||||
color={colors.bgPrimary}
|
||||
/>
|
||||
</TactilePressable>
|
||||
<TactilePressable android_ripple={ripple.bounded} onPress={onNext} haptic="light" hitSlop={12} style={styles.transportBtn} accessibilityLabel="Next">
|
||||
<TactilePressable android_ripple={ripple.bounded} onPress={onNext} haptic="action" hitSlop={12} style={styles.transportBtn} accessibilityLabel="Next">
|
||||
<Ionicons name="play-skip-forward" size={28} color={colors.textPrimary} />
|
||||
</TactilePressable>
|
||||
</View>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { radius, spacing } from '@/theme';
|
||||
import { motion } from '@/theme/motion';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
import type { BaseThemeId } from '@/theme/resolve';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
import { useSettingsStore, type NowPlayingScopeStyle } from '@/stores/settingsStore';
|
||||
@@ -266,7 +267,11 @@ function ThemeStep() {
|
||||
return (
|
||||
<Pressable android_ripple={ripple.bounded}
|
||||
key={option.id}
|
||||
onPress={() => void setBaseTheme(option.id)}
|
||||
onPress={() => {
|
||||
if (selected) return;
|
||||
playHaptic('selection');
|
||||
void setBaseTheme(option.id);
|
||||
}}
|
||||
style={[styles.themePill, selected && styles.themePillSelected]}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected }}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { LyricsBand } from '@/components/lyrics/LyricsBand';
|
||||
import { QueueTray } from '@/components/queue/QueueTray';
|
||||
import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
|
||||
import { seekTo } from '@/audio/playbackController';
|
||||
import { tickHaptic } from '@/lib/haptics';
|
||||
import { spacing } from '@/theme';
|
||||
import { createThemedStyles } from '@/theme/themed';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
@@ -43,7 +42,6 @@ export function NowPlayingCompanionPane({
|
||||
const selectCompanion = (next: string) => {
|
||||
const value: NowPlayingCompanion = next === 'lyrics' ? 'lyrics' : 'queue';
|
||||
if (value === companion) return;
|
||||
tickHaptic();
|
||||
void setCompanion(value);
|
||||
};
|
||||
|
||||
|
||||
@@ -707,7 +707,7 @@ export function NowPlayingOverlay() {
|
||||
<TactilePressable
|
||||
hitSlop={10}
|
||||
style={styles.inlineActionBtn} android_ripple={ripple.icon(22)}
|
||||
haptic="light"
|
||||
haptic={activeTrack.isFavorite ? 'toggleOff' : 'toggleOn'}
|
||||
confirmationScale={1.08}
|
||||
onPress={() => void sendDesktopControl('toggle-favorite')}
|
||||
accessibilityLabel={activeTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
@@ -750,7 +750,7 @@ export function NowPlayingOverlay() {
|
||||
desktopSnapshot?.shuffle === undefined && styles.controlDisabled,
|
||||
]}
|
||||
disabled={desktopSnapshot?.shuffle === undefined}
|
||||
haptic="selection"
|
||||
haptic={desktopSnapshot?.shuffle ? 'toggleOff' : 'toggleOn'}
|
||||
onPress={() => void sendDesktopControl('toggle-shuffle')}
|
||||
accessibilityLabel="Shuffle"
|
||||
accessibilityState={{ selected: Boolean(desktopSnapshot?.shuffle) }}
|
||||
@@ -768,7 +768,7 @@ export function NowPlayingOverlay() {
|
||||
</TactilePressable>
|
||||
<TactilePressable
|
||||
onPress={() => void sendDesktopControl('previous')}
|
||||
haptic="light"
|
||||
haptic="action"
|
||||
hitSlop={12}
|
||||
style={styles.transportMainBtn} android_ripple={ripple.icon(26)}
|
||||
accessibilityLabel="Previous"
|
||||
@@ -781,7 +781,7 @@ export function NowPlayingOverlay() {
|
||||
</TactilePressable>
|
||||
<TactilePressable
|
||||
onPress={() => void sendDesktopControl(isPlaying ? 'pause' : 'play')}
|
||||
haptic="light"
|
||||
haptic="action"
|
||||
pressedScale={0.97}
|
||||
hitSlop={12}
|
||||
style={styles.playButton} android_ripple={ripple.onAccent()}
|
||||
@@ -795,7 +795,7 @@ export function NowPlayingOverlay() {
|
||||
</TactilePressable>
|
||||
<TactilePressable
|
||||
onPress={() => void sendDesktopControl('next')}
|
||||
haptic="light"
|
||||
haptic="action"
|
||||
hitSlop={12}
|
||||
style={styles.transportMainBtn} android_ripple={ripple.icon(26)}
|
||||
accessibilityLabel="Next"
|
||||
@@ -814,7 +814,7 @@ export function NowPlayingOverlay() {
|
||||
desktopSnapshot?.repeat === undefined && styles.controlDisabled,
|
||||
]}
|
||||
disabled={desktopSnapshot?.repeat === undefined}
|
||||
haptic="selection"
|
||||
haptic="modeCycle"
|
||||
onPress={() => void sendDesktopControl('toggle-repeat')}
|
||||
accessibilityLabel="Repeat"
|
||||
accessibilityState={{ selected: desktopSnapshot?.repeat !== 'none' }}
|
||||
@@ -1092,7 +1092,7 @@ export function NowPlayingOverlay() {
|
||||
<TactilePressable
|
||||
hitSlop={10}
|
||||
style={styles.inlineActionBtn} android_ripple={ripple.icon(22)}
|
||||
haptic="light"
|
||||
haptic={isFavorite ? 'toggleOff' : 'toggleOn'}
|
||||
confirmationScale={1.08}
|
||||
onPress={() => void toggleFavorite(track)}
|
||||
accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
@@ -1131,7 +1131,7 @@ export function NowPlayingOverlay() {
|
||||
<TactilePressable
|
||||
hitSlop={10}
|
||||
style={styles.transportSideBtn} android_ripple={ripple.icon(24)}
|
||||
haptic="selection"
|
||||
haptic={shuffle ? 'toggleOff' : 'toggleOn'}
|
||||
onPress={() => void toggleShuffle()}
|
||||
accessibilityLabel="Shuffle"
|
||||
accessibilityState={{ selected: shuffle }}
|
||||
@@ -1149,7 +1149,7 @@ export function NowPlayingOverlay() {
|
||||
</TactilePressable>
|
||||
<TactilePressable
|
||||
onPress={skipToPrevious}
|
||||
haptic="light"
|
||||
haptic="action"
|
||||
hitSlop={12}
|
||||
style={styles.transportMainBtn} android_ripple={ripple.icon(26)}
|
||||
accessibilityLabel="Previous"
|
||||
@@ -1162,7 +1162,7 @@ export function NowPlayingOverlay() {
|
||||
</TactilePressable>
|
||||
<TactilePressable
|
||||
onPress={togglePlay}
|
||||
haptic="light"
|
||||
haptic="action"
|
||||
pressedScale={0.97}
|
||||
hitSlop={12}
|
||||
style={styles.playButton}
|
||||
@@ -1177,7 +1177,7 @@ export function NowPlayingOverlay() {
|
||||
</TactilePressable>
|
||||
<TactilePressable
|
||||
onPress={skipToNext}
|
||||
haptic="light"
|
||||
haptic="action"
|
||||
hitSlop={12}
|
||||
style={styles.transportMainBtn} android_ripple={ripple.icon(26)}
|
||||
accessibilityLabel="Next"
|
||||
@@ -1191,7 +1191,7 @@ export function NowPlayingOverlay() {
|
||||
<TactilePressable
|
||||
hitSlop={10}
|
||||
style={styles.transportSideBtn} android_ripple={ripple.icon(24)}
|
||||
haptic="selection"
|
||||
haptic="modeCycle"
|
||||
onPress={() => void cycleRepeat()}
|
||||
accessibilityLabel="Repeat"
|
||||
accessibilityState={{ selected: repeat !== 'none' }}
|
||||
@@ -1232,7 +1232,7 @@ export function NowPlayingOverlay() {
|
||||
<TactilePressable
|
||||
hitSlop={10}
|
||||
style={styles.subBtn} android_ripple={ripple.icon(20)}
|
||||
haptic="selection"
|
||||
haptic={scopeStageVisible ? 'toggleOff' : 'toggleOn'}
|
||||
onPress={() => void setScopeStageVisible(!scopeStageVisible)}
|
||||
accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'}
|
||||
accessibilityState={{ selected: scopeStageVisible }}
|
||||
|
||||
@@ -12,12 +12,12 @@ import Animated, {
|
||||
withSequence,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { commitHaptic, tickHaptic } from '@/lib/haptics';
|
||||
import { playHaptic, type HapticEvent } from '@/lib/haptics';
|
||||
import { motion } from '@/theme/motion';
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
||||
|
||||
type HapticFeedback = 'selection' | 'light' | 'none';
|
||||
type HapticFeedback = HapticEvent | 'none';
|
||||
|
||||
interface TactilePressableProps
|
||||
extends Omit<PressableProps, 'children' | 'style'> {
|
||||
@@ -60,8 +60,7 @@ export function TactilePressable({
|
||||
};
|
||||
|
||||
const handlePress: NonNullable<PressableProps['onPress']> = (event) => {
|
||||
if (haptic === 'selection') tickHaptic();
|
||||
else if (haptic === 'light') commitHaptic();
|
||||
if (haptic !== 'none') playHaptic(haptic);
|
||||
if (confirmationScale) {
|
||||
scale.value = withSequence(
|
||||
withTiming(confirmationScale, motion.quick),
|
||||
|
||||
@@ -46,7 +46,7 @@ import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
||||
import { motion } from '@/theme/motion';
|
||||
import { artworkThumbFromSource } from '@/library/artwork';
|
||||
import { dragArmHaptic, tickHaptic } from '@/lib/haptics';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
import { useQueueStore } from '@/stores/queueStore';
|
||||
import {
|
||||
jumpToQueueIndex,
|
||||
@@ -334,6 +334,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
||||
}
|
||||
|
||||
const nextEntries = arrayMove(snapshot, from, to);
|
||||
playHaptic('queueDrop');
|
||||
setVisibleEntries(nextEntries);
|
||||
clearDragAfterReorderCommit();
|
||||
commitNativeMove(
|
||||
@@ -347,7 +348,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
||||
|
||||
const onDragArm = useCallback(() => {
|
||||
dragInFlightRef.current = true;
|
||||
dragArmHaptic();
|
||||
playHaptic('queueLift');
|
||||
}, []);
|
||||
|
||||
const onDragAbort = useCallback(() => {
|
||||
@@ -380,7 +381,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
||||
);
|
||||
if (nextTarget !== dTarget.value) {
|
||||
dTarget.value = nextTarget;
|
||||
runOnJS(tickHaptic)();
|
||||
runOnJS(playHaptic)('frequentStep');
|
||||
}
|
||||
})
|
||||
.onEnd(() => {
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
spacing,
|
||||
} from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { commitHaptic, tickHaptic } from '@/lib/haptics';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
|
||||
const OPEN_THRESHOLD = 76;
|
||||
const RESET_THRESHOLD = 58;
|
||||
@@ -150,7 +150,7 @@ export function PullSearchGesture({
|
||||
}, []);
|
||||
|
||||
const open = useCallback(() => {
|
||||
commitHaptic();
|
||||
playHaptic('pullRelease');
|
||||
onOpen();
|
||||
resetUi();
|
||||
}, [onOpen, resetUi]);
|
||||
@@ -204,10 +204,11 @@ export function PullSearchGesture({
|
||||
if (!armedValue.value && nextPull >= OPEN_THRESHOLD) {
|
||||
armedValue.value = true;
|
||||
runOnJS(setArmed)(true);
|
||||
runOnJS(tickHaptic)();
|
||||
runOnJS(playHaptic)('pullLatch');
|
||||
} else if (armedValue.value && nextPull < RESET_THRESHOLD) {
|
||||
armedValue.value = false;
|
||||
runOnJS(setArmed)(false);
|
||||
runOnJS(playHaptic)('thresholdExit');
|
||||
}
|
||||
})
|
||||
.onEnd((event) => {
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
} from '@/library/artwork';
|
||||
import { multiFieldScore, MIN_SCORE_THRESHOLD } from '@/lib/fuzzySearch';
|
||||
import { formatDuration } from '@/lib/format';
|
||||
import { commitHaptic } from '@/lib/haptics';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
import { usePlaylistStore } from '@/stores/playlistStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
@@ -945,7 +945,7 @@ function QuickSearchPanel({
|
||||
};
|
||||
|
||||
const queueTrack = (track: DbTrack) => {
|
||||
commitHaptic();
|
||||
playHaptic('confirm');
|
||||
const existingTimer = queuedFeedbackTimers.current.get(track.path);
|
||||
if (existingTimer) clearTimeout(existingTimer);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { spacing } from '@/theme';
|
||||
import { ACCENTS, ACCENT_IDS, type AccentId } from '@/theme/accents';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
|
||||
const SWATCH_SIZE = 36;
|
||||
|
||||
@@ -26,7 +27,11 @@ export function AccentSwatchRow({ value, onChange }: AccentSwatchRowProps) {
|
||||
return (
|
||||
<Pressable android_ripple={ripple.bounded}
|
||||
key={id}
|
||||
onPress={() => onChange(id)}
|
||||
onPress={() => {
|
||||
if (selected) return;
|
||||
playHaptic('selection');
|
||||
onChange(id);
|
||||
}}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected }}
|
||||
accessibilityLabel={`${ACCENTS[id].label} accent`}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { radius, spacing } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
import type { NowPlayingScopeStyle } from '@/stores/settingsStore';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
|
||||
interface ScopeStyleCardsProps {
|
||||
/** null renders neither card selected (onboarding: no preselection bias). */
|
||||
@@ -59,11 +60,17 @@ function StyleCard({
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
|
||||
const handlePress = () => {
|
||||
if (selected) return;
|
||||
playHaptic('selection');
|
||||
onPress();
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
style={[styles.card, selected && styles.cardSelected]}
|
||||
onPress={onPress}
|
||||
onPress={handlePress}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected }}
|
||||
accessibilityLabel={`${title}. ${description}`}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useSettingsStore } from '@/stores/settingsStore';
|
||||
import { useThemeStore } from '@/stores/themeStore';
|
||||
import type { LastFmStatus } from '@/types/lastFm';
|
||||
import { Text } from '@/components/Text';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
|
||||
export function lastFmScrobbleSubtitle(status: LastFmStatus | null): string {
|
||||
const connected = status?.profiles.filter((p) => p.connected).length ?? 0;
|
||||
@@ -110,7 +111,11 @@ export function AppearanceSettingsPanel() {
|
||||
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
key={option.id}
|
||||
style={[styles.option, selected && styles.optionSelected]}
|
||||
onPress={() => void setBaseTheme(option.id)}
|
||||
onPress={() => {
|
||||
if (selected) return;
|
||||
playHaptic('selection');
|
||||
void setBaseTheme(option.id);
|
||||
}}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected }}
|
||||
>
|
||||
@@ -331,7 +336,11 @@ export function LibrarySettingsPanel() {
|
||||
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
key={option.mode}
|
||||
style={[styles.option, selected && styles.optionSelected]}
|
||||
onPress={() => void setArtistGroupingMode(option.mode)}
|
||||
onPress={() => {
|
||||
if (selected) return;
|
||||
playHaptic('selection');
|
||||
void setArtistGroupingMode(option.mode);
|
||||
}}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected }}
|
||||
>
|
||||
@@ -418,7 +427,11 @@ export function AudioSettingsPanel() {
|
||||
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
key={m.mode}
|
||||
style={[styles.modePill, selected && styles.modePillSelected]}
|
||||
onPress={() => void setReplayGainMode(m.mode)}
|
||||
onPress={() => {
|
||||
if (selected) return;
|
||||
playHaptic('selection');
|
||||
void setReplayGainMode(m.mode);
|
||||
}}
|
||||
>
|
||||
<Text variant="label" color={selected ? colors.accentTextStrong : colors.textSecondary}>
|
||||
{m.label}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
View,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
@@ -15,6 +14,7 @@ import { Text } from '@/components/Text';
|
||||
import { radius, spacing } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
||||
import { HapticSwitch } from '@/components/HapticSwitch';
|
||||
|
||||
export type SettingsIconName = keyof typeof Ionicons.glyphMap;
|
||||
|
||||
@@ -148,7 +148,7 @@ export function SettingsToggleRow({
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
<HapticSwitch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: colors.glassBorder, true: colors.accent }}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
|
||||
export function AppSheet({ onClose, children }: { onClose: () => void; children: ReactNode }) {
|
||||
const styles = useStyles();
|
||||
@@ -98,14 +99,21 @@ export function AppSheetItem({
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
const tint = destructive ? colors.warning : selected ? colors.accentTextStrong : colors.textPrimary;
|
||||
const selectable = selected !== undefined;
|
||||
|
||||
const handlePress = () => {
|
||||
if (selectable && !selected) playHaptic('selection');
|
||||
onPress();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.itemRow}>
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
style={styles.item}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
onPress={handlePress}
|
||||
accessibilityRole={selectable ? 'radio' : 'button'}
|
||||
accessibilityState={selectable ? { selected } : undefined}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons name={icon} size={20} color={destructive ? colors.warning : colors.textSecondary} />
|
||||
|
||||
Reference in New Issue
Block a user