diff --git a/modules/astra-haptics/android/build.gradle b/modules/astra-haptics/android/build.gradle new file mode 100644 index 0000000..37343d2 --- /dev/null +++ b/modules/astra-haptics/android/build.gradle @@ -0,0 +1,18 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'expo.modules.astrahaptics' +version = '0.1.0' + +android { + namespace "expo.modules.astrahaptics" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } + lintOptions { + abortOnError false + } +} diff --git a/modules/astra-haptics/android/src/main/AndroidManifest.xml b/modules/astra-haptics/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..eaa86c6 --- /dev/null +++ b/modules/astra-haptics/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/modules/astra-haptics/android/src/main/java/expo/modules/astrahaptics/AstraHapticsModule.kt b/modules/astra-haptics/android/src/main/java/expo/modules/astrahaptics/AstraHapticsModule.kt new file mode 100644 index 0000000..6b36492 --- /dev/null +++ b/modules/astra-haptics/android/src/main/java/expo/modules/astrahaptics/AstraHapticsModule.kt @@ -0,0 +1,163 @@ +package expo.modules.astrahaptics + +import android.content.Context +import android.media.AudioAttributes +import android.os.Build +import android.os.VibrationAttributes +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.provider.Settings +import expo.modules.kotlin.exception.Exceptions +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.records.Field +import expo.modules.kotlin.records.Record + +class HapticCompositionStepRecord : Record { + @Field + val primitive: String = "" + + @Field + val scale: Float = 1f + + @Field + val delayMs: Int = 0 +} + +private data class PrimitiveSpec( + val id: Int, + val minApi: Int, +) + +class AstraHapticsModule : Module() { + private val context: Context + get() = appContext.reactContext ?: throw Exceptions.ReactContextLost() + + private val vibrator: Vibrator + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + (context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager).defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + + override fun definition() = ModuleDefinition { + Name("AstraHaptics") + + Function("getCapabilities") { + capabilities() + } + + Function("isTouchFeedbackEnabled") { + touchFeedbackEnabled() + } + + Function("playComposition") { steps: List -> + playComposition(steps) + } + } + + private fun capabilities(): Map { + val currentVibrator = vibrator + val canCompose = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && currentVibrator.hasVibrator() + + val primitiveCapabilities = linkedMapOf>() + PRIMITIVES.entries.forEach { entry -> + val availableOnApi = Build.VERSION.SDK_INT >= entry.value.minApi + val supported = canCompose && availableOnApi && + currentVibrator.areAllPrimitivesSupported(entry.value.id) + val duration = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && supported) { + currentVibrator.getPrimitiveDurations(entry.value.id).firstOrNull() ?: 0 + } else { + 0 + } + primitiveCapabilities[entry.key] = mapOf( + "supported" to supported, + "durationMs" to duration, + ) + } + + return mapOf( + "apiLevel" to Build.VERSION.SDK_INT, + "hasVibrator" to currentVibrator.hasVibrator(), + "hasAmplitudeControl" to ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && currentVibrator.hasAmplitudeControl() + ), + "touchFeedbackEnabled" to touchFeedbackEnabled(), + "primitives" to primitiveCapabilities, + ) + } + + private fun playComposition(steps: List): Boolean { + if ( + Build.VERSION.SDK_INT < Build.VERSION_CODES.R || + steps.isEmpty() || + steps.size > MAX_STEPS || + !vibrator.hasVibrator() || + !touchFeedbackEnabled() + ) { + return false + } + + val specs = steps.map { step -> + if (!step.scale.isFinite() || step.scale <= 0f || step.scale > 1f) return false + if (step.delayMs < 0 || step.delayMs > MAX_DELAY_MS) return false + val spec = PRIMITIVES[step.primitive] ?: return false + if (Build.VERSION.SDK_INT < spec.minApi) return false + spec + } + + if (!vibrator.areAllPrimitivesSupported(*specs.map { it.id }.toIntArray())) return false + + return try { + val composition = VibrationEffect.startComposition() + steps.forEachIndexed { index, step -> + composition.addPrimitive(specs[index].id, step.scale, step.delayMs) + } + vibrateForTouch(composition.compose()) + true + } catch (_: Exception) { + false + } + } + + private fun vibrateForTouch(effect: VibrationEffect) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + vibrator.vibrate( + effect, + VibrationAttributes.createForUsage(VibrationAttributes.USAGE_TOUCH), + ) + return + } + + val attributes = AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) + .build() + vibrator.vibrate(effect, attributes) + } + + private fun touchFeedbackEnabled(): Boolean = + Settings.System.getInt( + context.contentResolver, + Settings.System.HAPTIC_FEEDBACK_ENABLED, + 1, + ) != 0 + + companion object { + private const val MAX_STEPS = 8 + private const val MAX_DELAY_MS = 1_000 + + private val PRIMITIVES = linkedMapOf( + "click" to PrimitiveSpec(VibrationEffect.Composition.PRIMITIVE_CLICK, 30), + "thud" to PrimitiveSpec(VibrationEffect.Composition.PRIMITIVE_THUD, 31), + "spin" to PrimitiveSpec(VibrationEffect.Composition.PRIMITIVE_SPIN, 31), + "quickRise" to PrimitiveSpec(VibrationEffect.Composition.PRIMITIVE_QUICK_RISE, 30), + "slowRise" to PrimitiveSpec(VibrationEffect.Composition.PRIMITIVE_SLOW_RISE, 30), + "quickFall" to PrimitiveSpec(VibrationEffect.Composition.PRIMITIVE_QUICK_FALL, 30), + "tick" to PrimitiveSpec(VibrationEffect.Composition.PRIMITIVE_TICK, 30), + "lowTick" to PrimitiveSpec(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, 31), + ) + } +} diff --git a/modules/astra-haptics/expo-module.config.json b/modules/astra-haptics/expo-module.config.json new file mode 100644 index 0000000..b41ca57 --- /dev/null +++ b/modules/astra-haptics/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["expo.modules.astrahaptics.AstraHapticsModule"] + } +} diff --git a/modules/astra-haptics/index.ts b/modules/astra-haptics/index.ts new file mode 100644 index 0000000..94f7a3e --- /dev/null +++ b/modules/astra-haptics/index.ts @@ -0,0 +1,66 @@ +import { Platform } from 'react-native'; +import { requireOptionalNativeModule, type NativeModule } from 'expo-modules-core'; +import type { + HapticCapabilities, + HapticCompositionStep, + HapticPrimitive, + HapticPrimitiveCapability, +} from './types'; + +declare class AstraHapticsModuleType extends NativeModule { + getCapabilities(): Omit; + isTouchFeedbackEnabled(): boolean; + playComposition(steps: HapticCompositionStep[]): boolean; +} + +const native = requireOptionalNativeModule('AstraHaptics'); + +const PRIMITIVES: HapticPrimitive[] = [ + 'click', + 'thud', + 'spin', + 'quickRise', + 'slowRise', + 'quickFall', + 'tick', + 'lowTick', +]; + +function unsupportedPrimitives(): Record { + return Object.fromEntries( + PRIMITIVES.map((primitive) => [primitive, { supported: false, durationMs: 0 }]) + ) as Record; +} + +export const AstraHaptics = { + isAvailable: native !== null, + + getCapabilities(): HapticCapabilities { + if (!native) { + return { + moduleAvailable: false, + apiLevel: typeof Platform.Version === 'number' ? Platform.Version : 0, + hasVibrator: false, + hasAmplitudeControl: false, + touchFeedbackEnabled: false, + primitives: unsupportedPrimitives(), + }; + } + return { moduleAvailable: true, ...native.getCapabilities() }; + }, + + isTouchFeedbackEnabled(): boolean { + return native?.isTouchFeedbackEnabled() ?? true; + }, + + playComposition(steps: HapticCompositionStep[]): boolean { + return native?.playComposition(steps) ?? false; + }, +}; + +export type { + HapticCapabilities, + HapticCompositionStep, + HapticPrimitive, + HapticPrimitiveCapability, +} from './types'; diff --git a/modules/astra-haptics/types.ts b/modules/astra-haptics/types.ts new file mode 100644 index 0000000..3f2115b --- /dev/null +++ b/modules/astra-haptics/types.ts @@ -0,0 +1,29 @@ +export type HapticPrimitive = + | 'click' + | 'thud' + | 'spin' + | 'quickRise' + | 'slowRise' + | 'quickFall' + | 'tick' + | 'lowTick'; + +export interface HapticCompositionStep { + primitive: HapticPrimitive; + scale: number; + delayMs?: number; +} + +export interface HapticPrimitiveCapability { + supported: boolean; + durationMs: number; +} + +export interface HapticCapabilities { + moduleAvailable: boolean; + apiLevel: number; + hasVibrator: boolean; + hasAmplitudeControl: boolean; + touchFeedbackEnabled: boolean; + primitives: Record; +} diff --git a/package.json b/package.json index e42fcf2..da1b967 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts", "test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts", "test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts", + "test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts", "typecheck": "tsc --noEmit", "postinstall": "patch-package" }, diff --git a/src/app/(tabs)/eq.tsx b/src/app/(tabs)/eq.tsx index 9d0929f..ccbb2be 100644 --- a/src/app/(tabs)/eq.tsx +++ b/src/app/(tabs)/eq.tsx @@ -39,6 +39,8 @@ import { } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; +import { hapticForToggle } from '@/lib/hapticCatalog'; +import { playHaptic } from '@/lib/haptics'; import { isWideWindow } from '@/theme/adaptive'; import { useEQStore } from '@/stores/eqStore'; import { useScopeActive } from '@/scope/scopeStore'; @@ -242,7 +244,11 @@ export default function EQScreen() { activeBandId={eq.activeBandId} enabled={eq.enabled} spectrumActive={scopeActive && focused} - onSelectBand={eq.selectBand} + onSelectBand={(id) => { + if (id === eq.activeBandId) return; + playHaptic('selection'); + eq.selectBand(id); + }} onChangeBand={(id, updates) => eq.updateBand(id, updates)} /> ); @@ -260,8 +266,15 @@ export default function EQScreen() { bands={eq.bands} activeBandId={eq.activeBandId} canAdd={eq.bands.length < EQ_MAX_BANDS} - onSelect={eq.selectBand} - onAdd={() => eq.addBand()} + onSelect={(id) => { + if (id === eq.activeBandId) return; + playHaptic('selection'); + eq.selectBand(id); + }} + onAdd={() => { + playHaptic('action'); + eq.addBand(); + }} /> ); @@ -289,7 +302,10 @@ export default function EQScreen() { { + playHaptic(hapticForToggle(!eq.enabled)); + eq.toggleEnabled(); + }} > { eq.removeBand(activeBand.id); + playHaptic('confirm'); closeSheet(); }} /> diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx index 94dc131..761abc1 100644 --- a/src/app/(tabs)/library/index.tsx +++ b/src/app/(tabs)/library/index.tsx @@ -49,7 +49,7 @@ import { playTracks } from '@/audio/playbackController'; import { dbTrackToTrack } from '@/library/trackAdapter'; -import { commitHaptic, dragArmHaptic } from '@/lib/haptics'; +import { playHaptic } from '@/lib/haptics'; import { sortTracks, TRACK_SORT_LABELS, @@ -161,12 +161,13 @@ export default function LibraryScreen() { // Multi-select (tracks view): long-press arms it, batch actions live in the // bottom bar, selection order follows the current display order. const enterSelection = (track: DbTrack) => { - dragArmHaptic(); + playHaptic('threshold'); setSelectMode(true); setSelectedIds(new Set([track.id])); }; const toggleSelected = (id: number) => { + playHaptic('selection'); setSelectedIds((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); @@ -194,14 +195,14 @@ export default function LibraryScreen() { const batchPlayNext = () => { const tracks = selectedDbTracks().map(dbTrackToTrack); - commitHaptic(); + playHaptic('confirm'); exitSelection(); void enqueueTopMany(tracks); }; const batchAddToQueue = () => { const tracks = selectedDbTracks().map(dbTrackToTrack); - commitHaptic(); + playHaptic('confirm'); exitSelection(); void enqueueEndMany(tracks); }; @@ -420,7 +421,7 @@ export default function LibraryScreen() { subtitle={`${selectedIds.size} ${selectedIds.size === 1 ? 'track' : 'tracks'}`} onClose={() => setPlaylistPickerOpen(false)} onAdded={() => { - commitHaptic(); + playHaptic('confirm'); exitSelection(); }} /> diff --git a/src/app/(tabs)/library/playlist/[id].tsx b/src/app/(tabs)/library/playlist/[id].tsx index a3c2be8..fd7566d 100644 --- a/src/app/(tabs)/library/playlist/[id].tsx +++ b/src/app/(tabs)/library/playlist/[id].tsx @@ -34,6 +34,7 @@ import { playTracks, shuffleTracks } from '@/audio/playbackController'; import { dbTrackToTrack } from '@/library/trackAdapter'; import { artworkThumbUri, artworkUri } from '@/library/artwork'; import { formatDuration } from '@/lib/format'; +import { playHaptic } from '@/lib/haptics'; import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack'; import type { DbTrack } from '@/types/library'; import type { Playlist, PlaylistTrackEntry } from '@/types/playlist'; @@ -58,7 +59,16 @@ function MissingRow({ entry, onLongPress }: { entry: PlaylistTrackEntry; onLongP const ripple = useRipple(); const colors = useColors(); return ( - + { + playHaptic('holdAccepted'); + onLongPress(); + }} + accessibilityRole="button" + > {entry.fallback_title ?? basename(entry.track_path)} diff --git a/src/app/desktop-sync.tsx b/src/app/desktop-sync.tsx index a3276e3..228b88e 100644 --- a/src/app/desktop-sync.tsx +++ b/src/app/desktop-sync.tsx @@ -10,13 +10,13 @@ import { Pressable, ScrollView, StyleSheet, - Switch, View, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { Screen } from '@/components/Screen'; import { Text } from '@/components/Text'; +import { HapticSwitch } from '@/components/HapticSwitch'; import { SyncConflictDetails } from '@/components/sync/SyncConflictDetails'; import { radius, spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; @@ -265,7 +265,7 @@ export default function DesktopSyncScreen() { foreground. Manual and desktop-requested syncs always work. - void setAutoSyncEnabled(value)} trackColor={{ false: colors.glassBorder, true: colors.accent }} diff --git a/src/app/lastfm/index.tsx b/src/app/lastfm/index.tsx index 32054dd..4a28e83 100644 --- a/src/app/lastfm/index.tsx +++ b/src/app/lastfm/index.tsx @@ -4,13 +4,13 @@ import { Pressable, ScrollView, StyleSheet, - Switch, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { Screen } from '@/components/Screen'; import { Text } from '@/components/Text'; +import { HapticSwitch } from '@/components/HapticSwitch'; import { radius, spacing, @@ -181,7 +181,7 @@ export default function LastFmScreen() { {profile.connected ? ( - void setProfileEnabled(profile.id, v)} trackColor={{ false: colors.glassBorder, true: colors.accent }} @@ -225,7 +225,7 @@ export default function LastFmScreen() { Submit played tracks + "now playing" to your connected destinations. - void setEnabled(v)} trackColor={{ false: colors.glassBorder, true: colors.accent }} diff --git a/src/app/settings/experimental.tsx b/src/app/settings/experimental.tsx index ec4ba15..e62437c 100644 --- a/src/app/settings/experimental.tsx +++ b/src/app/settings/experimental.tsx @@ -71,6 +71,12 @@ export default function ExperimentalSettingsScreen() { /> DEVELOPER + router.push('/settings/haptics-lab' as never)} + /> (() => + AstraHaptics.getCapabilities() + ); + const [status, setStatus] = useState(null); + + const refresh = () => { + setCapabilities(AstraHaptics.getCapabilities()); + setStatus(null); + }; + + const playComposition = (steps: readonly HapticCompositionStep[], label: string) => { + const played = AstraHaptics.playComposition(steps.map((step) => ({ ...step }))); + setStatus(played ? `Played ${label}.` : `${label} could not play on this device.`); + }; + + return ( + + + Audition Android's semantic feedback and Astra's shared composition + candidates. Timing calibration is recorded; the recipes below are ready for a + fresh vote. Custom candidates are not wired into production gestures yet. + + + DEVICE + + + + + + + + Refresh capabilities + + + + SEMANTIC VOCABULARY + + {SEMANTIC_EVENTS.map(({ event, label, description }) => ( + + + {label} + {description} + + playHaptic(event)} /> + + ))} + + + PRIMITIVES + + {PRIMITIVES.map(({ primitive, label }) => { + const capability = capabilities.primitives[primitive]; + return ( + + + {label} + + {capability.supported + ? capability.durationMs > 0 + ? `${capability.durationMs} ms` + : 'Supported' + : 'Unsupported'} + + + + {SCALES.map((scale) => ( + + playComposition([{ primitive, scale, delayMs: 0 }], `${label} ${scale}`) + } + /> + ))} + + + ); + })} + + + {HAPTIC_RECIPE_SECTIONS.map((section) => ( + + {section.label} + + {section.description} + + {section.groups.map((group) => { + const leadingCandidate = group.candidates.find( + (candidate) => candidate.id === group.leadingCandidateId + ); + return ( + + {group.label} + {group.description} + {leadingCandidate ? ( + + {section.id === 'timing' + ? 'Selected calibration' + : group.selectionStatus === 'provisional' + ? 'Provisional choice' + : 'Selected candidate'}{' '} + · {leadingCandidate.label} + + ) : null} + + {group.candidates.map((candidate) => { + const unsupported = unsupportedRecipePrimitives( + candidate.steps, + capabilities + ); + const enabled = canPlayHapticRecipe(candidate.steps, capabilities); + return ( + + playComposition(candidate.steps, candidate.label) + } + onLongPress={ + group.id === 'holdAccepted' + ? () => playComposition(candidate.steps, candidate.label) + : undefined + } + /> + {!enabled ? ( + + {unsupported.length > 0 + ? `Needs ${unsupported.join(', ')}` + : capabilities.touchFeedbackEnabled + ? 'Unavailable' + : 'Touch feedback is off'} + + ) : null} + + ); + })} + + + ); + })} + + ))} + + {status ? ( + + {status} + + ) : null} + + ); +} + +function CapabilityRow({ label, value }: { label: string; value: string }) { + const styles = useStyles(); + const colors = useColors(); + return ( + + {label} + {value} + + ); +} + +function AuditionButton({ + label, + disabled = false, + wide = false, + onPress, + onLongPress, +}: { + label: string; + disabled?: boolean; + wide?: boolean; + onPress?: () => void; + onLongPress?: () => void; +}) { + const styles = useStyles(); + const colors = useColors(); + const ripple = useRipple(); + return ( + + + {label} + + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + intro: { + lineHeight: 18, + marginBottom: spacing.sm, + }, + stack: { + gap: spacing.lg, + }, + capabilityRow: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: spacing.md, + paddingVertical: spacing.xs, + }, + refreshButton: { + alignSelf: 'flex-start', + marginTop: spacing.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.sm, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.accent, + backgroundColor: colors.glassHighlight, + }, + auditionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + }, + rowCopy: { + flex: 1, + minWidth: 0, + gap: 2, + }, + primitiveBlock: { + gap: spacing.sm, + }, + primitiveHeading: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.md, + }, + buttonRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + auditionButton: { + minWidth: 62, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.sm, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.accent, + backgroundColor: colors.glassHighlight, + }, + wideButton: { + alignSelf: 'stretch', + }, + disabled: { + borderColor: colors.glassBorder, + backgroundColor: colors.bgTertiary, + opacity: 0.65, + }, + recipeCard: { + gap: spacing.sm, + }, + recipeSection: { + gap: spacing.md, + }, + sectionIntro: { + lineHeight: 18, + }, + recipeButtons: { + gap: spacing.md, + marginTop: spacing.xs, + }, + recipeCandidate: { + gap: spacing.xs, + }, + status: { + marginTop: spacing.md, + textAlign: 'center', + }, +})); diff --git a/src/components/HapticSwitch.tsx b/src/components/HapticSwitch.tsx new file mode 100644 index 0000000..f308b86 --- /dev/null +++ b/src/components/HapticSwitch.tsx @@ -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 { + 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 ( + + ); +} + +export default HapticSwitch; diff --git a/src/components/SeekBar.tsx b/src/components/SeekBar.tsx index ec79d2f..b5aa8bc 100644 --- a/src/components/SeekBar.tsx +++ b/src/components/SeekBar.tsx @@ -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) => { diff --git a/src/components/SegmentedControl.tsx b/src/components/SegmentedControl.tsx index 4a0f739..16d8c8e 100644 --- a/src/components/SegmentedControl.tsx +++ b/src/components/SegmentedControl.tsx @@ -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 ( diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx index 9899c4a..473848c 100644 --- a/src/components/SwipeableRow.tsx +++ b/src/components/SwipeableRow.tsx @@ -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(() => { diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 6d64d5a..310b45e 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -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 ( { press.value = withTiming(1, motion.quick); }} diff --git a/src/components/WaveformSeekBar.tsx b/src/components/WaveformSeekBar.tsx index 7339659..80160a8 100644 --- a/src/components/WaveformSeekBar.tsx +++ b/src/components/WaveformSeekBar.tsx @@ -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) => { diff --git a/src/components/eq/BandDetailPanel.tsx b/src/components/eq/BandDetailPanel.tsx index 53ecc73..317bb2f 100644 --- a/src/components/eq/BandDetailPanel.tsx +++ b/src/components/eq/BandDetailPanel.tsx @@ -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 {band.enabled ? 'On' : 'Off'} - onUpdate({ enabled })} trackColor={{ false: colors.glassBorder, true: colors.accent }} diff --git a/src/components/library/AlphabetRail.tsx b/src/components/library/AlphabetRail.tsx index e9e72d5..30dfcb2 100644 --- a/src/components/library/AlphabetRail.tsx +++ b/src/components/library/AlphabetRail.tsx @@ -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(() => { diff --git a/src/components/library/FoldersView.tsx b/src/components/library/FoldersView.tsx index 5492740..1df9ad2 100644 --- a/src/components/library/FoldersView.tsx +++ b/src/components/library/FoldersView.tsx @@ -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" > diff --git a/src/components/library/PlaylistRow.tsx b/src/components/library/PlaylistRow.tsx index e75750f..77f750c 100644 --- a/src/components/library/PlaylistRow.tsx +++ b/src/components/library/PlaylistRow.tsx @@ -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'}`} > diff --git a/src/components/library/TrackRow.tsx b/src/components/library/TrackRow.tsx index c511bd6..8c6b517 100644 --- a/src/components/library/TrackRow.tsx +++ b/src/components/library/TrackRow.tsx @@ -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} > diff --git a/src/components/lyrics/LyricsView.tsx b/src/components/lyrics/LyricsView.tsx index 1a76654..5233683 100644 --- a/src/components/lyrics/LyricsView.tsx +++ b/src/components/lyrics/LyricsView.tsx @@ -96,7 +96,7 @@ export function LyricsView({ - + - + - + diff --git a/src/components/onboarding/OnboardingFlow.tsx b/src/components/onboarding/OnboardingFlow.tsx index 677b230..6026ec0 100644 --- a/src/components/onboarding/OnboardingFlow.tsx +++ b/src/components/onboarding/OnboardingFlow.tsx @@ -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 ( void setBaseTheme(option.id)} + onPress={() => { + if (selected) return; + playHaptic('selection'); + void setBaseTheme(option.id); + }} style={[styles.themePill, selected && styles.themePillSelected]} accessibilityRole="radio" accessibilityState={{ selected }} diff --git a/src/components/player/NowPlayingCompanionPane.tsx b/src/components/player/NowPlayingCompanionPane.tsx index 9258105..99a1e66 100644 --- a/src/components/player/NowPlayingCompanionPane.tsx +++ b/src/components/player/NowPlayingCompanionPane.tsx @@ -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); }; diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx index 5b00031..27191f3 100644 --- a/src/components/player/NowPlayingOverlay.tsx +++ b/src/components/player/NowPlayingOverlay.tsx @@ -707,7 +707,7 @@ export function NowPlayingOverlay() { 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() { 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() { 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() { 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() { void toggleFavorite(track)} accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'} @@ -1131,7 +1131,7 @@ export function NowPlayingOverlay() { void toggleShuffle()} accessibilityLabel="Shuffle" accessibilityState={{ selected: shuffle }} @@ -1149,7 +1149,7 @@ export function NowPlayingOverlay() { void cycleRepeat()} accessibilityLabel="Repeat" accessibilityState={{ selected: repeat !== 'none' }} @@ -1232,7 +1232,7 @@ export function NowPlayingOverlay() { void setScopeStageVisible(!scopeStageVisible)} accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'} accessibilityState={{ selected: scopeStageVisible }} diff --git a/src/components/player/TactilePressable.tsx b/src/components/player/TactilePressable.tsx index 766d084..a5e3461 100644 --- a/src/components/player/TactilePressable.tsx +++ b/src/components/player/TactilePressable.tsx @@ -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 { @@ -60,8 +60,7 @@ export function TactilePressable({ }; const handlePress: NonNullable = (event) => { - if (haptic === 'selection') tickHaptic(); - else if (haptic === 'light') commitHaptic(); + if (haptic !== 'none') playHaptic(haptic); if (confirmationScale) { scale.value = withSequence( withTiming(confirmationScale, motion.quick), diff --git a/src/components/queue/QueueTray.tsx b/src/components/queue/QueueTray.tsx index d16dbdf..98b8010 100644 --- a/src/components/queue/QueueTray.tsx +++ b/src/components/queue/QueueTray.tsx @@ -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(() => { diff --git a/src/components/search/PullSearchGesture.tsx b/src/components/search/PullSearchGesture.tsx index f5dcdc7..2a7e174 100644 --- a/src/components/search/PullSearchGesture.tsx +++ b/src/components/search/PullSearchGesture.tsx @@ -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) => { diff --git a/src/components/search/QuickSearchOverlay.tsx b/src/components/search/QuickSearchOverlay.tsx index c5e3dd9..512b97f 100644 --- a/src/components/search/QuickSearchOverlay.tsx +++ b/src/components/search/QuickSearchOverlay.tsx @@ -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); diff --git a/src/components/settings/AccentSwatchRow.tsx b/src/components/settings/AccentSwatchRow.tsx index d7c3050..bf51f3e 100644 --- a/src/components/settings/AccentSwatchRow.tsx +++ b/src/components/settings/AccentSwatchRow.tsx @@ -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 ( onChange(id)} + onPress={() => { + if (selected) return; + playHaptic('selection'); + onChange(id); + }} accessibilityRole="radio" accessibilityState={{ selected }} accessibilityLabel={`${ACCENTS[id].label} accent`} diff --git a/src/components/settings/ScopeStyleCards.tsx b/src/components/settings/ScopeStyleCards.tsx index 9bd4968..b75be40 100644 --- a/src/components/settings/ScopeStyleCards.tsx +++ b/src/components/settings/ScopeStyleCards.tsx @@ -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 ( p.connected).length ?? 0; @@ -110,7 +111,11 @@ export function AppearanceSettingsPanel() { 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() { 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() { void setReplayGainMode(m.mode)} + onPress={() => { + if (selected) return; + playHaptic('selection'); + void setReplayGainMode(m.mode); + }} > {m.label} diff --git a/src/components/settings/SettingsSectionScaffold.tsx b/src/components/settings/SettingsSectionScaffold.tsx index 0ba4f62..48be742 100644 --- a/src/components/settings/SettingsSectionScaffold.tsx +++ b/src/components/settings/SettingsSectionScaffold.tsx @@ -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} - 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 ( {icon ? ( diff --git a/src/lib/hapticCatalog.ts b/src/lib/hapticCatalog.ts new file mode 100644 index 0000000..1c98c0d --- /dev/null +++ b/src/lib/hapticCatalog.ts @@ -0,0 +1,80 @@ +export type HapticEvent = + | 'toggleOn' + | 'toggleOff' + | 'selection' + | 'frequentStep' + | 'threshold' + | 'thresholdExit' + | 'action' + | 'dragStart' + | 'dragEnd' + | 'queueLift' + | 'queueDrop' + | 'pullLatch' + | 'pullRelease' + | 'modeCycle' + | 'holdAccepted' + | 'confirm' + | 'reject'; + +export type AndroidSemanticHaptic = + | 'toggle-on' + | 'toggle-off' + | 'segment-tick' + | 'segment-frequent-tick' + | 'gesture-start' + | 'virtual-key' + | 'drag-start' + | 'gesture-end' + | 'confirm' + | 'reject'; + +export type LegacyHapticFallback = + | 'selection' + | 'lightImpact' + | 'mediumImpact' + | 'success' + | 'error'; + +export interface HapticDefinition { + semantic: AndroidSemanticHaptic; + fallback: LegacyHapticFallback; + recipeId?: string; +} + +/** + * Meaning-first haptic vocabulary. Android chooses the exact actuator rendering + * for the semantic effect; the fallback only runs on releases that predate the + * corresponding HapticFeedbackConstant. + */ +export const HAPTIC_DEFINITIONS: Readonly> = { + toggleOn: { semantic: 'toggle-on', fallback: 'selection', recipeId: 'toggleOnA' }, + toggleOff: { semantic: 'toggle-off', fallback: 'selection', recipeId: 'toggleOffB' }, + selection: { semantic: 'segment-tick', fallback: 'selection' }, + frequentStep: { semantic: 'segment-frequent-tick', fallback: 'selection' }, + threshold: { semantic: 'gesture-start', fallback: 'lightImpact' }, + thresholdExit: { + semantic: 'gesture-end', + fallback: 'lightImpact', + recipeId: 'thresholdExitA', + }, + action: { semantic: 'virtual-key', fallback: 'lightImpact' }, + dragStart: { semantic: 'drag-start', fallback: 'mediumImpact', recipeId: 'dragPickupB' }, + dragEnd: { semantic: 'gesture-end', fallback: 'lightImpact', recipeId: 'dragPlacementA' }, + queueLift: { semantic: 'drag-start', fallback: 'mediumImpact', recipeId: 'queueLiftB' }, + queueDrop: { semantic: 'gesture-end', fallback: 'lightImpact', recipeId: 'queueDropA' }, + pullLatch: { semantic: 'gesture-start', fallback: 'lightImpact', recipeId: 'pullLatchB' }, + pullRelease: { semantic: 'gesture-end', fallback: 'lightImpact', recipeId: 'pullReleaseA' }, + modeCycle: { semantic: 'segment-tick', fallback: 'selection', recipeId: 'modeCycleA' }, + holdAccepted: { + semantic: 'gesture-start', + fallback: 'mediumImpact', + recipeId: 'holdAcceptedA', + }, + confirm: { semantic: 'confirm', fallback: 'success', recipeId: 'confirmA' }, + reject: { semantic: 'reject', fallback: 'error', recipeId: 'rejectB' }, +}; + +export function hapticForToggle(nextValue: boolean): HapticEvent { + return nextValue ? 'toggleOn' : 'toggleOff'; +} diff --git a/src/lib/hapticRecipes.ts b/src/lib/hapticRecipes.ts new file mode 100644 index 0000000..4afb8dd --- /dev/null +++ b/src/lib/hapticRecipes.ts @@ -0,0 +1,339 @@ +import type { + HapticCapabilities, + HapticCompositionStep, + HapticPrimitive, +} from '../../modules/astra-haptics/types'; + +export interface HapticRecipeCandidate { + id: string; + label: string; + steps: readonly HapticCompositionStep[]; +} + +export interface HapticRecipeGroup { + id: string; + label: string; + description: string; + candidates: readonly HapticRecipeCandidate[]; + leadingCandidateId?: string; + selectionStatus?: 'selected' | 'provisional'; +} + +export interface HapticRecipeSection { + id: 'timing' | 'signatures' | 'state' | 'manipulation' | 'outcome' | 'gesture'; + label: string; + description: string; + groups: readonly HapticRecipeGroup[]; +} + +export const HAPTIC_GAPS_MS = { + riseLock: 45, + lift: 30, + seat: 30, + neutral: 30, +} as const; + +const recipe = ( + ...steps: [HapticPrimitive, number, number?][] +): readonly HapticCompositionStep[] => + steps.map(([primitive, scale, gapMs], index) => ({ + primitive, + scale, + delayMs: index === 0 ? 0 : (gapMs ?? 0), + })); + +const timedRecipe = ( + first: [HapticPrimitive, number], + second: [HapticPrimitive, number], + gapMs: number +): readonly HapticCompositionStep[] => [ + { primitive: first[0], scale: first[1], delayMs: 0 }, + { primitive: second[0], scale: second[1], delayMs: gapMs }, +]; + +const timingCandidates = ( + id: string, + first: [HapticPrimitive, number], + second: [HapticPrimitive, number] +): readonly HapticRecipeCandidate[] => [ + { id: `${id}0`, label: '0 ms · fused', steps: timedRecipe(first, second, 0) }, + { id: `${id}15`, label: '15 ms · subtle', steps: timedRecipe(first, second, 15) }, + { id: `${id}30`, label: '30 ms · clear', steps: timedRecipe(first, second, 30) }, + { id: `${id}45`, label: '45 ms · two beats', steps: timedRecipe(first, second, 45) }, +]; + +export const HAPTIC_RECIPE_SECTIONS: readonly HapticRecipeSection[] = [ + { + id: 'timing', + label: 'TIMING CALIBRATION', + description: 'Selected articulation: 45 ms for rise + lock, 30 ms for lift and weighted seat.', + groups: [ + { + id: 'timingRiseClick', + label: 'Rise + lock', + description: 'Quick rise (.55), then click (.65). Closest to toggle on.', + leadingCandidateId: 'timingRiseClick45', + candidates: timingCandidates( + 'timingRiseClick', + ['quickRise', 0.55], + ['click', 0.65] + ), + }, + { + id: 'timingLift', + label: 'Lift articulation', + description: 'Low tick (.55), then quick rise (.65). Closest to drag pickup.', + leadingCandidateId: 'timingLift30', + candidates: timingCandidates( + 'timingLift', + ['lowTick', 0.55], + ['quickRise', 0.65] + ), + }, + { + id: 'timingSeat', + label: 'Weighted seat', + description: 'Click (.70), then thud (.40). Closest to drag placement.', + leadingCandidateId: 'timingSeat30', + candidates: timingCandidates( + 'timingSeat', + ['click', 0.7], + ['thud', 0.4] + ), + }, + ], + }, + { + id: 'signatures', + label: 'INTERACTION SIGNATURES', + description: 'Retimed with the selected articulation. Revote all four candidates.', + groups: [ + { + id: 'queueLift', + label: 'Queue lift', + description: 'The row detaches from the queue.', + leadingCandidateId: 'queueLiftB', + selectionStatus: 'selected', + candidates: [ + { id: 'queueLiftA', label: 'A · weighted lift', steps: recipe(['lowTick', 0.7], ['quickRise', 0.5, HAPTIC_GAPS_MS.lift]) }, + { id: 'queueLiftB', label: 'B · crisp lift', steps: recipe(['quickRise', 0.7], ['click', 0.5, HAPTIC_GAPS_MS.riseLock]) }, + ], + }, + { + id: 'queueDrop', + label: 'Queue drop', + description: 'The row seats into its new position.', + leadingCandidateId: 'queueDropA', + selectionStatus: 'selected', + candidates: [ + { id: 'queueDropA', label: 'A · click + weight', steps: recipe(['click', 0.7], ['thud', 0.5, HAPTIC_GAPS_MS.seat]) }, + { id: 'queueDropB', label: 'B · falling seat', steps: recipe(['quickFall', 0.7], ['click', 0.5, HAPTIC_GAPS_MS.neutral]) }, + ], + }, + { + id: 'pullLatch', + label: 'Pull latch', + description: 'Pull-to-search crosses its armed threshold.', + leadingCandidateId: 'pullLatchB', + selectionStatus: 'selected', + candidates: [ + { id: 'pullLatchA', label: 'A · clean latch', steps: recipe(['tick', 0.7]) }, + { id: 'pullLatchB', label: 'B · weighted latch', steps: recipe(['lowTick', 0.5], ['click', 0.5, HAPTIC_GAPS_MS.neutral]) }, + ], + }, + { + id: 'pullRelease', + label: 'Pull release', + description: 'Search opens after the armed pull releases.', + leadingCandidateId: 'pullReleaseA', + selectionStatus: 'selected', + candidates: [ + { id: 'pullReleaseA', label: 'A · rise + release', steps: recipe(['quickRise', 0.5], ['click', 0.5, HAPTIC_GAPS_MS.riseLock]) }, + { id: 'pullReleaseB', label: 'B · clean rise', steps: recipe(['quickRise', 0.7]) }, + ], + }, + ], + }, + { + id: 'state', + label: 'STATE CHANGES', + description: 'Directional signatures for controls entering and leaving an active state.', + groups: [ + { + id: 'toggleOn', + label: 'Toggle on', + description: 'A switch or binary control engages.', + leadingCandidateId: 'toggleOnA', + selectionStatus: 'selected', + candidates: [ + { id: 'toggleOnA', label: 'A · precise rise', steps: recipe(['quickRise', 0.55], ['click', 0.65, HAPTIC_GAPS_MS.riseLock]) }, + { id: 'toggleOnB', label: 'B · weighted rise', steps: recipe(['lowTick', 0.5], ['quickRise', 0.6, HAPTIC_GAPS_MS.lift], ['click', 0.35, HAPTIC_GAPS_MS.riseLock]) }, + ], + }, + { + id: 'toggleOff', + label: 'Toggle off', + description: 'A switch or binary control disengages.', + leadingCandidateId: 'toggleOffB', + selectionStatus: 'selected', + candidates: [ + { id: 'toggleOffA', label: 'A · clean fall', steps: recipe(['click', 0.5], ['quickFall', 0.65, HAPTIC_GAPS_MS.neutral]) }, + { id: 'toggleOffB', label: 'B · weighted fall', steps: recipe(['quickFall', 0.6], ['lowTick', 0.55, HAPTIC_GAPS_MS.neutral]) }, + ], + }, + ], + }, + { + id: 'manipulation', + label: 'MANIPULATION', + description: 'Pickup and placement textures for direct manipulation.', + groups: [ + { + id: 'dragPickup', + label: 'Drag pickup', + description: 'An item lifts from rest and begins following the finger.', + leadingCandidateId: 'dragPickupB', + selectionStatus: 'selected', + candidates: [ + { id: 'dragPickupA', label: 'A · lift from rest', steps: recipe(['lowTick', 0.55], ['quickRise', 0.65, HAPTIC_GAPS_MS.lift]) }, + { id: 'dragPickupB', label: 'B · sprung pickup', steps: recipe(['quickRise', 0.7], ['click', 0.45, HAPTIC_GAPS_MS.riseLock]) }, + ], + }, + { + id: 'dragPlacement', + label: 'Drag placement', + description: 'An item lands at its destination.', + leadingCandidateId: 'dragPlacementA', + selectionStatus: 'selected', + candidates: [ + { id: 'dragPlacementA', label: 'A · weighted seat', steps: recipe(['click', 0.7], ['thud', 0.4, HAPTIC_GAPS_MS.seat]) }, + { id: 'dragPlacementB', label: 'B · soft landing', steps: recipe(['quickFall', 0.55], ['lowTick', 0.6, HAPTIC_GAPS_MS.neutral]) }, + ], + }, + ], + }, + { + id: 'outcome', + label: 'OUTCOMES', + description: 'Distinct positive and blocked endings without imitating notification buzzes.', + groups: [ + { + id: 'confirm', + label: 'Confirm', + description: 'A meaningful operation completes successfully.', + leadingCandidateId: 'confirmA', + selectionStatus: 'selected', + candidates: [ + { id: 'confirmA', label: 'A · rising resolve', steps: recipe(['quickRise', 0.45], ['click', 0.75, HAPTIC_GAPS_MS.riseLock]) }, + { id: 'confirmB', label: 'B · crisp resolve', steps: recipe(['tick', 0.5], ['click', 0.8, HAPTIC_GAPS_MS.neutral]) }, + ], + }, + { + id: 'reject', + label: 'Reject', + description: 'An attempted operation is blocked, expressed as a tactile “no.”', + leadingCandidateId: 'rejectB', + selectionStatus: 'selected', + candidates: [ + { id: 'rejectA', label: 'A · even double stop', steps: recipe(['click', 0.7], ['click', 0.7, HAPTIC_GAPS_MS.riseLock]) }, + { id: 'rejectB', label: 'B · descending no', steps: recipe(['click', 0.75], ['lowTick', 0.7, HAPTIC_GAPS_MS.riseLock]) }, + ], + }, + ], + }, + { + id: 'gesture', + label: 'GESTURE SHAPES', + description: 'Less common textures reserved for interactions whose motion matches the primitive.', + groups: [ + { + id: 'thresholdExit', + label: 'Threshold exit', + description: 'A gesture backs out of an armed state before release.', + leadingCandidateId: 'thresholdExitA', + selectionStatus: 'selected', + candidates: [ + { id: 'thresholdExitA', label: 'A · gentle retreat', steps: recipe(['quickFall', 0.45]) }, + { id: 'thresholdExitB', label: 'B · weighted retreat', steps: recipe(['lowTick', 0.45], ['quickFall', 0.5, HAPTIC_GAPS_MS.neutral]) }, + ], + }, + { + id: 'modeCycle', + label: 'Mode cycle', + description: 'A control rotates to the next mode, such as repeat state.', + leadingCandidateId: 'modeCycleA', + selectionStatus: 'selected', + candidates: [ + { id: 'modeCycleA', label: 'A · spin + lock', steps: recipe(['spin', 0.5], ['click', 0.55, HAPTIC_GAPS_MS.neutral]) }, + { id: 'modeCycleB', label: 'B · tick + spin', steps: recipe(['tick', 0.5], ['spin', 0.45, HAPTIC_GAPS_MS.neutral]) }, + ], + }, + { + id: 'holdAccepted', + label: 'Hold accepted', + description: 'A deliberate long press crosses its activation time.', + leadingCandidateId: 'holdAcceptedA', + selectionStatus: 'selected', + candidates: [ + { id: 'holdAcceptedA', label: 'A · swell + lock', steps: recipe(['slowRise', 0.5], ['click', 0.65, HAPTIC_GAPS_MS.riseLock]) }, + { id: 'holdAcceptedB', label: 'B · weighted hold', steps: recipe(['lowTick', 0.45], ['thud', 0.45, HAPTIC_GAPS_MS.neutral]) }, + ], + }, + ], + }, +] as const; + +export const HAPTIC_RECIPE_GROUPS: readonly HapticRecipeGroup[] = + HAPTIC_RECIPE_SECTIONS.flatMap((section) => section.groups); + +export function hapticRecipeCandidate( + candidateId: string +): HapticRecipeCandidate | undefined { + for (const group of HAPTIC_RECIPE_GROUPS) { + const candidate = group.candidates.find((item) => item.id === candidateId); + if (candidate) return candidate; + } + return undefined; +} + +export function validateHapticRecipe( + steps: readonly HapticCompositionStep[] +): boolean { + return ( + steps.length > 0 && + steps.length <= 8 && + steps.every( + (step) => + Number.isFinite(step.scale) && + step.scale > 0 && + step.scale <= 1 && + Number.isInteger(step.delayMs ?? 0) && + (step.delayMs ?? 0) >= 0 && + (step.delayMs ?? 0) <= 1_000 + ) + ); +} + +export function unsupportedRecipePrimitives( + steps: readonly HapticCompositionStep[], + capabilities: HapticCapabilities +): HapticPrimitive[] { + return [...new Set( + steps + .map((step) => step.primitive) + .filter((primitive) => !capabilities.primitives[primitive].supported) + )]; +} + +export function canPlayHapticRecipe( + steps: readonly HapticCompositionStep[], + capabilities: HapticCapabilities +): boolean { + return ( + capabilities.moduleAvailable && + capabilities.hasVibrator && + capabilities.touchFeedbackEnabled && + validateHapticRecipe(steps) && + unsupportedRecipePrimitives(steps, capabilities).length === 0 + ); +} diff --git a/src/lib/haptics.test.mts b/src/lib/haptics.test.mts new file mode 100644 index 0000000..52cec6b --- /dev/null +++ b/src/lib/haptics.test.mts @@ -0,0 +1,243 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { + HapticCapabilities, + HapticPrimitive, +} from '../../modules/astra-haptics/types.ts'; +import { + HAPTIC_DEFINITIONS, + hapticForToggle, + type HapticEvent, +} from './hapticCatalog.ts'; +import { + HAPTIC_GAPS_MS, + HAPTIC_RECIPE_GROUPS, + HAPTIC_RECIPE_SECTIONS, + canPlayHapticRecipe, + hapticRecipeCandidate, + unsupportedRecipePrimitives, + validateHapticRecipe, +} from './hapticRecipes.ts'; + +const expectedSemantics: Record = { + toggleOn: 'toggle-on', + toggleOff: 'toggle-off', + selection: 'segment-tick', + frequentStep: 'segment-frequent-tick', + threshold: 'gesture-start', + thresholdExit: 'gesture-end', + action: 'virtual-key', + dragStart: 'drag-start', + dragEnd: 'gesture-end', + queueLift: 'drag-start', + queueDrop: 'gesture-end', + pullLatch: 'gesture-start', + pullRelease: 'gesture-end', + modeCycle: 'segment-tick', + holdAccepted: 'gesture-start', + confirm: 'confirm', + reject: 'reject', +}; + +const expectedFallbacks: Record = { + toggleOn: 'selection', + toggleOff: 'selection', + selection: 'selection', + frequentStep: 'selection', + threshold: 'lightImpact', + thresholdExit: 'lightImpact', + action: 'lightImpact', + dragStart: 'mediumImpact', + dragEnd: 'lightImpact', + queueLift: 'mediumImpact', + queueDrop: 'lightImpact', + pullLatch: 'lightImpact', + pullRelease: 'lightImpact', + modeCycle: 'selection', + holdAccepted: 'mediumImpact', + confirm: 'success', + reject: 'error', +}; + +const primitives: HapticPrimitive[] = [ + 'click', + 'thud', + 'spin', + 'quickRise', + 'slowRise', + 'quickFall', + 'tick', + 'lowTick', +]; + +function capabilities( + supported: HapticPrimitive[] = primitives +): HapticCapabilities { + const supportedSet = new Set(supported); + return { + moduleAvailable: true, + apiLevel: 36, + hasVibrator: true, + hasAmplitudeControl: true, + touchFeedbackEnabled: true, + primitives: Object.fromEntries( + primitives.map((primitive) => [ + primitive, + { supported: supportedSet.has(primitive), durationMs: supportedSet.has(primitive) ? 12 : 0 }, + ]) + ) as HapticCapabilities['primitives'], + }; +} + +test('maps every application event to an Android semantic haptic', () => { + assert.deepEqual( + Object.fromEntries( + Object.entries(HAPTIC_DEFINITIONS).map(([event, definition]) => [ + event, + definition.semantic, + ]) + ), + expectedSemantics + ); + assert.deepEqual( + Object.fromEntries( + Object.entries(HAPTIC_DEFINITIONS).map(([event, definition]) => [ + event, + definition.fallback, + ]) + ), + expectedFallbacks + ); + assert.equal(hapticForToggle(true), 'toggleOn'); + assert.equal(hapticForToggle(false), 'toggleOff'); +}); + +test('keeps all tuning candidates within the native recipe contract', () => { + assert.equal(HAPTIC_RECIPE_SECTIONS.length, 6); + assert.equal(HAPTIC_RECIPE_GROUPS.length, 16); + assert.deepEqual( + HAPTIC_RECIPE_SECTIONS.flatMap((section) => section.groups), + HAPTIC_RECIPE_GROUPS + ); + for (const group of HAPTIC_RECIPE_GROUPS) { + assert.equal(group.candidates.length, group.id.startsWith('timing') ? 4 : 2); + for (const candidate of group.candidates) { + assert.equal(validateHapticRecipe(candidate.steps), true, candidate.id); + } + } +}); + +test('calibrates the same primitive pairs at four explicit pauses', () => { + const timingSection = HAPTIC_RECIPE_SECTIONS.find((section) => section.id === 'timing'); + assert.ok(timingSection); + assert.equal(timingSection.groups.length, 3); + for (const group of timingSection.groups) { + assert.deepEqual( + group.candidates.map((candidate) => candidate.steps.map((step) => step.delayMs)), + [[0, 0], [0, 15], [0, 30], [0, 45]] + ); + } + assert.deepEqual( + timingSection.groups.map((group) => group.leadingCandidateId), + ['timingRiseClick45', 'timingLift30', 'timingSeat30'] + ); + assert.deepEqual(HAPTIC_GAPS_MS, { + riseLock: 45, + lift: 30, + seat: 30, + neutral: 30, + }); +}); + +test('records the retimed vote and keeps every composition articulated', () => { + const selectedCandidates: Record = { + queueLift: 'queueLiftB', + queueDrop: 'queueDropA', + pullLatch: 'pullLatchB', + pullRelease: 'pullReleaseA', + toggleOn: 'toggleOnA', + toggleOff: 'toggleOffB', + dragPickup: 'dragPickupB', + dragPlacement: 'dragPlacementA', + confirm: 'confirmA', + reject: 'rejectB', + thresholdExit: 'thresholdExitA', + modeCycle: 'modeCycleA', + holdAccepted: 'holdAcceptedA', + }; + for (const group of HAPTIC_RECIPE_GROUPS) { + if (group.id.startsWith('timing')) continue; + assert.equal(group.leadingCandidateId, selectedCandidates[group.id], group.id); + assert.equal( + group.selectionStatus, + 'selected', + group.id + ); + for (const candidate of group.candidates) { + if (candidate.steps.length < 2) continue; + assert.equal( + candidate.steps.slice(1).every((step) => (step.delayMs ?? 0) > 0), + true, + candidate.id + ); + } + } +}); + +test('points every production composition at its selected catalog recipe', () => { + for (const [event, definition] of Object.entries(HAPTIC_DEFINITIONS)) { + if (!definition.recipeId) continue; + const candidate = hapticRecipeCandidate(definition.recipeId); + assert.ok(candidate, event); + const group = HAPTIC_RECIPE_GROUPS.find((item) => + item.candidates.some((itemCandidate) => itemCandidate.id === definition.recipeId) + ); + assert.equal(group?.leadingCandidateId, definition.recipeId, event); + assert.equal(group?.selectionStatus, 'selected', event); + } +}); + +test('offers two articulated reject rhythms for a tactile no', () => { + const reject = HAPTIC_RECIPE_GROUPS.find((group) => group.id === 'reject'); + assert.ok(reject); + assert.deepEqual( + reject.candidates.map((candidate) => ({ + primitives: candidate.steps.map((step) => step.primitive), + delays: candidate.steps.map((step) => step.delayMs), + })), + [ + { primitives: ['click', 'click'], delays: [0, 45] }, + { primitives: ['click', 'lowTick'], delays: [0, 45] }, + ] + ); +}); + +test('rejects invalid scale, delay, and empty recipes', () => { + assert.equal(validateHapticRecipe([]), false); + assert.equal(validateHapticRecipe([{ primitive: 'click', scale: 0 }]), false); + assert.equal(validateHapticRecipe([{ primitive: 'click', scale: 1.01 }]), false); + assert.equal( + validateHapticRecipe([{ primitive: 'click', scale: 0.5, delayMs: -1 }]), + false + ); + assert.equal( + validateHapticRecipe([{ primitive: 'click', scale: 0.5, delayMs: 1.5 }]), + false + ); +}); + +test('requires every primitive and the system touch-feedback gate', () => { + const queueLiftGroup = HAPTIC_RECIPE_GROUPS.find((group) => group.id === 'queueLift'); + assert.ok(queueLiftGroup); + const queueLift = queueLiftGroup.candidates[0].steps; + assert.equal(canPlayHapticRecipe(queueLift, capabilities()), true); + assert.deepEqual(unsupportedRecipePrimitives(queueLift, capabilities(['quickRise'])), [ + 'lowTick', + ]); + assert.equal(canPlayHapticRecipe(queueLift, capabilities(['quickRise'])), false); + + const touchDisabled = { ...capabilities(), touchFeedbackEnabled: false }; + assert.equal(canPlayHapticRecipe(queueLift, touchDisabled), false); + const moduleMissing = { ...capabilities(), moduleAvailable: false }; + assert.equal(canPlayHapticRecipe(queueLift, moduleMissing), false); +}); diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts index 03c3c36..32dcbdd 100644 --- a/src/lib/haptics.ts +++ b/src/lib/haptics.ts @@ -1,23 +1,50 @@ import * as Haptics from 'expo-haptics'; +import { + HAPTIC_DEFINITIONS, + type HapticEvent, + type LegacyHapticFallback, +} from './hapticCatalog'; +import { hapticRecipeCandidate } from './hapticRecipes'; +import { AstraHaptics } from '../../modules/astra-haptics'; /** - * Fire-and-forget haptic wrappers. Calls are best-effort — devices without a - * vibrator (or with system haptics disabled) reject silently. Keeping them here - * lets call sites stay declarative and makes the feedback vocabulary consistent - * across swipe rows and drag-reorder. + * Fire-and-forget semantic haptics. Android renders each meaning for the + * current actuator and touch-feedback preference. Older Android releases fall + * back to Expo's legacy vibration effects without surfacing errors to callers. */ - -/** Subtle tick at a gesture decision point (swipe arm/disarm). */ -export function tickHaptic(): void { - void Haptics.selectionAsync().catch(() => {}); +export function playHaptic(event: HapticEvent): void { + const definition = HAPTIC_DEFINITIONS[event]; + if (definition.recipeId) { + const candidate = hapticRecipeCandidate(definition.recipeId); + if (candidate) { + try { + if (AstraHaptics.playComposition(candidate.steps.map((step) => ({ ...step })))) { + return; + } + } catch { + // Optional or older native builds continue through the semantic path. + } + } + } + void Haptics.performAndroidHapticsAsync( + definition.semantic as Haptics.AndroidHaptics + ).catch(() => playLegacyFallback(definition.fallback)); } -/** Confirmation when a swipe commits to its action. */ -export function commitHaptic(): void { - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); +function playLegacyFallback(fallback: LegacyHapticFallback): Promise { + if (!AstraHaptics.isTouchFeedbackEnabled()) return Promise.resolve(); + switch (fallback) { + case 'selection': + return Haptics.selectionAsync().catch(() => {}); + case 'lightImpact': + return Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); + case 'mediumImpact': + return Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); + case 'success': + return Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); + case 'error': + return Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}); + } } -/** Stronger bump when a hold-to-drag reorder engages. */ -export function dragArmHaptic(): void { - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); -} +export type { HapticEvent } from './hapticCatalog';