diff --git a/package.json b/package.json index 7dd5d70..82f52ff 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts src/components/player/nowPlayingDismiss.test.mts src/playback/playbackTargetPresentation.test.mts", "test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs", "test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts src/navigation/homeLibraryNavigation.test.mts src/navigation/tabTransition.test.mts", + "test:library-layout": "node --experimental-strip-types --test src/library/libraryLayout.test.mts", "test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts", "test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts", "test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts", diff --git a/scripts/run-release-tests.mjs b/scripts/run-release-tests.mjs index 9900eb6..1111074 100644 --- a/scripts/run-release-tests.mjs +++ b/scripts/run-release-tests.mjs @@ -20,6 +20,7 @@ const TEST_SCRIPTS = [ 'test:now-playing-layout', 'test:memory-lifecycle', 'test:ui-navigation', + 'test:library-layout', 'test:haptics', 'test:home-greeting', 'test:session', diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx index f59e931..379e860 100644 --- a/src/app/(tabs)/library/index.tsx +++ b/src/app/(tabs)/library/index.tsx @@ -20,6 +20,8 @@ import { Text } from '@/components/Text'; import { ViewModeSwitcher } from '@/components/library/ViewModeSwitcher'; import { AlbumGridItem } from '@/components/library/AlbumGridItem'; import { ArtistGridItem } from '@/components/library/ArtistGridItem'; +import { AlbumRow } from '@/components/library/AlbumRow'; +import { ArtistRow } from '@/components/library/ArtistRow'; import { TrackRow } from '@/components/library/TrackRow'; import { FoldersView } from '@/components/library/FoldersView'; import { PlaylistsView } from '@/components/library/PlaylistsView'; @@ -64,6 +66,12 @@ import { ARTIST_SORT_LABELS, type ArtistSort } from '@/lib/artistSort'; +import { + LIBRARY_LAYOUT_OPTIONS, + libraryLayoutColumns, + libraryLayoutLabel, + type LibraryLayout, +} from '@/library/libraryLayout'; import type { DbTrack } from '@/types/library'; @@ -95,6 +103,10 @@ export default function LibraryScreen() { const setAlbumSort = useLibraryStore((s) => s.setAlbumSort); const artistSort = useLibraryStore((s) => s.artistSort); const setArtistSort = useLibraryStore((s) => s.setArtistSort); + const albumLayout = useLibraryStore((s) => s.albumLayout); + const setAlbumLayout = useLibraryStore((s) => s.setAlbumLayout); + const artistLayout = useLibraryStore((s) => s.artistLayout); + const setArtistLayout = useLibraryStore((s) => s.setArtistLayout); const loadNextTracks = useLibraryStore((s) => s.loadNextTracks); const loadNextAlbums = useLibraryStore((s) => s.loadNextAlbums); const loadNextArtists = useLibraryStore((s) => s.loadNextArtists); @@ -117,6 +129,7 @@ export default function LibraryScreen() { const [actionTrack, setActionTrack] = useState(null); const [sortSheetOpen, setSortSheetOpen] = useState(false); + const [layoutSheetOpen, setLayoutSheetOpen] = useState(false); const [selectMode, setSelectMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(() => new Set()); const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); @@ -290,6 +303,20 @@ export default function LibraryScreen() { selected: option === artistSort, onSelect: () => setArtistSort(option), })); + const activeLayout = + viewMode === 'albums' + ? albumLayout + : viewMode === 'artists' + ? artistLayout + : null; + const activeLayoutLabel = activeLayout ? libraryLayoutLabel(activeLayout) : null; + const layoutSheetLabel = viewMode === 'albums' ? 'ALBUM LAYOUT' : 'ARTIST LAYOUT'; + + const setActiveLayout = (layout: LibraryLayout) => { + scrollTop.setScrollAtTop(true); + if (viewMode === 'albums') setAlbumLayout(layout); + if (viewMode === 'artists') setArtistLayout(layout); + }; return ( @@ -319,6 +346,7 @@ export default function LibraryScreen() { value={viewMode} onChange={(mode) => { if (selectMode) exitSelection(); + setLayoutSheetOpen(false); scrollTop.setScrollAtTop(true); setViewMode(mode); }} @@ -343,23 +371,41 @@ export default function LibraryScreen() { ) : sortable ? ( - setSortSheetOpen(true)} - accessibilityRole="button" - accessibilityLabel={`Sort by ${sortLabel}`} - > - - {sortLabel} - + + setSortSheetOpen(true)} + accessibilityRole="button" + accessibilityLabel={`Sort by ${sortLabel}`} + > + + {sortLabel} + + {activeLayout && activeLayoutLabel ? ( + setLayoutSheetOpen(true)} + accessibilityRole="button" + accessibilityLabel={`Change ${viewMode} layout. Current layout: ${activeLayoutLabel}`} + > + + + ) : null} + ) : null} {viewMode === 'albums' ? ( album.identity_key} showsVerticalScrollIndicator={false} overScrollMode="never" @@ -373,8 +419,8 @@ export default function LibraryScreen() { onStartReachedThreshold={START_REACHED_THRESHOLD} ListFooterComponent={albumNextCursor ? listFooter : null} renderItem={({ item }) => ( - - router.push({ @@ -383,16 +429,28 @@ export default function LibraryScreen() { }) } /> - + ) : ( + + + router.push({ + pathname: '/library/album/[key]', + params: { key: item.identity_key }, + }) + } + /> + + ) )} /> ) : null} {viewMode === 'artists' ? ( artist.artist} showsVerticalScrollIndicator={false} overScrollMode="never" @@ -406,8 +464,8 @@ export default function LibraryScreen() { onStartReachedThreshold={START_REACHED_THRESHOLD} ListFooterComponent={artistNextCursor ? listFooter : null} renderItem={({ item }) => ( - - router.push({ @@ -416,7 +474,19 @@ export default function LibraryScreen() { }) } /> - + ) : ( + + + router.push({ + pathname: '/library/artist/[name]', + params: { name: item.artist }, + }) + } + /> + + ) )} /> ) : null} @@ -516,6 +586,24 @@ export default function LibraryScreen() { ))} ) : null} + {layoutSheetOpen && activeLayout ? ( + setLayoutSheetOpen(false)}> + + {LIBRARY_LAYOUT_OPTIONS.map((option) => ( + { + setActiveLayout(option.value); + setLayoutSheetOpen(false); + }} + /> + ))} + + ) : null} ); } @@ -537,13 +625,21 @@ const styles = StyleSheet.create({ error: { marginBottom: spacing.md, }, + controlsRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + gap: spacing.md, + marginBottom: spacing.xs, + }, sortTrigger: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, - alignSelf: 'flex-end', paddingVertical: spacing.xs, - marginBottom: spacing.xs, + }, + layoutTrigger: { + padding: spacing.xs, }, // Same vertical rhythm as sortTrigger so entering selection doesn't shift the list. selectionHeader: { diff --git a/src/components/library/AlbumRow.tsx b/src/components/library/AlbumRow.tsx index b61ceeb..5d134a9 100644 --- a/src/components/library/AlbumRow.tsx +++ b/src/components/library/AlbumRow.tsx @@ -15,7 +15,7 @@ import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { albumArtworkSource } from '@/library/artwork'; import type { Album } from '@/types/library'; -/** Compact album list row (search results) — the grid uses AlbumGridItem. */ +/** Compact album list row for library browsing and search results. */ export function AlbumRow({ album, onPress }: { album: Album; onPress: () => void }) { const styles = useStyles(); const colors = useColors(); @@ -29,6 +29,8 @@ export function AlbumRow({ album, onPress }: { album: Album; onPress: () => void source={{ uri: artUri }} style={styles.artImage} contentFit="cover" + recyclingKey={album.identity_key} + transition={null} /> ) : ( diff --git a/src/components/library/ArtistGridItem.tsx b/src/components/library/ArtistGridItem.tsx index 9cfed16..d5d7d4f 100644 --- a/src/components/library/ArtistGridItem.tsx +++ b/src/components/library/ArtistGridItem.tsx @@ -15,7 +15,7 @@ import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { artworkUri } from '@/library/artwork'; import type { Artist } from '@/types/library'; -/** 2-column grid cell: square art (2x2 album mosaic when available) + counts, matching the album grid. */ +/** Resizable grid cell: square art (2x2 album mosaic when available) + counts. */ export function ArtistGridItem({ artist, onPress }: { artist: Artist; onPress: () => void }) { const styles = useStyles(); const colors = useColors(); diff --git a/src/components/library/ArtistRow.tsx b/src/components/library/ArtistRow.tsx index 58a08fc..56f20fa 100644 --- a/src/components/library/ArtistRow.tsx +++ b/src/components/library/ArtistRow.tsx @@ -19,6 +19,8 @@ export function ArtistRow({ artist, onPress }: { artist: Artist; onPress: () => const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); + const albums = `${artist.album_count} ${artist.album_count === 1 ? 'album' : 'albums'}`; + const tracks = `${artist.track_count} ${artist.track_count === 1 ? 'track' : 'tracks'}`; return ( @@ -27,6 +29,8 @@ export function ArtistRow({ artist, onPress }: { artist: Artist; onPress: () => source={{ uri: artworkUri(artist.artwork_hash) }} style={styles.artImage} contentFit="cover" + recyclingKey={artist.artist} + transition={null} /> ) : ( @@ -36,8 +40,8 @@ export function ArtistRow({ artist, onPress }: { artist: Artist; onPress: () => {artist.artist} - - {artist.track_count} {artist.track_count === 1 ? 'track' : 'tracks'} + + {albums} · {tracks} diff --git a/src/library/libraryLayout.test.mts b/src/library/libraryLayout.test.mts new file mode 100644 index 0000000..d495b4c --- /dev/null +++ b/src/library/libraryLayout.test.mts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + DEFAULT_LIBRARY_LAYOUT, + LIBRARY_LAYOUT_OPTIONS, + libraryLayoutColumns, + libraryLayoutLabel, + parseLibraryLayout, + type LibraryLayout, +} from './libraryLayout.ts'; + +test('defaults missing and invalid library layouts to the current three-column grid', () => { + assert.equal(parseLibraryLayout(null), DEFAULT_LIBRARY_LAYOUT); + assert.equal(parseLibraryLayout(''), DEFAULT_LIBRARY_LAYOUT); + assert.equal(parseLibraryLayout('grid'), DEFAULT_LIBRARY_LAYOUT); + assert.equal(parseLibraryLayout('grid-5'), DEFAULT_LIBRARY_LAYOUT); +}); + +test('restores every supported persisted library layout', () => { + for (const option of LIBRARY_LAYOUT_OPTIONS) { + assert.equal(parseLibraryLayout(option.value), option.value); + } +}); + +test('maps list and grid layouts to their visible column counts and labels', () => { + const expectations: Array<[LibraryLayout, number, string]> = [ + ['list', 1, 'List'], + ['grid-2', 2, 'Large grid'], + ['grid-3', 3, 'Medium grid'], + ['grid-4', 4, 'Compact grid'], + ]; + + for (const [layout, columns, label] of expectations) { + assert.equal(libraryLayoutColumns(layout), columns); + assert.equal(libraryLayoutLabel(layout), label); + } +}); diff --git a/src/library/libraryLayout.ts b/src/library/libraryLayout.ts new file mode 100644 index 0000000..006cd95 --- /dev/null +++ b/src/library/libraryLayout.ts @@ -0,0 +1,62 @@ +export type LibraryLayout = 'list' | 'grid-2' | 'grid-3' | 'grid-4'; + +export interface LibraryLayoutOption { + value: LibraryLayout; + label: string; + subtitle: string; + icon: 'list-outline' | 'grid-outline'; + columns: number; +} + +export const DEFAULT_LIBRARY_LAYOUT: LibraryLayout = 'grid-3'; + +export const LIBRARY_LAYOUT_OPTIONS: readonly LibraryLayoutOption[] = [ + { + value: 'list', + label: 'List', + subtitle: 'Compact rows', + icon: 'list-outline', + columns: 1, + }, + { + value: 'grid-2', + label: 'Large grid', + subtitle: '2 columns', + icon: 'grid-outline', + columns: 2, + }, + { + value: 'grid-3', + label: 'Medium grid', + subtitle: '3 columns', + icon: 'grid-outline', + columns: 3, + }, + { + value: 'grid-4', + label: 'Compact grid', + subtitle: '4 columns', + icon: 'grid-outline', + columns: 4, + }, +] as const; + +const LIBRARY_LAYOUTS = new Set( + LIBRARY_LAYOUT_OPTIONS.map((option) => option.value) +); + +export function parseLibraryLayout(value: string | null): LibraryLayout { + return value !== null && LIBRARY_LAYOUTS.has(value as LibraryLayout) + ? (value as LibraryLayout) + : DEFAULT_LIBRARY_LAYOUT; +} + +export function libraryLayoutColumns(layout: LibraryLayout): number { + return LIBRARY_LAYOUT_OPTIONS.find((option) => option.value === layout)?.columns + ?? LIBRARY_LAYOUT_OPTIONS.find((option) => option.value === DEFAULT_LIBRARY_LAYOUT)!.columns; +} + +export function libraryLayoutLabel(layout: LibraryLayout): string { + return LIBRARY_LAYOUT_OPTIONS.find((option) => option.value === layout)?.label + ?? LIBRARY_LAYOUT_OPTIONS.find((option) => option.value === DEFAULT_LIBRARY_LAYOUT)!.label; +} diff --git a/src/stores/libraryStore.ts b/src/stores/libraryStore.ts index 16c2df5..153ee2a 100644 --- a/src/stores/libraryStore.ts +++ b/src/stores/libraryStore.ts @@ -20,6 +20,11 @@ import { endScanService, reportScanProgress } from '@/library/scanService'; import { ALBUM_SORT_LABELS, type AlbumSort } from '@/lib/albumSort'; import { ARTIST_SORT_LABELS, type ArtistSort } from '@/lib/artistSort'; import { TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort'; +import { + DEFAULT_LIBRARY_LAYOUT, + parseLibraryLayout, + type LibraryLayout, +} from '@/library/libraryLayout'; import { useSettingsStore } from './settingsStore'; type ViewMode = 'tracks' | 'albums' | 'artists' | 'playlists' | 'folders'; @@ -28,6 +33,8 @@ const VIEW_MODE_KEY = 'library_view_mode'; const TRACK_SORT_KEY = 'library_track_sort'; const ALBUM_SORT_KEY = 'library_album_sort'; const ARTIST_SORT_KEY = 'library_artist_sort'; +const ALBUM_LAYOUT_KEY = 'library_album_layout'; +const ARTIST_LAYOUT_KEY = 'library_artist_layout'; const INCLUDE_COLLAB_ARTISTS_KEY = 'library_include_collab_artists'; const PAGE_SIZE = 200; @@ -83,6 +90,8 @@ interface LibraryStore { trackSort: TrackSort; albumSort: AlbumSort; artistSort: ArtistSort; + albumLayout: LibraryLayout; + artistLayout: LibraryLayout; includeCollabArtists: boolean; isScanning: boolean; isCancelling: boolean; @@ -120,6 +129,8 @@ interface LibraryStore { setTrackSort: (sort: TrackSort) => void; setAlbumSort: (sort: AlbumSort) => void; setArtistSort: (sort: ArtistSort) => void; + setAlbumLayout: (layout: LibraryLayout) => void; + setArtistLayout: (layout: LibraryLayout) => void; setIncludeCollabArtists: (include: boolean) => void; addFolder: () => Promise; removeFolder: (folderId: number) => Promise; @@ -407,6 +418,8 @@ export const useLibraryStore = create((set, get) => { trackSort: 'title', albumSort: 'name', artistSort: 'name', + albumLayout: DEFAULT_LIBRARY_LAYOUT, + artistLayout: DEFAULT_LIBRARY_LAYOUT, includeCollabArtists: false, isScanning: false, isCancelling: false, @@ -441,6 +454,8 @@ export const useLibraryStore = create((set, get) => { TRACK_SORT_KEY, ALBUM_SORT_KEY, ARTIST_SORT_KEY, + ALBUM_LAYOUT_KEY, + ARTIST_LAYOUT_KEY, INCLUDE_COLLAB_ARTISTS_KEY, ]); const viewMode = parseViewMode(values[VIEW_MODE_KEY] ?? null); @@ -452,6 +467,8 @@ export const useLibraryStore = create((set, get) => { ...(trackSort ? { trackSort } : {}), ...(albumSort ? { albumSort } : {}), ...(artistSort ? { artistSort } : {}), + albumLayout: parseLibraryLayout(values[ALBUM_LAYOUT_KEY] ?? null), + artistLayout: parseLibraryLayout(values[ARTIST_LAYOUT_KEY] ?? null), includeCollabArtists: values[INCLUDE_COLLAB_ARTISTS_KEY] === 'true', }); @@ -953,6 +970,18 @@ export const useLibraryStore = create((set, get) => { void resetSectionAnchors(); }, + setAlbumLayout: (albumLayout) => { + if (get().albumLayout === albumLayout) return; + set({ albumLayout, jumpAnchorIndex: 0 }); + persistSetting(ALBUM_LAYOUT_KEY, albumLayout); + }, + + setArtistLayout: (artistLayout) => { + if (get().artistLayout === artistLayout) return; + set({ artistLayout, jumpAnchorIndex: 0 }); + persistSetting(ARTIST_LAYOUT_KEY, artistLayout); + }, + setIncludeCollabArtists: (includeCollabArtists) => { anchorGeneration += 1; set({