From 261d0950904db7fb60f031b31e3ea1b2ef6d05ce Mon Sep 17 00:00:00 2001
From: Boof2015 <75185879+Boof2015@users.noreply.github.com>
Date: Mon, 13 Jul 2026 02:20:57 -0400
Subject: [PATCH] custom linear haptics
---
modules/astra-haptics/android/build.gradle | 18 +
.../android/src/main/AndroidManifest.xml | 3 +
.../astrahaptics/AstraHapticsModule.kt | 163 ++++++++
modules/astra-haptics/expo-module.config.json | 6 +
modules/astra-haptics/index.ts | 66 ++++
modules/astra-haptics/types.ts | 29 ++
package.json | 1 +
src/app/(tabs)/eq.tsx | 25 +-
src/app/(tabs)/library/index.tsx | 11 +-
src/app/(tabs)/library/playlist/[id].tsx | 12 +-
src/app/desktop-sync.tsx | 4 +-
src/app/lastfm/index.tsx | 6 +-
src/app/settings/experimental.tsx | 6 +
src/app/settings/haptics-lab.tsx | 367 ++++++++++++++++++
src/components/HapticSwitch.tsx | 31 ++
src/components/SeekBar.tsx | 4 +-
src/components/SegmentedControl.tsx | 9 +-
src/components/SwipeableRow.tsx | 6 +-
src/components/TabBar.tsx | 8 +-
src/components/WaveformSeekBar.tsx | 4 +-
src/components/eq/BandDetailPanel.tsx | 4 +-
src/components/library/AlphabetRail.tsx | 6 +-
src/components/library/FoldersView.tsx | 11 +-
src/components/library/PlaylistRow.tsx | 10 +-
src/components/library/TrackRow.tsx | 10 +-
src/components/lyrics/LyricsView.tsx | 8 +-
src/components/onboarding/OnboardingFlow.tsx | 7 +-
.../player/NowPlayingCompanionPane.tsx | 2 -
src/components/player/NowPlayingOverlay.tsx | 26 +-
src/components/player/TactilePressable.tsx | 7 +-
src/components/queue/QueueTray.tsx | 7 +-
src/components/search/PullSearchGesture.tsx | 7 +-
src/components/search/QuickSearchOverlay.tsx | 4 +-
src/components/settings/AccentSwatchRow.tsx | 7 +-
src/components/settings/ScopeStyleCards.tsx | 9 +-
src/components/settings/SettingsPanels.tsx | 19 +-
.../settings/SettingsSectionScaffold.tsx | 4 +-
src/components/sheets/AppSheet.tsx | 12 +-
src/lib/hapticCatalog.ts | 80 ++++
src/lib/hapticRecipes.ts | 339 ++++++++++++++++
src/lib/haptics.test.mts | 243 ++++++++++++
src/lib/haptics.ts | 57 ++-
42 files changed, 1569 insertions(+), 89 deletions(-)
create mode 100644 modules/astra-haptics/android/build.gradle
create mode 100644 modules/astra-haptics/android/src/main/AndroidManifest.xml
create mode 100644 modules/astra-haptics/android/src/main/java/expo/modules/astrahaptics/AstraHapticsModule.kt
create mode 100644 modules/astra-haptics/expo-module.config.json
create mode 100644 modules/astra-haptics/index.ts
create mode 100644 modules/astra-haptics/types.ts
create mode 100644 src/app/settings/haptics-lab.tsx
create mode 100644 src/components/HapticSwitch.tsx
create mode 100644 src/lib/hapticCatalog.ts
create mode 100644 src/lib/hapticRecipes.ts
create mode 100644 src/lib/haptics.test.mts
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';