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); + }, +}));