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