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