mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
port over astra desktop tag algos
This commit is contained in:
@@ -29,6 +29,7 @@ export default function TabsLayout() {
|
||||
<Tabs.Screen name="index" />
|
||||
<Tabs.Screen name="library" />
|
||||
<Tabs.Screen name="eq" />
|
||||
<Tabs.Screen name="settings" />
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,22 +10,25 @@ import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
import { playTracks, shuffleTracks } from '@/audio/playbackController';
|
||||
import { dbTrackToTrack } from '@/library/trackAdapter';
|
||||
import { filterTracksByArtist } from '@/library/artistGrouping';
|
||||
import type { DbTrack } from '@/types/library';
|
||||
|
||||
export default function ArtistScreen() {
|
||||
const router = useRouter();
|
||||
const { name } = useLocalSearchParams<{ name: string }>();
|
||||
const allTracks = useLibraryStore((s) => s.tracks);
|
||||
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
|
||||
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
|
||||
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
|
||||
|
||||
// Store tracks are ordered artist/album/disc/track, so the filtered slice
|
||||
// keeps album grouping and track order.
|
||||
// Match the artist list's grouping mode; store tracks are ordered
|
||||
// artist/album/disc/track, so the filtered slice keeps album/track order.
|
||||
const tracks = useMemo(
|
||||
() => allTracks.filter((track) => track.artist === name),
|
||||
[allTracks, name]
|
||||
() => filterTracksByArtist(allTracks, name, groupingMode),
|
||||
[allTracks, name, groupingMode]
|
||||
);
|
||||
|
||||
const playFrom = (index: number) => {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { View, Pressable, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Text } from '@/components/Text';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
import type { ArtistGroupingMode } from '@/library/artistGrouping';
|
||||
|
||||
const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; description: string }[] = [
|
||||
{
|
||||
mode: 'astra',
|
||||
title: 'Astra grouping',
|
||||
description: 'Parse collaborators ("feat.", "&", "x") — featured artists get their own entry.',
|
||||
},
|
||||
{
|
||||
mode: 'fileTags',
|
||||
title: 'File tags',
|
||||
description: 'Group by the album artist / artist tag exactly as written.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
|
||||
const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Text variant="title" style={styles.heading}>
|
||||
Settings
|
||||
</Text>
|
||||
|
||||
<Text variant="label" color={colors.textTertiary} style={styles.sectionLabel}>
|
||||
LIBRARY
|
||||
</Text>
|
||||
<Text variant="body" style={styles.settingTitle}>
|
||||
Artist grouping
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
|
||||
How tracks are organized into artists in the library.
|
||||
</Text>
|
||||
|
||||
<View style={styles.options}>
|
||||
{ARTIST_GROUPING_OPTIONS.map((option) => {
|
||||
const selected = option.mode === groupingMode;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.mode}
|
||||
style={[styles.option, selected && styles.optionSelected]}
|
||||
onPress={() => void setArtistGroupingMode(option.mode)}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected }}
|
||||
>
|
||||
<View style={styles.optionText}>
|
||||
<Text variant="body" color={selected ? colors.accentTextStrong : colors.textPrimary}>
|
||||
{option.title}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
|
||||
{option.description}
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
|
||||
) : (
|
||||
<Ionicons name="ellipse-outline" size={20} color={colors.textTertiary} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
marginTop: spacing.xl,
|
||||
marginBottom: spacing.xxl,
|
||||
},
|
||||
sectionLabel: {
|
||||
letterSpacing: 1,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
settingTitle: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
settingNote: {
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
options: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
option: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
padding: spacing.lg,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
},
|
||||
optionSelected: {
|
||||
borderColor: colors.accent,
|
||||
backgroundColor: colors.glassHighlight,
|
||||
},
|
||||
optionText: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
optionDescription: {
|
||||
lineHeight: 16,
|
||||
},
|
||||
});
|
||||
@@ -11,6 +11,7 @@ export const TAB_META: Record<string, { label: string; icon: IconName }> = {
|
||||
index: { label: 'Home', icon: 'home' },
|
||||
library: { label: 'Library', icon: 'musical-notes' },
|
||||
eq: { label: 'EQ', icon: 'options' },
|
||||
settings: { label: 'Settings', icon: 'settings' },
|
||||
};
|
||||
|
||||
export interface TabItem {
|
||||
|
||||
+18
-11
@@ -1,6 +1,6 @@
|
||||
// Library queries — SQL ported/adapted from the desktop library service.
|
||||
|
||||
import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library';
|
||||
import type { Album, DbTrack, LibraryFolder } from '@/types/library';
|
||||
import type { LibraryDatabase, SqlParams } from './database';
|
||||
|
||||
/** Row shape the scanner produces for insert/update (id and timestamps are db-managed). */
|
||||
@@ -112,16 +112,8 @@ export function getAlbums(db: LibraryDatabase): Promise<Album[]> {
|
||||
`);
|
||||
}
|
||||
|
||||
export function getArtists(db: LibraryDatabase): Promise<Artist[]> {
|
||||
return db.all<Artist>(`
|
||||
SELECT artist,
|
||||
COUNT(*) AS track_count,
|
||||
MAX(artwork_hash) AS artwork_hash
|
||||
FROM tracks
|
||||
GROUP BY artist
|
||||
ORDER BY artist COLLATE NOCASE
|
||||
`);
|
||||
}
|
||||
// NOTE: the artist browse list is built in JS (src/library/artistGrouping.ts) so it
|
||||
// can honor the astra-grouping vs file-tags mode; there is no SQL getArtists anymore.
|
||||
|
||||
export function getAllTracks(db: LibraryDatabase): Promise<DbTrack[]> {
|
||||
return db.all<DbTrack>(`
|
||||
@@ -149,6 +141,21 @@ export async function getTrackCount(db: LibraryDatabase): Promise<number> {
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
// --- Settings (key-value preferences) ----------------------------------------
|
||||
|
||||
export async function getSetting(db: LibraryDatabase, key: string): Promise<string | null> {
|
||||
const row = await db.get<{ value: string }>('SELECT value FROM settings WHERE key = ?', [key]);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export async function setSetting(db: LibraryDatabase, key: string, value: string): Promise<void> {
|
||||
await db.run(
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
[key, value]
|
||||
);
|
||||
}
|
||||
|
||||
// --- Folders -----------------------------------------------------------------
|
||||
|
||||
type FolderRow = Omit<LibraryFolder, 'available'>;
|
||||
|
||||
+10
-2
@@ -1,11 +1,12 @@
|
||||
// Library schema — a trimmed port of the desktop schema (astra
|
||||
// src/main/services/library.ts). v1 covers M1 (local scan + browse);
|
||||
// v2 adds playlists + favorites (M2); v3 forces re-extraction of tracks whose
|
||||
// non-ASCII tags were truncated by the pre-fix op-sqlite binding (see database.ts).
|
||||
// non-ASCII tags were truncated by the pre-fix op-sqlite binding (see database.ts);
|
||||
// v4 adds a key-value settings table (artist grouping mode, future prefs).
|
||||
|
||||
import type { LibraryDatabase } from './database';
|
||||
|
||||
export const SCHEMA_VERSION = 3;
|
||||
export const SCHEMA_VERSION = 4;
|
||||
|
||||
// One statement per entry — op-sqlite executes single statements.
|
||||
const MIGRATIONS: readonly (readonly string[])[] = [
|
||||
@@ -82,6 +83,13 @@ const MIGRATIONS: readonly (readonly string[])[] = [
|
||||
// see database.ts). The damage is irreversible in place, so mark every track
|
||||
// stale; libraryStore re-extracts them on next launch now that binding is fixed.
|
||||
[`UPDATE tracks SET mtime = -1`],
|
||||
// v3 -> v4 — persisted app preferences as a simple key-value store.
|
||||
[
|
||||
`CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT NOT NULL
|
||||
)`,
|
||||
],
|
||||
];
|
||||
|
||||
export async function migrate(db: LibraryDatabase): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Artist browse grouping — ported from desktop (astra src/main/services/library.ts
|
||||
// and src/shared/library/artistCredits.ts). Two modes:
|
||||
// 'astra' (desktop "canonical"): parse the artist string into collaborators,
|
||||
// file each track under its primary artist, and also index it under
|
||||
// every collaborator so featured artists are browsable.
|
||||
// 'fileTags' (desktop "strict"): use the tag verbatim (album_artist || artist).
|
||||
//
|
||||
// Mobile has no parsed `artist_names_json` columns (MMR yields one artist string),
|
||||
// so desktop's parsed-array paths collapse to splitCollaborators(artist) — which is
|
||||
// the parsing heuristic. Everything here is derivable from artist + album_artist.
|
||||
|
||||
import type { Artist, DbTrack } from '@/types/library';
|
||||
|
||||
export type ArtistGroupingMode = 'astra' | 'fileTags';
|
||||
|
||||
const UNKNOWN_ARTIST = 'Unknown Artist';
|
||||
|
||||
/** Track fields the grouping logic reads (subset of DbTrack, for testability). */
|
||||
type ArtistTrackLike = Pick<
|
||||
DbTrack,
|
||||
'artist' | 'album_artist' | 'artwork_hash' | 'year' | 'added_at' | 'modified_at'
|
||||
>;
|
||||
|
||||
export function normalizeDisplay(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function normalizeKey(value: string): string {
|
||||
return normalizeDisplay(value).toLocaleLowerCase();
|
||||
}
|
||||
|
||||
/** Split "A & B feat. C; D" into ["A","B","C","D"], deduped by normalized key. */
|
||||
export function splitCollaborators(rawArtist: string): string[] {
|
||||
const normalized = normalizeDisplay(rawArtist);
|
||||
if (!normalized) return [];
|
||||
|
||||
const unified = normalized
|
||||
.replace(/\s*;\s*/g, ',')
|
||||
.replace(/\s+&\s+/g, ',')
|
||||
.replace(/\s+[x×]\s+/gi, ',')
|
||||
.replace(/\s+(?:feat\.?|ft\.?|featuring|with)\s+/gi, ',');
|
||||
|
||||
return dedupeByKey(unified.split(','));
|
||||
}
|
||||
|
||||
/** Like splitCollaborators but keeps "&" (e.g. "Earth, Wind & Fire" stays whole). */
|
||||
export function splitAlbumArtistCollaborators(rawAlbumArtist: string): string[] {
|
||||
const normalized = normalizeDisplay(rawAlbumArtist);
|
||||
if (!normalized) return [];
|
||||
|
||||
const unified = normalized
|
||||
.replace(/\s*;\s*/g, ',')
|
||||
.replace(/\s+[x×]\s+/gi, ',')
|
||||
.replace(/\s+(?:feat\.?|ft\.?|featuring|with)\s+/gi, ',');
|
||||
|
||||
return dedupeByKey(unified.split(','));
|
||||
}
|
||||
|
||||
function dedupeByKey(parts: string[]): string[] {
|
||||
const unique = new Map<string, string>();
|
||||
for (const part of parts) {
|
||||
const display = normalizeDisplay(part);
|
||||
if (!display) continue;
|
||||
const key = normalizeKey(display);
|
||||
if (!key || unique.has(key)) continue;
|
||||
unique.set(key, display);
|
||||
}
|
||||
return Array.from(unique.values());
|
||||
}
|
||||
|
||||
/** File-tags artist: album_artist if present, else the raw track artist. */
|
||||
export function resolveStrictBrowseArtist(track: Pick<DbTrack, 'artist' | 'album_artist'>): string {
|
||||
const albumArtist = normalizeDisplay(track.album_artist ?? '');
|
||||
if (albumArtist) return albumArtist;
|
||||
return normalizeDisplay(track.artist) || UNKNOWN_ARTIST;
|
||||
}
|
||||
|
||||
/** Astra-grouping primary: album_artist's first collaborator, else artist's first. */
|
||||
export function resolveCanonicalBrowseArtist(track: Pick<DbTrack, 'artist' | 'album_artist'>): string {
|
||||
const albumArtist = normalizeDisplay(track.album_artist ?? '');
|
||||
if (albumArtist) {
|
||||
return splitAlbumArtistCollaborators(albumArtist)[0] ?? albumArtist;
|
||||
}
|
||||
return splitCollaborators(track.artist)[0] ?? UNKNOWN_ARTIST;
|
||||
}
|
||||
|
||||
/** Every artist a track is indexed under in astra mode: primary + all collaborators. */
|
||||
export function getCanonicalArtistIndexNames(track: Pick<DbTrack, 'artist' | 'album_artist'>): string[] {
|
||||
const unique = new Map<string, string>();
|
||||
const add = (name: string) => {
|
||||
const display = normalizeDisplay(name);
|
||||
const key = normalizeKey(display);
|
||||
if (!key || unique.has(key)) return;
|
||||
unique.set(key, display);
|
||||
};
|
||||
|
||||
add(resolveCanonicalBrowseArtist(track));
|
||||
|
||||
const trackArtists = splitCollaborators(track.artist);
|
||||
for (const name of trackArtists) add(name);
|
||||
if (trackArtists.length === 0) {
|
||||
for (const name of splitAlbumArtistCollaborators(track.album_artist ?? '')) add(name);
|
||||
}
|
||||
|
||||
return Array.from(unique.values());
|
||||
}
|
||||
|
||||
/** Whether a track belongs to the given artist key under the active browse mode. */
|
||||
export function trackMatchesBrowseArtist(
|
||||
track: Pick<DbTrack, 'artist' | 'album_artist'>,
|
||||
targetArtistKey: string,
|
||||
mode: ArtistGroupingMode
|
||||
): boolean {
|
||||
const browseKey = normalizeKey(
|
||||
mode === 'fileTags' ? resolveStrictBrowseArtist(track) : resolveCanonicalBrowseArtist(track)
|
||||
);
|
||||
if (browseKey === targetArtistKey) return true;
|
||||
if (mode === 'fileTags') return false;
|
||||
|
||||
const albumArtistKey = normalizeKey(track.album_artist ?? '');
|
||||
if (albumArtistKey && albumArtistKey === targetArtistKey) return true;
|
||||
|
||||
const trackArtistKey = normalizeKey(track.artist);
|
||||
if (trackArtistKey && trackArtistKey === targetArtistKey) return true;
|
||||
|
||||
if (splitAlbumArtistCollaborators(track.album_artist ?? '').some((n) => normalizeKey(n) === targetArtistKey)) {
|
||||
return true;
|
||||
}
|
||||
return splitCollaborators(track.artist).some((n) => normalizeKey(n) === targetArtistKey);
|
||||
}
|
||||
|
||||
interface ArtistAggregate {
|
||||
artist: string;
|
||||
track_count: number;
|
||||
artwork_hash: string | null;
|
||||
artworkYear: number;
|
||||
artworkAddedAt: number;
|
||||
artworkModifiedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate the artist browse list from in-memory tracks (replaces SQL getArtists).
|
||||
* Artwork is the cover of the artist's newest track (year, then added/modified) —
|
||||
* matches desktop getArtists.
|
||||
*/
|
||||
export function buildArtistList(tracks: readonly ArtistTrackLike[], mode: ArtistGroupingMode): Artist[] {
|
||||
const byKey = new Map<string, ArtistAggregate>();
|
||||
|
||||
for (const track of tracks) {
|
||||
const indexNames = mode === 'fileTags'
|
||||
? [resolveStrictBrowseArtist(track)]
|
||||
: getCanonicalArtistIndexNames(track);
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const name of indexNames) {
|
||||
const key = normalizeKey(name);
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
let aggregate = byKey.get(key);
|
||||
if (!aggregate) {
|
||||
aggregate = {
|
||||
artist: name,
|
||||
track_count: 0,
|
||||
artwork_hash: null,
|
||||
artworkYear: -1,
|
||||
artworkAddedAt: -1,
|
||||
artworkModifiedAt: -1,
|
||||
};
|
||||
byKey.set(key, aggregate);
|
||||
}
|
||||
aggregate.track_count += 1;
|
||||
|
||||
if (!track.artwork_hash) continue;
|
||||
const candidateYear = track.year ?? -1;
|
||||
const better =
|
||||
aggregate.artwork_hash == null ||
|
||||
candidateYear > aggregate.artworkYear ||
|
||||
(candidateYear === aggregate.artworkYear &&
|
||||
(track.added_at > aggregate.artworkAddedAt ||
|
||||
(track.added_at === aggregate.artworkAddedAt && track.modified_at > aggregate.artworkModifiedAt)));
|
||||
if (!better) continue;
|
||||
aggregate.artwork_hash = track.artwork_hash;
|
||||
aggregate.artworkYear = candidateYear;
|
||||
aggregate.artworkAddedAt = track.added_at;
|
||||
aggregate.artworkModifiedAt = track.modified_at;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byKey.values())
|
||||
.map(({ artist, track_count, artwork_hash }) => ({ artist, track_count, artwork_hash }))
|
||||
.sort((a, b) => a.artist.localeCompare(b.artist, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
|
||||
/** Tracks belonging to one artist under the active mode (preserves input order). */
|
||||
export function filterTracksByArtist(
|
||||
tracks: readonly DbTrack[],
|
||||
artistName: string,
|
||||
mode: ArtistGroupingMode
|
||||
): DbTrack[] {
|
||||
const key = normalizeKey(artistName);
|
||||
if (!key) return [];
|
||||
return tracks.filter((track) => trackMatchesBrowseArtist(track, key, mode));
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library';
|
||||
import { openLibraryDb } from '@/db/database';
|
||||
import { getAlbums, getAllTracks, getArtists, getTrackCount } from '@/db/queries';
|
||||
import { getAlbums, getAllTracks, getTrackCount } from '@/db/queries';
|
||||
import { buildArtistList } from '@/library/artistGrouping';
|
||||
import {
|
||||
addFolderViaPicker,
|
||||
loadFolders,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from '@/library/scanner';
|
||||
import type { TrackSort } from '@/lib/trackSort';
|
||||
import { usePlaylistStore } from './playlistStore';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
|
||||
/**
|
||||
* Library state — SQLite is the source of truth (no persist middleware);
|
||||
@@ -45,6 +47,7 @@ interface LibraryStore {
|
||||
|
||||
initialize: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
recomputeArtists: () => void;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
setTrackSort: (sort: TrackSort) => void;
|
||||
addFolder: () => Promise<void>;
|
||||
@@ -88,6 +91,12 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
|
||||
if (!initPromise) {
|
||||
initPromise = (async () => {
|
||||
const db = await openLibraryDb();
|
||||
// Load the persisted grouping mode before the first refresh so the artist
|
||||
// list is built correctly; recompute it whenever the mode changes later.
|
||||
await useSettingsStore.getState().load();
|
||||
useSettingsStore.subscribe((state, prev) => {
|
||||
if (state.artistGroupingMode !== prev.artistGroupingMode) get().recomputeArtists();
|
||||
});
|
||||
await get().refresh();
|
||||
set({ initialized: true });
|
||||
// One-time recovery: the v3 migration marks tracks stale (mtime = -1)
|
||||
@@ -108,18 +117,26 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
|
||||
|
||||
refresh: async () => {
|
||||
const db = await openLibraryDb();
|
||||
const [tracks, albums, artists, folders, totalTrackCount] = await Promise.all([
|
||||
const [tracks, albums, folders, totalTrackCount] = await Promise.all([
|
||||
getAllTracks(db),
|
||||
getAlbums(db),
|
||||
getArtists(db),
|
||||
loadFolders(),
|
||||
getTrackCount(db),
|
||||
]);
|
||||
// The artist list is derived in JS so it can honor the grouping mode.
|
||||
const artists = buildArtistList(tracks, useSettingsStore.getState().artistGroupingMode);
|
||||
set({ tracks, albums, artists, folders, totalTrackCount });
|
||||
// Playlist counts/missing states depend on tracks — keep them in step.
|
||||
await usePlaylistStore.getState().refresh();
|
||||
},
|
||||
|
||||
// Rebuild the artist list from in-memory tracks (e.g. on grouping-mode change),
|
||||
// without re-querying SQLite.
|
||||
recomputeArtists: () =>
|
||||
set((state) => ({
|
||||
artists: buildArtistList(state.tracks, useSettingsStore.getState().artistGroupingMode),
|
||||
})),
|
||||
|
||||
setViewMode: (viewMode) => set({ viewMode }),
|
||||
|
||||
setTrackSort: (trackSort) => set({ trackSort }),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { create } from 'zustand';
|
||||
import { openLibraryDb } from '@/db/database';
|
||||
import { getSetting, setSetting } from '@/db/queries';
|
||||
import type { ArtistGroupingMode } from '@/library/artistGrouping';
|
||||
|
||||
/**
|
||||
* Persisted app preferences. SQLite (settings table) is the source of truth — this
|
||||
* store mirrors it in memory. Kept free of cross-store imports; libraryStore
|
||||
* subscribes here to recompute the artist list when the grouping mode changes.
|
||||
*/
|
||||
const ARTIST_GROUPING_KEY = 'artist_grouping_mode';
|
||||
|
||||
function parseGroupingMode(value: string | null): ArtistGroupingMode {
|
||||
return value === 'fileTags' ? 'fileTags' : 'astra';
|
||||
}
|
||||
|
||||
interface SettingsStore {
|
||||
artistGroupingMode: ArtistGroupingMode;
|
||||
loaded: boolean;
|
||||
load: () => Promise<void>;
|
||||
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
artistGroupingMode: 'astra',
|
||||
loaded: false,
|
||||
|
||||
load: async () => {
|
||||
if (get().loaded) return;
|
||||
const db = await openLibraryDb();
|
||||
const stored = await getSetting(db, ARTIST_GROUPING_KEY);
|
||||
set({ artistGroupingMode: parseGroupingMode(stored), loaded: true });
|
||||
},
|
||||
|
||||
setArtistGroupingMode: async (mode) => {
|
||||
if (get().artistGroupingMode === mode) return;
|
||||
set({ artistGroupingMode: mode });
|
||||
const db = await openLibraryDb();
|
||||
await setSetting(db, ARTIST_GROUPING_KEY, mode);
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user