From d385b39c8cc0156047eb9c4e31cfbf022d20f480 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:04:34 -0400 Subject: [PATCH] better navbar in library --- package.json | 5 + src/app/(tabs)/library/index.tsx | 152 ++++++--- src/components/library/FoldersView.tsx | 11 + src/components/library/LibraryContextBar.tsx | 308 +++++++++++++++---- src/components/library/PlaylistsView.tsx | 13 +- src/library/libraryViewMode.ts | 8 + src/library/libraryViewPresentation.test.mts | 70 ++++- src/library/libraryViewPresentation.ts | 97 ++++-- 8 files changed, 545 insertions(+), 119 deletions(-) diff --git a/package.json b/package.json index 2a2295d..65915ff 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,11 @@ "name": "astra-mobile", "main": "index.js", "version": "0.1.1", + "reanimated": { + "staticFeatureFlags": { + "ANDROID_SYNCHRONOUSLY_UPDATE_UI_PROPS": true + } + }, "dependencies": { "@boof2015/astra-signal": "0.3.0", "@boof2015/xlrc": "^0.2.1", diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx index 3eb849a..cb19a94 100644 --- a/src/app/(tabs)/library/index.tsx +++ b/src/app/(tabs)/library/index.tsx @@ -19,6 +19,7 @@ import { import { Ionicons } from '@expo/vector-icons'; import { FlashList, type FlashListRef } from '@shopify/flash-list'; import { useFocusEffect, useRouter } from 'expo-router'; +import Animated, { FadeIn, ReduceMotion } from 'react-native-reanimated'; import { Screen } from '@/components/Screen'; import { ScreenHeader, @@ -54,6 +55,7 @@ import { useScrollTopGate } from '@/components/search/PullSearchGesture'; import { spacing } from '@/theme'; +import { motion } from '@/theme/motion'; import { useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; import { useShellLayout } from '@/navigation/useShellLayout'; @@ -63,12 +65,13 @@ import type { ScrollToTopHandle } from '@/navigation/scrollToTopHandle'; import { needsWindowRewind } from '@/library/libraryWindowTop'; import { flashListInitialAnchor, + flashListMaintainsVisiblePosition, libraryContextBottomClearance, libraryContextOverlayHeight, libraryContextScrimHeight, } from '@/library/libraryViewPresentation'; import { - LIBRARY_VIEW_MODES, + libraryViewModeLabel, type LibraryViewMode, } from '@/library/libraryViewMode'; import { useMiniPlayerVisible } from '@/playback/useMiniPlayerVisible'; @@ -109,6 +112,9 @@ import type { const TRACK_SORT_OPTIONS: TrackSort[] = ['artist', 'title', 'recently_added', 'duration']; const ALBUM_SORT_OPTIONS: AlbumSort[] = ['artist', 'name', 'recently_added', 'year']; const ARTIST_SORT_OPTIONS: ArtistSort[] = ['name', 'track_count']; +const CONTEXT_SCRIM_ENTERING = FadeIn + .duration(motion.quick.duration) + .reduceMotion(ReduceMotion.System); /** How long the finger has to settle on a rail letter before the list jumps. */ const JUMP_DEBOUNCE_MS = 100; /** @@ -117,14 +123,6 @@ const JUMP_DEBOUNCE_MS = 100; */ const END_REACHED_THRESHOLD = 2; const START_REACHED_THRESHOLD = 0.5; -const MODE_SHEET_ICONS: Record = { - albums: 'albums-outline', - artists: 'people-outline', - tracks: 'musical-notes-outline', - playlists: 'list-outline', - folders: 'folder-outline', -}; - /** * The pinned chrome deck, in declared slots. * @@ -204,10 +202,11 @@ export default function LibraryScreen() { const miniPlayerVisible = useMiniPlayerVisible(); const [actionTrack, setActionTrack] = useState(null); - const [modeSheetOpen, setModeSheetOpen] = useState(false); const [sortSheetOpen, setSortSheetOpen] = useState(false); const [layoutSheetOpen, setLayoutSheetOpen] = useState(false); + const [viewOptionsSheetOpen, setViewOptionsSheetOpen] = useState(false); const [playlistAddMenuOpen, setPlaylistAddMenuOpen] = useState(false); + const [childSheetOpen, setChildSheetOpen] = useState(false); const [selectMode, setSelectMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(() => new Set()); const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); @@ -273,6 +272,9 @@ export default function LibraryScreen() { const initialAnchorProps = initialScrollIndex === undefined ? {} : { initialScrollIndex }; + const maintainVisibleContentPosition = { + disabled: !flashListMaintainsVisiblePosition(jumpAnchorIndex), + }; const contextBottomClearance = libraryContextBottomClearance( sceneBottomInset, miniPlayerVisible @@ -435,10 +437,11 @@ export default function LibraryScreen() { }; const changeViewMode = (mode: LibraryViewMode) => { - setModeSheetOpen(false); setSortSheetOpen(false); setLayoutSheetOpen(false); + setViewOptionsSheetOpen(false); setPlaylistAddMenuOpen(false); + setChildSheetOpen(false); if (selectMode) exitSelection(); if (mode === viewMode) return; // setViewMode clears the shared A-Z anchor. The post-commit effect below @@ -532,9 +535,18 @@ export default function LibraryScreen() { : viewMode; const listMountIdentity = `${surfaceHeadIdentity}:${sectionJumpRevision}`; const activeListMountIdentity = useRef(listMountIdentity); + const settledHeadListIdentity = useRef(null); useLayoutEffect(() => { activeListMountIdentity.current = listMountIdentity; - }, [listMountIdentity]); + // FlashList can report its item-0 correction before `onLoad`. Keep that + // native bookkeeping event away from the header until the list is moved to + // its true scroll origin below. Positive A-Z anchors and the two child-list + // surfaces are already deliberately positioned and need no such gate. + settledHeadListIdentity.current = + jumpAnchorIndex > 0 || viewMode === 'playlists' || viewMode === 'folders' + ? listMountIdentity + : null; + }, [jumpAnchorIndex, listMountIdentity, viewMode]); useEffect(() => { if (jumpAnchorIndex > 0) return; @@ -553,12 +565,35 @@ export default function LibraryScreen() { if (viewMode === 'artists') setArtistLayout(layout); }; + const settleActiveFlashListAtHead = useCallback(() => { + const state = useLibraryStore.getState(); + if ( + activeListMountIdentity.current !== listMountIdentity || + state.jumpAnchorIndex > 0 || + (state.viewMode !== 'albums' && + state.viewMode !== 'artists' && + state.viewMode !== 'tracks') + ) { + return; + } + const list = state.viewMode === 'albums' + ? albumListRef.current + : state.viewMode === 'artists' + ? artistListRef.current + : trackListRef.current; + list?.scrollToOffset({ offset: 0, animated: false }); + settledHeadListIdentity.current = listMountIdentity; + setScrollAtTop(true); + resetHeader(); + }, [listMountIdentity, resetHeader, setScrollAtTop]); + // Two independent consumers of the same scroll: the pull-to-search gate and // the collapsing header. Neither owns the list, so the screen fans out. const onListScroll = (event: NativeSyntheticEvent) => { // Native scroll delivery can trail an unmount. A section, sort, layout, or // A-Z remount must not inherit one last offset from the surface it replaced. if (activeListMountIdentity.current !== listMountIdentity) return; + if (settledHeadListIdentity.current !== listMountIdentity) return; scrollTop.onScroll(event); header.onScroll(event); }; @@ -696,6 +731,7 @@ export default function LibraryScreen() { keyExtractor={(album) => album.identity_key} showsVerticalScrollIndicator={false} overScrollMode="never" + maintainVisibleContentPosition={maintainVisibleContentPosition} contentContainerStyle={{ paddingTop: header.contentPaddingTop, paddingHorizontal: spacing.lg, @@ -706,6 +742,7 @@ export default function LibraryScreen() { onScroll={onListScroll} scrollEventThrottle={scrollTop.scrollEventThrottle} {...initialAnchorProps} + onLoad={settleActiveFlashListAtHead} onEndReached={() => void loadNextAlbums()} onEndReachedThreshold={END_REACHED_THRESHOLD} onStartReached={() => void loadPreviousAlbums()} @@ -748,6 +785,7 @@ export default function LibraryScreen() { keyExtractor={(artist) => artist.artist} showsVerticalScrollIndicator={false} overScrollMode="never" + maintainVisibleContentPosition={maintainVisibleContentPosition} contentContainerStyle={{ paddingTop: header.contentPaddingTop, paddingHorizontal: spacing.lg, @@ -758,6 +796,7 @@ export default function LibraryScreen() { onScroll={onListScroll} scrollEventThrottle={scrollTop.scrollEventThrottle} {...initialAnchorProps} + onLoad={settleActiveFlashListAtHead} onEndReached={() => void loadNextArtists()} onEndReachedThreshold={END_REACHED_THRESHOLD} onStartReached={() => void loadPreviousArtists()} @@ -799,6 +838,7 @@ export default function LibraryScreen() { keyExtractor={(track) => String(track.id)} showsVerticalScrollIndicator={false} overScrollMode="never" + maintainVisibleContentPosition={maintainVisibleContentPosition} contentContainerStyle={{ paddingTop: header.contentPaddingTop, paddingHorizontal: spacing.lg, @@ -809,6 +849,7 @@ export default function LibraryScreen() { onScroll={onListScroll} scrollEventThrottle={scrollTop.scrollEventThrottle} {...initialAnchorProps} + onLoad={settleActiveFlashListAtHead} onEndReached={() => void loadNextTracks()} onEndReachedThreshold={END_REACHED_THRESHOLD} onStartReached={() => void loadPreviousTracks()} @@ -840,6 +881,7 @@ export default function LibraryScreen() { listHeader={inlineStatus} addMenuOpen={playlistAddMenuOpen} onCloseAddMenu={() => setPlaylistAddMenuOpen(false)} + onSheetOpenChange={setChildSheetOpen} /> ) : null} @@ -851,6 +893,7 @@ export default function LibraryScreen() { contentPaddingTop={header.contentPaddingTop} contentPaddingBottom={listBottomPadding} listHeader={inlineStatus} + onSheetOpenChange={setChildSheetOpen} /> ) : null} @@ -888,20 +931,48 @@ export default function LibraryScreen() { /> - {phoneContextBar && !showLibraryStatus ? ( + {phoneContextBar && !showLibraryStatus && !( + actionTrack || + playlistPickerOpen || + sortSheetOpen || + layoutSheetOpen || + viewOptionsSheetOpen || + playlistAddMenuOpen || + childSheetOpen + ) ? ( <> - + + + setModeSheetOpen(true)} + onChangeMode={changeViewMode} onSearch={() => openQuickSearch()} - onSort={sortable ? () => setSortSheetOpen(true) : undefined} - sortLabel={sortable ? sortLabel : undefined} - onLayout={activeLayout ? () => setLayoutSheetOpen(true) : undefined} - layoutLabel={activeLayoutLabel ?? undefined} - onAddPlaylist={ - viewMode === 'playlists' ? () => setPlaylistAddMenuOpen(true) : undefined + contextAction={ + viewMode === 'albums' || viewMode === 'artists' + ? { + icon: 'options-outline', + label: `${libraryViewModeLabel(viewMode)} sort and layout options`, + onPress: () => setViewOptionsSheetOpen(true), + } + : viewMode === 'tracks' + ? { + icon: 'swap-vertical', + label: `Sort Tracks, currently ${sortLabel}`, + onPress: () => setSortSheetOpen(true), + } + : viewMode === 'playlists' + ? { + icon: 'add', + label: 'Add or import playlist', + onPress: () => setPlaylistAddMenuOpen(true), + } + : undefined } selection={ selectMode && viewMode === 'tracks' @@ -937,20 +1008,6 @@ export default function LibraryScreen() { }} /> ) : null} - {modeSheetOpen ? ( - setModeSheetOpen(false)}> - - {LIBRARY_VIEW_MODES.map((mode) => ( - changeViewMode(mode.key)} - /> - ))} - - ) : null} {sortSheetOpen ? ( setSortSheetOpen(false)}> @@ -985,6 +1042,31 @@ export default function LibraryScreen() { ))} ) : null} + {viewOptionsSheetOpen && activeLayout ? ( + setViewOptionsSheetOpen(false)}> + + + {sortItems.map(({ key, label, selected, onSelect }) => ( + + ))} + + {LIBRARY_LAYOUT_OPTIONS.map((option) => ( + setActiveLayout(option.value)} + /> + ))} + + ) : null} ); } diff --git a/src/components/library/FoldersView.tsx b/src/components/library/FoldersView.tsx index 4bbd8ee..e44f685 100644 --- a/src/components/library/FoldersView.tsx +++ b/src/components/library/FoldersView.tsx @@ -47,6 +47,8 @@ interface FoldersViewProps { listHeader?: ReactNode; /** Lets the Library screen send this list back to the top on a tab re-tap. */ listRef?: (list: ScrollToTopHandle | null) => void; + /** Lets Library replace its dock while a folder/track action sheet is present. */ + onSheetOpenChange?: (open: boolean) => void; } interface LoadedNode { @@ -219,6 +221,7 @@ export function FoldersView({ contentPaddingBottom, listHeader, listRef, + onSheetOpenChange, }: FoldersViewProps) { const sceneBottomInset = useSceneBottomInset(); const styles = useStyles(); @@ -230,6 +233,14 @@ export function FoldersView({ const [actionTrack, setActionTrack] = useState(null); const [actionFolder, setActionFolder] = useState(null); + const sheetOpen = actionTrack !== null || actionFolder !== null; + useEffect(() => { + onSheetOpenChange?.(sheetOpen); + return () => { + if (sheetOpen) onSheetOpenChange?.(false); + }; + }, [onSheetOpenChange, sheetOpen]); + const replaceRoots = async () => { const roots = await AstraLibraryData.getFolderNodes(null); setNodes(new Map(roots.map((node) => [ diff --git a/src/components/library/LibraryContextBar.tsx b/src/components/library/LibraryContextBar.tsx index daf6061..5b959b3 100644 --- a/src/components/library/LibraryContextBar.tsx +++ b/src/components/library/LibraryContextBar.tsx @@ -1,15 +1,38 @@ -import { Pressable, StyleSheet, View } from 'react-native'; +/* eslint-disable react-hooks/immutability -- Reanimated shared values are mutable gesture state. */ +import { useEffect, useMemo } from 'react'; +import { + Pressable, + StyleSheet, + View, + useWindowDimensions, + type LayoutChangeEvent, +} from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; import { Text } from '@/components/Text'; +import { useSelectionSlide } from '@/components/selectionSlide'; import { radius, spacing } from '@/theme'; +import { motion } from '@/theme/motion'; import { createThemedStyles, useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; +import { playHaptic } from '@/lib/haptics'; import { LIBRARY_CONTEXT_BAR_HEIGHT, LIBRARY_CONTEXT_TOP_GAP, + libraryDockShowsActiveLabel, + libraryDockSwipeDistance, + resolveLibraryDockSwipe, + type LibraryDockSwipeDirection, } from '@/library/libraryViewPresentation'; import { - libraryViewModeLabel, + LIBRARY_VIEW_MODES, + adjacentLibraryViewMode, type LibraryViewMode, } from '@/library/libraryViewMode'; @@ -25,6 +48,12 @@ const MODE_ICONS: Record = { export const LIBRARY_CONTEXT_ACTION_SIZE = 44; +export interface LibraryDockContextAction { + icon: IconName; + label: string; + onPress: () => void; +} + interface SelectionActions { count: number; onPlayNext: () => void; @@ -36,36 +65,156 @@ interface SelectionActions { export function LibraryContextBar({ mode, bottomClearance, - onOpenModePicker, + onChangeMode, onSearch, - onSort, - sortLabel, - onLayout, - layoutLabel, - onAddPlaylist, + contextAction, selection, }: { mode: LibraryViewMode; bottomClearance: number; - onOpenModePicker: () => void; + onChangeMode: (mode: LibraryViewMode) => void; onSearch: () => void; - onSort?: () => void; - sortLabel?: string; - onLayout?: () => void; - layoutLabel?: string; - onAddPlaylist?: () => void; + contextAction?: LibraryDockContextAction; selection?: SelectionActions; }) { const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); + const { width, fontScale } = useWindowDimensions(); + const showActiveLabel = libraryDockShowsActiveLabel(width, fontScale); + const modeIndex = LIBRARY_VIEW_MODES.findIndex((entry) => entry.key === mode); + const slide = useSelectionSlide(mode, 'horizontal', showActiveLabel ? 'labelled' : 'icons'); + const dragX = useSharedValue(0); + const primeProgress = useSharedValue(0); + const swipePrimed = useSharedValue(false); + const sectionWidth = useSharedValue(0); + const pendingSwipeDirection = useSharedValue(0); + const entranceProgress = useSharedValue(0); + const accessoryEntranceStyle = useAnimatedStyle(() => ({ + opacity: entranceProgress.value, + transform: [{ translateY: (1 - entranceProgress.value) * spacing.sm }], + })); + const selectionStyle = useAnimatedStyle(() => ({ + opacity: slide.presence.value, + width: slide.extent.value, + transform: [ + { translateX: slide.offset.value + dragX.value }, + { scaleX: 1 + primeProgress.value * 0.05 }, + ], + })); + + useEffect(() => { + entranceProgress.value = withTiming(1, motion.quick); + }, [entranceProgress]); + + useEffect(() => { + // A committed swipe keeps its preview offset through the React state + // handoff. Resetting it in `onFinalize` made the mark visibly retreat to + // its old section for a frame before `mode` started the real selection + // slide. Once the destination has committed, both animations run together: + // the selection slide covers the remaining distance while this preview + // contribution blends back to zero. + dragX.value = withTiming(0, motion.quick); + primeProgress.value = withTiming(0, motion.quick); + swipePrimed.value = false; + }, [dragX, mode, primeProgress, swipePrimed]); + + const commitSwipe = (direction: LibraryDockSwipeDirection) => { + const next = adjacentLibraryViewMode(mode, direction); + if (!next) { + playHaptic('thresholdExit'); + return; + } + playHaptic('modeCycle'); + onChangeMode(next); + }; + + const swipeGesture = useMemo( + () => Gesture.Pan() + .activeOffsetX([-12, 12]) + .failOffsetY([-12, 12]) + .onBegin(() => { + 'worklet'; + pendingSwipeDirection.value = 0; + primeProgress.value = 0; + swipePrimed.value = false; + }) + .onUpdate((event) => { + 'worklet'; + const threshold = libraryDockSwipeDistance(sectionWidth.value); + const progress = Math.min(1, Math.abs(event.translationX) / threshold); + const previewLimit = Math.max(10, Math.min(20, slide.extent.value * 0.28)); + const direction: LibraryDockSwipeDirection = event.translationX < 0 ? 1 : -1; + // The marker previews its destination, not the finger direction: a + // leftward content swipe advances the selected mark to the right. + dragX.value = direction * previewLimit * progress; + primeProgress.value = progress; + + const canMove = direction === 1 + ? modeIndex < LIBRARY_VIEW_MODES.length - 1 + : modeIndex > 0; + const nowPrimed = progress >= 1 && canMove; + if (nowPrimed === swipePrimed.value) return; + swipePrimed.value = nowPrimed; + runOnJS(playHaptic)(nowPrimed ? 'threshold' : 'thresholdExit'); + }) + .onEnd((event) => { + 'worklet'; + const direction = resolveLibraryDockSwipe({ + translationX: event.translationX, + velocityX: event.velocityX, + width: sectionWidth.value, + }); + const canMove = direction === 1 + ? modeIndex < LIBRARY_VIEW_MODES.length - 1 + : direction === -1 && modeIndex > 0; + // An edge swipe has no destination render to release a held preview, + // so treat it as a cancellation and return immediately from the edge. + pendingSwipeDirection.value = direction && canMove ? direction : 0; + }) + .onFinalize(() => { + 'worklet'; + const direction = pendingSwipeDirection.value; + const wasPrimed = swipePrimed.value; + pendingSwipeDirection.value = 0; + swipePrimed.value = false; + if (!direction) { + dragX.value = withTiming(0, motion.quick); + primeProgress.value = withTiming(0, motion.quick); + if (wasPrimed) runOnJS(playHaptic)('thresholdExit'); + } + // Mount the incoming FlashList only after RNGH has fully released the + // dock touch. Committing from `onEnd` let that list inherit the tail of + // the native gesture and consume its expanded-header padding. Leave a + // successful swipe visually primed until the resulting `mode` render; + // the effect above then folds that offset into the destination slide. + if (direction) runOnJS(commitSwipe)(direction); + }), + // eslint-disable-next-line react-hooks/exhaustive-deps -- commitSwipe captures the current mode and callback for this gesture instance + [ + dragX, + mode, + modeIndex, + onChangeMode, + pendingSwipeDirection, + primeProgress, + sectionWidth, + slide.extent, + swipePrimed, + ] + ); + + const onSectionLayout = (event: LayoutChangeEvent) => { + sectionWidth.value = event.nativeEvent.layout.width; + }; return ( - @@ -98,48 +247,72 @@ export function LibraryContextBar({ ) : ( <> - - - - {libraryViewModeLabel(mode)} - - - + + + + {LIBRARY_VIEW_MODES.map((entry) => { + const selected = entry.key === mode; + return ( + { + if (selected) return; + playHaptic('selection'); + onChangeMode(entry.key); + }} + accessibilityRole="tab" + accessibilityLabel={`${entry.label} Library section`} + accessibilityHint={ + selected ? 'Swipe left or right here to change sections' : undefined + } + accessibilityState={{ selected }} + > + + {showActiveLabel && selected ? ( + + {entry.label} + + ) : null} + + ); + })} + + - {onSort ? ( + {contextAction ? ( - ) : null} - {onLayout ? ( - - ) : null} - {onAddPlaylist ? ( - - ) : null} + ) : ( + + )} )} - + ); } @@ -179,7 +352,7 @@ const useStyles = createThemedStyles((colors) => ({ left: 0, right: 0, bottom: 0, - zIndex: 10, + zIndex: 1, paddingTop: LIBRARY_CONTEXT_TOP_GAP, paddingHorizontal: spacing.md, }, @@ -193,17 +366,42 @@ const useStyles = createThemedStyles((colors) => ({ borderRadius: radius.lg, overflow: 'hidden', }, - modeButton: { + sections: { flex: 1, minWidth: 0, height: '100%', flexDirection: 'row', alignItems: 'center', - gap: spacing.sm, - paddingHorizontal: spacing.md, }, - modeLabel: { - flex: 1, + activeSection: { + position: 'absolute', + top: spacing.xs, + bottom: spacing.xs, + left: 0, + backgroundColor: colors.glassHighlight, + borderColor: colors.accent, + borderWidth: StyleSheet.hairlineWidth, + borderRadius: radius.md, + }, + section: { + height: '100%', + minWidth: 0, + flexBasis: 0, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + overflow: 'hidden', + }, + sectionIcon: { + flexGrow: 1, + }, + sectionLabelled: { + flexGrow: 2, + paddingHorizontal: spacing.xs, + }, + sectionLabel: { + flexShrink: 1, minWidth: 0, }, selectionCount: { diff --git a/src/components/library/PlaylistsView.tsx b/src/components/library/PlaylistsView.tsx index c0a1be7..8ab7277 100644 --- a/src/components/library/PlaylistsView.tsx +++ b/src/components/library/PlaylistsView.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { View, type NativeScrollEvent, @@ -44,6 +44,7 @@ export function PlaylistsView({ listHeader, addMenuOpen = false, onCloseAddMenu, + onSheetOpenChange, listRef, }: { onScroll?: (event: NativeSyntheticEvent) => void; @@ -57,6 +58,8 @@ export function PlaylistsView({ /** Controlled by Library's contextual add action on phones. */ addMenuOpen?: boolean; onCloseAddMenu?: () => void; + /** Lets Library replace its dock while a playlist action sheet is present. */ + onSheetOpenChange?: (open: boolean) => void; /** Lets the Library screen send this list back to the top on a tab re-tap. */ listRef?: (list: ScrollToTopHandle | null) => void; }) { @@ -75,6 +78,14 @@ export function PlaylistsView({ const [prompt, setPrompt] = useState(null); const [menuFor, setMenuFor] = useState(null); + const sheetOpen = menuFor !== null || addMenuOpen; + useEffect(() => { + onSheetOpenChange?.(sheetOpen); + return () => { + if (sheetOpen) onSheetOpenChange?.(false); + }; + }, [onSheetOpenChange, sheetOpen]); + const handleExport = async (target: number | 'favorites') => { try { const result = await exportM3u(target); diff --git a/src/library/libraryViewMode.ts b/src/library/libraryViewMode.ts index bcbf00a..68a0e82 100644 --- a/src/library/libraryViewMode.ts +++ b/src/library/libraryViewMode.ts @@ -14,3 +14,11 @@ export const LIBRARY_VIEW_MODES: readonly { export function libraryViewModeLabel(mode: LibraryViewMode): string { return LIBRARY_VIEW_MODES.find((entry) => entry.key === mode)?.label ?? mode; } + +export function adjacentLibraryViewMode( + mode: LibraryViewMode, + direction: -1 | 1 +): LibraryViewMode | undefined { + const index = LIBRARY_VIEW_MODES.findIndex((entry) => entry.key === mode); + return LIBRARY_VIEW_MODES[index + direction]?.key; +} diff --git a/src/library/libraryViewPresentation.test.mts b/src/library/libraryViewPresentation.test.mts index d66e953..9bc732b 100644 --- a/src/library/libraryViewPresentation.test.mts +++ b/src/library/libraryViewPresentation.test.mts @@ -2,12 +2,17 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { flashListInitialAnchor, - libraryContextActionCount, + flashListMaintainsVisiblePosition, libraryContextBottomClearance, libraryContextOverlayHeight, libraryContextScrimHeight, - libraryContextSectionWidth, + libraryDockSectionWidth, + libraryDockShowsActiveLabel, + libraryDockSwipeDistance, + libraryDockTargetWidths, + resolveLibraryDockSwipe, } from './libraryViewPresentation.ts'; +import { adjacentLibraryViewMode } from './libraryViewMode.ts'; import { effectiveMiniPlayerVisible } from '../playback/playbackTargetPresentation.ts'; test('the catalog head omits FlashList initialScrollIndex entirely', () => { @@ -27,18 +32,67 @@ test('a real A-Z anchor remains an explicit initial index', () => { assert.equal(flashListInitialAnchor(137), 137); }); -test('every phone command-bar action set leaves a useful section target', () => { +test('only a positive A-Z window maintains its visible item while prepending', () => { + assert.equal(flashListMaintainsVisiblePosition(0), false); + assert.equal(flashListMaintainsVisiblePosition(-1), false); + assert.equal(flashListMaintainsVisiblePosition(1), true); + assert.equal(flashListMaintainsVisiblePosition(137), true); +}); + +test('the direct dock keeps all five section targets usable on phone widths', () => { for (const width of [320, 360, 411]) { - for (const mode of ['albums', 'artists', 'tracks', 'playlists', 'folders'] as const) { - const sectionWidth = libraryContextSectionWidth( - width, - libraryContextActionCount(mode) + for (const fontScale of [1, 1.2, 2]) { + const sectionWidth = libraryDockSectionWidth(width); + const labelled = libraryDockShowsActiveLabel(width, fontScale); + const targets = libraryDockTargetWidths(sectionWidth, labelled); + assert.ok( + targets.inactive >= 40, + `${width}dp/${fontScale}x: ${targets.inactive}dp target` ); - assert.ok(sectionWidth >= 140, `${width}dp ${mode}: only ${sectionWidth}dp for section`); + assert.ok(targets.active >= targets.inactive); } } }); +test('folders reserves the same trailing action geometry as every other section', () => { + for (const width of [320, 360, 411]) { + assert.equal(libraryDockSectionWidth(width), width - 24 - 88); + } +}); + +test('active labels only appear when width and text scale leave enough room', () => { + assert.equal(libraryDockShowsActiveLabel(320, 1), false); + assert.equal(libraryDockShowsActiveLabel(360, 1), true); + assert.equal(libraryDockShowsActiveLabel(411, 1), true); + assert.equal(libraryDockShowsActiveLabel(360, 1.2), false); + assert.equal(libraryDockShowsActiveLabel(411, 2), false); +}); + +test('swipe priming uses the same distance as swipe commitment', () => { + assert.equal(libraryDockSwipeDistance(208), 33.28); + assert.equal(libraryDockSwipeDistance(248), 39.68); + assert.equal(libraryDockSwipeDistance(500), 56); + assert.equal(libraryDockSwipeDistance(0), 32); +}); + +test('dock swipes require deliberate distance or velocity and follow reading order', () => { + assert.equal(resolveLibraryDockSwipe({ translationX: -40, velocityX: 0, width: 208 }), 1); + assert.equal(resolveLibraryDockSwipe({ translationX: 40, velocityX: 0, width: 208 }), -1); + assert.equal(resolveLibraryDockSwipe({ translationX: -15, velocityX: -600, width: 208 }), 1); + assert.equal(resolveLibraryDockSwipe({ translationX: 15, velocityX: 600, width: 208 }), -1); + assert.equal(resolveLibraryDockSwipe({ translationX: 11, velocityX: 900, width: 208 }), null); + assert.equal(resolveLibraryDockSwipe({ translationX: 20, velocityX: 200, width: 208 }), null); +}); + +test('dock swipes stop at catalog edges', () => { + assert.equal(adjacentLibraryViewMode('albums', -1), undefined); + assert.equal(adjacentLibraryViewMode('albums', 1), 'artists'); + assert.equal(adjacentLibraryViewMode('tracks', -1), 'artists'); + assert.equal(adjacentLibraryViewMode('tracks', 1), 'playlists'); + assert.equal(adjacentLibraryViewMode('folders', -1), 'playlists'); + assert.equal(adjacentLibraryViewMode('folders', 1), undefined); +}); + test('phone chrome only reserves the player footprint while it is visible', () => { assert.equal(libraryContextBottomClearance(76, true), 76); assert.equal(libraryContextBottomClearance(76, false), 8); diff --git a/src/library/libraryViewPresentation.ts b/src/library/libraryViewPresentation.ts index 2f19945..e513bb0 100644 --- a/src/library/libraryViewPresentation.ts +++ b/src/library/libraryViewPresentation.ts @@ -1,5 +1,3 @@ -import type { LibraryViewMode } from './libraryViewMode.ts'; - export const LIBRARY_CONTEXT_BAR_HEIGHT = 52; export const LIBRARY_CONTEXT_TOP_GAP = 8; export const LIBRARY_CONTEXT_FADE_TAIL = 48; @@ -14,10 +12,83 @@ export function flashListInitialAnchor(index: number): number | undefined { return Number.isInteger(index) && index > 0 ? index : undefined; } -export function libraryContextActionCount(mode: LibraryViewMode): number { - if (mode === 'albums' || mode === 'artists') return 3; - if (mode === 'tracks' || mode === 'playlists') return 2; - return 1; +/** + * Head-mounted catalogs do not need FlashList's default scroll anchor. Leaving + * it enabled lets the native list preserve item 0 by consuming the collapsing + * header's content padding. A positive A-Z window does need it while earlier + * pages are prepended. + */ +export function flashListMaintainsVisiblePosition(index: number): boolean { + return flashListInitialAnchor(index) !== undefined; +} + +const LIBRARY_DOCK_ACTION_SIZE = 44; +const LIBRARY_DOCK_HORIZONTAL_INSET = 12; +const LIBRARY_DOCK_SECTION_COUNT = 5; +const LIBRARY_DOCK_LABEL_MIN_WIDTH = 248; +const LIBRARY_DOCK_LABEL_MAX_FONT_SCALE = 1.05; + +/** Space shared by the five direct section targets after search/options. */ +export function libraryDockSectionWidth( + availableWidth: number, + horizontalInset = LIBRARY_DOCK_HORIZONTAL_INSET, + actionSize = LIBRARY_DOCK_ACTION_SIZE +): number { + // Search and the contextual slot are always reserved. Folders leaves the + // latter visually empty instead of letting every section target jump wider. + return Math.max(0, availableWidth - horizontalInset * 2 - actionSize * 2); +} + +/** + * A labelled active target needs two shares while its four neighbours keep one + * each. Narrow screens and enlarged text use equal icon-only targets instead. + */ +export function libraryDockShowsActiveLabel( + availableWidth: number, + fontScale: number +): boolean { + return libraryDockSectionWidth(availableWidth) >= + LIBRARY_DOCK_LABEL_MIN_WIDTH && + fontScale <= LIBRARY_DOCK_LABEL_MAX_FONT_SCALE; +} + +export function libraryDockTargetWidths( + sectionWidth: number, + showActiveLabel: boolean +): { active: number; inactive: number } { + const shares = showActiveLabel + ? LIBRARY_DOCK_SECTION_COUNT + 1 + : LIBRARY_DOCK_SECTION_COUNT; + const inactive = Math.max(0, sectionWidth) / shares; + return { + active: showActiveLabel ? inactive * 2 : inactive, + inactive, + }; +} + +export type LibraryDockSwipeDirection = -1 | 1; + +export function libraryDockSwipeDistance(width: number): number { + 'worklet'; + return Math.max(32, Math.min(56, width * 0.16)); +} + +/** Resolve a deliberate horizontal dock gesture into an adjacent-section step. */ +export function resolveLibraryDockSwipe({ + translationX, + velocityX, + width, +}: { + translationX: number; + velocityX: number; + width: number; +}): LibraryDockSwipeDirection | null { + 'worklet'; + const distanceThreshold = libraryDockSwipeDistance(width); + const passedDistance = Math.abs(translationX) >= distanceThreshold; + const passedFling = Math.abs(translationX) >= 12 && Math.abs(velocityX) >= 500; + if (!passedDistance && !passedFling) return null; + return translationX < 0 ? 1 : -1; } /** Phone chrome clears the player only while the player actually exists. */ @@ -43,17 +114,3 @@ export function libraryContextOverlayHeight(bottomClearance: number): number { export function libraryContextScrimHeight(bottomClearance: number): number { return libraryContextOverlayHeight(bottomClearance) + LIBRARY_CONTEXT_FADE_TAIL; } - -/** - * The section control owns the space left after fixed action targets. Keeping - * this arithmetic pure lets narrow-phone tests prove the label never steals a - * touch target from the actions beside it. - */ -export function libraryContextSectionWidth( - availableWidth: number, - actionCount: number, - horizontalInset = 12, - actionSize = 44 -): number { - return Math.max(0, availableWidth - horizontalInset * 2 - actionCount * actionSize); -}