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
+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',
},
}));