track controls

This commit is contained in:
Boof2015
2026-07-02 22:23:08 -04:00
parent 5be18ba9b6
commit 0003c7d778
13 changed files with 466 additions and 197 deletions
+56 -18
View File
@@ -3,12 +3,14 @@ import {
Pressable,
StyleSheet,
View,
type GestureResponderEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { Text } from '@/components/Text';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { PullSearchScrollView } from '@/components/search/PullSearchGesture';
import { playTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
@@ -18,9 +20,10 @@ import {
type FlattenedFolderTreeRow,
} from '@/library/folderTree';
import { formatDuration } from '@/lib/format';
import { colors, spacing } from '@/theme';
import { colors, radius, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import type { DbTrack } from '@/types/library';
interface FoldersViewProps {
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
@@ -74,20 +77,27 @@ function FolderRow({
function FolderTrackRow({
row,
active,
onOpenActions,
}: {
row: Extract<FlattenedFolderTreeRow, { type: 'track' }>;
active: boolean;
onOpenActions: () => void;
}) {
const index = row.folderTracks.findIndex((track) => track.path === row.track.path);
const playFolderTrack = () => {
void playTracks(row.folderTracks.map(dbTrackToTrack), Math.max(0, index));
};
const openActions = (event: GestureResponderEvent) => {
event.stopPropagation();
onOpenActions();
};
return (
<Pressable
style={[styles.trackRow, active && styles.trackRowActive]}
onPress={playFolderTrack}
onLongPress={onOpenActions}
accessibilityRole="button"
>
<View style={[styles.indent, { width: row.depth * 18 + 16 }]} />
@@ -103,6 +113,15 @@ function FolderTrackRow({
<Text variant="mono" style={styles.duration}>
{formatDuration(row.track.duration)}
</Text>
<Pressable
style={({ pressed }) => [styles.actionsButton, pressed && styles.actionsButtonPressed]}
onPress={openActions}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`More actions for ${row.track.title}`}
>
<Ionicons name="ellipsis-horizontal" size={18} color={colors.textTertiary} />
</Pressable>
</Pressable>
);
}
@@ -112,6 +131,7 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps)
const tracks = useLibraryStore((s) => s.tracks);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(() => new Set());
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const tree = useMemo(() => buildFolderTree(folders, tracks), [folders, tracks]);
const rows = useMemo(() => flattenFolderTree(tree, expandedNodeIds), [expandedNodeIds, tree]);
@@ -141,23 +161,30 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps)
}
return (
<FlashList
data={rows}
keyExtractor={(row) => row.id}
showsVerticalScrollIndicator={false}
overScrollMode="never"
renderScrollComponent={PullSearchScrollView}
onScroll={onScroll}
scrollEventThrottle={scrollEventThrottle}
contentContainerStyle={styles.listContent}
renderItem={({ item }) =>
item.type === 'folder' ? (
<FolderRow row={item} onToggle={toggleFolder} />
) : (
<FolderTrackRow row={item} active={item.track.path === currentPath} />
)
}
/>
<>
<FlashList
data={rows}
keyExtractor={(row) => row.id}
showsVerticalScrollIndicator={false}
overScrollMode="never"
renderScrollComponent={PullSearchScrollView}
onScroll={onScroll}
scrollEventThrottle={scrollEventThrottle}
contentContainerStyle={styles.listContent}
renderItem={({ item }) =>
item.type === 'folder' ? (
<FolderRow row={item} onToggle={toggleFolder} />
) : (
<FolderTrackRow
row={item}
active={item.track.path === currentPath}
onOpenActions={() => setActionTrack(item.track)}
/>
)
}
/>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
</>
);
}
@@ -217,6 +244,17 @@ const styles = StyleSheet.create({
color: colors.textTertiary,
fontSize: 12,
},
actionsButton: {
width: 34,
height: 34,
flexShrink: 0,
borderRadius: radius.pill,
alignItems: 'center',
justifyContent: 'center',
},
actionsButtonPressed: {
backgroundColor: colors.glassBg,
},
empty: {
flex: 1,
alignItems: 'center',
+216 -50
View File
@@ -1,9 +1,21 @@
import { useState } from 'react';
import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet';
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
import { Pressable, StyleSheet, View } from 'react-native';
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { useRouter } from 'expo-router';
import { Text } from '@/components/Text';
import { AppSheet, AppSheetItem, AppSheetSection, type AppSheetItemProps } from '@/components/sheets/AppSheet';
import { enqueueEnd, enqueueTop } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { resolveCanonicalBrowseArtist, resolveStrictBrowseArtist } from '@/library/artistGrouping';
import { colors, fonts, radius, spacing } from '@/theme';
import { usePlaylistStore } from '@/stores/playlistStore';
import { useSettingsStore } from '@/stores/settingsStore';
import type { DbTrack } from '@/types/library';
export interface TrackActionSheetItem extends AppSheetItemProps {
key: string;
}
interface TrackActionsSheetProps {
/** null = hidden. */
track: DbTrack | null;
@@ -11,10 +23,10 @@ interface TrackActionsSheetProps {
/** Allows callers with their own menu to jump straight to playlist picking. */
initialStep?: 'menu' | 'pickPlaylist';
/** Screen-specific extras (e.g. playlist detail: remove / move). Handlers should close. */
extraItems?: ActionSheetItem[];
extraItems?: TrackActionSheetItem[];
}
/** Long-press track menu: favorite toggle, add-to-playlist (pick or create), extras. */
/** Track menu: queue, playlist, navigation, favorites, and optional screen extras. */
export function TrackActionsSheet(props: TrackActionsSheetProps) {
// Mount fresh per track so the step state resets.
if (!props.track) return null;
@@ -27,78 +39,232 @@ function TrackActionsSheetInner({
initialStep = 'menu',
extraItems = [],
}: TrackActionsSheetProps & { track: DbTrack }) {
const router = useRouter();
const [step, setStep] = useState<'menu' | 'pickPlaylist' | 'newPlaylist'>(initialStep);
const [playlistName, setPlaylistName] = useState('');
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
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[] = [
const artistName =
groupingMode === 'fileTags' ? resolveStrictBrowseArtist(track) : resolveCanonicalBrowseArtist(track);
const trimmedPlaylistName = playlistName.trim();
const closeAndRun = (run: () => void) => {
onClose();
run();
};
const addToNewPlaylist = () => {
if (!trimmedPlaylistName) return;
void (async () => {
const playlist = await createPlaylist(trimmedPlaylistName);
await addTracksToPlaylist(playlist.id, [track]);
})();
onClose();
};
const menuItems: TrackActionSheetItem[] = [
{
key: 'play-next',
label: 'Play next',
icon: 'play-skip-forward',
onPress: () => closeAndRun(() => void enqueueTop(dbTrackToTrack(track))),
},
{
key: 'add-to-queue',
label: 'Add to queue',
icon: 'list-outline',
onPress: () => closeAndRun(() => void enqueueEnd(dbTrackToTrack(track))),
},
{
key: 'add-to-playlist',
label: 'Add to playlist...',
icon: 'add-circle-outline',
onPress: () => setStep('pickPlaylist'),
},
{
key: 'view-album',
label: 'View album',
icon: 'albums-outline',
onPress: () =>
closeAndRun(() =>
router.push({
pathname: '/library/album/[key]',
params: { key: track.album_identity_key },
})
),
},
{
key: 'view-artist',
label: 'View artist',
icon: 'person-outline',
onPress: () =>
closeAndRun(() =>
router.push({
pathname: '/library/artist/[name]',
params: { name: artistName },
})
),
},
{
key: 'favorite',
label: isFavorite ? 'Remove from favorites' : 'Add to favorites',
icon: isFavorite ? 'heart-dislike-outline' : 'heart-outline',
onPress: () => {
void toggleFavorite(track);
onClose();
},
selected: isFavorite,
onPress: () => closeAndRun(() => void toggleFavorite(track)),
},
{
key: 'add-to-playlist',
label: 'Add to playlist…',
icon: 'add-circle-outline',
onPress: () => setStep('pickPlaylist'),
},
...extraItems,
];
const pickItems: ActionSheetItem[] = [
const pickItems: TrackActionSheetItem[] = [
...playlists.map((playlist) => ({
key: `playlist-${playlist.id}`,
label: playlist.name,
icon: 'musical-notes-outline' as const,
onPress: () => {
void addTracksToPlaylist(playlist.id, [track]);
onClose();
},
onPress: () => closeAndRun(() => void addTracksToPlaylist(playlist.id, [track])),
})),
{
key: 'new-playlist',
label: 'New playlist',
label: 'New playlist...',
icon: 'add',
onPress: () => setStep('newPlaylist'),
},
];
if (step === 'pickPlaylist') {
return (
<AppSheet onClose={onClose}>
<SheetTitle title="Add to playlist" subtitle={track.title} />
{initialStep === 'menu' ? (
<AppSheetItem label="Track actions" icon="arrow-back" onPress={() => setStep('menu')} />
) : null}
{playlists.length === 0 ? (
<Text variant="caption" color={colors.textTertiary} style={styles.empty}>
No playlists yet.
</Text>
) : null}
{pickItems.map(({ key, ...item }) => (
<AppSheetItem key={key} {...item} />
))}
</AppSheet>
);
}
if (step === 'newPlaylist') {
return (
<AppSheet onClose={onClose}>
<SheetTitle title="New playlist" subtitle={track.title} />
<BottomSheetTextInput
value={playlistName}
onChangeText={setPlaylistName}
placeholder="Playlist name"
placeholderTextColor={colors.textTertiary}
style={styles.input}
autoFocus
returnKeyType="done"
onSubmitEditing={addToNewPlaylist}
selectionColor={colors.accent}
/>
<View style={styles.actions}>
<Pressable style={[styles.btn, styles.cancel]} onPress={() => setStep('pickPlaylist')}>
<Text variant="label" color={colors.textSecondary}>
Back
</Text>
</Pressable>
<Pressable
style={[styles.btn, styles.create, !trimmedPlaylistName && styles.createDisabled]}
disabled={!trimmedPlaylistName}
onPress={addToNewPlaylist}
>
<Text variant="label" color={colors.accentTextStrong}>
Create
</Text>
</Pressable>
</View>
</AppSheet>
);
}
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}
/>
</>
<AppSheet onClose={onClose}>
<SheetTitle title={track.title} subtitle={track.artist} />
{menuItems.map(({ key, ...item }) => (
<AppSheetItem key={key} {...item} />
))}
{extraItems.length > 0 ? (
<>
<AppSheetSection label="PLAYLIST" />
{extraItems.map(({ key, ...item }) => (
<AppSheetItem key={key} {...item} />
))}
</>
) : null}
</AppSheet>
);
}
function SheetTitle({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<View style={styles.titleBlock}>
<Text variant="heading" numberOfLines={1} style={styles.title}>
{title}
</Text>
{subtitle ? (
<Text variant="label" numberOfLines={1} color={colors.textSecondary}>
{subtitle}
</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
titleBlock: {
marginTop: spacing.xs,
marginBottom: spacing.sm,
gap: 2,
},
title: {
paddingRight: spacing.lg,
},
empty: {
paddingVertical: spacing.sm,
},
input: {
color: colors.textPrimary,
fontFamily: fonts.sans.regular,
fontSize: 16,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
marginTop: spacing.lg,
},
btn: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radius.pill,
},
cancel: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
create: {
backgroundColor: colors.accentGlow,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.accent,
},
createDisabled: {
opacity: 0.4,
},
});
+33 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { View, Pressable, StyleSheet } from 'react-native';
import { View, Pressable, StyleSheet, type GestureResponderEvent } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { FormatBadges } from '@/components/FormatBadge';
@@ -20,6 +21,7 @@ export function TrackRow({
track,
onPress,
onLongPress,
onOpenActions,
showArtist = true,
subtitle,
active = false,
@@ -29,6 +31,8 @@ export function TrackRow({
onPress: () => void;
/** Opens the track actions sheet where wired. */
onLongPress?: () => void;
/** Visible trailing affordance for the track actions sheet. */
onOpenActions?: () => void;
/** Hide on album detail where every row shares the artist. */
showArtist?: boolean;
/** Overrides the secondary line; useful for artist pages that need album context. */
@@ -44,12 +48,16 @@ export function TrackRow({
const thumbUri = failedArtKey !== artKey ? trackArtworkThumbSource(track) : null;
const secondaryText = subtitle ?? (showArtist ? track.artist : null);
const openActions = (event: GestureResponderEvent) => {
event.stopPropagation();
onOpenActions?.();
};
const row = (
<Pressable
style={styles.row}
onPress={onPress}
onLongPress={onLongPress}
onLongPress={onLongPress ?? onOpenActions}
accessibilityRole="button"
>
<View style={styles.art}>
@@ -103,6 +111,18 @@ export function TrackRow({
<Text variant="mono" style={styles.duration}>
{formatDuration(track.duration)}
</Text>
{onOpenActions ? (
<Pressable
style={({ pressed }) => [styles.actionsButton, pressed && styles.actionsButtonPressed]}
onPress={openActions}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`More actions for ${track.title}`}
>
<Ionicons name="ellipsis-horizontal" size={18} color={colors.textTertiary} />
</Pressable>
) : null}
</Pressable>
);
@@ -186,4 +206,15 @@ const styles = StyleSheet.create({
color: colors.textTertiary,
textAlign: 'right',
},
actionsButton: {
width: 34,
height: 34,
flexShrink: 0,
borderRadius: radius.pill,
alignItems: 'center',
justifyContent: 'center',
},
actionsButtonPressed: {
backgroundColor: colors.glassBg,
},
});