mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
m2, library ux
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user