From 2016a0037d02dd9d21269f05654834e0d80f4b00 Mon Sep 17 00:00:00 2001
From: Boof2015 <75185879+Boof2015@users.noreply.github.com>
Date: Wed, 1 Jul 2026 19:01:20 -0400
Subject: [PATCH] proper folder ui system
---
src/app/(tabs)/index.tsx | 20 +-
src/app/(tabs)/library/index.tsx | 7 +-
src/app/(tabs)/settings.tsx | 215 +++++++++++++-
src/components/library/EmptyLibrary.tsx | 8 +-
src/components/library/FoldersView.tsx | 290 +++++++++++++------
src/components/search/QuickSearchOverlay.tsx | 17 +-
src/library/folderTree.test.mts | 183 ++++++++++++
src/library/folderTree.ts | 239 +++++++++++++++
src/library/playlistFiles.ts | 16 +-
9 files changed, 867 insertions(+), 128 deletions(-)
create mode 100644 src/library/folderTree.test.mts
create mode 100644 src/library/folderTree.ts
diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx
index 5f5b0d5..a1d762a 100644
--- a/src/app/(tabs)/index.tsx
+++ b/src/app/(tabs)/index.tsx
@@ -306,13 +306,11 @@ function RandomAlbumCard({
}
function EmptyHomeCard({
- isScanning,
scanError,
- onAddFolder,
+ onManageFolders,
}: {
- isScanning: boolean;
scanError: string | null;
- onAddFolder: () => void;
+ onManageFolders: () => void;
}) {
return (
@@ -329,14 +327,13 @@ function EmptyHomeCard({
) : null}
-
+
- Add folder
+ Folder settings
@@ -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() {
) : null}
void addFolder()}
+ onManageFolders={() => router.push('/settings')}
/>
>
) : (
diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx
index 719fd09..37e7c25 100644
--- a/src/app/(tabs)/library/index.tsx
+++ b/src/app/(tabs)/library/index.tsx
@@ -185,7 +185,12 @@ export default function LibraryScreen() {
/>
) : null}
- {viewMode === 'folders' ? : null}
+ {viewMode === 'folders' ? (
+
+ ) : null}
>
)}
diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx
index d1a34df..1f49f67 100644
--- a/src/app/(tabs)/settings.tsx
+++ b/src/app/(tabs)/settings.tsx
@@ -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 (
+
+
+
+
+ {folder.display_name}
+
+
+ {folder.available
+ ? formatTrackCount(folder.track_count)
+ : 'Access lost. Remove and add again.'}
+
+
+ onRemove(folder)}
+ accessibilityRole="button"
+ accessibilityLabel={`Remove ${folder.display_name}`}
+ style={disabled && styles.actionDisabled}
+ >
+
+
+
+ );
+}
+
+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 (
+
+
+
+ Local music folders
+
+ {folders.length === 0
+ ? 'Choose folders to scan into Astra.'
+ : `${formatFolderCount(folders.length)} / ${formatTrackCount(totalTracks)}`}
+
+
+ void addFolder()}
+ accessibilityRole="button"
+ >
+
+
+ Add
+
+
+
+
+ {folders.length > 0 ? (
+
+ void rescan()}
+ accessibilityRole="button"
+ >
+
+
+ Rescan all
+
+
+
+ ) : null}
+
+
+
+ {scanError ? (
+
+ Scan problem: {scanError}
+
+ ) : null}
+
+ {unavailableCount > 0 ? (
+
+ {formatFolderCount(unavailableCount)} need access again.
+
+ ) : null}
+
+ {folders.length > 0 ? (
+
+ {folders.map((folder) => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
export default function SettingsScreen() {
const router = useRouter();
const remoteSources = useRemoteSourcesStore((s) => s.sources);
@@ -106,6 +252,11 @@ export default function SettingsScreen() {
+ LIBRARY FOLDERS
+
+
+
+
AUDIO
@@ -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',
diff --git a/src/components/library/EmptyLibrary.tsx b/src/components/library/EmptyLibrary.tsx
index 1891320..72cdf18 100644
--- a/src/components/library/EmptyLibrary.tsx
+++ b/src/components/library/EmptyLibrary.tsx
@@ -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 (
@@ -16,10 +16,10 @@ export function EmptyLibrary() {
Pick a folder on this device and Astra will scan it into your library.
- void addFolder()} accessibilityRole="button">
+ router.push('/settings')} accessibilityRole="button">
- Add music folder
+ Folder settings
diff --git a/src/components/library/FoldersView.tsx b/src/components/library/FoldersView.tsx
index edcb29f..3ffbb16 100644
--- a/src/components/library/FoldersView.tsx
+++ b/src/components/library/FoldersView.tsx
@@ -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) => 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;
+ onToggle: (nodeId: string) => void;
+}) {
+ const { node, depth, isExpanded } = row;
return (
-
+ onToggle(node.id)}
+ accessibilityRole="button"
+ accessibilityState={{ expanded: isExpanded }}
+ >
+
-
+
+
- {folder.display_name}
-
-
- {folder.available
- ? `${folder.track_count} ${folder.track_count === 1 ? 'track' : 'tracks'}`
- : 'Access lost — remove and add the folder again'}
+ {node.name}
+ {!node.available ? (
+
+ Access lost
+
+ ) : null}
-
-
-
-
+
+ {node.totalTrackCount}
+
+
);
}
-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;
+ 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 (
-
- {folders.map((folder) => (
-
- ))}
-
-
- void addFolder()}
- accessibilityRole="button"
- >
-
-
- Add folder
-
-
- {folders.length > 0 ? (
- void rescan()}
- accessibilityRole="button"
- >
-
-
- Rescan all
-
-
- ) : null}
+
+
+
+
+
+ {row.track.title}
+
+
+ {row.track.artist}
+
-
+
+ {formatDuration(row.track.duration)}
+
+
+ );
+}
+
+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>(() => 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 (
+
+
+ No folders with tracks
+
+ Add or rescan local folders in Settings.
+
+
+ );
+ }
+
+ return (
+ row.id}
+ showsVerticalScrollIndicator={false}
+ overScrollMode="never"
+ renderScrollComponent={PullSearchScrollView}
+ onScroll={onScroll}
+ scrollEventThrottle={scrollEventThrottle}
+ contentContainerStyle={styles.listContent}
+ renderItem={({ item }) =>
+ item.type === 'folder' ? (
+
+ ) : (
+
+ )
+ }
+ />
);
}
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,
},
});
diff --git a/src/components/search/QuickSearchOverlay.tsx b/src/components/search/QuickSearchOverlay.tsx
index 6a5a4bc..8a0ecba 100644
--- a/src/components/search/QuickSearchOverlay.tsx
+++ b/src/components/search/QuickSearchOverlay.tsx
@@ -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',
diff --git a/src/library/folderTree.test.mts b/src/library/folderTree.test.mts
new file mode 100644
index 0000000..6a23efb
--- /dev/null
+++ b/src/library/folderTree.test.mts
@@ -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 {
+ 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 {
+ 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']
+ );
+});
diff --git a/src/library/folderTree.ts b/src/library/folderTree.ts
new file mode 100644
index 0000000..fc75913
--- /dev/null
+++ b/src/library/folderTree.ts
@@ -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();
+
+ 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();
+
+ 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
+): 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;
+}
diff --git a/src/library/playlistFiles.ts b/src/library/playlistFiles.ts
index b971051..42b325b 100644
--- a/src/library/playlistFiles.ts
+++ b/src/library/playlistFiles.ts
@@ -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 ---------------------------------------------------------