m2, library ux

This commit is contained in:
Boof2015
2026-06-13 13:13:53 -04:00
parent 23be9875e8
commit 3d30a09713
24 changed files with 2397 additions and 39 deletions
+10 -1
View File
@@ -10,12 +10,14 @@ import {
type ScanProgress,
type ScanResult,
} from '@/library/scanner';
import type { TrackSort } from '@/lib/trackSort';
import { usePlaylistStore } from './playlistStore';
/**
* Library state — SQLite is the source of truth (no persist middleware);
* this store mirrors it in memory for the UI plus scan/UI state.
*/
type ViewMode = 'tracks' | 'albums' | 'artists' | 'folders';
type ViewMode = 'tracks' | 'albums' | 'artists' | 'playlists' | 'folders';
export type FolderWithCount = LibraryFolder & { track_count: number };
@@ -36,6 +38,7 @@ interface LibraryStore {
folders: FolderWithCount[];
totalTrackCount: number;
viewMode: ViewMode;
trackSort: TrackSort;
isScanning: boolean;
scanProgress: ScanProgressState;
scanError: string | null;
@@ -43,6 +46,7 @@ interface LibraryStore {
initialize: () => Promise<void>;
refresh: () => Promise<void>;
setViewMode: (mode: ViewMode) => void;
setTrackSort: (sort: TrackSort) => void;
addFolder: () => Promise<void>;
removeFolder: (folderId: number) => Promise<void>;
rescan: () => Promise<void>;
@@ -75,6 +79,7 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
folders: [],
totalTrackCount: 0,
viewMode: 'albums',
trackSort: 'artist',
isScanning: false,
scanProgress: { ...IDLE_PROGRESS },
scanError: null,
@@ -103,10 +108,14 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
getTrackCount(db),
]);
set({ tracks, albums, artists, folders, totalTrackCount });
// Playlist counts/missing states depend on tracks — keep them in step.
await usePlaylistStore.getState().refresh();
},
setViewMode: (viewMode) => set({ viewMode }),
setTrackSort: (trackSort) => set({ trackSort }),
addFolder: () => runScan(() => addFolderViaPicker({ onProgress })),
removeFolder: async (folderId) => {
+256
View File
@@ -0,0 +1,256 @@
import { create } from 'zustand';
import type { DbTrack } from '@/types/library';
import type { Playlist, PlaylistTrackEntry } from '@/types/playlist';
import { openLibraryDb, type LibraryDatabase } from '@/db/database';
import { getAllTracks } from '@/db/queries';
import * as playlistDb from '@/db/playlistQueries';
import {
buildImportIndex,
decodedDocPath,
exportPlaylistM3u,
matchImportEntry,
normalizeEntryPath,
pickAndParseM3u,
type M3uExportResult,
} from '@/library/playlistFiles';
import type { M3uExportEntry } from '@/lib/m3u';
export interface M3uImportSummary {
playlistId: number;
name: string;
total: number;
matchedByPath: number;
matchedByMetadata: number;
missing: number;
ambiguous: number;
}
/**
* Playlists + favorites state — SQLite is the source of truth (no persist);
* every mutation re-queries. libraryStore.refresh() chains into refresh() so
* scans and folder removals update counts/missing states.
*/
interface PlaylistStore {
playlists: Playlist[];
favoritePaths: Set<string>;
favoriteTracks: DbTrack[];
activePlaylistId: number | null;
activeEntries: PlaylistTrackEntry[];
refresh: () => Promise<void>;
openPlaylist: (id: number) => Promise<void>;
closePlaylist: () => void;
createPlaylist: (name: string) => Promise<Playlist>;
renamePlaylist: (id: number, name: string) => Promise<void>;
deletePlaylist: (id: number) => Promise<void>;
addTracksToPlaylist: (id: number, tracks: DbTrack[]) => Promise<number>;
removeFromPlaylist: (id: number, trackPath: string) => Promise<void>;
moveTrack: (id: number, trackPath: string, direction: -1 | 1) => Promise<void>;
toggleFavorite: (track: DbTrack) => Promise<void>;
markPlayed: (id: number) => Promise<void>;
importM3u: () => Promise<M3uImportSummary | null>;
exportM3u: (target: number | 'favorites') => Promise<M3uExportResult | null>;
}
function trackToExportEntry(track: DbTrack): M3uExportEntry {
return {
path: decodedDocPath(track.path) ?? track.file_name,
title: track.title,
artist: track.artist,
duration: track.duration,
};
}
function entryToExportEntry(entry: PlaylistTrackEntry): M3uExportEntry {
if (entry.track) return trackToExportEntry(entry.track);
return {
path: decodedDocPath(entry.track_path) ?? entry.track_path,
title: entry.fallback_title,
artist: entry.fallback_artist,
duration: null,
};
}
export const usePlaylistStore = create<PlaylistStore>((set, get) => {
const reloadActive = async (db: LibraryDatabase) => {
const id = get().activePlaylistId;
if (id == null) return;
const activeEntries = await playlistDb.getPlaylistEntries(db, id);
set({ activeEntries });
};
const refreshWith = async (db: LibraryDatabase) => {
const [playlists, favoritePathList, favoriteTracks] = await Promise.all([
playlistDb.getPlaylists(db),
playlistDb.getFavoritePaths(db),
playlistDb.getFavoriteTracks(db),
]);
set({ playlists, favoritePaths: new Set(favoritePathList), favoriteTracks });
await reloadActive(db);
};
return {
playlists: [],
favoritePaths: new Set<string>(),
favoriteTracks: [],
activePlaylistId: null,
activeEntries: [],
refresh: async () => {
const db = await openLibraryDb();
await refreshWith(db);
},
openPlaylist: async (id) => {
const db = await openLibraryDb();
const activeEntries = await playlistDb.getPlaylistEntries(db, id);
set({ activePlaylistId: id, activeEntries });
},
closePlaylist: () => set({ activePlaylistId: null, activeEntries: [] }),
createPlaylist: async (name) => {
const db = await openLibraryDb();
const playlist = await playlistDb.createPlaylist(db, name);
await refreshWith(db);
return playlist;
},
renamePlaylist: async (id, name) => {
const db = await openLibraryDb();
await playlistDb.renamePlaylist(db, id, name);
await refreshWith(db);
},
deletePlaylist: async (id) => {
const db = await openLibraryDb();
await playlistDb.deletePlaylist(db, id);
if (get().activePlaylistId === id) {
set({ activePlaylistId: null, activeEntries: [] });
}
await refreshWith(db);
},
addTracksToPlaylist: async (id, tracks) => {
const db = await openLibraryDb();
const inserted = await playlistDb.addPlaylistEntries(
db,
id,
tracks.map((track) => ({
trackPath: track.path,
fallbackTitle: track.title,
fallbackArtist: track.artist,
fallbackAlbum: track.album,
}))
);
await refreshWith(db);
return inserted;
},
removeFromPlaylist: async (id, trackPath) => {
const db = await openLibraryDb();
await playlistDb.removeFromPlaylist(db, id, trackPath);
await refreshWith(db);
},
moveTrack: async (id, trackPath, direction) => {
const db = await openLibraryDb();
await playlistDb.movePlaylistTrack(db, id, trackPath, direction);
await refreshWith(db);
},
toggleFavorite: async (track) => {
const wasFavorite = get().favoritePaths.has(track.path);
// Optimistic Set swap (always a fresh Set — never mutate in place).
const optimistic = new Set(get().favoritePaths);
if (wasFavorite) {
optimistic.delete(track.path);
} else {
optimistic.add(track.path);
}
set({ favoritePaths: optimistic });
const db = await openLibraryDb();
if (wasFavorite) {
await playlistDb.removeFavorite(db, track.path);
} else {
await playlistDb.addFavorite(db, track.path);
}
const [favoritePathList, favoriteTracks] = await Promise.all([
playlistDb.getFavoritePaths(db),
playlistDb.getFavoriteTracks(db),
]);
set({ favoritePaths: new Set(favoritePathList), favoriteTracks });
},
markPlayed: async (id) => {
const db = await openLibraryDb();
await playlistDb.markPlaylistPlayed(db, id);
const playlists = await playlistDb.getPlaylists(db);
set({ playlists });
},
importM3u: async () => {
const picked = await pickAndParseM3u();
if (!picked) return null;
const db = await openLibraryDb();
const index = buildImportIndex(await getAllTracks(db));
const summary: Omit<M3uImportSummary, 'playlistId'> = {
name: picked.name,
total: picked.entries.length,
matchedByPath: 0,
matchedByMetadata: 0,
missing: 0,
ambiguous: 0,
};
const inserts: playlistDb.PlaylistEntryInsert[] = [];
for (const entry of picked.entries) {
const match = matchImportEntry(entry, index);
if (match.kind === 'matched') {
if (match.via === 'path') {
summary.matchedByPath += 1;
} else {
summary.matchedByMetadata += 1;
}
inserts.push({
trackPath: match.track.path,
fallbackTitle: match.track.title,
fallbackArtist: match.track.artist,
fallbackAlbum: match.track.album,
});
} else {
// Preserve unmatched entries as "missing" rows (desktop model).
summary.missing += 1;
if (match.kind === 'ambiguous') summary.ambiguous += 1;
inserts.push({
trackPath: normalizeEntryPath(entry.path),
fallbackTitle: entry.title ?? null,
fallbackArtist: entry.artist ?? null,
});
}
}
const playlist = await playlistDb.createPlaylist(db, picked.name);
await playlistDb.addPlaylistEntries(db, playlist.id, inserts);
await refreshWith(db);
return { ...summary, playlistId: playlist.id };
},
exportM3u: async (target) => {
const db = await openLibraryDb();
let name: string;
let entries: M3uExportEntry[];
if (target === 'favorites') {
name = 'Favorites';
entries = get().favoriteTracks.map(trackToExportEntry);
} else {
name = get().playlists.find((playlist) => playlist.id === target)?.name ?? 'Playlist';
const playlistEntries = await playlistDb.getPlaylistEntries(db, target);
entries = playlistEntries.map(entryToExportEntry);
}
return exportPlaylistM3u(name, entries);
},
};
});