mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-17 19:24:22 +02:00
grid and list view customizability
This commit is contained in:
@@ -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: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: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: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: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: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",
|
"test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const TEST_SCRIPTS = [
|
|||||||
'test:now-playing-layout',
|
'test:now-playing-layout',
|
||||||
'test:memory-lifecycle',
|
'test:memory-lifecycle',
|
||||||
'test:ui-navigation',
|
'test:ui-navigation',
|
||||||
|
'test:library-layout',
|
||||||
'test:haptics',
|
'test:haptics',
|
||||||
'test:home-greeting',
|
'test:home-greeting',
|
||||||
'test:session',
|
'test:session',
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { Text } from '@/components/Text';
|
|||||||
import { ViewModeSwitcher } from '@/components/library/ViewModeSwitcher';
|
import { ViewModeSwitcher } from '@/components/library/ViewModeSwitcher';
|
||||||
import { AlbumGridItem } from '@/components/library/AlbumGridItem';
|
import { AlbumGridItem } from '@/components/library/AlbumGridItem';
|
||||||
import { ArtistGridItem } from '@/components/library/ArtistGridItem';
|
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 { TrackRow } from '@/components/library/TrackRow';
|
||||||
import { FoldersView } from '@/components/library/FoldersView';
|
import { FoldersView } from '@/components/library/FoldersView';
|
||||||
import { PlaylistsView } from '@/components/library/PlaylistsView';
|
import { PlaylistsView } from '@/components/library/PlaylistsView';
|
||||||
@@ -64,6 +66,12 @@ import {
|
|||||||
ARTIST_SORT_LABELS,
|
ARTIST_SORT_LABELS,
|
||||||
type ArtistSort
|
type ArtistSort
|
||||||
} from '@/lib/artistSort';
|
} from '@/lib/artistSort';
|
||||||
|
import {
|
||||||
|
LIBRARY_LAYOUT_OPTIONS,
|
||||||
|
libraryLayoutColumns,
|
||||||
|
libraryLayoutLabel,
|
||||||
|
type LibraryLayout,
|
||||||
|
} from '@/library/libraryLayout';
|
||||||
import type {
|
import type {
|
||||||
DbTrack
|
DbTrack
|
||||||
} from '@/types/library';
|
} from '@/types/library';
|
||||||
@@ -95,6 +103,10 @@ export default function LibraryScreen() {
|
|||||||
const setAlbumSort = useLibraryStore((s) => s.setAlbumSort);
|
const setAlbumSort = useLibraryStore((s) => s.setAlbumSort);
|
||||||
const artistSort = useLibraryStore((s) => s.artistSort);
|
const artistSort = useLibraryStore((s) => s.artistSort);
|
||||||
const setArtistSort = useLibraryStore((s) => s.setArtistSort);
|
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 loadNextTracks = useLibraryStore((s) => s.loadNextTracks);
|
||||||
const loadNextAlbums = useLibraryStore((s) => s.loadNextAlbums);
|
const loadNextAlbums = useLibraryStore((s) => s.loadNextAlbums);
|
||||||
const loadNextArtists = useLibraryStore((s) => s.loadNextArtists);
|
const loadNextArtists = useLibraryStore((s) => s.loadNextArtists);
|
||||||
@@ -117,6 +129,7 @@ export default function LibraryScreen() {
|
|||||||
|
|
||||||
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
|
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
|
||||||
const [sortSheetOpen, setSortSheetOpen] = useState(false);
|
const [sortSheetOpen, setSortSheetOpen] = useState(false);
|
||||||
|
const [layoutSheetOpen, setLayoutSheetOpen] = useState(false);
|
||||||
const [selectMode, setSelectMode] = useState(false);
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set());
|
||||||
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||||
@@ -290,6 +303,20 @@ export default function LibraryScreen() {
|
|||||||
selected: option === artistSort,
|
selected: option === artistSort,
|
||||||
onSelect: () => setArtistSort(option),
|
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 (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
@@ -319,6 +346,7 @@ export default function LibraryScreen() {
|
|||||||
value={viewMode}
|
value={viewMode}
|
||||||
onChange={(mode) => {
|
onChange={(mode) => {
|
||||||
if (selectMode) exitSelection();
|
if (selectMode) exitSelection();
|
||||||
|
setLayoutSheetOpen(false);
|
||||||
scrollTop.setScrollAtTop(true);
|
scrollTop.setScrollAtTop(true);
|
||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
}}
|
}}
|
||||||
@@ -343,23 +371,41 @@ export default function LibraryScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
) : sortable ? (
|
) : sortable ? (
|
||||||
<Pressable android_ripple={ripple.bounded}
|
<View style={styles.controlsRow}>
|
||||||
style={styles.sortTrigger}
|
<Pressable android_ripple={ripple.bounded}
|
||||||
onPress={() => setSortSheetOpen(true)}
|
style={styles.sortTrigger}
|
||||||
accessibilityRole="button"
|
onPress={() => setSortSheetOpen(true)}
|
||||||
accessibilityLabel={`Sort by ${sortLabel}`}
|
accessibilityRole="button"
|
||||||
>
|
accessibilityLabel={`Sort by ${sortLabel}`}
|
||||||
<Ionicons name="swap-vertical" size={14} color={colors.textSecondary} />
|
>
|
||||||
<Text variant="label">{sortLabel}</Text>
|
<Ionicons name="swap-vertical" size={14} color={colors.textSecondary} />
|
||||||
</Pressable>
|
<Text variant="label">{sortLabel}</Text>
|
||||||
|
</Pressable>
|
||||||
|
{activeLayout && activeLayoutLabel ? (
|
||||||
|
<Pressable
|
||||||
|
android_ripple={ripple.bounded}
|
||||||
|
style={styles.layoutTrigger}
|
||||||
|
hitSlop={8}
|
||||||
|
onPress={() => setLayoutSheetOpen(true)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Change ${viewMode} layout. Current layout: ${activeLayoutLabel}`}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={activeLayout === 'list' ? 'list-outline' : 'grid-outline'}
|
||||||
|
size={18}
|
||||||
|
color={colors.textSecondary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View style={styles.listArea}>
|
<View style={styles.listArea}>
|
||||||
{viewMode === 'albums' ? (
|
{viewMode === 'albums' ? (
|
||||||
<FlashList
|
<FlashList
|
||||||
key={`albums-${albumSort}-${sectionJumpRevision}`}
|
key={`albums-${albumSort}-${albumLayout}-${sectionJumpRevision}`}
|
||||||
data={sortedAlbums}
|
data={sortedAlbums}
|
||||||
numColumns={3}
|
numColumns={libraryLayoutColumns(albumLayout)}
|
||||||
keyExtractor={(album) => album.identity_key}
|
keyExtractor={(album) => album.identity_key}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
overScrollMode="never"
|
overScrollMode="never"
|
||||||
@@ -373,8 +419,8 @@ export default function LibraryScreen() {
|
|||||||
onStartReachedThreshold={START_REACHED_THRESHOLD}
|
onStartReachedThreshold={START_REACHED_THRESHOLD}
|
||||||
ListFooterComponent={albumNextCursor ? listFooter : null}
|
ListFooterComponent={albumNextCursor ? listFooter : null}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View style={styles.gridCell}>
|
albumLayout === 'list' ? (
|
||||||
<AlbumGridItem
|
<AlbumRow
|
||||||
album={item}
|
album={item}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push({
|
router.push({
|
||||||
@@ -383,16 +429,28 @@ export default function LibraryScreen() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
) : (
|
||||||
|
<View style={styles.gridCell}>
|
||||||
|
<AlbumGridItem
|
||||||
|
album={item}
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '/library/album/[key]',
|
||||||
|
params: { key: item.identity_key },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{viewMode === 'artists' ? (
|
{viewMode === 'artists' ? (
|
||||||
<FlashList
|
<FlashList
|
||||||
key={`artists-${artistSort}-${sectionJumpRevision}`}
|
key={`artists-${artistSort}-${artistLayout}-${sectionJumpRevision}`}
|
||||||
data={sortedArtists}
|
data={sortedArtists}
|
||||||
numColumns={3}
|
numColumns={libraryLayoutColumns(artistLayout)}
|
||||||
keyExtractor={(artist) => artist.artist}
|
keyExtractor={(artist) => artist.artist}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
overScrollMode="never"
|
overScrollMode="never"
|
||||||
@@ -406,8 +464,8 @@ export default function LibraryScreen() {
|
|||||||
onStartReachedThreshold={START_REACHED_THRESHOLD}
|
onStartReachedThreshold={START_REACHED_THRESHOLD}
|
||||||
ListFooterComponent={artistNextCursor ? listFooter : null}
|
ListFooterComponent={artistNextCursor ? listFooter : null}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View style={styles.gridCell}>
|
artistLayout === 'list' ? (
|
||||||
<ArtistGridItem
|
<ArtistRow
|
||||||
artist={item}
|
artist={item}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push({
|
router.push({
|
||||||
@@ -416,7 +474,19 @@ export default function LibraryScreen() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
) : (
|
||||||
|
<View style={styles.gridCell}>
|
||||||
|
<ArtistGridItem
|
||||||
|
artist={item}
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '/library/artist/[name]',
|
||||||
|
params: { name: item.artist },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -516,6 +586,24 @@ export default function LibraryScreen() {
|
|||||||
))}
|
))}
|
||||||
</AppSheet>
|
</AppSheet>
|
||||||
) : null}
|
) : null}
|
||||||
|
{layoutSheetOpen && activeLayout ? (
|
||||||
|
<AppSheet onClose={() => setLayoutSheetOpen(false)}>
|
||||||
|
<AppSheetSection label={layoutSheetLabel} />
|
||||||
|
{LIBRARY_LAYOUT_OPTIONS.map((option) => (
|
||||||
|
<AppSheetItem
|
||||||
|
key={option.value}
|
||||||
|
label={option.label}
|
||||||
|
subtitle={option.subtitle}
|
||||||
|
icon={option.icon}
|
||||||
|
selected={option.value === activeLayout}
|
||||||
|
onPress={() => {
|
||||||
|
setActiveLayout(option.value);
|
||||||
|
setLayoutSheetOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AppSheet>
|
||||||
|
) : null}
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -537,13 +625,21 @@ const styles = StyleSheet.create({
|
|||||||
error: {
|
error: {
|
||||||
marginBottom: spacing.md,
|
marginBottom: spacing.md,
|
||||||
},
|
},
|
||||||
|
controlsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: spacing.md,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
sortTrigger: {
|
sortTrigger: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
alignSelf: 'flex-end',
|
|
||||||
paddingVertical: spacing.xs,
|
paddingVertical: spacing.xs,
|
||||||
marginBottom: spacing.xs,
|
},
|
||||||
|
layoutTrigger: {
|
||||||
|
padding: spacing.xs,
|
||||||
},
|
},
|
||||||
// Same vertical rhythm as sortTrigger so entering selection doesn't shift the list.
|
// Same vertical rhythm as sortTrigger so entering selection doesn't shift the list.
|
||||||
selectionHeader: {
|
selectionHeader: {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
|||||||
import { albumArtworkSource } from '@/library/artwork';
|
import { albumArtworkSource } from '@/library/artwork';
|
||||||
import type { Album } from '@/types/library';
|
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 }) {
|
export function AlbumRow({ album, onPress }: { album: Album; onPress: () => void }) {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
@@ -29,6 +29,8 @@ export function AlbumRow({ album, onPress }: { album: Album; onPress: () => void
|
|||||||
source={{ uri: artUri }}
|
source={{ uri: artUri }}
|
||||||
style={styles.artImage}
|
style={styles.artImage}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
|
recyclingKey={album.identity_key}
|
||||||
|
transition={null}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Ionicons name="disc-outline" size={20} color={colors.textTertiary} />
|
<Ionicons name="disc-outline" size={20} color={colors.textTertiary} />
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
|||||||
import { artworkUri } from '@/library/artwork';
|
import { artworkUri } from '@/library/artwork';
|
||||||
import type { Artist } from '@/types/library';
|
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 }) {
|
export function ArtistGridItem({ artist, onPress }: { artist: Artist; onPress: () => void }) {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export function ArtistRow({ artist, onPress }: { artist: Artist; onPress: () =>
|
|||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const ripple = useRipple();
|
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 (
|
return (
|
||||||
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} style={styles.row} onPress={onPress} accessibilityRole="button">
|
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} style={styles.row} onPress={onPress} accessibilityRole="button">
|
||||||
<View style={styles.art}>
|
<View style={styles.art}>
|
||||||
@@ -27,6 +29,8 @@ export function ArtistRow({ artist, onPress }: { artist: Artist; onPress: () =>
|
|||||||
source={{ uri: artworkUri(artist.artwork_hash) }}
|
source={{ uri: artworkUri(artist.artwork_hash) }}
|
||||||
style={styles.artImage}
|
style={styles.artImage}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
|
recyclingKey={artist.artist}
|
||||||
|
transition={null}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Ionicons name="person" size={20} color={colors.textTertiary} />
|
<Ionicons name="person" size={20} color={colors.textTertiary} />
|
||||||
@@ -36,8 +40,8 @@ export function ArtistRow({ artist, onPress }: { artist: Artist; onPress: () =>
|
|||||||
<Text variant="body" numberOfLines={1}>
|
<Text variant="body" numberOfLines={1}>
|
||||||
{artist.artist}
|
{artist.artist}
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="label">
|
<Text variant="label" numberOfLines={1}>
|
||||||
{artist.track_count} {artist.track_count === 1 ? 'track' : 'tracks'}
|
{albums} · {tracks}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Ionicons name="chevron-forward" size={16} color={colors.textTertiary} />
|
<Ionicons name="chevron-forward" size={16} color={colors.textTertiary} />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<LibraryLayout>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@ import { endScanService, reportScanProgress } from '@/library/scanService';
|
|||||||
import { ALBUM_SORT_LABELS, type AlbumSort } from '@/lib/albumSort';
|
import { ALBUM_SORT_LABELS, type AlbumSort } from '@/lib/albumSort';
|
||||||
import { ARTIST_SORT_LABELS, type ArtistSort } from '@/lib/artistSort';
|
import { ARTIST_SORT_LABELS, type ArtistSort } from '@/lib/artistSort';
|
||||||
import { TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort';
|
import { TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort';
|
||||||
|
import {
|
||||||
|
DEFAULT_LIBRARY_LAYOUT,
|
||||||
|
parseLibraryLayout,
|
||||||
|
type LibraryLayout,
|
||||||
|
} from '@/library/libraryLayout';
|
||||||
import { useSettingsStore } from './settingsStore';
|
import { useSettingsStore } from './settingsStore';
|
||||||
|
|
||||||
type ViewMode = 'tracks' | 'albums' | 'artists' | 'playlists' | 'folders';
|
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 TRACK_SORT_KEY = 'library_track_sort';
|
||||||
const ALBUM_SORT_KEY = 'library_album_sort';
|
const ALBUM_SORT_KEY = 'library_album_sort';
|
||||||
const ARTIST_SORT_KEY = 'library_artist_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 INCLUDE_COLLAB_ARTISTS_KEY = 'library_include_collab_artists';
|
||||||
const PAGE_SIZE = 200;
|
const PAGE_SIZE = 200;
|
||||||
|
|
||||||
@@ -83,6 +90,8 @@ interface LibraryStore {
|
|||||||
trackSort: TrackSort;
|
trackSort: TrackSort;
|
||||||
albumSort: AlbumSort;
|
albumSort: AlbumSort;
|
||||||
artistSort: ArtistSort;
|
artistSort: ArtistSort;
|
||||||
|
albumLayout: LibraryLayout;
|
||||||
|
artistLayout: LibraryLayout;
|
||||||
includeCollabArtists: boolean;
|
includeCollabArtists: boolean;
|
||||||
isScanning: boolean;
|
isScanning: boolean;
|
||||||
isCancelling: boolean;
|
isCancelling: boolean;
|
||||||
@@ -120,6 +129,8 @@ interface LibraryStore {
|
|||||||
setTrackSort: (sort: TrackSort) => void;
|
setTrackSort: (sort: TrackSort) => void;
|
||||||
setAlbumSort: (sort: AlbumSort) => void;
|
setAlbumSort: (sort: AlbumSort) => void;
|
||||||
setArtistSort: (sort: ArtistSort) => void;
|
setArtistSort: (sort: ArtistSort) => void;
|
||||||
|
setAlbumLayout: (layout: LibraryLayout) => void;
|
||||||
|
setArtistLayout: (layout: LibraryLayout) => void;
|
||||||
setIncludeCollabArtists: (include: boolean) => void;
|
setIncludeCollabArtists: (include: boolean) => void;
|
||||||
addFolder: () => Promise<void>;
|
addFolder: () => Promise<void>;
|
||||||
removeFolder: (folderId: number) => Promise<void>;
|
removeFolder: (folderId: number) => Promise<void>;
|
||||||
@@ -407,6 +418,8 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
|
|||||||
trackSort: 'title',
|
trackSort: 'title',
|
||||||
albumSort: 'name',
|
albumSort: 'name',
|
||||||
artistSort: 'name',
|
artistSort: 'name',
|
||||||
|
albumLayout: DEFAULT_LIBRARY_LAYOUT,
|
||||||
|
artistLayout: DEFAULT_LIBRARY_LAYOUT,
|
||||||
includeCollabArtists: false,
|
includeCollabArtists: false,
|
||||||
isScanning: false,
|
isScanning: false,
|
||||||
isCancelling: false,
|
isCancelling: false,
|
||||||
@@ -441,6 +454,8 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
|
|||||||
TRACK_SORT_KEY,
|
TRACK_SORT_KEY,
|
||||||
ALBUM_SORT_KEY,
|
ALBUM_SORT_KEY,
|
||||||
ARTIST_SORT_KEY,
|
ARTIST_SORT_KEY,
|
||||||
|
ALBUM_LAYOUT_KEY,
|
||||||
|
ARTIST_LAYOUT_KEY,
|
||||||
INCLUDE_COLLAB_ARTISTS_KEY,
|
INCLUDE_COLLAB_ARTISTS_KEY,
|
||||||
]);
|
]);
|
||||||
const viewMode = parseViewMode(values[VIEW_MODE_KEY] ?? null);
|
const viewMode = parseViewMode(values[VIEW_MODE_KEY] ?? null);
|
||||||
@@ -452,6 +467,8 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
|
|||||||
...(trackSort ? { trackSort } : {}),
|
...(trackSort ? { trackSort } : {}),
|
||||||
...(albumSort ? { albumSort } : {}),
|
...(albumSort ? { albumSort } : {}),
|
||||||
...(artistSort ? { artistSort } : {}),
|
...(artistSort ? { artistSort } : {}),
|
||||||
|
albumLayout: parseLibraryLayout(values[ALBUM_LAYOUT_KEY] ?? null),
|
||||||
|
artistLayout: parseLibraryLayout(values[ARTIST_LAYOUT_KEY] ?? null),
|
||||||
includeCollabArtists: values[INCLUDE_COLLAB_ARTISTS_KEY] === 'true',
|
includeCollabArtists: values[INCLUDE_COLLAB_ARTISTS_KEY] === 'true',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -953,6 +970,18 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
|
|||||||
void resetSectionAnchors();
|
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) => {
|
setIncludeCollabArtists: (includeCollabArtists) => {
|
||||||
anchorGeneration += 1;
|
anchorGeneration += 1;
|
||||||
set({
|
set({
|
||||||
|
|||||||
Reference in New Issue
Block a user