custom linear haptics

This commit is contained in:
Boof2015
2026-07-13 02:20:57 -04:00
parent a34d9cba89
commit 261d095090
42 changed files with 1569 additions and 89 deletions
@@ -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
}
}
@@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.VIBRATE" />
</manifest>
@@ -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<HapticCompositionStepRecord> ->
playComposition(steps)
}
}
private fun capabilities(): Map<String, Any> {
val currentVibrator = vibrator
val canCompose = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && currentVibrator.hasVibrator()
val primitiveCapabilities = linkedMapOf<String, Map<String, Any>>()
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<HapticCompositionStepRecord>): 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),
)
}
}
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.astrahaptics.AstraHapticsModule"]
}
}
+66
View File
@@ -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<HapticCapabilities, 'moduleAvailable'>;
isTouchFeedbackEnabled(): boolean;
playComposition(steps: HapticCompositionStep[]): boolean;
}
const native = requireOptionalNativeModule<AstraHapticsModuleType>('AstraHaptics');
const PRIMITIVES: HapticPrimitive[] = [
'click',
'thud',
'spin',
'quickRise',
'slowRise',
'quickFall',
'tick',
'lowTick',
];
function unsupportedPrimitives(): Record<HapticPrimitive, HapticPrimitiveCapability> {
return Object.fromEntries(
PRIMITIVES.map((primitive) => [primitive, { supported: false, durationMs: 0 }])
) as Record<HapticPrimitive, HapticPrimitiveCapability>;
}
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';
+29
View File
@@ -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<HapticPrimitive, HapticPrimitiveCapability>;
}
+1
View File
@@ -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"
},
+21 -4
View File
@@ -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() {
</View>
<Pressable android_ripple={ripple.bounded}
style={[styles.eqToggle, eq.enabled && styles.eqToggleOn]}
onPress={eq.toggleEnabled}
onPress={() => {
playHaptic(hapticForToggle(!eq.enabled));
eq.toggleEnabled();
}}
>
<Ionicons
name="power"
@@ -410,6 +426,7 @@ export default function EQScreen() {
icon="remove-circle-outline"
onPress={() => {
eq.removeBand(activeBand.id);
playHaptic('confirm');
closeSheet();
}}
/>
+6 -5
View File
@@ -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();
}}
/>
+11 -1
View File
@@ -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 (
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} style={styles.missingRow} onLongPress={onLongPress} accessibilityRole="button">
<Pressable
android_ripple={ripple.bounded}
unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.missingRow}
onLongPress={() => {
playHaptic('holdAccepted');
onLongPress();
}}
accessibilityRole="button"
>
<View style={styles.missingMeta}>
<Text variant="body" numberOfLines={1} color={colors.textTertiary}>
{entry.fallback_title ?? basename(entry.track_path)}
+2 -2
View File
@@ -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.
</Text>
</View>
<Switch
<HapticSwitch
value={autoSyncEnabled}
onValueChange={(value) => void setAutoSyncEnabled(value)}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
+3 -3
View File
@@ -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() {
</Text>
</View>
{profile.connected ? (
<Switch
<HapticSwitch
value={profile.enabled}
onValueChange={(v) => void setProfileEnabled(profile.id, v)}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
@@ -225,7 +225,7 @@ export default function LastFmScreen() {
Submit played tracks + &quot;now playing&quot; to your connected destinations.
</Text>
</View>
<Switch
<HapticSwitch
value={status?.enabled ?? false}
onValueChange={(v) => void setEnabled(v)}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
+6
View File
@@ -71,6 +71,12 @@ export default function ExperimentalSettingsScreen() {
/>
<SettingsSectionLabel>DEVELOPER</SettingsSectionLabel>
<SettingsNavRow
icon="pulse-outline"
title="Haptics Lab"
subtitle="Audition semantic feedback, device primitives, and signature candidates."
onPress={() => router.push('/settings/haptics-lab' as never)}
/>
<SettingsNavRow
icon="refresh-outline"
title="Replay onboarding"
+367
View File
@@ -0,0 +1,367 @@
import { useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import {
SettingsCard,
SettingsSectionLabel,
SettingsSectionScreen,
} from '@/components/settings/SettingsSectionScaffold';
import { Text } from '@/components/Text';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { playHaptic, type HapticEvent } from '@/lib/haptics';
import {
HAPTIC_RECIPE_SECTIONS,
canPlayHapticRecipe,
unsupportedRecipePrimitives,
} from '@/lib/hapticRecipes';
import {
AstraHaptics,
type HapticCapabilities,
type HapticCompositionStep,
type HapticPrimitive,
} from '../../../modules/astra-haptics';
const SEMANTIC_EVENTS: {
event: HapticEvent;
label: string;
description: string;
}[] = [
{ event: 'toggleOn', label: 'Toggle on', description: 'A setting enters its active state.' },
{ event: 'toggleOff', label: 'Toggle off', description: 'A setting leaves its active state.' },
{ event: 'selection', label: 'Selection', description: 'A discrete choice changes.' },
{ event: 'frequentStep', label: 'Frequent step', description: 'A repeated row or letter crossing.' },
{ event: 'threshold', label: 'Threshold', description: 'A gesture becomes armed.' },
{ event: 'action', label: 'Action', description: 'A direct control commits.' },
{ event: 'dragStart', label: 'Drag start', description: 'An item is picked up.' },
{ event: 'dragEnd', label: 'Drag end', description: 'An item is released.' },
{ event: 'confirm', label: 'Confirm', description: 'An operation succeeds.' },
{ event: 'reject', label: 'Reject', description: 'An operation is rejected.' },
];
const PRIMITIVES: { primitive: HapticPrimitive; label: string }[] = [
{ primitive: 'click', label: 'Click' },
{ primitive: 'tick', label: 'Tick' },
{ primitive: 'lowTick', label: 'Low tick' },
{ primitive: 'thud', label: 'Thud' },
{ primitive: 'quickRise', label: 'Quick rise' },
{ primitive: 'slowRise', label: 'Slow rise' },
{ primitive: 'quickFall', label: 'Quick fall' },
{ primitive: 'spin', label: 'Spin' },
];
const SCALES = [0.5, 0.7, 1] as const;
function yesNo(value: boolean): string {
return value ? 'Yes' : 'No';
}
export default function HapticsLabScreen() {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
const [capabilities, setCapabilities] = useState<HapticCapabilities>(() =>
AstraHaptics.getCapabilities()
);
const [status, setStatus] = useState<string | null>(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 (
<SettingsSectionScreen title="Haptics Lab">
<Text variant="caption" color={colors.textSecondary} style={styles.intro}>
Audition Android&apos;s semantic feedback and Astra&apos;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.
</Text>
<SettingsSectionLabel>DEVICE</SettingsSectionLabel>
<SettingsCard>
<CapabilityRow label="Native module" value={yesNo(capabilities.moduleAvailable)} />
<CapabilityRow label="Android API" value={String(capabilities.apiLevel)} />
<CapabilityRow label="Vibrator" value={yesNo(capabilities.hasVibrator)} />
<CapabilityRow
label="Amplitude control"
value={yesNo(capabilities.hasAmplitudeControl)}
/>
<CapabilityRow
label="Touch feedback enabled"
value={yesNo(capabilities.touchFeedbackEnabled)}
/>
<Pressable
android_ripple={ripple.bounded}
style={styles.refreshButton}
onPress={refresh}
accessibilityRole="button"
>
<Text variant="label" color={colors.accentTextStrong}>Refresh capabilities</Text>
</Pressable>
</SettingsCard>
<SettingsSectionLabel spaced>SEMANTIC VOCABULARY</SettingsSectionLabel>
<SettingsCard style={styles.stack}>
{SEMANTIC_EVENTS.map(({ event, label, description }) => (
<View key={event} style={styles.auditionRow}>
<View style={styles.rowCopy}>
<Text variant="body">{label}</Text>
<Text variant="caption" color={colors.textSecondary}>{description}</Text>
</View>
<AuditionButton label="Feel" onPress={() => playHaptic(event)} />
</View>
))}
</SettingsCard>
<SettingsSectionLabel spaced>PRIMITIVES</SettingsSectionLabel>
<SettingsCard style={styles.stack}>
{PRIMITIVES.map(({ primitive, label }) => {
const capability = capabilities.primitives[primitive];
return (
<View key={primitive} style={styles.primitiveBlock}>
<View style={styles.primitiveHeading}>
<Text variant="body">{label}</Text>
<Text variant="caption" color={colors.textSecondary}>
{capability.supported
? capability.durationMs > 0
? `${capability.durationMs} ms`
: 'Supported'
: 'Unsupported'}
</Text>
</View>
<View style={styles.buttonRow}>
{SCALES.map((scale) => (
<AuditionButton
key={scale}
label={scale.toFixed(1)}
disabled={!capability.supported || !capabilities.touchFeedbackEnabled}
onPress={() =>
playComposition([{ primitive, scale, delayMs: 0 }], `${label} ${scale}`)
}
/>
))}
</View>
</View>
);
})}
</SettingsCard>
{HAPTIC_RECIPE_SECTIONS.map((section) => (
<View key={section.id} style={styles.recipeSection}>
<SettingsSectionLabel spaced>{section.label}</SettingsSectionLabel>
<Text variant="caption" color={colors.textSecondary} style={styles.sectionIntro}>
{section.description}
</Text>
{section.groups.map((group) => {
const leadingCandidate = group.candidates.find(
(candidate) => candidate.id === group.leadingCandidateId
);
return (
<SettingsCard key={group.id} style={styles.recipeCard}>
<Text variant="heading">{group.label}</Text>
<Text variant="caption" color={colors.textSecondary}>{group.description}</Text>
{leadingCandidate ? (
<Text variant="label" color={colors.accentTextStrong}>
{section.id === 'timing'
? 'Selected calibration'
: group.selectionStatus === 'provisional'
? 'Provisional choice'
: 'Selected candidate'}{' '}
· {leadingCandidate.label}
</Text>
) : null}
<View style={styles.recipeButtons}>
{group.candidates.map((candidate) => {
const unsupported = unsupportedRecipePrimitives(
candidate.steps,
capabilities
);
const enabled = canPlayHapticRecipe(candidate.steps, capabilities);
return (
<View key={candidate.id} style={styles.recipeCandidate}>
<AuditionButton
label={
group.id === 'holdAccepted'
? `Hold · ${candidate.label}`
: candidate.label
}
disabled={!enabled}
wide
onPress={
group.id === 'holdAccepted'
? undefined
: () => playComposition(candidate.steps, candidate.label)
}
onLongPress={
group.id === 'holdAccepted'
? () => playComposition(candidate.steps, candidate.label)
: undefined
}
/>
{!enabled ? (
<Text variant="caption" color={colors.textTertiary}>
{unsupported.length > 0
? `Needs ${unsupported.join(', ')}`
: capabilities.touchFeedbackEnabled
? 'Unavailable'
: 'Touch feedback is off'}
</Text>
) : null}
</View>
);
})}
</View>
</SettingsCard>
);
})}
</View>
))}
{status ? (
<Text variant="caption" color={colors.textSecondary} style={styles.status}>
{status}
</Text>
) : null}
</SettingsSectionScreen>
);
}
function CapabilityRow({ label, value }: { label: string; value: string }) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.capabilityRow}>
<Text variant="body">{label}</Text>
<Text variant="label" color={colors.textSecondary}>{value}</Text>
</View>
);
}
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 (
<Pressable
android_ripple={ripple.bounded}
disabled={disabled}
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={500}
style={[styles.auditionButton, wide && styles.wideButton, disabled && styles.disabled]}
accessibilityRole="button"
accessibilityState={{ disabled }}
>
<Text variant="label" color={disabled ? colors.textTertiary : colors.accentTextStrong}>
{label}
</Text>
</Pressable>
);
}
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',
},
}));
+31
View File
@@ -0,0 +1,31 @@
import { Switch, type SwitchProps } from 'react-native';
import { hapticForToggle } from '@/lib/hapticCatalog';
import { playHaptic } from '@/lib/haptics';
export interface HapticSwitchProps
extends Omit<SwitchProps, 'value' | 'onValueChange'> {
value: boolean;
onValueChange: (value: boolean) => void;
}
/** Switch feedback fires only for a user-requested value transition. */
export function HapticSwitch({
value,
onValueChange,
...props
}: HapticSwitchProps) {
const handleValueChange = (nextValue: boolean) => {
if (nextValue !== value) playHaptic(hapticForToggle(nextValue));
onValueChange(nextValue);
};
return (
<Switch
{...props}
value={value}
onValueChange={handleValueChange}
/>
);
}
export default HapticSwitch;
+2 -2
View File
@@ -11,7 +11,7 @@ import {
} from '@/theme';
import { createThemedStyles } from '@/theme/themed';
import { formatDuration } from '@/lib/format';
import { tickHaptic } from '@/lib/haptics';
import { playHaptic } from '@/lib/haptics';
const THUMB_SIZE = 12;
@@ -59,7 +59,7 @@ export function SeekBar({ currentTime, duration, onSeek, trackKey }: SeekBarProp
const fraction = clamp(event.nativeEvent.locationX / Math.max(1, widthRef.current));
grantRef.current = { fraction, pageX: event.nativeEvent.pageX };
setScrub(fraction);
tickHaptic();
playHaptic('threshold');
};
const handleMove = (event: GestureResponderEvent) => {
+8 -1
View File
@@ -18,6 +18,7 @@ import {
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { motion } from '@/theme/motion';
import { playHaptic } from '@/lib/haptics';
const THUMB_INSET = 3;
@@ -106,11 +107,17 @@ function SegmentButton({
color: interpolateColor(progress.value, [0, 1], [inactiveColor, activeColor]),
}));
const handlePress = () => {
if (focused) return;
playHaptic('selection');
onPress();
};
return (
<Pressable
android_ripple={ripple.bounded}
style={styles.segment}
onPress={onPress}
onPress={handlePress}
accessibilityRole="tab"
accessibilityState={{ selected: focused }}
>
+3 -3
View File
@@ -18,7 +18,7 @@ import Animated, {
} from 'react-native-reanimated';
import { useColors } from '@/theme/themed';
import { motion } from '@/theme/motion';
import { commitHaptic, tickHaptic } from '@/lib/haptics';
import { playHaptic } from '@/lib/haptics';
type IconName = keyof typeof Ionicons.glyphMap;
@@ -79,7 +79,7 @@ export function SwipeableRow({
const onCommit = (direction: 'right' | 'left') => {
if (direction === 'right') swipeRight?.onCommit();
else swipeLeft?.onCommit();
commitHaptic();
playHaptic('confirm');
};
const pan = Gesture.Pan()
@@ -95,7 +95,7 @@ export function SwipeableRow({
const nowArmed = Math.abs(t) >= arm;
if (nowArmed !== armed.value) {
armed.value = nowArmed;
runOnJS(tickHaptic)();
runOnJS(playHaptic)('threshold');
}
})
.onEnd(() => {
+7 -1
View File
@@ -25,6 +25,7 @@ import { motion } from '@/theme/motion';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playHaptic } from '@/lib/haptics';
type IconName = keyof typeof Ionicons.glyphMap;
type MiniPlayerPhase = 'hidden' | 'reserved' | 'visible';
@@ -184,11 +185,16 @@ function TabButton({ meta, focused, onPress }: TabButtonProps) {
color: interpolateColor(progress.value, [0, 1], [inactiveColor, activeColor]),
}));
const handlePress = () => {
if (!focused) playHaptic('selection');
onPress();
};
return (
<Pressable
android_ripple={ripple.icon(26)}
style={styles.tab}
onPress={onPress}
onPress={handlePress}
onPressIn={() => {
press.value = withTiming(1, motion.quick);
}}
+2 -2
View File
@@ -23,7 +23,7 @@ import { formatDuration } from '@/lib/format';
import { downsampleWaveform, getWaveform } from '@/scope/waveform';
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
import { usePlayerStore } from '@/stores/playerStore';
import { tickHaptic } from '@/lib/haptics';
import { playHaptic } from '@/lib/haptics';
const CANVAS_HEIGHT = 58;
const BAR_WIDTH = 3;
@@ -125,7 +125,7 @@ export function WaveformSeekBar({
const fraction = clamp(event.nativeEvent.locationX / Math.max(1, widthRef.current));
grantRef.current = { fraction, pageX: event.nativeEvent.pageX };
setScrub(fraction);
tickHaptic();
playHaptic('threshold');
};
const handleMove = (event: GestureResponderEvent) => {
+2 -2
View File
@@ -1,11 +1,11 @@
import {
Pressable,
StyleSheet,
Switch,
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { HapticSwitch } from '@/components/HapticSwitch';
import {
radius,
spacing,
@@ -71,7 +71,7 @@ export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEdit
</Pressable>
<View style={styles.toggle}>
<Text variant="label">{band.enabled ? 'On' : 'Off'}</Text>
<Switch
<HapticSwitch
value={band.enabled}
onValueChange={(enabled) => onUpdate({ enabled })}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
+3 -3
View File
@@ -7,7 +7,7 @@ import { Text } from '@/components/Text';
import { radius, spacing } from '@/theme';
import { createThemedStyles } from '@/theme/themed';
import { rgbaFromHex } from '@/theme/colorUtils';
import { tickHaptic } from '@/lib/haptics';
import { playHaptic } from '@/lib/haptics';
import { usePullSearchGestureRef } from '@/components/search/PullSearchGesture';
import { RAIL_LETTERS } from '@/lib/letterIndex';
@@ -60,7 +60,7 @@ export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProp
);
const letter = RAIL_LETTERS[index];
lastLetter.value = letter;
runOnJS(tickHaptic)();
runOnJS(playHaptic)('frequentStep');
runOnJS(scrubTo)(letter);
})
.onUpdate((event) => {
@@ -76,7 +76,7 @@ export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProp
const letter = RAIL_LETTERS[index];
if (letter === lastLetter.value) return;
lastLetter.value = letter;
runOnJS(tickHaptic)();
runOnJS(playHaptic)('frequentStep');
runOnJS(scrubTo)(letter);
})
.onFinalize(() => {
+9 -2
View File
@@ -31,6 +31,7 @@ import {
type FolderTreeNode
} from '@/library/folderTree';
import { formatDuration } from '@/lib/format';
import { playHaptic } from '@/lib/haptics';
import {
radius,
spacing,
@@ -78,7 +79,10 @@ function FolderRow({
android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.folderRow}
onPress={() => onToggle(node.id)}
onLongPress={() => onOpenActions(node)}
onLongPress={() => {
playHaptic('holdAccepted');
onOpenActions(node);
}}
accessibilityRole="button"
accessibilityState={{ expanded: isExpanded }}
>
@@ -157,7 +161,10 @@ function FolderTrackRow({
android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={[styles.trackRow, active && styles.trackRowActive]}
onPress={playFolderTrack}
onLongPress={onOpenActions}
onLongPress={() => {
playHaptic('holdAccepted');
onOpenActions();
}}
accessibilityRole="button"
>
<View style={[styles.indent, { width: row.depth * 18 + 16 }]} />
+9 -1
View File
@@ -13,6 +13,7 @@ import {
import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { artworkUri } from '@/library/artwork';
import { playHaptic } from '@/lib/haptics';
export function PlaylistRow({
name,
@@ -46,7 +47,14 @@ export function PlaylistRow({
android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.row}
onPress={onPress}
onLongPress={onLongPress}
onLongPress={
onLongPress
? () => {
playHaptic('holdAccepted');
onLongPress();
}
: undefined
}
accessibilityRole="button"
accessibilityLabel={`${name}, ${dynamic ? 'dynamic playlist, ' : ''}${trackCount} ${trackCount === 1 ? 'track' : 'tracks'}`}
>
+9 -1
View File
@@ -19,6 +19,7 @@ import {
import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { formatDuration } from '@/lib/format';
import { playHaptic } from '@/lib/haptics';
import { trackArtworkThumbSource } from '@/library/artwork';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { enqueueEnd, enqueueTop } from '@/audio/playbackController';
@@ -69,6 +70,13 @@ export function TrackRow({
const thumbUri = failedArtKey !== artKey ? trackArtworkThumbSource(track) : null;
const secondaryText = subtitle ?? (showArtist ? track.artist : null);
const longPressAction = selectionMode ? onToggleSelect : (onLongPress ?? onOpenActions);
const handleLongPress = longPressAction
? () => {
playHaptic('holdAccepted');
longPressAction();
}
: undefined;
const openActions = (event: GestureResponderEvent) => {
event.stopPropagation();
onOpenActions?.();
@@ -79,7 +87,7 @@ export function TrackRow({
android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={[styles.row, selectionMode && selected && styles.rowSelected]}
onPress={selectionMode ? onToggleSelect : onPress}
onLongPress={selectionMode ? onToggleSelect : (onLongPress ?? onOpenActions)}
onLongPress={handleLongPress}
accessibilityRole="button"
accessibilityState={selectionMode ? { selected } : undefined}
>
+4 -4
View File
@@ -96,7 +96,7 @@ export function LyricsView({
<TactilePressable android_ripple={ripple.bounded}
onPress={onToggleFavorite}
haptic="light"
haptic={isFavorite ? 'toggleOff' : 'toggleOn'}
confirmationScale={1.08}
hitSlop={10}
style={styles.stripBtn}
@@ -133,17 +133,17 @@ export function LyricsView({
<View style={styles.controls}>
<SeekBar currentTime={currentTime} duration={duration} trackKey={track.id} onSeek={onSeek} />
<View style={styles.transport}>
<TactilePressable android_ripple={ripple.bounded} onPress={onPrev} haptic="light" hitSlop={12} style={styles.transportBtn} accessibilityLabel="Previous">
<TactilePressable android_ripple={ripple.bounded} onPress={onPrev} haptic="action" hitSlop={12} style={styles.transportBtn} accessibilityLabel="Previous">
<Ionicons name="play-skip-back" size={28} color={colors.textPrimary} />
</TactilePressable>
<TactilePressable android_ripple={ripple.bounded} onPress={onPlayPause} haptic="light" pressedScale={0.97} hitSlop={12} style={styles.playButton} accessibilityLabel={isPlaying ? 'Pause' : 'Play'}>
<TactilePressable android_ripple={ripple.bounded} onPress={onPlayPause} haptic="action" pressedScale={0.97} hitSlop={12} style={styles.playButton} accessibilityLabel={isPlaying ? 'Pause' : 'Play'}>
<Ionicons
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
size={28}
color={colors.bgPrimary}
/>
</TactilePressable>
<TactilePressable android_ripple={ripple.bounded} onPress={onNext} haptic="light" hitSlop={12} style={styles.transportBtn} accessibilityLabel="Next">
<TactilePressable android_ripple={ripple.bounded} onPress={onNext} haptic="action" hitSlop={12} style={styles.transportBtn} accessibilityLabel="Next">
<Ionicons name="play-skip-forward" size={28} color={colors.textPrimary} />
</TactilePressable>
</View>
+6 -1
View File
@@ -28,6 +28,7 @@ import { radius, spacing } from '@/theme';
import { motion } from '@/theme/motion';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { playHaptic } from '@/lib/haptics';
import type { BaseThemeId } from '@/theme/resolve';
import { useLibraryStore } from '@/stores/libraryStore';
import { useSettingsStore, type NowPlayingScopeStyle } from '@/stores/settingsStore';
@@ -266,7 +267,11 @@ function ThemeStep() {
return (
<Pressable android_ripple={ripple.bounded}
key={option.id}
onPress={() => void setBaseTheme(option.id)}
onPress={() => {
if (selected) return;
playHaptic('selection');
void setBaseTheme(option.id);
}}
style={[styles.themePill, selected && styles.themePillSelected]}
accessibilityRole="radio"
accessibilityState={{ selected }}
@@ -4,7 +4,6 @@ import { LyricsBand } from '@/components/lyrics/LyricsBand';
import { QueueTray } from '@/components/queue/QueueTray';
import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
import { seekTo } from '@/audio/playbackController';
import { tickHaptic } from '@/lib/haptics';
import { spacing } from '@/theme';
import { createThemedStyles } from '@/theme/themed';
import { usePlayerStore } from '@/stores/playerStore';
@@ -43,7 +42,6 @@ export function NowPlayingCompanionPane({
const selectCompanion = (next: string) => {
const value: NowPlayingCompanion = next === 'lyrics' ? 'lyrics' : 'queue';
if (value === companion) return;
tickHaptic();
void setCompanion(value);
};
+13 -13
View File
@@ -707,7 +707,7 @@ export function NowPlayingOverlay() {
<TactilePressable
hitSlop={10}
style={styles.inlineActionBtn} android_ripple={ripple.icon(22)}
haptic="light"
haptic={activeTrack.isFavorite ? 'toggleOff' : 'toggleOn'}
confirmationScale={1.08}
onPress={() => void sendDesktopControl('toggle-favorite')}
accessibilityLabel={activeTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
@@ -750,7 +750,7 @@ export function NowPlayingOverlay() {
desktopSnapshot?.shuffle === undefined && styles.controlDisabled,
]}
disabled={desktopSnapshot?.shuffle === undefined}
haptic="selection"
haptic={desktopSnapshot?.shuffle ? 'toggleOff' : 'toggleOn'}
onPress={() => void sendDesktopControl('toggle-shuffle')}
accessibilityLabel="Shuffle"
accessibilityState={{ selected: Boolean(desktopSnapshot?.shuffle) }}
@@ -768,7 +768,7 @@ export function NowPlayingOverlay() {
</TactilePressable>
<TactilePressable
onPress={() => void sendDesktopControl('previous')}
haptic="light"
haptic="action"
hitSlop={12}
style={styles.transportMainBtn} android_ripple={ripple.icon(26)}
accessibilityLabel="Previous"
@@ -781,7 +781,7 @@ export function NowPlayingOverlay() {
</TactilePressable>
<TactilePressable
onPress={() => void sendDesktopControl(isPlaying ? 'pause' : 'play')}
haptic="light"
haptic="action"
pressedScale={0.97}
hitSlop={12}
style={styles.playButton} android_ripple={ripple.onAccent()}
@@ -795,7 +795,7 @@ export function NowPlayingOverlay() {
</TactilePressable>
<TactilePressable
onPress={() => void sendDesktopControl('next')}
haptic="light"
haptic="action"
hitSlop={12}
style={styles.transportMainBtn} android_ripple={ripple.icon(26)}
accessibilityLabel="Next"
@@ -814,7 +814,7 @@ export function NowPlayingOverlay() {
desktopSnapshot?.repeat === undefined && styles.controlDisabled,
]}
disabled={desktopSnapshot?.repeat === undefined}
haptic="selection"
haptic="modeCycle"
onPress={() => void sendDesktopControl('toggle-repeat')}
accessibilityLabel="Repeat"
accessibilityState={{ selected: desktopSnapshot?.repeat !== 'none' }}
@@ -1092,7 +1092,7 @@ export function NowPlayingOverlay() {
<TactilePressable
hitSlop={10}
style={styles.inlineActionBtn} android_ripple={ripple.icon(22)}
haptic="light"
haptic={isFavorite ? 'toggleOff' : 'toggleOn'}
confirmationScale={1.08}
onPress={() => void toggleFavorite(track)}
accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
@@ -1131,7 +1131,7 @@ export function NowPlayingOverlay() {
<TactilePressable
hitSlop={10}
style={styles.transportSideBtn} android_ripple={ripple.icon(24)}
haptic="selection"
haptic={shuffle ? 'toggleOff' : 'toggleOn'}
onPress={() => void toggleShuffle()}
accessibilityLabel="Shuffle"
accessibilityState={{ selected: shuffle }}
@@ -1149,7 +1149,7 @@ export function NowPlayingOverlay() {
</TactilePressable>
<TactilePressable
onPress={skipToPrevious}
haptic="light"
haptic="action"
hitSlop={12}
style={styles.transportMainBtn} android_ripple={ripple.icon(26)}
accessibilityLabel="Previous"
@@ -1162,7 +1162,7 @@ export function NowPlayingOverlay() {
</TactilePressable>
<TactilePressable
onPress={togglePlay}
haptic="light"
haptic="action"
pressedScale={0.97}
hitSlop={12}
style={styles.playButton}
@@ -1177,7 +1177,7 @@ export function NowPlayingOverlay() {
</TactilePressable>
<TactilePressable
onPress={skipToNext}
haptic="light"
haptic="action"
hitSlop={12}
style={styles.transportMainBtn} android_ripple={ripple.icon(26)}
accessibilityLabel="Next"
@@ -1191,7 +1191,7 @@ export function NowPlayingOverlay() {
<TactilePressable
hitSlop={10}
style={styles.transportSideBtn} android_ripple={ripple.icon(24)}
haptic="selection"
haptic="modeCycle"
onPress={() => void cycleRepeat()}
accessibilityLabel="Repeat"
accessibilityState={{ selected: repeat !== 'none' }}
@@ -1232,7 +1232,7 @@ export function NowPlayingOverlay() {
<TactilePressable
hitSlop={10}
style={styles.subBtn} android_ripple={ripple.icon(20)}
haptic="selection"
haptic={scopeStageVisible ? 'toggleOff' : 'toggleOn'}
onPress={() => void setScopeStageVisible(!scopeStageVisible)}
accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'}
accessibilityState={{ selected: scopeStageVisible }}
+3 -4
View File
@@ -12,12 +12,12 @@ import Animated, {
withSequence,
withTiming,
} from 'react-native-reanimated';
import { commitHaptic, tickHaptic } from '@/lib/haptics';
import { playHaptic, type HapticEvent } from '@/lib/haptics';
import { motion } from '@/theme/motion';
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
type HapticFeedback = 'selection' | 'light' | 'none';
type HapticFeedback = HapticEvent | 'none';
interface TactilePressableProps
extends Omit<PressableProps, 'children' | 'style'> {
@@ -60,8 +60,7 @@ export function TactilePressable({
};
const handlePress: NonNullable<PressableProps['onPress']> = (event) => {
if (haptic === 'selection') tickHaptic();
else if (haptic === 'light') commitHaptic();
if (haptic !== 'none') playHaptic(haptic);
if (confirmationScale) {
scale.value = withSequence(
withTiming(confirmationScale, motion.quick),
+4 -3
View File
@@ -46,7 +46,7 @@ import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { motion } from '@/theme/motion';
import { artworkThumbFromSource } from '@/library/artwork';
import { dragArmHaptic, tickHaptic } from '@/lib/haptics';
import { playHaptic } from '@/lib/haptics';
import { useQueueStore } from '@/stores/queueStore';
import {
jumpToQueueIndex,
@@ -334,6 +334,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
}
const nextEntries = arrayMove(snapshot, from, to);
playHaptic('queueDrop');
setVisibleEntries(nextEntries);
clearDragAfterReorderCommit();
commitNativeMove(
@@ -347,7 +348,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
const onDragArm = useCallback(() => {
dragInFlightRef.current = true;
dragArmHaptic();
playHaptic('queueLift');
}, []);
const onDragAbort = useCallback(() => {
@@ -380,7 +381,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
);
if (nextTarget !== dTarget.value) {
dTarget.value = nextTarget;
runOnJS(tickHaptic)();
runOnJS(playHaptic)('frequentStep');
}
})
.onEnd(() => {
+4 -3
View File
@@ -37,7 +37,7 @@ import {
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { commitHaptic, tickHaptic } from '@/lib/haptics';
import { playHaptic } from '@/lib/haptics';
const OPEN_THRESHOLD = 76;
const RESET_THRESHOLD = 58;
@@ -150,7 +150,7 @@ export function PullSearchGesture({
}, []);
const open = useCallback(() => {
commitHaptic();
playHaptic('pullRelease');
onOpen();
resetUi();
}, [onOpen, resetUi]);
@@ -204,10 +204,11 @@ export function PullSearchGesture({
if (!armedValue.value && nextPull >= OPEN_THRESHOLD) {
armedValue.value = true;
runOnJS(setArmed)(true);
runOnJS(tickHaptic)();
runOnJS(playHaptic)('pullLatch');
} else if (armedValue.value && nextPull < RESET_THRESHOLD) {
armedValue.value = false;
runOnJS(setArmed)(false);
runOnJS(playHaptic)('thresholdExit');
}
})
.onEnd((event) => {
+2 -2
View File
@@ -39,7 +39,7 @@ import {
} from '@/library/artwork';
import { multiFieldScore, MIN_SCORE_THRESHOLD } from '@/lib/fuzzySearch';
import { formatDuration } from '@/lib/format';
import { commitHaptic } from '@/lib/haptics';
import { playHaptic } from '@/lib/haptics';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlayerStore } from '@/stores/playerStore';
@@ -945,7 +945,7 @@ function QuickSearchPanel({
};
const queueTrack = (track: DbTrack) => {
commitHaptic();
playHaptic('confirm');
const existingTimer = queuedFeedbackTimers.current.get(track.path);
if (existingTimer) clearTimeout(existingTimer);
+6 -1
View File
@@ -5,6 +5,7 @@ import { spacing } from '@/theme';
import { ACCENTS, ACCENT_IDS, type AccentId } from '@/theme/accents';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { playHaptic } from '@/lib/haptics';
const SWATCH_SIZE = 36;
@@ -26,7 +27,11 @@ export function AccentSwatchRow({ value, onChange }: AccentSwatchRowProps) {
return (
<Pressable android_ripple={ripple.bounded}
key={id}
onPress={() => onChange(id)}
onPress={() => {
if (selected) return;
playHaptic('selection');
onChange(id);
}}
accessibilityRole="radio"
accessibilityState={{ selected }}
accessibilityLabel={`${ACCENTS[id].label} accent`}
+8 -1
View File
@@ -4,6 +4,7 @@ import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type { NowPlayingScopeStyle } from '@/stores/settingsStore';
import { playHaptic } from '@/lib/haptics';
interface ScopeStyleCardsProps {
/** null renders neither card selected (onboarding: no preselection bias). */
@@ -59,11 +60,17 @@ function StyleCard({
const colors = useColors();
const ripple = useRipple();
const handlePress = () => {
if (selected) return;
playHaptic('selection');
onPress();
};
return (
<Pressable
android_ripple={ripple.bounded}
style={[styles.card, selected && styles.cardSelected]}
onPress={onPress}
onPress={handlePress}
accessibilityRole="radio"
accessibilityState={{ selected }}
accessibilityLabel={`${title}. ${description}`}
+16 -3
View File
@@ -27,6 +27,7 @@ import { useSettingsStore } from '@/stores/settingsStore';
import { useThemeStore } from '@/stores/themeStore';
import type { LastFmStatus } from '@/types/lastFm';
import { Text } from '@/components/Text';
import { playHaptic } from '@/lib/haptics';
export function lastFmScrobbleSubtitle(status: LastFmStatus | null): string {
const connected = status?.profiles.filter((p) => p.connected).length ?? 0;
@@ -110,7 +111,11 @@ export function AppearanceSettingsPanel() {
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
key={option.id}
style={[styles.option, selected && styles.optionSelected]}
onPress={() => void setBaseTheme(option.id)}
onPress={() => {
if (selected) return;
playHaptic('selection');
void setBaseTheme(option.id);
}}
accessibilityRole="radio"
accessibilityState={{ selected }}
>
@@ -331,7 +336,11 @@ export function LibrarySettingsPanel() {
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
key={option.mode}
style={[styles.option, selected && styles.optionSelected]}
onPress={() => void setArtistGroupingMode(option.mode)}
onPress={() => {
if (selected) return;
playHaptic('selection');
void setArtistGroupingMode(option.mode);
}}
accessibilityRole="radio"
accessibilityState={{ selected }}
>
@@ -418,7 +427,11 @@ export function AudioSettingsPanel() {
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
key={m.mode}
style={[styles.modePill, selected && styles.modePillSelected]}
onPress={() => void setReplayGainMode(m.mode)}
onPress={() => {
if (selected) return;
playHaptic('selection');
void setReplayGainMode(m.mode);
}}
>
<Text variant="label" color={selected ? colors.accentTextStrong : colors.textSecondary}>
{m.label}
@@ -3,7 +3,6 @@ import {
Pressable,
ScrollView,
StyleSheet,
Switch,
View,
type StyleProp,
type ViewStyle,
@@ -15,6 +14,7 @@ import { Text } from '@/components/Text';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { HapticSwitch } from '@/components/HapticSwitch';
export type SettingsIconName = keyof typeof Ionicons.glyphMap;
@@ -148,7 +148,7 @@ export function SettingsToggleRow({
{description}
</Text>
</View>
<Switch
<HapticSwitch
value={value}
onValueChange={onValueChange}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
+10 -2
View File
@@ -17,6 +17,7 @@ import {
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { playHaptic } from '@/lib/haptics';
export function AppSheet({ onClose, children }: { onClose: () => void; children: ReactNode }) {
const styles = useStyles();
@@ -98,14 +99,21 @@ export function AppSheetItem({
const colors = useColors();
const ripple = useRipple();
const tint = destructive ? colors.warning : selected ? colors.accentTextStrong : colors.textPrimary;
const selectable = selected !== undefined;
const handlePress = () => {
if (selectable && !selected) playHaptic('selection');
onPress();
};
return (
<View style={styles.itemRow}>
<Pressable
android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.item}
onPress={onPress}
accessibilityRole="button"
onPress={handlePress}
accessibilityRole={selectable ? 'radio' : 'button'}
accessibilityState={selectable ? { selected } : undefined}
>
{icon ? (
<Ionicons name={icon} size={20} color={destructive ? colors.warning : colors.textSecondary} />
+80
View File
@@ -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<Record<HapticEvent, HapticDefinition>> = {
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';
}
+339
View File
@@ -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
);
}
+243
View File
@@ -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<HapticEvent, string> = {
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<HapticEvent, string> = {
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<string, string | undefined> = {
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);
});
+42 -15
View File
@@ -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<void> {
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';