global search gesture

This commit is contained in:
Boof2015
2026-06-28 14:44:59 -04:00
parent dcdddb350d
commit 3558ea30e4
4 changed files with 443 additions and 95 deletions
+20 -2
View File
@@ -10,10 +10,16 @@ import { SpectrumCurve } from '@/components/SpectrumCurve';
import { TrackRow } from '@/components/library/TrackRow';
import { PlaylistRow } from '@/components/library/PlaylistRow';
import { ScanProgress } from '@/components/library/ScanProgress';
import {
PullSearchGesture,
PullSearchScrollView,
useScrollTopGate,
} from '@/components/search/PullSearchGesture';
import { colors, fonts, radius, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSearchStore } from '@/stores/searchStore';
import {
playTracks,
shuffleTracks,
@@ -352,9 +358,11 @@ export default function HomeScreen() {
const playbackState = usePlayerStore((s) => s.playbackState);
const currentTime = usePlayerStore((s) => s.currentTime);
const duration = usePlayerStore((s) => s.duration);
const openQuickSearch = useSearchStore((s) => s.openQuickSearch);
const [randomAlbumKey, setRandomAlbumKey] = useState<string | null>(null);
const [randomSeed] = useState(() => Math.random());
const scrollTop = useScrollTopGate();
const tracksByAlbum = useMemo(() => {
const map = new Map<string, DbTrack[]>();
@@ -418,9 +426,18 @@ export default function HomeScreen() {
}
};
const openSearch = () => openQuickSearch();
return (
<Screen>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
<PullSearchGesture atTop={scrollTop.atTop} onOpen={openSearch}>
<PullSearchScrollView
showsVerticalScrollIndicator={false}
overScrollMode="never"
contentContainerStyle={styles.content}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
>
<View style={styles.header}>
<AstraLogo size={36} />
<Text style={styles.wordmark}>ASTRA</Text>
@@ -548,7 +565,8 @@ export default function HomeScreen() {
</View>
</>
)}
</ScrollView>
</PullSearchScrollView>
</PullSearchGesture>
</Screen>
);
}
+123 -91
View File
@@ -15,6 +15,11 @@ import { ScanProgress } from '@/components/library/ScanProgress';
import { EmptyLibrary } from '@/components/library/EmptyLibrary';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { ActionSheet } from '@/components/sheets/ActionSheet';
import {
PullSearchGesture,
PullSearchScrollView,
useScrollTopGate,
} from '@/components/search/PullSearchGesture';
import { colors, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
@@ -43,6 +48,7 @@ export default function LibraryScreen() {
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const [sortSheetOpen, setSortSheetOpen] = useState(false);
const scrollTop = useScrollTopGate();
const isEmpty = tracks.length === 0 && folders.length === 0 && !isScanning;
@@ -52,111 +58,137 @@ export default function LibraryScreen() {
const playAllFrom = (index: number) => {
void playTracks(sortedTracks.map(dbTrackToTrack), index);
};
const openSearch = () => openQuickSearch();
return (
<Screen>
<View style={styles.headingRow}>
<Text variant="title" style={styles.heading}>
Library
</Text>
{!isEmpty ? (
<Pressable
hitSlop={8}
onPress={() => openQuickSearch()}
accessibilityRole="button"
accessibilityLabel="Search library"
>
<Ionicons name="search" size={22} color={colors.textSecondary} />
</Pressable>
) : null}
</View>
{isEmpty ? (
<EmptyLibrary />
) : (
<>
<View style={styles.switcher}>
<ViewModeSwitcher value={viewMode} onChange={setViewMode} />
</View>
<ScanProgress />
{scanError ? (
<Text variant="caption" color={colors.warning} style={styles.error} numberOfLines={2}>
Scan problem: {scanError}
</Text>
<PullSearchGesture atTop={scrollTop.atTop} onOpen={openSearch}>
<View style={styles.headingRow}>
<Text variant="title" style={styles.heading}>
Library
</Text>
{!isEmpty ? (
<Pressable
hitSlop={8}
onPress={() => openQuickSearch()}
accessibilityRole="button"
accessibilityLabel="Search library"
>
<Ionicons name="search" size={22} color={colors.textSecondary} />
</Pressable>
) : null}
</View>
{viewMode === 'albums' ? (
<FlashList
data={albums}
numColumns={2}
keyExtractor={(album) => album.identity_key}
showsVerticalScrollIndicator={false}
renderItem={({ item }) => (
<View style={styles.gridCell}>
<AlbumGridItem
album={item}
{isEmpty ? (
<EmptyLibrary />
) : (
<>
<View style={styles.switcher}>
<ViewModeSwitcher
value={viewMode}
onChange={(mode) => {
scrollTop.setScrollAtTop(true);
setViewMode(mode);
}}
/>
</View>
<ScanProgress />
{scanError ? (
<Text variant="caption" color={colors.warning} style={styles.error} numberOfLines={2}>
Scan problem: {scanError}
</Text>
) : null}
{viewMode === 'albums' ? (
<FlashList
data={albums}
numColumns={2}
keyExtractor={(album) => album.identity_key}
showsVerticalScrollIndicator={false}
overScrollMode="never"
renderScrollComponent={PullSearchScrollView}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
renderItem={({ item }) => (
<View style={styles.gridCell}>
<AlbumGridItem
album={item}
onPress={() =>
router.push({
pathname: '/library/album/[key]',
params: { key: item.identity_key },
})
}
/>
</View>
)}
/>
) : null}
{viewMode === 'artists' ? (
<FlashList
data={artists}
keyExtractor={(artist) => artist.artist}
showsVerticalScrollIndicator={false}
overScrollMode="never"
renderScrollComponent={PullSearchScrollView}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
renderItem={({ item }) => (
<ArtistRow
artist={item}
onPress={() =>
router.push({
pathname: '/library/album/[key]',
params: { key: item.identity_key },
pathname: '/library/artist/[name]',
params: { name: item.artist },
})
}
/>
</View>
)}
/>
) : null}
{viewMode === 'artists' ? (
<FlashList
data={artists}
keyExtractor={(artist) => artist.artist}
showsVerticalScrollIndicator={false}
renderItem={({ item }) => (
<ArtistRow
artist={item}
onPress={() =>
router.push({
pathname: '/library/artist/[name]',
params: { name: item.artist },
})
}
/>
)}
/>
) : null}
{viewMode === 'tracks' ? (
<>
<Pressable
style={styles.sortTrigger}
onPress={() => setSortSheetOpen(true)}
accessibilityRole="button"
>
<Ionicons name="swap-vertical" size={14} color={colors.textSecondary} />
<Text variant="label">{TRACK_SORT_LABELS[trackSort]}</Text>
</Pressable>
<FlashList
data={sortedTracks}
keyExtractor={(track) => String(track.id)}
showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => (
<TrackRow
track={item}
active={item.path === currentPath}
onPress={() => playAllFrom(index)}
onLongPress={() => setActionTrack(item)}
/>
)}
/>
</>
) : null}
) : null}
{viewMode === 'playlists' ? <PlaylistsView /> : null}
{viewMode === 'tracks' ? (
<>
<Pressable
style={styles.sortTrigger}
onPress={() => setSortSheetOpen(true)}
accessibilityRole="button"
>
<Ionicons name="swap-vertical" size={14} color={colors.textSecondary} />
<Text variant="label">{TRACK_SORT_LABELS[trackSort]}</Text>
</Pressable>
<FlashList
data={sortedTracks}
keyExtractor={(track) => String(track.id)}
showsVerticalScrollIndicator={false}
overScrollMode="never"
renderScrollComponent={PullSearchScrollView}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
renderItem={({ item, index }) => (
<TrackRow
track={item}
active={item.path === currentPath}
onPress={() => playAllFrom(index)}
onLongPress={() => setActionTrack(item)}
/>
)}
/>
</>
) : null}
{viewMode === 'folders' ? <FoldersView /> : null}
</>
)}
{viewMode === 'playlists' ? (
<PlaylistsView
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
/>
) : null}
{viewMode === 'folders' ? <FoldersView /> : null}
</>
)}
</PullSearchGesture>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
<ActionSheet
+20 -2
View File
@@ -1,5 +1,12 @@
import { useState } from 'react';
import { View, Pressable, StyleSheet, Alert } from 'react-native';
import {
View,
Pressable,
StyleSheet,
Alert,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useRouter } from 'expo-router';
@@ -7,6 +14,7 @@ import { Text } from '@/components/Text';
import { ActionSheet } from '@/components/sheets/ActionSheet';
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
import { PlaylistRow } from '@/components/library/PlaylistRow';
import { PullSearchScrollView } from '@/components/search/PullSearchGesture';
import { colors, radius, spacing } from '@/theme';
import { usePlaylistStore } from '@/stores/playlistStore';
import type { Playlist } from '@/types/playlist';
@@ -23,7 +31,13 @@ function fileDisplayName(fileUri: string): string {
type Prompt = { kind: 'create' } | { kind: 'rename'; playlist: Playlist } | null;
export function PlaylistsView() {
export function PlaylistsView({
onScroll,
scrollEventThrottle,
}: {
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
scrollEventThrottle?: number;
}) {
const router = useRouter();
const playlists = usePlaylistStore((s) => s.playlists);
const favoriteCount = usePlaylistStore((s) => s.favoriteTracks.length);
@@ -125,6 +139,10 @@ export function PlaylistsView() {
data={playlists}
keyExtractor={(playlist) => String(playlist.id)}
showsVerticalScrollIndicator={false}
overScrollMode="never"
renderScrollComponent={PullSearchScrollView}
onScroll={onScroll}
scrollEventThrottle={scrollEventThrottle}
ListHeaderComponent={
<PlaylistRow
name="Favorites"
+280
View File
@@ -0,0 +1,280 @@
/* eslint-disable react-hooks/immutability -- Reanimated shared values are mutable gesture state. */
import {
createContext,
forwardRef,
useCallback,
useContext,
useMemo,
useRef,
useState,
type MutableRefObject,
type ReactNode,
} from 'react';
import {
ScrollView as RNScrollView,
StyleSheet,
View,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ScrollViewProps,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import {
Gesture,
GestureDetector,
ScrollView as GestureScrollView,
type GestureType,
type NativeViewGestureHandlerProps,
} from 'react-native-gesture-handler';
import { runOnJS, runOnUI, useSharedValue } from 'react-native-reanimated';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { commitHaptic, tickHaptic } from '@/lib/haptics';
const OPEN_THRESHOLD = 76;
const RESET_THRESHOLD = 58;
const MAX_PULL = 112;
type PullSearchGestureRef = MutableRefObject<GestureType | undefined>;
type SimultaneousHandlers = NativeViewGestureHandlerProps['simultaneousHandlers'];
type PullSearchScrollViewProps = ScrollViewProps & Pick<NativeViewGestureHandlerProps, 'simultaneousHandlers'>;
type PullSearchContextValue = {
gestureRef: PullSearchGestureRef;
cancelIfScrolledAway: (offsetY: number) => void;
};
const PullSearchGestureContext = createContext<PullSearchContextValue | null>(null);
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function mergeSimultaneousHandlers(
existing: SimultaneousHandlers,
contextValue: PullSearchContextValue | null
): SimultaneousHandlers {
if (!contextValue) return existing;
if (!existing) return contextValue.gestureRef;
return Array.isArray(existing)
? [...existing, contextValue.gestureRef]
: [existing, contextValue.gestureRef];
}
export const PullSearchScrollView = forwardRef<RNScrollView, PullSearchScrollViewProps>(
function PullSearchScrollView({ simultaneousHandlers, onScroll, ...props }, ref) {
const pullSearchContext = useContext(PullSearchGestureContext);
const mergedHandlers = useMemo(
() => mergeSimultaneousHandlers(simultaneousHandlers, pullSearchContext),
[pullSearchContext, simultaneousHandlers]
);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
onScroll?.(event);
pullSearchContext?.cancelIfScrolledAway(event.nativeEvent.contentOffset.y);
},
[onScroll, pullSearchContext]
);
return (
<GestureScrollView
ref={ref}
{...props}
onScroll={handleScroll}
simultaneousHandlers={mergedHandlers}
/>
);
}
);
export function useScrollTopGate(initialAtTop = true) {
const atTopRef = useRef(initialAtTop);
const [atTop, setAtTop] = useState(initialAtTop);
const setScrollAtTop = useCallback((next: boolean) => {
if (next === atTopRef.current) return;
atTopRef.current = next;
setAtTop(next);
}, []);
const onScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
setScrollAtTop(event.nativeEvent.contentOffset.y <= 2);
},
[setScrollAtTop]
);
return { atTop, onScroll, scrollEventThrottle: 16 as const, setScrollAtTop };
}
export function PullSearchGesture({
children,
enabled = true,
atTop,
onOpen,
}: {
children: ReactNode;
enabled?: boolean;
atTop: boolean;
onOpen: () => void;
}) {
const [pull, setPull] = useState(0);
const [armed, setArmed] = useState(false);
const [dragging, setDragging] = useState(false);
const pullGestureRef = useMemo<PullSearchGestureRef>(() => ({ current: undefined }), []);
const pullValue = useSharedValue(0);
const armedValue = useSharedValue(false);
const draggingValue = useSharedValue(false);
const resetUi = useCallback(() => {
setArmed(false);
setPull(0);
setDragging(false);
}, []);
const open = useCallback(() => {
commitHaptic();
onOpen();
resetUi();
}, [onOpen, resetUi]);
const resetShared = useCallback(() => {
runOnUI(() => {
'worklet';
pullValue.value = 0;
armedValue.value = false;
draggingValue.value = false;
})();
}, [armedValue, draggingValue, pullValue]);
const cancelIfScrolledAway = useCallback(
(offsetY: number) => {
if (offsetY <= 2 || !dragging) return;
resetShared();
resetUi();
},
[dragging, resetShared, resetUi]
);
const contextValue = useMemo<PullSearchContextValue>(
() => ({ gestureRef: pullGestureRef, cancelIfScrolledAway }),
[cancelIfScrolledAway, pullGestureRef]
);
const pullGesture = useMemo(
() =>
Gesture.Pan()
.withRef(pullGestureRef)
.enabled(enabled && atTop)
.activeOffsetY(10)
.failOffsetY(-8)
.failOffsetX([-28, 28])
.onStart(() => {
'worklet';
pullValue.value = 0;
armedValue.value = false;
draggingValue.value = true;
runOnJS(setPull)(0);
runOnJS(setArmed)(false);
runOnJS(setDragging)(true);
})
.onChange((event) => {
'worklet';
const nextPull = Math.max(0, Math.min(MAX_PULL, pullValue.value + event.changeY));
pullValue.value = nextPull;
runOnJS(setPull)(nextPull);
if (!armedValue.value && nextPull >= OPEN_THRESHOLD) {
armedValue.value = true;
runOnJS(setArmed)(true);
runOnJS(tickHaptic)();
} else if (armedValue.value && nextPull < RESET_THRESHOLD) {
armedValue.value = false;
runOnJS(setArmed)(false);
}
})
.onEnd((event) => {
'worklet';
const finalPull = pullValue.value;
pullValue.value = 0;
armedValue.value = false;
draggingValue.value = false;
if (finalPull >= OPEN_THRESHOLD || (finalPull > 44 && event.velocityY > 1250)) {
runOnJS(open)();
return;
}
runOnJS(resetUi)();
})
.onFinalize((_event, success) => {
'worklet';
if (success) return;
pullValue.value = 0;
armedValue.value = false;
draggingValue.value = false;
runOnJS(resetUi)();
}),
[
armedValue,
atTop,
draggingValue,
enabled,
open,
pullGestureRef,
pullValue,
resetUi,
]
);
const progress = clamp(pull / OPEN_THRESHOLD, 0, 1);
const indicatorStyle = {
opacity: pull <= 0 ? 0 : Math.max(0.72, progress),
transform: [
{ translateY: -18 + (clamp(pull, 0, MAX_PULL) / MAX_PULL) * 38 },
{ scale: armed ? 1.03 : 0.94 + progress * 0.06 },
],
};
return (
<PullSearchGestureContext.Provider value={contextValue}>
<GestureDetector gesture={pullGesture}>
<View collapsable={false} style={styles.root}>
{children}
{dragging ? (
<View pointerEvents="none" style={[styles.indicator, indicatorStyle]}>
<Ionicons name="search" size={16} color={armed ? colors.accentTextStrong : colors.textSecondary} />
<Text variant="label" color={armed ? colors.accentTextStrong : colors.textSecondary}>
{armed ? 'Release' : 'Search'}
</Text>
</View>
) : null}
</View>
</GestureDetector>
</PullSearchGestureContext.Provider>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
},
indicator: {
position: 'absolute',
top: spacing.xs,
alignSelf: 'center',
zIndex: 20,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgTertiary,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
shadowColor: '#000',
shadowOpacity: 0.28,
shadowRadius: 14,
shadowOffset: { width: 0, height: 8 },
elevation: 10,
},
});