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 ---------------------------------------------------------