better navbar in library

This commit is contained in:
Boof2015
2026-08-03 22:04:34 -04:00
parent f3c3559d7c
commit d385b39c8c
8 changed files with 545 additions and 119 deletions
+5
View File
@@ -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",
+117 -35
View File
@@ -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<LibraryViewMode, keyof typeof Ionicons.glyphMap> = {
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<DbTrack | null>(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<Set<number>>(() => 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<string | null>(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<NativeScrollEvent>) => {
// 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() {
/>
</PullSearchGesture>
{phoneContextBar && !showLibraryStatus ? (
{phoneContextBar && !showLibraryStatus && !(
actionTrack ||
playlistPickerOpen ||
sortSheetOpen ||
layoutSheetOpen ||
viewOptionsSheetOpen ||
playlistAddMenuOpen ||
childSheetOpen
) ? (
<>
<MiniPlayerScrim height={libraryContextScrimHeight(contextBottomClearance)} />
<Animated.View
pointerEvents="none"
entering={CONTEXT_SCRIM_ENTERING}
style={StyleSheet.absoluteFill}
>
<MiniPlayerScrim height={libraryContextScrimHeight(contextBottomClearance)} />
</Animated.View>
<LibraryContextBar
mode={viewMode}
bottomClearance={contextBottomClearance}
onOpenModePicker={() => 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 ? (
<AppSheet onClose={() => setModeSheetOpen(false)}>
<AppSheetTitle title="Browse Library" />
{LIBRARY_VIEW_MODES.map((mode) => (
<AppSheetItem
key={mode.key}
label={mode.label}
icon={MODE_SHEET_ICONS[mode.key]}
selected={mode.key === viewMode}
onPress={() => changeViewMode(mode.key)}
/>
))}
</AppSheet>
) : null}
{sortSheetOpen ? (
<AppSheet onClose={() => setSortSheetOpen(false)}>
<AppSheetSection label={sortSheetLabel} />
@@ -985,6 +1042,31 @@ export default function LibraryScreen() {
))}
</AppSheet>
) : null}
{viewOptionsSheetOpen && activeLayout ? (
<AppSheet scrollable onClose={() => setViewOptionsSheetOpen(false)}>
<AppSheetTitle title={`${libraryViewModeLabel(viewMode)} options`} />
<AppSheetSection label={sortSheetLabel} />
{sortItems.map(({ key, label, selected, onSelect }) => (
<AppSheetItem
key={`sort:${key}`}
label={label}
selected={selected}
onPress={onSelect}
/>
))}
<AppSheetSection label={layoutSheetLabel} />
{LIBRARY_LAYOUT_OPTIONS.map((option) => (
<AppSheetItem
key={`layout:${option.value}`}
label={option.label}
subtitle={option.subtitle}
icon={option.icon}
selected={option.value === activeLayout}
onPress={() => setActiveLayout(option.value)}
/>
))}
</AppSheet>
) : null}
</Screen>
);
}
+11
View File
@@ -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<DbTrack | null>(null);
const [actionFolder, setActionFolder] = useState<NativeFolderNode | null>(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) => [
+253 -55
View File
@@ -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<LibraryViewMode, IconName> = {
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<LibraryDockSwipeDirection | 0>(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 (
<View
<Animated.View
pointerEvents="box-none"
style={[
styles.accessory,
{ paddingBottom: bottomClearance },
accessoryEntranceStyle,
]}
>
<View style={styles.bar}>
@@ -98,48 +247,72 @@ export function LibraryContextBar({
</>
) : (
<>
<Pressable
android_ripple={ripple.bounded}
unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.modeButton}
onPress={onOpenModePicker}
accessibilityRole="button"
accessibilityLabel={`Change Library section. Current section: ${libraryViewModeLabel(mode)}`}
>
<Ionicons name={MODE_ICONS[mode]} size={19} color={colors.accent} />
<Text variant="body" numberOfLines={1} style={styles.modeLabel}>
{libraryViewModeLabel(mode)}
</Text>
<Ionicons name="chevron-up" size={16} color={colors.textTertiary} />
</Pressable>
<GestureDetector gesture={swipeGesture}>
<View style={styles.sections} onLayout={onSectionLayout}>
<Animated.View
pointerEvents="none"
style={[styles.activeSection, selectionStyle]}
/>
{LIBRARY_VIEW_MODES.map((entry) => {
const selected = entry.key === mode;
return (
<Pressable
key={entry.key}
android_ripple={ripple.bounded}
unstable_pressDelay={SCROLL_PRESS_DELAY}
onLayout={slide.measure(entry.key)}
style={[
styles.section,
showActiveLabel && selected ? styles.sectionLabelled : styles.sectionIcon,
]}
onPress={() => {
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 }}
>
<Ionicons
name={MODE_ICONS[entry.key]}
size={19}
color={selected ? colors.accent : colors.textSecondary}
/>
{showActiveLabel && selected ? (
<Text
variant="label"
numberOfLines={1}
style={styles.sectionLabel}
color={colors.accentTextStrong}
>
{entry.label}
</Text>
) : null}
</Pressable>
);
})}
</View>
</GestureDetector>
<View style={styles.actions}>
<ContextAction icon="search" label="Search library" onPress={onSearch} />
{onSort ? (
{contextAction ? (
<ContextAction
icon="swap-vertical"
label={`Sort ${libraryViewModeLabel(mode)}${sortLabel ? `, currently ${sortLabel}` : ''}`}
onPress={onSort}
icon={contextAction.icon}
label={contextAction.label}
onPress={contextAction.onPress}
/>
) : null}
{onLayout ? (
<ContextAction
icon={layoutLabel === 'List' ? 'list-outline' : 'grid-outline'}
label={`Change ${libraryViewModeLabel(mode)} layout${layoutLabel ? `. Current layout: ${layoutLabel}` : ''}`}
onPress={onLayout}
/>
) : null}
{onAddPlaylist ? (
<ContextAction
icon="add"
label="Add or import playlist"
onPress={onAddPlaylist}
/>
) : null}
) : (
<View style={styles.action} pointerEvents="none" />
)}
</View>
</>
)}
</View>
</View>
</Animated.View>
);
}
@@ -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: {
+12 -1
View File
@@ -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<NativeScrollEvent>) => 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<Prompt>(null);
const [menuFor, setMenuFor] = useState<Playlist | 'favorites' | null>(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);
+8
View File
@@ -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;
}
+62 -8
View File
@@ -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);
+77 -20
View File
@@ -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);
}