diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx
index a9969c1..5710491 100644
--- a/src/app/(tabs)/_layout.tsx
+++ b/src/app/(tabs)/_layout.tsx
@@ -29,6 +29,7 @@ export default function TabsLayout() {
+
);
}
diff --git a/src/app/(tabs)/library/artist/[name].tsx b/src/app/(tabs)/library/artist/[name].tsx
index d772130..77aba38 100644
--- a/src/app/(tabs)/library/artist/[name].tsx
+++ b/src/app/(tabs)/library/artist/[name].tsx
@@ -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(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) => {
diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx
new file mode 100644
index 0000000..ef46d8f
--- /dev/null
+++ b/src/app/(tabs)/settings.tsx
@@ -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 (
+
+
+ Settings
+
+
+
+ LIBRARY
+
+
+ Artist grouping
+
+
+ How tracks are organized into artists in the library.
+
+
+
+ {ARTIST_GROUPING_OPTIONS.map((option) => {
+ const selected = option.mode === groupingMode;
+ return (
+ void setArtistGroupingMode(option.mode)}
+ accessibilityRole="radio"
+ accessibilityState={{ selected }}
+ >
+
+
+ {option.title}
+
+
+ {option.description}
+
+
+ {selected ? (
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
+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,
+ },
+});
diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx
index 6d6451c..2660df7 100644
--- a/src/components/TabBar.tsx
+++ b/src/components/TabBar.tsx
@@ -11,6 +11,7 @@ export const TAB_META: Record = {
index: { label: 'Home', icon: 'home' },
library: { label: 'Library', icon: 'musical-notes' },
eq: { label: 'EQ', icon: 'options' },
+ settings: { label: 'Settings', icon: 'settings' },
};
export interface TabItem {
diff --git a/src/db/queries.ts b/src/db/queries.ts
index 91c8126..dd040a0 100644
--- a/src/db/queries.ts
+++ b/src/db/queries.ts
@@ -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 {
`);
}
-export function getArtists(db: LibraryDatabase): Promise {
- return db.all(`
- 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 {
return db.all(`
@@ -149,6 +141,21 @@ export async function getTrackCount(db: LibraryDatabase): Promise {
return row?.count ?? 0;
}
+// --- Settings (key-value preferences) ----------------------------------------
+
+export async function getSetting(db: LibraryDatabase, key: string): Promise {
+ 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 {
+ await db.run(
+ `INSERT INTO settings (key, value) VALUES (?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
+ [key, value]
+ );
+}
+
// --- Folders -----------------------------------------------------------------
type FolderRow = Omit;
diff --git a/src/db/schema.ts b/src/db/schema.ts
index d1927f6..c866503 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -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 {
diff --git a/src/library/artistGrouping.ts b/src/library/artistGrouping.ts
new file mode 100644
index 0000000..b1a25c8
--- /dev/null
+++ b/src/library/artistGrouping.ts
@@ -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();
+ 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): 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): 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): string[] {
+ const unique = new Map();
+ 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,
+ 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();
+
+ for (const track of tracks) {
+ const indexNames = mode === 'fileTags'
+ ? [resolveStrictBrowseArtist(track)]
+ : getCanonicalArtistIndexNames(track);
+
+ const seen = new Set();
+ 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));
+}
diff --git a/src/stores/libraryStore.ts b/src/stores/libraryStore.ts
index b9367aa..a9a6ab8 100644
--- a/src/stores/libraryStore.ts
+++ b/src/stores/libraryStore.ts
@@ -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;
refresh: () => Promise;
+ recomputeArtists: () => void;
setViewMode: (mode: ViewMode) => void;
setTrackSort: (sort: TrackSort) => void;
addFolder: () => Promise;
@@ -88,6 +91,12 @@ export const useLibraryStore = create((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((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 }),
diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts
new file mode 100644
index 0000000..b4ba38f
--- /dev/null
+++ b/src/stores/settingsStore.ts
@@ -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;
+ setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise;
+}
+
+export const useSettingsStore = create((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);
+ },
+}));