proper folder ui system

This commit is contained in:
Boof2015
2026-07-01 19:01:20 -04:00
parent 4d67e52f2e
commit 2016a0037d
9 changed files with 867 additions and 128 deletions
+7 -13
View File
@@ -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')}
/>
</>
) : (
+6 -1
View File
@@ -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
View File
@@ -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',
+4 -4
View File
@@ -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>
+200 -90
View File
@@ -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,
},
});
+12 -5
View File
@@ -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',
+183
View File
@@ -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']
);
});
+239
View File
@@ -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;
}
+2 -14
View File
@@ -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 ---------------------------------------------------------