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