custom linear haptics

This commit is contained in:
Boof2015
2026-07-13 02:20:57 -04:00
parent a34d9cba89
commit 261d095090
42 changed files with 1569 additions and 89 deletions
+31
View File
@@ -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;
+2 -2
View File
@@ -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) => {
+8 -1
View File
@@ -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 }}
>
+3 -3
View File
@@ -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(() => {
+7 -1
View File
@@ -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);
}}
+2 -2
View File
@@ -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) => {
+2 -2
View File
@@ -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 }}
+3 -3
View File
@@ -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(() => {
+9 -2
View File
@@ -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 }]} />
+9 -1
View File
@@ -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'}`}
>
+9 -1
View File
@@ -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}
>
+4 -4
View File
@@ -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>
+6 -1
View File
@@ -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);
};
+13 -13
View File
@@ -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 }}
+3 -4
View File
@@ -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),
+4 -3
View File
@@ -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(() => {
+4 -3
View File
@@ -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) => {
+2 -2
View File
@@ -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);
+6 -1
View File
@@ -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`}
+8 -1
View File
@@ -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}`}
+16 -3
View File
@@ -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 }}
+10 -2
View File
@@ -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} />