diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index 18a5ae0..6fa29dd 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -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(null); const [randomSeed] = useState(() => Math.random()); + const scrollTop = useScrollTopGate(); const tracksByAlbum = useMemo(() => { const map = new Map(); @@ -418,9 +426,18 @@ export default function HomeScreen() { } }; + const openSearch = () => openQuickSearch(); + return ( - + + ASTRA @@ -548,7 +565,8 @@ export default function HomeScreen() { )} - + + ); } diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx index 482ed55..719fd09 100644 --- a/src/app/(tabs)/library/index.tsx +++ b/src/app/(tabs)/library/index.tsx @@ -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(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 ( - - - Library - - {!isEmpty ? ( - openQuickSearch()} - accessibilityRole="button" - accessibilityLabel="Search library" - > - - - ) : null} - - - {isEmpty ? ( - - ) : ( - <> - - - - - {scanError ? ( - - Scan problem: {scanError} - + + + + Library + + {!isEmpty ? ( + openQuickSearch()} + accessibilityRole="button" + accessibilityLabel="Search library" + > + + ) : null} + - {viewMode === 'albums' ? ( - album.identity_key} - showsVerticalScrollIndicator={false} - renderItem={({ item }) => ( - - + ) : ( + <> + + { + scrollTop.setScrollAtTop(true); + setViewMode(mode); + }} + /> + + + {scanError ? ( + + Scan problem: {scanError} + + ) : null} + + {viewMode === 'albums' ? ( + album.identity_key} + showsVerticalScrollIndicator={false} + overScrollMode="never" + renderScrollComponent={PullSearchScrollView} + onScroll={scrollTop.onScroll} + scrollEventThrottle={scrollTop.scrollEventThrottle} + renderItem={({ item }) => ( + + + router.push({ + pathname: '/library/album/[key]', + params: { key: item.identity_key }, + }) + } + /> + + )} + /> + ) : null} + + {viewMode === 'artists' ? ( + artist.artist} + showsVerticalScrollIndicator={false} + overScrollMode="never" + renderScrollComponent={PullSearchScrollView} + onScroll={scrollTop.onScroll} + scrollEventThrottle={scrollTop.scrollEventThrottle} + renderItem={({ item }) => ( + router.push({ - pathname: '/library/album/[key]', - params: { key: item.identity_key }, + pathname: '/library/artist/[name]', + params: { name: item.artist }, }) } /> - - )} - /> - ) : null} - - {viewMode === 'artists' ? ( - artist.artist} - showsVerticalScrollIndicator={false} - renderItem={({ item }) => ( - - router.push({ - pathname: '/library/artist/[name]', - params: { name: item.artist }, - }) - } - /> - )} - /> - ) : null} - - {viewMode === 'tracks' ? ( - <> - setSortSheetOpen(true)} - accessibilityRole="button" - > - - {TRACK_SORT_LABELS[trackSort]} - - String(track.id)} - showsVerticalScrollIndicator={false} - renderItem={({ item, index }) => ( - playAllFrom(index)} - onLongPress={() => setActionTrack(item)} - /> )} /> - - ) : null} + ) : null} - {viewMode === 'playlists' ? : null} + {viewMode === 'tracks' ? ( + <> + setSortSheetOpen(true)} + accessibilityRole="button" + > + + {TRACK_SORT_LABELS[trackSort]} + + String(track.id)} + showsVerticalScrollIndicator={false} + overScrollMode="never" + renderScrollComponent={PullSearchScrollView} + onScroll={scrollTop.onScroll} + scrollEventThrottle={scrollTop.scrollEventThrottle} + renderItem={({ item, index }) => ( + playAllFrom(index)} + onLongPress={() => setActionTrack(item)} + /> + )} + /> + + ) : null} - {viewMode === 'folders' ? : null} - - )} + {viewMode === 'playlists' ? ( + + ) : null} + + {viewMode === 'folders' ? : null} + + )} + setActionTrack(null)} /> ) => 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={ ; +type SimultaneousHandlers = NativeViewGestureHandlerProps['simultaneousHandlers']; +type PullSearchScrollViewProps = ScrollViewProps & Pick; +type PullSearchContextValue = { + gestureRef: PullSearchGestureRef; + cancelIfScrolledAway: (offsetY: number) => void; +}; + +const PullSearchGestureContext = createContext(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( + function PullSearchScrollView({ simultaneousHandlers, onScroll, ...props }, ref) { + const pullSearchContext = useContext(PullSearchGestureContext); + const mergedHandlers = useMemo( + () => mergeSimultaneousHandlers(simultaneousHandlers, pullSearchContext), + [pullSearchContext, simultaneousHandlers] + ); + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + onScroll?.(event); + pullSearchContext?.cancelIfScrolledAway(event.nativeEvent.contentOffset.y); + }, + [onScroll, pullSearchContext] + ); + + return ( + + ); + } +); + +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) => { + 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(() => ({ 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( + () => ({ 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 ( + + + + {children} + {dragging ? ( + + + + {armed ? 'Release' : 'Search'} + + + ) : null} + + + + ); +} + +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, + }, +});