mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
better navbar in library
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user