fix ui bug

This commit is contained in:
Boof2015
2026-08-11 17:59:16 -04:00
parent ed58110171
commit bfd73b1d41
6 changed files with 195 additions and 186 deletions
+30 -30
View File
@@ -17,9 +17,7 @@ import {
import { Ionicons } from '@expo/vector-icons';
import { type FlashListRef } from '@shopify/flash-list';
import { useFocusEffect, useRouter } from 'expo-router';
import Animated, {
FadeIn,
ReduceMotion,
import {
runOnJS,
useAnimatedScrollHandler,
useSharedValue,
@@ -38,11 +36,19 @@ import { ArtistGridItem } from '@/components/library/ArtistGridItem';
import { AlbumRow } from '@/components/library/AlbumRow';
import { ArtistRow } from '@/components/library/ArtistRow';
import { TrackRow } from '@/components/library/TrackRow';
import { FoldersView } from '@/components/library/FoldersView';
import {
FolderActionsSheet,
FoldersView,
type FolderActionTarget,
} from '@/components/library/FoldersView';
import { LibraryContextBar } from '@/components/library/LibraryContextBar';
import { LibrarySurfaceTransition } from '@/components/library/LibrarySurfaceTransition';
import { MiniPlayerScrim } from '@/components/MiniPlayerScrim';
import { PlaylistsView } from '@/components/library/PlaylistsView';
import {
PlaylistOverlays,
PlaylistsView,
type PlaylistActionTarget,
} from '@/components/library/PlaylistsView';
import { ScanProgress } from '@/components/library/ScanProgress';
import { EmptyLibrary } from '@/components/library/EmptyLibrary';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
@@ -61,7 +67,6 @@ import {
useScrollTopGate
} from '@/components/search/PullSearchGesture';
import { spacing } from '@/theme';
import { motion } from '@/theme/motion';
import { useColors } from '@/theme/themed';
import { AppPressable } from '@/components/AppPressable';
import { useShellLayout } from '@/navigation/useShellLayout';
@@ -73,6 +78,7 @@ import {
flashListInitialAnchor,
flashListMaintainsVisiblePosition,
libraryContextBottomClearance,
libraryContextBarVisible,
libraryContextOverlayHeight,
libraryContextScrimHeight,
} from '@/library/libraryViewPresentation';
@@ -118,9 +124,6 @@ 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;
/**
@@ -207,11 +210,12 @@ export default function LibraryScreen() {
const miniPlayerVisible = useMiniPlayerVisible();
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const [actionFolder, setActionFolder] = useState<FolderActionTarget | null>(null);
const [playlistMenuFor, setPlaylistMenuFor] = useState<PlaylistActionTarget | null>(null);
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);
@@ -446,11 +450,13 @@ export default function LibraryScreen() {
};
const changeViewMode = (mode: LibraryViewMode) => {
setActionTrack(null);
setActionFolder(null);
setPlaylistMenuFor(null);
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
@@ -930,9 +936,7 @@ export default function LibraryScreen() {
contentPaddingTop={header.contentPaddingTop}
contentPaddingBottom={listBottomPadding}
listHeader={inlineStatus}
addMenuOpen={playlistAddMenuOpen}
onCloseAddMenu={() => setPlaylistAddMenuOpen(false)}
onSheetOpenChange={setChildSheetOpen}
onOpenActions={setPlaylistMenuFor}
/>
) : null}
@@ -944,7 +948,8 @@ export default function LibraryScreen() {
contentPaddingTop={header.contentPaddingTop}
contentPaddingBottom={listBottomPadding}
listHeader={inlineStatus}
onSheetOpenChange={setChildSheetOpen}
onOpenTrackActions={setActionTrack}
onOpenFolderActions={setActionFolder}
/>
) : null}
@@ -983,23 +988,11 @@ export default function LibraryScreen() {
/>
</PullSearchGesture>
{phoneContextBar && !showLibraryStatus && !(
actionTrack ||
playlistPickerOpen ||
sortSheetOpen ||
layoutSheetOpen ||
viewOptionsSheetOpen ||
playlistAddMenuOpen ||
childSheetOpen
) ? (
{libraryContextBarVisible(phoneContextBar, showLibraryStatus) ? (
<>
<Animated.View
pointerEvents="none"
entering={CONTEXT_SCRIM_ENTERING}
style={StyleSheet.absoluteFill}
>
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
<MiniPlayerScrim height={libraryContextScrimHeight(contextBottomClearance)} />
</Animated.View>
</View>
<LibraryContextBar
mode={viewMode}
bottomClearance={contextBottomClearance}
@@ -1049,6 +1042,7 @@ export default function LibraryScreen() {
) : null}
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
<FolderActionsSheet folder={actionFolder} onClose={() => setActionFolder(null)} />
{playlistPickerOpen ? (
<PlaylistPickerSheet
tracks={selectedDbTracks()}
@@ -1119,6 +1113,12 @@ export default function LibraryScreen() {
))}
</AppSheet>
) : null}
<PlaylistOverlays
menuFor={playlistMenuFor}
onCloseMenu={() => setPlaylistMenuFor(null)}
addMenuOpen={playlistAddMenuOpen}
onCloseAddMenu={() => setPlaylistAddMenuOpen(false)}
/>
</Screen>
);
}
+54 -63
View File
@@ -13,7 +13,6 @@ import {
} from '../../../modules/astra-library-scanner';
import { Text } from '@/components/Text';
import { ReanimatedFlashList } from '@/components/ReanimatedFlashList';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import {
AppSheet,
AppSheetItem,
@@ -36,6 +35,8 @@ import type { ScrollToTopHandle } from '@/navigation/scrollToTopHandle';
const PAGE_SIZE = 100;
export type FolderActionTarget = NativeFolderNode;
interface FoldersViewProps {
onScroll?: ScrollHandlerProcessed;
scrollEventThrottle?: number;
@@ -46,8 +47,9 @@ 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;
/** Hoists sheets above Library's persistent section bar. */
onOpenTrackActions: (track: DbTrack) => void;
onOpenFolderActions: (folder: NativeFolderNode) => void;
}
interface LoadedNode {
@@ -218,7 +220,8 @@ export function FoldersView({
contentPaddingBottom,
listHeader,
listRef,
onSheetOpenChange,
onOpenTrackActions,
onOpenFolderActions,
}: FoldersViewProps) {
const sceneBottomInset = useSceneBottomInset();
const styles = useStyles();
@@ -227,16 +230,6 @@ export function FoldersView({
const [nodes, setNodes] = useState<Map<string, LoadedNode>>(() => new Map());
const [rootIds, setRootIds] = useState<string[]>([]);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
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);
@@ -386,7 +379,7 @@ export function FoldersView({
onToggle={() => toggleNode(item.id)}
onPlay={() => playFolder(item.state.node)}
onShuffle={() => playFolder(item.state.node, true)}
onOpenActions={() => setActionFolder(item.state.node)}
onOpenActions={() => onOpenFolderActions(item.state.node)}
/>
);
}
@@ -405,62 +398,60 @@ export function FoldersView({
track={item.track}
node={item.node}
active={item.track.path === currentPath}
onOpenActions={() => setActionTrack(item.track)}
onOpenActions={() => onOpenTrackActions(item.track)}
/>
);
}}
/>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
{actionFolder ? (
<AppSheet onClose={() => setActionFolder(null)}>
<AppSheetTitle
title={actionFolder.name}
subtitle={`${actionFolder.totalTrackCount} ${actionFolder.totalTrackCount === 1 ? 'track' : 'tracks'}`}
/>
<AppSheetItem
label="Play"
icon="play"
onPress={() => {
playFolder(actionFolder);
setActionFolder(null);
}}
/>
<AppSheetItem
label="Shuffle"
icon="shuffle"
onPress={() => {
playFolder(actionFolder, true);
setActionFolder(null);
}}
/>
<AppSheetItem
label="Play next"
icon="play-skip-forward"
onPress={() => {
void enqueueLibraryQuery(
{ kind: 'folder', folderNodeId: actionFolder.id },
'next',
);
setActionFolder(null);
}}
/>
<AppSheetItem
label="Add to queue"
icon="list-outline"
onPress={() => {
void enqueueLibraryQuery(
{ kind: 'folder', folderNodeId: actionFolder.id },
'end',
);
setActionFolder(null);
}}
/>
</AppSheet>
) : null}
</>
);
}
/** Screen-level overlay so the sheet paints above the persistent Library bar. */
export function FolderActionsSheet({
folder,
onClose,
}: {
folder: NativeFolderNode | null;
onClose: () => void;
}) {
if (!folder) return null;
const play = (shuffle = false) => {
if (folder.totalTrackCount > 0) {
void playLibraryQuery(
{ kind: 'folder', folderNodeId: folder.id },
{
shuffle,
source: { kind: 'folder', label: folder.name },
}
);
}
onClose();
};
const enqueue = (position: 'next' | 'end') => {
void enqueueLibraryQuery(
{ kind: 'folder', folderNodeId: folder.id },
position,
);
onClose();
};
return (
<AppSheet onClose={onClose}>
<AppSheetTitle
title={folder.name}
subtitle={`${folder.totalTrackCount} ${folder.totalTrackCount === 1 ? 'track' : 'tracks'}`}
/>
<AppSheetItem label="Play" icon="play" onPress={() => play()} />
<AppSheetItem label="Shuffle" icon="shuffle" onPress={() => play(true)} />
<AppSheetItem label="Play next" icon="play-skip-forward" onPress={() => enqueue('next')} />
<AppSheetItem label="Add to queue" icon="list-outline" onPress={() => enqueue('end')} />
</AppSheet>
);
}
const useStyles = createThemedStyles((colors) => ({
emptyShell: {
flex: 1,
+2 -13
View File
@@ -87,11 +87,6 @@ export function LibraryContextBar({
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,
@@ -101,10 +96,6 @@ export function LibraryContextBar({
],
}));
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
@@ -207,12 +198,11 @@ export function LibraryContextBar({
};
return (
<Animated.View
<View
pointerEvents="box-none"
style={[
styles.accessory,
{ paddingBottom: bottomClearance },
accessoryEntranceStyle,
]}
>
<View style={styles.bar}>
@@ -310,7 +300,7 @@ export function LibraryContextBar({
</>
)}
</View>
</Animated.View>
</View>
);
}
@@ -349,7 +339,6 @@ const useStyles = createThemedStyles((colors) => ({
left: 0,
right: 0,
bottom: 0,
zIndex: 1,
paddingTop: LIBRARY_CONTEXT_TOP_GAP,
paddingHorizontal: spacing.md,
},
+90 -80
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, type ReactNode } from 'react';
import { useState, type ReactNode } from 'react';
import { View } from 'react-native';
import type { ScrollHandlerProcessed } from 'react-native-reanimated';
import { Ionicons } from '@expo/vector-icons';
@@ -33,15 +33,15 @@ function fileDisplayName(fileUri: string): string {
type Prompt = { kind: 'create' } | { kind: 'rename'; playlist: Playlist } | null;
export type PlaylistActionTarget = Playlist | 'favorites';
export function PlaylistsView({
onScroll,
scrollEventThrottle,
contentPaddingTop = 0,
contentPaddingBottom,
listHeader,
addMenuOpen = false,
onCloseAddMenu,
onSheetOpenChange,
onOpenActions,
listRef,
}: {
onScroll?: ScrollHandlerProcessed;
@@ -52,11 +52,8 @@ export function PlaylistsView({
contentPaddingBottom?: number;
/** Phone-only scan/error content that scrolls away with the playlist rows. */
listHeader?: ReactNode;
/** 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;
/** Hoists action sheets above Library's persistent section bar. */
onOpenActions: (target: PlaylistActionTarget) => void;
/** Lets the Library screen send this list back to the top on a tab re-tap. */
listRef?: (list: ScrollToTopHandle | null) => void;
}) {
@@ -66,22 +63,84 @@ export function PlaylistsView({
const router = useRouter();
const playlists = usePlaylistStore((s) => s.playlists);
const favoriteCount = usePlaylistStore((s) => s.favoriteTracks.length);
return (
<View style={styles.container}>
<ReanimatedFlashList
ref={listRef}
data={playlists}
keyExtractor={(playlist) => String(playlist.id)}
showsVerticalScrollIndicator={false}
overScrollMode="never"
renderScrollComponent={PullSearchScrollView}
onScroll={onScroll}
scrollEventThrottle={scrollEventThrottle}
contentContainerStyle={{
paddingTop: contentPaddingTop,
paddingHorizontal: spacing.lg,
paddingBottom: contentPaddingBottom ?? sceneBottomInset,
}}
ListHeaderComponent={
<>
{listHeader}
<PlaylistRow
name="Favorites"
trackCount={favoriteCount}
coverHash={null}
pinned
onPress={() => router.push('/library/playlist/favorites')}
onLongPress={() => onOpenActions('favorites')}
/>
</>
}
renderItem={({ item }) => (
<PlaylistRow
name={item.name}
trackCount={item.track_count}
missingCount={item.missing_track_count}
coverHash={item.auto_cover_hash}
remote={item.remote_source_id != null}
dynamic={item.kind === 'dynamic'}
onPress={() => router.push(`/library/playlist/${item.id}`)}
onLongPress={() => onOpenActions(item)}
/>
)}
ListEmptyComponent={
<View style={styles.empty}>
<Ionicons name="musical-notes-outline" size={28} color={colors.textTertiary} />
<Text variant="body" color={colors.textSecondary} style={styles.emptyText}>
No playlists yet.
</Text>
</View>
}
/>
</View>
);
}
/**
* Playlist overlays live beside the other Library sheets, after the persistent
* section bar in tree order. Keeping this component mounted also lets rename
* and create prompts survive the action sheet closing before the prompt opens.
*/
export function PlaylistOverlays({
menuFor,
onCloseMenu,
addMenuOpen,
onCloseAddMenu,
}: {
menuFor: PlaylistActionTarget | null;
onCloseMenu: () => void;
addMenuOpen: boolean;
onCloseAddMenu: () => void;
}) {
const router = useRouter();
const createPlaylist = usePlaylistStore((s) => s.createPlaylist);
const renamePlaylist = usePlaylistStore((s) => s.renamePlaylist);
const deletePlaylist = usePlaylistStore((s) => s.deletePlaylist);
const importM3u = usePlaylistStore((s) => s.importM3u);
const exportM3u = usePlaylistStore((s) => s.exportM3u);
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 {
@@ -137,7 +196,7 @@ export function PlaylistsView({
label: 'Export M3U',
icon: 'download-outline' as const,
onPress: () => {
setMenuFor(null);
onCloseMenu();
void handleExport('favorites');
},
},
@@ -152,7 +211,7 @@ export function PlaylistsView({
icon: 'options-outline' as const,
onPress: () => {
const id = menuFor.id;
setMenuFor(null);
onCloseMenu();
router.push({
pathname: '/library/playlist/edit-dynamic' as never,
params: { id: String(id) },
@@ -167,7 +226,7 @@ export function PlaylistsView({
icon: 'pencil-outline' as const,
onPress: () => {
setPrompt({ kind: 'rename', playlist: menuFor });
setMenuFor(null);
onCloseMenu();
},
},
{
@@ -176,7 +235,7 @@ export function PlaylistsView({
icon: 'download-outline' as const,
onPress: () => {
const id = menuFor.id;
setMenuFor(null);
onCloseMenu();
void handleExport(id);
},
},
@@ -187,7 +246,7 @@ export function PlaylistsView({
destructive: true,
onPress: () => {
const playlist = menuFor;
setMenuFor(null);
onCloseMenu();
confirmDelete(playlist);
},
},
@@ -195,58 +254,9 @@ export function PlaylistsView({
: [];
return (
<View style={styles.container}>
<ReanimatedFlashList
ref={listRef}
data={playlists}
keyExtractor={(playlist) => String(playlist.id)}
showsVerticalScrollIndicator={false}
overScrollMode="never"
renderScrollComponent={PullSearchScrollView}
onScroll={onScroll}
scrollEventThrottle={scrollEventThrottle}
contentContainerStyle={{
paddingTop: contentPaddingTop,
paddingHorizontal: spacing.lg,
paddingBottom: contentPaddingBottom ?? sceneBottomInset,
}}
ListHeaderComponent={
<>
{listHeader}
<PlaylistRow
name="Favorites"
trackCount={favoriteCount}
coverHash={null}
pinned
onPress={() => router.push('/library/playlist/favorites')}
onLongPress={() => setMenuFor('favorites')}
/>
</>
}
renderItem={({ item }) => (
<PlaylistRow
name={item.name}
trackCount={item.track_count}
missingCount={item.missing_track_count}
coverHash={item.auto_cover_hash}
remote={item.remote_source_id != null}
dynamic={item.kind === 'dynamic'}
onPress={() => router.push(`/library/playlist/${item.id}`)}
onLongPress={() => setMenuFor(item)}
/>
)}
ListEmptyComponent={
<View style={styles.empty}>
<Ionicons name="musical-notes-outline" size={28} color={colors.textTertiary} />
<Text variant="body" color={colors.textSecondary} style={styles.emptyText}>
No playlists yet.
</Text>
</View>
}
/>
<>
{menuFor !== null ? (
<AppSheet onClose={() => setMenuFor(null)}>
<AppSheet onClose={onCloseMenu}>
<AppSheetTitle title={menuFor === 'favorites' ? 'Favorites' : menuFor.name} />
{menuItems.map(({ key, ...item }) => (
<AppSheetItem key={key} {...item} />
@@ -254,13 +264,13 @@ export function PlaylistsView({
</AppSheet>
) : null}
{addMenuOpen ? (
<AppSheet onClose={() => onCloseAddMenu?.()}>
<AppSheet onClose={onCloseAddMenu}>
<AppSheetTitle title="Add playlist" />
<AppSheetItem
label="Standard playlist"
icon="list-outline"
onPress={() => {
onCloseAddMenu?.();
onCloseAddMenu();
setPrompt({ kind: 'create' });
}}
/>
@@ -268,7 +278,7 @@ export function PlaylistsView({
label="Dynamic playlist"
icon="sparkles-outline"
onPress={() => {
onCloseAddMenu?.();
onCloseAddMenu();
router.push('/library/playlist/edit-dynamic' as never);
}}
/>
@@ -276,7 +286,7 @@ export function PlaylistsView({
label="Import M3U"
icon="document-text-outline"
onPress={() => {
onCloseAddMenu?.();
onCloseAddMenu();
void handleImport();
}}
/>
@@ -298,7 +308,7 @@ export function PlaylistsView({
}}
onClose={() => setPrompt(null)}
/>
</View>
</>
);
}
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
flashListInitialAnchor,
flashListMaintainsVisiblePosition,
libraryContextBarVisible,
libraryContextBottomClearance,
libraryContextOverlayHeight,
libraryContextScrimHeight,
@@ -99,6 +100,13 @@ test('phone chrome only reserves the player footprint while it is visible', () =
assert.equal(libraryContextBottomClearance(-10, true), 0);
});
test('only phone layout and an explicit library status can hide the section bar', () => {
assert.equal(libraryContextBarVisible(true, false), true);
assert.equal(libraryContextBarVisible(false, false), false);
assert.equal(libraryContextBarVisible(true, true), false);
assert.equal(libraryContextBarVisible(false, true), false);
});
test('the floating bar reserves its end-of-list runway while the fade starts above it', () => {
assert.equal(libraryContextOverlayHeight(76), 136);
assert.equal(libraryContextScrimHeight(76), 184);
+11
View File
@@ -100,6 +100,17 @@ export function libraryContextBottomClearance(
return miniPlayerVisible ? Math.max(0, sceneBottomInset) : restingGap;
}
/**
* The phone Library bar is navigation, so transient overlays never participate
* in its lifetime. Sheets paint above it instead of asking it to unmount.
*/
export function libraryContextBarVisible(
phoneContextBar: boolean,
showLibraryStatus: boolean
): boolean {
return phoneContextBar && !showLibraryStatus;
}
/** The complete bottom stack covered by the floating Library command bar. */
export function libraryContextOverlayHeight(bottomClearance: number): number {
return Math.max(0, bottomClearance) +