mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
proper folder ui system
This commit is contained in:
@@ -306,13 +306,11 @@ function RandomAlbumCard({
|
||||
}
|
||||
|
||||
function EmptyHomeCard({
|
||||
isScanning,
|
||||
scanError,
|
||||
onAddFolder,
|
||||
onManageFolders,
|
||||
}: {
|
||||
isScanning: boolean;
|
||||
scanError: string | null;
|
||||
onAddFolder: () => void;
|
||||
onManageFolders: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.emptyCard}>
|
||||
@@ -329,14 +327,13 @@ function EmptyHomeCard({
|
||||
) : null}
|
||||
</View>
|
||||
<Pressable
|
||||
style={[styles.primaryButton, isScanning && styles.buttonDisabled]}
|
||||
disabled={isScanning}
|
||||
onPress={onAddFolder}
|
||||
style={styles.primaryButton}
|
||||
onPress={onManageFolders}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Ionicons name="add" size={18} color={colors.bgPrimary} />
|
||||
<Ionicons name="folder-open-outline" size={18} color={colors.bgPrimary} />
|
||||
<Text variant="body" style={styles.primaryButtonText}>
|
||||
Add folder
|
||||
Folder settings
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -348,9 +345,7 @@ export default function HomeScreen() {
|
||||
const tracks = useLibraryStore((s) => s.tracks);
|
||||
const albums = useLibraryStore((s) => s.albums);
|
||||
const recentlyPlayedTracks = useLibraryStore((s) => s.recentlyPlayedTracks);
|
||||
const isScanning = useLibraryStore((s) => s.isScanning);
|
||||
const scanError = useLibraryStore((s) => s.scanError);
|
||||
const addFolder = useLibraryStore((s) => s.addFolder);
|
||||
const playlists = usePlaylistStore((s) => s.playlists);
|
||||
const favoriteTracks = usePlaylistStore((s) => s.favoriteTracks);
|
||||
const currentTrack = usePlayerStore((s) => s.currentTrack);
|
||||
@@ -462,9 +457,8 @@ export default function HomeScreen() {
|
||||
</View>
|
||||
) : null}
|
||||
<EmptyHomeCard
|
||||
isScanning={isScanning}
|
||||
scanError={scanError}
|
||||
onAddFolder={() => void addFolder()}
|
||||
onManageFolders={() => router.push('/settings')}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -185,7 +185,12 @@ export default function LibraryScreen() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{viewMode === 'folders' ? <FoldersView /> : null}
|
||||
{viewMode === 'folders' ? (
|
||||
<FoldersView
|
||||
onScroll={scrollTop.onScroll}
|
||||
scrollEventThrottle={scrollTop.scrollEventThrottle}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</PullSearchGesture>
|
||||
|
||||
+214
-1
@@ -1,12 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { View, Pressable, ScrollView, StyleSheet, Switch } from 'react-native';
|
||||
import { Alert, View, Pressable, ScrollView, StyleSheet, Switch } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Text } from '@/components/Text';
|
||||
import { EQSlider } from '@/components/eq/EQSlider';
|
||||
import { ScanProgress } from '@/components/library/ScanProgress';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
import { useLibraryStore, type FolderWithCount } from '@/stores/libraryStore';
|
||||
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
|
||||
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
|
||||
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
|
||||
@@ -70,6 +72,150 @@ function ToggleRow({
|
||||
);
|
||||
}
|
||||
|
||||
function formatFolderCount(count: number): string {
|
||||
return `${count} ${count === 1 ? 'folder' : 'folders'}`;
|
||||
}
|
||||
|
||||
function formatTrackCount(count: number): string {
|
||||
return `${count} ${count === 1 ? 'track' : 'tracks'}`;
|
||||
}
|
||||
|
||||
function LibraryFolderSettingsRow({
|
||||
folder,
|
||||
disabled,
|
||||
onRemove,
|
||||
}: {
|
||||
folder: FolderWithCount;
|
||||
disabled: boolean;
|
||||
onRemove: (folder: FolderWithCount) => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.folderSettingsRow}>
|
||||
<Ionicons
|
||||
name={folder.available ? 'folder-outline' : 'alert-circle-outline'}
|
||||
size={20}
|
||||
color={folder.available ? colors.textSecondary : colors.warning}
|
||||
/>
|
||||
<View style={styles.folderSettingsMeta}>
|
||||
<Text variant="body" numberOfLines={1}>
|
||||
{folder.display_name}
|
||||
</Text>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={folder.available ? colors.textSecondary : colors.warning}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{folder.available
|
||||
? formatTrackCount(folder.track_count)
|
||||
: 'Access lost. Remove and add again.'}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
hitSlop={8}
|
||||
disabled={disabled}
|
||||
onPress={() => onRemove(folder)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Remove ${folder.display_name}`}
|
||||
style={disabled && styles.actionDisabled}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={18} color={colors.textTertiary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryFoldersSettings() {
|
||||
const folders = useLibraryStore((s) => s.folders);
|
||||
const isScanning = useLibraryStore((s) => s.isScanning);
|
||||
const scanError = useLibraryStore((s) => s.scanError);
|
||||
const addFolder = useLibraryStore((s) => s.addFolder);
|
||||
const removeFolder = useLibraryStore((s) => s.removeFolder);
|
||||
const rescan = useLibraryStore((s) => s.rescan);
|
||||
|
||||
const unavailableCount = folders.filter((folder) => !folder.available).length;
|
||||
const totalTracks = folders.reduce((sum, folder) => sum + folder.track_count, 0);
|
||||
|
||||
const confirmRemove = (folder: FolderWithCount) => {
|
||||
Alert.alert(
|
||||
'Remove folder?',
|
||||
`"${folder.display_name}" and its ${formatTrackCount(folder.track_count)} will be removed from the library. Files on disk are not touched.`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Remove', style: 'destructive', onPress: () => void removeFolder(folder.id) },
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.folderSettingsHeader}>
|
||||
<View style={styles.folderSettingsTitleBlock}>
|
||||
<Text variant="body">Local music folders</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
|
||||
{folders.length === 0
|
||||
? 'Choose folders to scan into Astra.'
|
||||
: `${formatFolderCount(folders.length)} / ${formatTrackCount(totalTracks)}`}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={[styles.folderPrimaryAction, isScanning && styles.actionDisabled]}
|
||||
disabled={isScanning}
|
||||
onPress={() => void addFolder()}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Ionicons name="add" size={17} color={colors.bgPrimary} />
|
||||
<Text variant="label" style={styles.folderPrimaryActionText}>
|
||||
Add
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{folders.length > 0 ? (
|
||||
<View style={styles.folderSettingsActions}>
|
||||
<Pressable
|
||||
style={[styles.folderSecondaryAction, isScanning && styles.actionDisabled]}
|
||||
disabled={isScanning}
|
||||
onPress={() => void rescan()}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Ionicons name="refresh" size={16} color={colors.textSecondary} />
|
||||
<Text variant="label" color={colors.textSecondary}>
|
||||
Rescan all
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ScanProgress />
|
||||
|
||||
{scanError ? (
|
||||
<Text variant="caption" color={colors.warning} style={styles.folderSettingsNotice} numberOfLines={2}>
|
||||
Scan problem: {scanError}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{unavailableCount > 0 ? (
|
||||
<Text variant="caption" color={colors.warning} style={styles.folderSettingsNotice} numberOfLines={2}>
|
||||
{formatFolderCount(unavailableCount)} need access again.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{folders.length > 0 ? (
|
||||
<View style={styles.folderSettingsList}>
|
||||
{folders.map((folder) => (
|
||||
<LibraryFolderSettingsRow
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
disabled={isScanning}
|
||||
onRemove={confirmRemove}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const router = useRouter();
|
||||
const remoteSources = useRemoteSourcesStore((s) => s.sources);
|
||||
@@ -106,6 +252,11 @@ export default function SettingsScreen() {
|
||||
</Text>
|
||||
|
||||
<Text variant="label" color={colors.textTertiary} style={styles.sectionLabel}>
|
||||
LIBRARY FOLDERS
|
||||
</Text>
|
||||
<LibraryFoldersSettings />
|
||||
|
||||
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
|
||||
AUDIO
|
||||
</Text>
|
||||
<View style={styles.card}>
|
||||
@@ -284,6 +435,68 @@ const styles = StyleSheet.create({
|
||||
cardSpacing: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
folderSettingsHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
folderSettingsTitleBlock: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 2,
|
||||
},
|
||||
folderPrimaryAction: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.accent,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
folderPrimaryActionText: {
|
||||
color: colors.bgPrimary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
folderSettingsActions: {
|
||||
flexDirection: 'row',
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
folderSecondaryAction: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
borderRadius: radius.pill,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
folderSettingsNotice: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
folderSettingsList: {
|
||||
marginTop: spacing.md,
|
||||
borderTopColor: colors.glassBorder,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
folderSettingsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
minHeight: 52,
|
||||
borderBottomColor: colors.glassBorder,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
folderSettingsMeta: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 2,
|
||||
},
|
||||
actionDisabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
toggleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { View, Pressable, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Text } from '@/components/Text';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
|
||||
export function EmptyLibrary() {
|
||||
const addFolder = useLibraryStore((s) => s.addFolder);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<View style={styles.empty}>
|
||||
@@ -16,10 +16,10 @@ export function EmptyLibrary() {
|
||||
<Text variant="body" color={colors.textSecondary} style={styles.body}>
|
||||
Pick a folder on this device and Astra will scan it into your library.
|
||||
</Text>
|
||||
<Pressable style={styles.cta} onPress={() => void addFolder()} accessibilityRole="button">
|
||||
<Pressable style={styles.cta} onPress={() => router.push('/settings')} accessibilityRole="button">
|
||||
<Ionicons name="folder-open-outline" size={18} color={colors.bgPrimary} />
|
||||
<Text variant="body" style={styles.ctaLabel}>
|
||||
Add music folder
|
||||
Folder settings
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -1,121 +1,231 @@
|
||||
import { View, Pressable, StyleSheet, Alert } from 'react-native';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
View,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { Text } from '@/components/Text';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { PullSearchScrollView } from '@/components/search/PullSearchGesture';
|
||||
import { playTracks } from '@/audio/playbackController';
|
||||
import { dbTrackToTrack } from '@/library/trackAdapter';
|
||||
import {
|
||||
buildFolderTree,
|
||||
flattenFolderTree,
|
||||
type FlattenedFolderTreeRow,
|
||||
} from '@/library/folderTree';
|
||||
import { formatDuration } from '@/lib/format';
|
||||
import { colors, spacing } from '@/theme';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
import type { FolderWithCount } from '@/stores/libraryStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
function FolderRow({ folder }: { folder: FolderWithCount }) {
|
||||
const removeFolder = useLibraryStore((s) => s.removeFolder);
|
||||
interface FoldersViewProps {
|
||||
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||
scrollEventThrottle?: number;
|
||||
}
|
||||
|
||||
const confirmRemove = () => {
|
||||
Alert.alert(
|
||||
'Remove folder?',
|
||||
`"${folder.display_name}" and its ${folder.track_count} tracks will be removed from the library. Files on disk are not touched.`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Remove', style: 'destructive', onPress: () => void removeFolder(folder.id) },
|
||||
]
|
||||
);
|
||||
};
|
||||
function FolderRow({
|
||||
row,
|
||||
onToggle,
|
||||
}: {
|
||||
row: Extract<FlattenedFolderTreeRow, { type: 'folder' }>;
|
||||
onToggle: (nodeId: string) => void;
|
||||
}) {
|
||||
const { node, depth, isExpanded } = row;
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Pressable
|
||||
style={styles.folderRow}
|
||||
onPress={() => onToggle(node.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: isExpanded }}
|
||||
>
|
||||
<View style={[styles.indent, { width: depth * 18 }]} />
|
||||
<Ionicons
|
||||
name={folder.available ? 'folder-outline' : 'alert-circle-outline'}
|
||||
size={22}
|
||||
color={folder.available ? colors.textSecondary : colors.warning}
|
||||
name={isExpanded ? 'chevron-down' : 'chevron-forward'}
|
||||
size={16}
|
||||
color={colors.textTertiary}
|
||||
/>
|
||||
<View style={styles.meta}>
|
||||
<Ionicons
|
||||
name={node.available ? 'folder-outline' : 'alert-circle-outline'}
|
||||
size={19}
|
||||
color={node.available ? colors.textSecondary : colors.warning}
|
||||
/>
|
||||
<View style={styles.folderMeta}>
|
||||
<Text variant="body" numberOfLines={1}>
|
||||
{folder.display_name}
|
||||
</Text>
|
||||
<Text variant="label" numberOfLines={1}>
|
||||
{folder.available
|
||||
? `${folder.track_count} ${folder.track_count === 1 ? 'track' : 'tracks'}`
|
||||
: 'Access lost — remove and add the folder again'}
|
||||
{node.name}
|
||||
</Text>
|
||||
{!node.available ? (
|
||||
<Text variant="caption" color={colors.warning} numberOfLines={1}>
|
||||
Access lost
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Pressable hitSlop={8} onPress={confirmRemove} accessibilityRole="button">
|
||||
<Ionicons name="trash-outline" size={18} color={colors.textTertiary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text variant="mono" style={styles.count}>
|
||||
{node.totalTrackCount}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function FoldersView() {
|
||||
const folders = useLibraryStore((s) => s.folders);
|
||||
const isScanning = useLibraryStore((s) => s.isScanning);
|
||||
const addFolder = useLibraryStore((s) => s.addFolder);
|
||||
const rescan = useLibraryStore((s) => s.rescan);
|
||||
function FolderTrackRow({
|
||||
row,
|
||||
active,
|
||||
}: {
|
||||
row: Extract<FlattenedFolderTreeRow, { type: 'track' }>;
|
||||
active: boolean;
|
||||
}) {
|
||||
const index = row.folderTracks.findIndex((track) => track.path === row.track.path);
|
||||
|
||||
const playFolderTrack = () => {
|
||||
void playTracks(row.folderTracks.map(dbTrackToTrack), Math.max(0, index));
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{folders.map((folder) => (
|
||||
<FolderRow key={folder.id} folder={folder} />
|
||||
))}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={[styles.action, isScanning && styles.actionDisabled]}
|
||||
disabled={isScanning}
|
||||
onPress={() => void addFolder()}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Ionicons name="add" size={18} color={colors.accent} />
|
||||
<Text variant="body" color={colors.accent}>
|
||||
Add folder
|
||||
</Text>
|
||||
</Pressable>
|
||||
{folders.length > 0 ? (
|
||||
<Pressable
|
||||
style={[styles.action, isScanning && styles.actionDisabled]}
|
||||
disabled={isScanning}
|
||||
onPress={() => void rescan()}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Ionicons name="refresh" size={16} color={colors.textSecondary} />
|
||||
<Text variant="body" color={colors.textSecondary}>
|
||||
Rescan all
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
style={[styles.trackRow, active && styles.trackRowActive]}
|
||||
onPress={playFolderTrack}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<View style={[styles.indent, { width: row.depth * 18 + 16 }]} />
|
||||
<Ionicons name={active ? 'volume-high' : 'musical-note'} size={15} color={active ? colors.accent : colors.textTertiary} />
|
||||
<View style={styles.trackMeta}>
|
||||
<Text variant="body" style={[styles.trackTitle, active && styles.trackTitleActive]} numberOfLines={1}>
|
||||
{row.track.title}
|
||||
</Text>
|
||||
<Text variant="label" numberOfLines={1}>
|
||||
{row.track.artist}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text variant="mono" style={styles.duration}>
|
||||
{formatDuration(row.track.duration)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps) {
|
||||
const folders = useLibraryStore((s) => s.folders);
|
||||
const tracks = useLibraryStore((s) => s.tracks);
|
||||
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
|
||||
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const tree = useMemo(() => buildFolderTree(folders, tracks), [folders, tracks]);
|
||||
const rows = useMemo(() => flattenFolderTree(tree, expandedNodeIds), [expandedNodeIds, tree]);
|
||||
|
||||
const toggleFolder = (nodeId: string) => {
|
||||
setExpandedNodeIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(nodeId)) {
|
||||
next.delete(nodeId);
|
||||
} else {
|
||||
next.add(nodeId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (tree.length === 0) {
|
||||
return (
|
||||
<View style={styles.empty}>
|
||||
<Ionicons name="folder-open-outline" size={36} color={colors.textTertiary} />
|
||||
<Text variant="heading">No folders with tracks</Text>
|
||||
<Text variant="body" color={colors.textSecondary} style={styles.emptyText}>
|
||||
Add or rescan local folders in Settings.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={rows}
|
||||
keyExtractor={(row) => row.id}
|
||||
showsVerticalScrollIndicator={false}
|
||||
overScrollMode="never"
|
||||
renderScrollComponent={PullSearchScrollView}
|
||||
onScroll={onScroll}
|
||||
scrollEventThrottle={scrollEventThrottle}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={({ item }) =>
|
||||
item.type === 'folder' ? (
|
||||
<FolderRow row={item} onToggle={toggleFolder} />
|
||||
) : (
|
||||
<FolderTrackRow row={item} active={item.track.path === currentPath} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: spacing.xs,
|
||||
listContent: {
|
||||
paddingBottom: spacing.xxl,
|
||||
},
|
||||
row: {
|
||||
indent: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
folderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
minHeight: 48,
|
||||
gap: spacing.sm,
|
||||
borderBottomColor: colors.glassBorder,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
meta: {
|
||||
flex: 1,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
action: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
borderColor: colors.glassBorder,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderRadius: radius.pill,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
actionDisabled: {
|
||||
opacity: 0.4,
|
||||
folderMeta: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 2,
|
||||
},
|
||||
count: {
|
||||
minWidth: 34,
|
||||
textAlign: 'right',
|
||||
color: colors.textTertiary,
|
||||
fontSize: 12,
|
||||
},
|
||||
trackRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minHeight: 46,
|
||||
gap: spacing.sm,
|
||||
borderBottomColor: colors.glassBorder,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
trackRowActive: {
|
||||
backgroundColor: colors.accentGlow,
|
||||
},
|
||||
trackMeta: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 2,
|
||||
},
|
||||
trackTitle: {
|
||||
fontSize: 15,
|
||||
},
|
||||
trackTitleActive: {
|
||||
color: colors.accent,
|
||||
},
|
||||
duration: {
|
||||
minWidth: 42,
|
||||
textAlign: 'right',
|
||||
color: colors.textTertiary,
|
||||
fontSize: 12,
|
||||
},
|
||||
empty: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingBottom: spacing.xxl,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: 'center',
|
||||
maxWidth: 260,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -66,6 +66,14 @@ const NAV_ENTRIES: {
|
||||
icon: 'musical-notes',
|
||||
keywords: ['library', 'tracks', 'songs', 'browse', 'collection'],
|
||||
},
|
||||
{
|
||||
id: 'nav:library-folders',
|
||||
label: 'Browse folders',
|
||||
href: '/library',
|
||||
icon: 'folder-open-outline',
|
||||
keywords: ['folders', 'folder explorer', 'browse folders', 'library folders'],
|
||||
libraryViewMode: 'folders',
|
||||
},
|
||||
{
|
||||
id: 'nav:eq',
|
||||
label: 'Equalizer',
|
||||
@@ -109,12 +117,11 @@ const SETTING_ENTRIES: {
|
||||
},
|
||||
{
|
||||
id: 'setting:folders',
|
||||
label: 'Library folders',
|
||||
subtitle: 'Scanned music folders',
|
||||
href: '/library',
|
||||
label: 'Manage folders',
|
||||
subtitle: 'Add / rescan / remove folders',
|
||||
href: '/settings',
|
||||
icon: 'folder-open-outline',
|
||||
keywords: ['folders', 'scan', 'rescan', 'local files', 'storage'],
|
||||
libraryViewMode: 'folders',
|
||||
keywords: ['folders', 'scan', 'rescan', 'add folder', 'remove folder', 'local files', 'storage'],
|
||||
},
|
||||
{
|
||||
id: 'setting:sources',
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildFolderTree,
|
||||
decodedSafDocumentPath,
|
||||
decodedSafTreePath,
|
||||
flattenFolderTree,
|
||||
} from './folderTree.ts';
|
||||
import type { DbTrack, LibraryFolder } from '@/types/library';
|
||||
|
||||
function folder(overrides: Partial<LibraryFolder> = {}): LibraryFolder {
|
||||
return {
|
||||
id: 1,
|
||||
tree_uri: 'content://com.android.externalstorage.documents/tree/primary%3AMusic%2FAstraTest',
|
||||
display_name: 'AstraTest',
|
||||
added_at: 1,
|
||||
last_scanned_at: 2,
|
||||
available: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function track(overrides: Partial<DbTrack> = {}): DbTrack {
|
||||
return {
|
||||
id: 1,
|
||||
path: 'content://com.android.externalstorage.documents/document/primary%3AMusic%2FAstraTest%2FAlbum%2F01.flac',
|
||||
folder_id: 1,
|
||||
title: 'Track',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
album_artist: null,
|
||||
album_identity_key: 'artist|album',
|
||||
duration: 180,
|
||||
track_number: 1,
|
||||
disc_number: null,
|
||||
year: null,
|
||||
genre: null,
|
||||
artwork_hash: null,
|
||||
format: 'FLAC',
|
||||
sample_rate: null,
|
||||
bit_depth: null,
|
||||
bitrate: null,
|
||||
channels: null,
|
||||
codec: null,
|
||||
source_type: 'local',
|
||||
source_id: null,
|
||||
source_track_id: null,
|
||||
source_path: null,
|
||||
artwork_source_id: null,
|
||||
file_name: '01.flac',
|
||||
size: null,
|
||||
mtime: 1,
|
||||
added_at: 1,
|
||||
modified_at: 1,
|
||||
loudness_lufs: null,
|
||||
sample_peak: null,
|
||||
replay_gain_track_db: null,
|
||||
replay_gain_album_db: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('decodes SAF tree and document paths', () => {
|
||||
assert.equal(
|
||||
decodedSafTreePath('content://com.android.externalstorage.documents/tree/primary%3AMusic%2FAstraTest'),
|
||||
'Music/AstraTest'
|
||||
);
|
||||
assert.equal(
|
||||
decodedSafDocumentPath(
|
||||
'content://com.android.externalstorage.documents/document/primary%3AMusic%2FAstraTest%2FAlbum%2F01.flac'
|
||||
),
|
||||
'Music/AstraTest/Album/01.flac'
|
||||
);
|
||||
assert.equal(decodedSafTreePath('content://com.android.externalstorage.documents/tree/primary%3A'), '');
|
||||
assert.equal(decodedSafDocumentPath('content://example/document/primary%3AMusic%ZZ'), null);
|
||||
assert.equal(decodedSafTreePath('content://example/not-a-tree/primary%3AMusic'), null);
|
||||
});
|
||||
|
||||
test('builds nested local folder tree from indexed SAF tracks', () => {
|
||||
const folders = [folder()];
|
||||
const tracks = [
|
||||
track({
|
||||
id: 1,
|
||||
title: 'Root Song',
|
||||
file_name: 'Root.flac',
|
||||
path: 'content://com.android.externalstorage.documents/document/primary%3AMusic%2FAstraTest%2FRoot.flac',
|
||||
}),
|
||||
track({
|
||||
id: 2,
|
||||
title: 'Nested Song',
|
||||
file_name: '01.flac',
|
||||
path: 'content://com.android.externalstorage.documents/document/primary%3AMusic%2FAstraTest%2FAlbum%2FDisc%201%2F01.flac',
|
||||
}),
|
||||
track({
|
||||
id: 3,
|
||||
title: 'Remote Song',
|
||||
folder_id: null,
|
||||
source_type: 'subsonic',
|
||||
path: 'subsonic://server/track/3',
|
||||
file_name: 'remote.flac',
|
||||
}),
|
||||
];
|
||||
|
||||
const tree = buildFolderTree(folders, tracks);
|
||||
|
||||
assert.equal(tree.length, 1);
|
||||
assert.equal(tree[0].name, 'AstraTest');
|
||||
assert.equal(tree[0].totalTrackCount, 2);
|
||||
assert.deepEqual(tree[0].tracks.map((entry) => entry.title), ['Root Song']);
|
||||
assert.equal(tree[0].children[0].name, 'Album');
|
||||
assert.equal(tree[0].children[0].children[0].name, 'Disc 1');
|
||||
assert.deepEqual(tree[0].children[0].children[0].tracks.map((entry) => entry.title), ['Nested Song']);
|
||||
});
|
||||
|
||||
test('falls back to root rows for undecodable document URIs', () => {
|
||||
const tree = buildFolderTree(
|
||||
[folder()],
|
||||
[
|
||||
track({
|
||||
path: 'content://com.android.externalstorage.documents/document/primary%3AMusic%ZZ',
|
||||
file_name: 'Fallback.flac',
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
assert.equal(tree.length, 1);
|
||||
assert.equal(tree[0].tracks.length, 1);
|
||||
assert.equal(tree[0].tracks[0].file_name, 'Fallback.flac');
|
||||
});
|
||||
|
||||
test('retains unavailable folders that still have indexed local tracks', () => {
|
||||
const tree = buildFolderTree(
|
||||
[
|
||||
folder({
|
||||
available: false,
|
||||
}),
|
||||
],
|
||||
[track()]
|
||||
);
|
||||
|
||||
assert.equal(tree.length, 1);
|
||||
assert.equal(tree[0].available, false);
|
||||
assert.equal(tree[0].totalTrackCount, 1);
|
||||
});
|
||||
|
||||
test('flattens only expanded folder nodes', () => {
|
||||
const tree = buildFolderTree(
|
||||
[folder()],
|
||||
[
|
||||
track({
|
||||
id: 1,
|
||||
title: 'Root Song',
|
||||
file_name: 'Root.flac',
|
||||
path: 'content://com.android.externalstorage.documents/document/primary%3AMusic%2FAstraTest%2FRoot.flac',
|
||||
}),
|
||||
track({
|
||||
id: 2,
|
||||
title: 'Nested Song',
|
||||
file_name: '01.flac',
|
||||
path: 'content://com.android.externalstorage.documents/document/primary%3AMusic%2FAstraTest%2FAlbum%2F01.flac',
|
||||
}),
|
||||
]
|
||||
);
|
||||
const root = tree[0];
|
||||
const album = root.children[0];
|
||||
|
||||
assert.deepEqual(
|
||||
flattenFolderTree(tree, new Set()).map((row) => row.type),
|
||||
['folder']
|
||||
);
|
||||
assert.deepEqual(
|
||||
flattenFolderTree(tree, new Set([root.id])).map((row) =>
|
||||
row.type === 'folder' ? row.node.name : row.track.title
|
||||
),
|
||||
['AstraTest', 'Album', 'Root Song']
|
||||
);
|
||||
assert.deepEqual(
|
||||
flattenFolderTree(tree, new Set([root.id, album.id])).map((row) =>
|
||||
row.type === 'folder' ? row.node.name : row.track.title
|
||||
),
|
||||
['AstraTest', 'Album', 'Nested Song', 'Root Song']
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { DbTrack, LibraryFolder } from '@/types/library';
|
||||
|
||||
export interface FolderTreeSourceFolder extends LibraryFolder {
|
||||
track_count?: number;
|
||||
}
|
||||
|
||||
export interface FolderTreeNode {
|
||||
id: string;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
depth: number;
|
||||
available: boolean;
|
||||
children: FolderTreeNode[];
|
||||
tracks: DbTrack[];
|
||||
subtreeTracks: DbTrack[];
|
||||
totalTrackCount: number;
|
||||
}
|
||||
|
||||
export type FlattenedFolderTreeRow =
|
||||
| {
|
||||
type: 'folder';
|
||||
id: string;
|
||||
node: FolderTreeNode;
|
||||
depth: number;
|
||||
isExpanded: boolean;
|
||||
canExpand: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'track';
|
||||
id: string;
|
||||
track: DbTrack;
|
||||
folderTracks: DbTrack[];
|
||||
depth: number;
|
||||
};
|
||||
|
||||
function decodedSafPathFromUri(uri: string, marker: '/tree/' | '/document/'): string | null {
|
||||
const idx = uri.indexOf(marker);
|
||||
if (idx < 0) return null;
|
||||
|
||||
let docId: string;
|
||||
try {
|
||||
docId = decodeURIComponent(uri.slice(idx + marker.length));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const colon = docId.indexOf(':');
|
||||
return colon >= 0 ? docId.slice(colon + 1) : docId;
|
||||
}
|
||||
|
||||
/** "content://.../tree/primary%3AMusic%2FAstraTest" -> "Music/AstraTest" */
|
||||
export function decodedSafTreePath(treeUri: string): string | null {
|
||||
return decodedSafPathFromUri(treeUri, '/tree/');
|
||||
}
|
||||
|
||||
/** "content://.../document/primary%3AMusic%2FA%2Ff.flac" -> "Music/A/f.flac" */
|
||||
export function decodedSafDocumentPath(documentUri: string): string | null {
|
||||
return decodedSafPathFromUri(documentUri, '/document/');
|
||||
}
|
||||
|
||||
function compareNames(a: string, b: string): number {
|
||||
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function compareTracksByPath(a: DbTrack, b: DbTrack): number {
|
||||
const aPath = decodedSafDocumentPath(a.path) ?? a.path;
|
||||
const bPath = decodedSafDocumentPath(b.path) ?? b.path;
|
||||
return compareNames(aPath, bPath);
|
||||
}
|
||||
|
||||
function relativeTrackPath(folderRootPath: string | null, track: DbTrack): string {
|
||||
const docPath = decodedSafDocumentPath(track.path);
|
||||
if (!docPath) return track.file_name;
|
||||
|
||||
if (!folderRootPath) return docPath;
|
||||
if (docPath === folderRootPath) return track.file_name;
|
||||
if (docPath.startsWith(`${folderRootPath}/`)) return docPath.slice(folderRootPath.length + 1);
|
||||
|
||||
return track.file_name;
|
||||
}
|
||||
|
||||
function makeNode({
|
||||
id,
|
||||
name,
|
||||
fullPath,
|
||||
depth,
|
||||
available,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
depth: number;
|
||||
available: boolean;
|
||||
}): FolderTreeNode {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
fullPath,
|
||||
depth,
|
||||
available,
|
||||
children: [],
|
||||
tracks: [],
|
||||
subtreeTracks: [],
|
||||
totalTrackCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function finalizeNode(node: FolderTreeNode): number {
|
||||
node.children.sort((a, b) => compareNames(a.name, b.name));
|
||||
node.tracks.sort(compareTracksByPath);
|
||||
|
||||
const subtreeTracks: DbTrack[] = [];
|
||||
let totalTrackCount = node.tracks.length;
|
||||
|
||||
for (const child of node.children) {
|
||||
totalTrackCount += finalizeNode(child);
|
||||
subtreeTracks.push(...child.subtreeTracks);
|
||||
}
|
||||
|
||||
subtreeTracks.push(...node.tracks);
|
||||
node.subtreeTracks = subtreeTracks;
|
||||
node.totalTrackCount = totalTrackCount;
|
||||
return totalTrackCount;
|
||||
}
|
||||
|
||||
export function buildFolderTree(
|
||||
folders: readonly FolderTreeSourceFolder[],
|
||||
tracks: readonly DbTrack[]
|
||||
): FolderTreeNode[] {
|
||||
const foldersById = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
const tracksByFolderId = new Map<number, DbTrack[]>();
|
||||
|
||||
for (const track of tracks) {
|
||||
if (track.source_type !== 'local' || track.folder_id == null) continue;
|
||||
if (!foldersById.has(track.folder_id)) continue;
|
||||
|
||||
const folderTracks = tracksByFolderId.get(track.folder_id);
|
||||
if (folderTracks) {
|
||||
folderTracks.push(track);
|
||||
} else {
|
||||
tracksByFolderId.set(track.folder_id, [track]);
|
||||
}
|
||||
}
|
||||
|
||||
const roots: FolderTreeNode[] = [];
|
||||
|
||||
for (const folder of folders) {
|
||||
const folderTracks = tracksByFolderId.get(folder.id) ?? [];
|
||||
if (folderTracks.length === 0) continue;
|
||||
|
||||
const rootPath = decodedSafTreePath(folder.tree_uri);
|
||||
const rootFullPath = rootPath || folder.display_name;
|
||||
const root = makeNode({
|
||||
id: `folder:${folder.id}`,
|
||||
name: folder.display_name,
|
||||
fullPath: rootFullPath,
|
||||
depth: 0,
|
||||
available: folder.available,
|
||||
});
|
||||
const childrenByPath = new Map<string, FolderTreeNode>();
|
||||
|
||||
for (const track of folderTracks) {
|
||||
const relativePath = relativeTrackPath(rootPath, track);
|
||||
const segments = relativePath.split('/').filter(Boolean);
|
||||
segments.pop();
|
||||
|
||||
let current = root;
|
||||
let pathSoFar = rootFullPath;
|
||||
|
||||
for (const segment of segments) {
|
||||
pathSoFar = pathSoFar ? `${pathSoFar}/${segment}` : segment;
|
||||
const id = `folder:${folder.id}:${pathSoFar}`;
|
||||
let child = childrenByPath.get(id);
|
||||
if (!child) {
|
||||
child = makeNode({
|
||||
id,
|
||||
name: segment,
|
||||
fullPath: pathSoFar,
|
||||
depth: current.depth + 1,
|
||||
available: folder.available,
|
||||
});
|
||||
childrenByPath.set(id, child);
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
|
||||
current.tracks.push(track);
|
||||
}
|
||||
|
||||
finalizeNode(root);
|
||||
roots.push(root);
|
||||
}
|
||||
|
||||
return roots.sort((a, b) => compareNames(a.name, b.name));
|
||||
}
|
||||
|
||||
export function flattenFolderTree(
|
||||
tree: readonly FolderTreeNode[],
|
||||
expandedNodeIds: ReadonlySet<string>
|
||||
): FlattenedFolderTreeRow[] {
|
||||
const rows: FlattenedFolderTreeRow[] = [];
|
||||
|
||||
const visit = (node: FolderTreeNode) => {
|
||||
const canExpand = node.children.length > 0 || node.tracks.length > 0;
|
||||
const isExpanded = expandedNodeIds.has(node.id);
|
||||
|
||||
rows.push({
|
||||
type: 'folder',
|
||||
id: node.id,
|
||||
node,
|
||||
depth: node.depth,
|
||||
isExpanded,
|
||||
canExpand,
|
||||
});
|
||||
|
||||
if (!isExpanded) return;
|
||||
|
||||
for (const child of node.children) {
|
||||
visit(child);
|
||||
}
|
||||
|
||||
for (const track of node.tracks) {
|
||||
rows.push({
|
||||
type: 'track',
|
||||
id: `track:${track.id}`,
|
||||
track,
|
||||
folderTracks: node.tracks,
|
||||
depth: node.depth + 1,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of tree) {
|
||||
visit(root);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
@@ -10,22 +10,10 @@ import {
|
||||
writeAsStringAsync,
|
||||
} from 'expo-file-system/legacy';
|
||||
import type { DbTrack } from '@/types/library';
|
||||
import { decodedSafDocumentPath } from './folderTree';
|
||||
import { parseM3u, serializeM3u, type M3uEntry, type M3uExportEntry } from '@/lib/m3u';
|
||||
|
||||
/** "content://…/document/primary%3AMusic%2FA%2Ff.flac" -> "Music/A/f.flac" */
|
||||
export function decodedDocPath(contentUri: string): string | null {
|
||||
const marker = '/document/';
|
||||
const idx = contentUri.indexOf(marker);
|
||||
if (idx < 0) return null;
|
||||
let docId: string;
|
||||
try {
|
||||
docId = decodeURIComponent(contentUri.slice(idx + marker.length));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const colon = docId.indexOf(':');
|
||||
return colon >= 0 ? docId.slice(colon + 1) : docId;
|
||||
}
|
||||
export const decodedDocPath = decodedSafDocumentPath;
|
||||
|
||||
// --- Import matching ---------------------------------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user