m2, library ux

This commit is contained in:
Boof2015
2026-06-13 13:13:53 -04:00
parent 23be9875e8
commit 3d30a09713
24 changed files with 2397 additions and 39 deletions
+65
View File
@@ -0,0 +1,65 @@
import { View, Pressable, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { artworkUri } from '@/library/artwork';
import type { Album } from '@/types/library';
/** Compact album list row (search results) — the grid uses AlbumGridItem. */
export function AlbumRow({ album, onPress }: { album: Album; onPress: () => void }) {
return (
<Pressable style={styles.row} onPress={onPress} accessibilityRole="button">
<View style={styles.art}>
{album.artwork_hash ? (
<Image
source={{ uri: artworkUri(album.artwork_hash) }}
style={styles.artImage}
contentFit="cover"
/>
) : (
<Ionicons name="disc-outline" size={20} color={colors.textTertiary} />
)}
</View>
<View style={styles.meta}>
<Text variant="body" numberOfLines={1}>
{album.album}
</Text>
<Text variant="label" numberOfLines={1}>
{album.artist}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textTertiary} />
</Pressable>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.sm + 2,
gap: spacing.md,
borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
},
art: {
width: 44,
height: 44,
borderRadius: radius.sm,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
artImage: {
width: '100%',
height: '100%',
},
meta: {
flex: 1,
gap: 2,
},
});
+95
View File
@@ -0,0 +1,95 @@
import { View, Pressable, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { artworkUri } from '@/library/artwork';
export function PlaylistRow({
name,
trackCount,
missingCount = 0,
coverHash,
pinned = false,
onPress,
onLongPress,
}: {
name: string;
trackCount: number;
missingCount?: number;
coverHash: string | null;
/** Favorites pseudo-playlist: heart cover instead of artwork/logo. */
pinned?: boolean;
onPress: () => void;
onLongPress?: () => void;
}) {
return (
<Pressable
style={styles.row}
onPress={onPress}
onLongPress={onLongPress}
accessibilityRole="button"
>
<View style={styles.cover}>
{coverHash ? (
<Image
source={{ uri: artworkUri(coverHash) }}
style={styles.coverImage}
contentFit="cover"
transition={120}
/>
) : (
<Ionicons
name={pinned ? 'heart' : 'musical-notes-outline'}
size={20}
color={pinned ? colors.accent : colors.textTertiary}
/>
)}
</View>
<View style={styles.meta}>
<Text variant="body" numberOfLines={1}>
{name}
</Text>
<Text variant="label" numberOfLines={1}>
{`${trackCount} ${trackCount === 1 ? 'track' : 'tracks'}`}
{missingCount > 0 ? (
<Text variant="label" color={colors.warning}>
{` · ${missingCount} missing`}
</Text>
) : null}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.sm + 2,
borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
},
cover: {
width: 48,
height: 48,
borderRadius: radius.sm,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
coverImage: {
width: '100%',
height: '100%',
},
meta: {
flex: 1,
gap: 2,
},
});
+219
View File
@@ -0,0 +1,219 @@
import { useState } from 'react';
import { View, Pressable, StyleSheet, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useRouter } from 'expo-router';
import { Text } from '@/components/Text';
import { ActionSheet } from '@/components/sheets/ActionSheet';
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
import { PlaylistRow } from '@/components/library/PlaylistRow';
import { colors, radius, spacing } from '@/theme';
import { usePlaylistStore } from '@/stores/playlistStore';
import type { Playlist } from '@/types/playlist';
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** "content://…/Test.m3u8" -> "Test.m3u8" for the export confirmation. */
function fileDisplayName(fileUri: string): string {
const decoded = decodeURIComponent(fileUri.split('/').pop() ?? fileUri);
return decoded.split(/[/:]/).pop() || fileUri;
}
type Prompt = { kind: 'create' } | { kind: 'rename'; playlist: Playlist } | null;
export function PlaylistsView() {
const router = useRouter();
const playlists = usePlaylistStore((s) => s.playlists);
const favoriteCount = usePlaylistStore((s) => s.favoriteTracks.length);
const createPlaylist = usePlaylistStore((s) => s.createPlaylist);
const renamePlaylist = usePlaylistStore((s) => s.renamePlaylist);
const deletePlaylist = usePlaylistStore((s) => s.deletePlaylist);
const importM3u = usePlaylistStore((s) => s.importM3u);
const exportM3u = usePlaylistStore((s) => s.exportM3u);
const [prompt, setPrompt] = useState<Prompt>(null);
const [menuFor, setMenuFor] = useState<Playlist | 'favorites' | null>(null);
const handleExport = async (target: number | 'favorites') => {
try {
const result = await exportM3u(target);
if (result) {
Alert.alert(
'Playlist exported',
`Wrote ${result.entryCount} ${result.entryCount === 1 ? 'entry' : 'entries'} to "${fileDisplayName(result.fileUri)}".`
);
}
} catch (err) {
Alert.alert('Export failed', errorMessage(err));
}
};
const handleImport = async () => {
try {
const summary = await importM3u();
if (!summary) return;
const matched = summary.matchedByPath + summary.matchedByMetadata;
const parts = [`${matched} of ${summary.total} entries matched the library`];
if (summary.missing > 0) parts.push(`${summary.missing} kept as missing`);
if (summary.ambiguous > 0) parts.push(`${summary.ambiguous} ambiguous`);
Alert.alert(`Imported "${summary.name}"`, `${parts.join(', ')}.`);
} catch (err) {
Alert.alert('Import failed', errorMessage(err));
}
};
const confirmDelete = (playlist: Playlist) => {
Alert.alert('Delete playlist?', `"${playlist.name}" will be deleted. Tracks are not touched.`, [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Delete', style: 'destructive', onPress: () => void deletePlaylist(playlist.id) },
]);
};
const menuItems =
menuFor === 'favorites'
? [
{
key: 'export',
label: 'Export M3U',
icon: 'download-outline' as const,
onPress: () => {
setMenuFor(null);
void handleExport('favorites');
},
},
]
: menuFor
? [
{
key: 'rename',
label: 'Rename…',
icon: 'pencil-outline' as const,
onPress: () => {
setPrompt({ kind: 'rename', playlist: menuFor });
setMenuFor(null);
},
},
{
key: 'export',
label: 'Export M3U',
icon: 'download-outline' as const,
onPress: () => {
const id = menuFor.id;
setMenuFor(null);
void handleExport(id);
},
},
{
key: 'delete',
label: 'Delete…',
icon: 'trash-outline' as const,
destructive: true,
onPress: () => {
const playlist = menuFor;
setMenuFor(null);
confirmDelete(playlist);
},
},
]
: [];
return (
<View style={styles.container}>
<FlashList
data={playlists}
keyExtractor={(playlist) => String(playlist.id)}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<PlaylistRow
name="Favorites"
trackCount={favoriteCount}
coverHash={null}
pinned
onPress={() => router.push('/library/playlist/favorites')}
onLongPress={() => setMenuFor('favorites')}
/>
}
renderItem={({ item }) => (
<PlaylistRow
name={item.name}
trackCount={item.track_count}
missingCount={item.missing_track_count}
coverHash={item.auto_cover_hash}
onPress={() => router.push(`/library/playlist/${item.id}`)}
onLongPress={() => setMenuFor(item)}
/>
)}
ListFooterComponent={
<View style={styles.actions}>
<Pressable
style={styles.action}
onPress={() => setPrompt({ kind: 'create' })}
accessibilityRole="button"
>
<Ionicons name="add" size={18} color={colors.accent} />
<Text variant="body" color={colors.accent}>
New playlist
</Text>
</Pressable>
<Pressable
style={styles.action}
onPress={() => void handleImport()}
accessibilityRole="button"
>
<Ionicons name="document-text-outline" size={16} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Import M3U
</Text>
</Pressable>
</View>
}
/>
<ActionSheet
visible={menuFor !== null}
title={menuFor === 'favorites' ? 'Favorites' : (menuFor?.name ?? '')}
items={menuItems}
onClose={() => setMenuFor(null)}
/>
<TextPromptModal
visible={prompt !== null}
title={prompt?.kind === 'rename' ? 'Rename playlist' : 'New playlist'}
placeholder="Playlist name"
initialValue={prompt?.kind === 'rename' ? prompt.playlist.name : ''}
submitLabel={prompt?.kind === 'rename' ? 'Rename' : 'Create'}
onSubmit={(name) => {
if (prompt?.kind === 'rename') {
void renamePlaylist(prompt.playlist.id, name);
} else {
void createPlaylist(name);
}
setPrompt(null);
}}
onClose={() => setPrompt(null)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
actions: {
flexDirection: 'row',
gap: spacing.md,
marginTop: spacing.lg,
},
action: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.pill,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
});
@@ -0,0 +1,101 @@
import { useState } from 'react';
import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet';
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
import { usePlaylistStore } from '@/stores/playlistStore';
import type { DbTrack } from '@/types/library';
interface TrackActionsSheetProps {
/** null = hidden. */
track: DbTrack | null;
onClose: () => void;
/** Screen-specific extras (e.g. playlist detail: remove / move). Handlers should close. */
extraItems?: ActionSheetItem[];
}
/** Long-press track menu: favorite toggle, add-to-playlist (pick or create), extras. */
export function TrackActionsSheet(props: TrackActionsSheetProps) {
// Mount fresh per track so the step state resets.
if (!props.track) return null;
return <TrackActionsSheetInner {...props} track={props.track} />;
}
function TrackActionsSheetInner({
track,
onClose,
extraItems = [],
}: TrackActionsSheetProps & { track: DbTrack }) {
const [step, setStep] = useState<'menu' | 'pickPlaylist' | 'newPlaylist'>('menu');
const playlists = usePlaylistStore((s) => s.playlists);
const isFavorite = usePlaylistStore((s) => s.favoritePaths.has(track.path));
const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite);
const addTracksToPlaylist = usePlaylistStore((s) => s.addTracksToPlaylist);
const createPlaylist = usePlaylistStore((s) => s.createPlaylist);
const menuItems: ActionSheetItem[] = [
{
key: 'favorite',
label: isFavorite ? 'Remove from favorites' : 'Add to favorites',
icon: isFavorite ? 'heart-dislike-outline' : 'heart-outline',
onPress: () => {
void toggleFavorite(track);
onClose();
},
},
{
key: 'add-to-playlist',
label: 'Add to playlist…',
icon: 'add-circle-outline',
onPress: () => setStep('pickPlaylist'),
},
...extraItems,
];
const pickItems: ActionSheetItem[] = [
...playlists.map((playlist) => ({
key: `playlist-${playlist.id}`,
label: playlist.name,
icon: 'musical-notes-outline' as const,
onPress: () => {
void addTracksToPlaylist(playlist.id, [track]);
onClose();
},
})),
{
key: 'new-playlist',
label: 'New playlist…',
icon: 'add',
onPress: () => setStep('newPlaylist'),
},
];
return (
<>
<ActionSheet
visible={step === 'menu'}
title={track.title}
items={menuItems}
onClose={onClose}
/>
<ActionSheet
visible={step === 'pickPlaylist'}
title="Add to playlist"
items={pickItems}
onClose={onClose}
/>
<TextPromptModal
visible={step === 'newPlaylist'}
title="New playlist"
placeholder="Playlist name"
submitLabel="Create"
onSubmit={(name) => {
void (async () => {
const playlist = await createPlaylist(name);
await addTracksToPlaylist(playlist.id, [track]);
})();
onClose();
}}
onClose={onClose}
/>
</>
);
}
+9 -1
View File
@@ -8,17 +8,25 @@ import type { DbTrack } from '@/types/library';
export function TrackRow({
track,
onPress,
onLongPress,
showArtist = true,
active = false,
}: {
track: DbTrack;
onPress: () => void;
/** Opens the track actions sheet where wired. */
onLongPress?: () => void;
/** Hide on album detail where every row shares the artist. */
showArtist?: boolean;
active?: boolean;
}) {
return (
<Pressable style={styles.row} onPress={onPress} accessibilityRole="button">
<Pressable
style={styles.row}
onPress={onPress}
onLongPress={onLongPress}
accessibilityRole="button"
>
{track.track_number != null && !showArtist ? (
<Text variant="mono" style={styles.trackNumber}>
{track.track_number}
+5 -4
View File
@@ -1,13 +1,14 @@
import { View, Pressable, StyleSheet } from 'react-native';
import { Pressable, ScrollView, StyleSheet } from 'react-native';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
export type LibraryViewMode = 'albums' | 'artists' | 'tracks' | 'folders';
export type LibraryViewMode = 'albums' | 'artists' | 'tracks' | 'playlists' | 'folders';
const MODES: { key: LibraryViewMode; label: string }[] = [
{ key: 'albums', label: 'Albums' },
{ key: 'artists', label: 'Artists' },
{ key: 'tracks', label: 'Tracks' },
{ key: 'playlists', label: 'Playlists' },
{ key: 'folders', label: 'Folders' },
];
@@ -19,7 +20,7 @@ export function ViewModeSwitcher({
onChange: (mode: LibraryViewMode) => void;
}) {
return (
<View style={styles.row}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.row}>
{MODES.map((mode) => {
const active = mode.key === value;
return (
@@ -39,7 +40,7 @@ export function ViewModeSwitcher({
</Pressable>
);
})}
</View>
</ScrollView>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { Modal, Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
export interface ActionSheetItem {
key: string;
label: string;
icon?: keyof typeof Ionicons.glyphMap;
destructive?: boolean;
selected?: boolean;
onPress: () => void;
}
/**
* Bottom action sheet on a plain RN Modal. Item presses do NOT auto-close —
* the consumer decides (allows multi-step flows like add-to-playlist).
*/
export function ActionSheet({
visible,
title,
items,
onClose,
}: {
visible: boolean;
title?: string;
items: ActionSheetItem[];
onClose: () => void;
}) {
const insets = useSafeAreaInsets();
return (
<Modal
visible={visible}
transparent
animationType="slide"
statusBarTranslucent
onRequestClose={onClose}
>
<Pressable style={styles.backdrop} onPress={onClose} accessibilityRole="button">
<Pressable style={[styles.card, { paddingBottom: insets.bottom + spacing.md }]}>
<View style={styles.grabber} />
{title ? (
<Text variant="label" numberOfLines={1} style={styles.title}>
{title}
</Text>
) : null}
{items.map((item) => (
<Pressable
key={item.key}
style={({ pressed }) => [styles.item, pressed && styles.itemPressed]}
onPress={item.onPress}
accessibilityRole="button"
>
{item.icon ? (
<Ionicons
name={item.icon}
size={20}
color={item.destructive ? colors.warning : colors.textSecondary}
/>
) : null}
<Text
variant="body"
numberOfLines={1}
style={styles.itemLabel}
color={item.destructive ? colors.warning : undefined}
>
{item.label}
</Text>
{item.selected ? (
<Ionicons name="checkmark" size={18} color={colors.accent} />
) : null}
</Pressable>
))}
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
justifyContent: 'flex-end',
},
card: {
backgroundColor: colors.bgSecondary,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
paddingHorizontal: spacing.lg,
paddingTop: spacing.sm,
},
grabber: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: radius.pill,
backgroundColor: colors.glassBorder,
marginBottom: spacing.sm,
},
title: {
marginBottom: spacing.xs,
},
item: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
},
itemPressed: {
opacity: 0.6,
},
itemLabel: {
flex: 1,
},
});
+121
View File
@@ -0,0 +1,121 @@
import { useState } from 'react';
import { Modal, Pressable, StyleSheet, TextInput, View } from 'react-native';
import { Text } from '@/components/Text';
import { colors, fonts, fontSize, radius, spacing } from '@/theme';
interface TextPromptModalProps {
visible: boolean;
title: string;
placeholder?: string;
initialValue?: string;
submitLabel?: string;
onSubmit: (value: string) => void;
onClose: () => void;
}
/** Text input prompt (RN's Alert.prompt is iOS-only). */
export function TextPromptModal(props: TextPromptModalProps) {
// Mount the inner component fresh per open so the input state resets.
if (!props.visible) return null;
return <TextPromptModalInner {...props} />;
}
function TextPromptModalInner({
title,
placeholder,
initialValue = '',
submitLabel = 'Save',
onSubmit,
onClose,
}: TextPromptModalProps) {
const [value, setValue] = useState(initialValue);
const trimmed = value.trim();
const submit = () => {
if (!trimmed) return;
onSubmit(trimmed);
};
return (
<Modal visible transparent animationType="fade" statusBarTranslucent onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose} accessibilityRole="button">
<Pressable style={styles.card}>
<Text variant="heading" style={styles.title}>
{title}
</Text>
<TextInput
style={styles.input}
value={value}
onChangeText={setValue}
placeholder={placeholder}
placeholderTextColor={colors.textTertiary}
autoFocus
returnKeyType="done"
onSubmitEditing={submit}
selectionColor={colors.accent}
/>
<View style={styles.actions}>
<Pressable style={styles.action} onPress={onClose} accessibilityRole="button">
<Text variant="body" color={colors.textSecondary}>
Cancel
</Text>
</Pressable>
<Pressable
style={[styles.action, !trimmed && styles.actionDisabled]}
disabled={!trimmed}
onPress={submit}
accessibilityRole="button"
>
<Text variant="body" color={colors.accent}>
{submitLabel}
</Text>
</Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
justifyContent: 'center',
padding: spacing.xl,
},
card: {
backgroundColor: colors.bgSecondary,
borderRadius: radius.lg,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
padding: spacing.lg,
gap: spacing.md,
},
title: {
fontSize: fontSize.base,
},
input: {
fontFamily: fonts.sans.regular,
fontSize: fontSize.base,
color: colors.textPrimary,
backgroundColor: colors.bgTertiary,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
},
action: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
actionDisabled: {
opacity: 0.4,
},
});