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
+5
View File
@@ -8,6 +8,7 @@ import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { SpectrumCurve } from '@/components/SpectrumCurve';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { PlaylistRow } from '@/components/library/PlaylistRow';
import { ScanProgress } from '@/components/library/ScanProgress';
import {
@@ -357,6 +358,7 @@ export default function HomeScreen() {
const [randomAlbumKey, setRandomAlbumKey] = useState<string | null>(null);
const [randomSeed] = useState(() => Math.random());
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const scrollTop = useScrollTopGate();
const hasLibrary = tracks.length > 0;
@@ -511,6 +513,8 @@ export default function HomeScreen() {
active={track.path === currentPath}
swipeToQueue={false}
onPress={() => playTrackList(recentTracks, index)}
onLongPress={() => setActionTrack(track)}
onOpenActions={() => setActionTrack(track)}
/>
))}
</View>
@@ -564,6 +568,7 @@ export default function HomeScreen() {
)}
</PullSearchScrollView>
</PullSearchGesture>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
</Screen>
);
}
+1
View File
@@ -111,6 +111,7 @@ export default function AlbumScreen() {
active={item.path === currentPath}
onPress={() => playFrom(index)}
onLongPress={() => setActionTrack(item)}
onOpenActions={() => setActionTrack(item)}
/>
)}
/>
+1
View File
@@ -119,6 +119,7 @@ export default function ArtistScreen() {
active={item.track.path === currentPath}
onPress={() => playTrackListFrom(sourceTracks, item.index)}
onLongPress={() => setActionTrack(item.track)}
onOpenActions={() => setActionTrack(item.track)}
/>
);
}
@@ -62,6 +62,7 @@ export default function ArtistAppearancesScreen() {
active={item.path === currentPath}
onPress={() => playFrom(index)}
onLongPress={() => setActionTrack(item)}
onOpenActions={() => setActionTrack(item)}
/>
)}
ListEmptyComponent={<EmptyList label="No appearances found for this artist." />}
@@ -62,6 +62,7 @@ export default function ArtistSongsScreen() {
active={item.path === currentPath}
onPress={() => playFrom(index)}
onLongPress={() => setActionTrack(item)}
onOpenActions={() => setActionTrack(item)}
/>
)}
ListEmptyComponent={<EmptyList label="No songs found for this artist." />}
+1
View File
@@ -175,6 +175,7 @@ export default function LibraryScreen() {
active={item.path === currentPath}
onPress={() => playAllFrom(index)}
onLongPress={() => setActionTrack(item)}
onOpenActions={() => setActionTrack(item)}
/>
)}
/>
+1
View File
@@ -231,6 +231,7 @@ export default function PlaylistScreen() {
active={item.track.path === currentPath}
onPress={() => startPlayback(playableIndexByEntryId.get(item.id) ?? 0)}
onLongPress={() => setActionEntry(item)}
onOpenActions={() => setActionEntry(item)}
/>
) : (
<MissingRow entry={item} onLongPress={() => setMissingEntry(item)} />
+7
View File
@@ -1,3 +1,4 @@
import { useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
@@ -5,11 +6,13 @@ import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import type { DbTrack } from '@/types/library';
function formatCount(count: number, noun: string): string {
return `${count} ${count === 1 ? noun : `${noun}s`}`;
@@ -30,6 +33,7 @@ export default function RecentlyPlayedScreen() {
const router = useRouter();
const tracks = useLibraryStore((s) => s.recentlyPlayedTracks);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const playFrom = (index: number) => {
if (tracks.length === 0) return;
@@ -62,11 +66,14 @@ export default function RecentlyPlayedScreen() {
active={item.path === currentPath}
swipeToQueue={false}
onPress={() => playFrom(index)}
onLongPress={() => setActionTrack(item)}
onOpenActions={() => setActionTrack(item)}
/>
)}
ListEmptyComponent={<EmptyList />}
contentContainerStyle={styles.listContent}
/>
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
</Screen>
);
}
+12 -127
View File
@@ -1,132 +1,17 @@
import { useCallback, type ReactNode } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetView,
type BottomSheetBackdropProps,
} from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import {
AppSheet,
AppSheetItem,
AppSheetSection,
type AppSheetItemProps,
} from '@/components/sheets/AppSheet';
/**
* Bottom sheet for the EQ screen's menus — same chrome/behaviour as the now-playing
* QueueTray (inline gorhom BottomSheet, dimmed backdrop, grab handle, pan-to-close)
* so trays stay consistent across the app. Dynamically sized to its content; render
* it conditionally ({open && <EqSheet onClose=...>}).
* EQ-named wrappers around the shared app sheet chrome. Keeping these exports
* avoids churn in EQ call sites while the same bottom-sheet UX is reused elsewhere.
*/
export function EqSheet({ onClose, children }: { onClose: () => void; children: ReactNode }) {
const insets = useSafeAreaInsets();
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
appearsOnIndex={0}
disappearsOnIndex={-1}
pressBehavior="close"
opacity={0.58}
/>
),
[]
);
return (
<BottomSheet
index={0}
enableDynamicSizing
enablePanDownToClose
onClose={onClose}
backdropComponent={renderBackdrop}
backgroundStyle={styles.sheetBg}
handleIndicatorStyle={styles.handle}
>
<BottomSheetView style={[styles.content, { paddingBottom: insets.bottom + spacing.md }]}>
{children}
</BottomSheetView>
</BottomSheet>
);
}
/** Section label inside a sheet. */
export function EqSheetSection({ label }: { label: string }) {
return (
<Text variant="caption" style={styles.section}>
{label}
</Text>
);
}
interface EqSheetItemProps {
label: string;
icon?: keyof typeof Ionicons.glyphMap;
selected?: boolean;
destructive?: boolean;
onPress: () => void;
/** Optional trailing control (e.g. a delete button). */
trailing?: ReactNode;
}
/** One tappable row, styled like the ActionSheet items it replaces. */
export function EqSheetItem({ label, icon, selected, destructive, onPress, trailing }: EqSheetItemProps) {
const tint = destructive ? colors.warning : selected ? colors.accentTextStrong : colors.textPrimary;
return (
<View style={styles.itemRow}>
<Pressable
style={({ pressed }) => [styles.item, pressed && styles.itemPressed]}
onPress={onPress}
accessibilityRole="button"
>
{icon ? (
<Ionicons name={icon} size={20} color={destructive ? colors.warning : colors.textSecondary} />
) : null}
<Text variant="body" numberOfLines={1} style={styles.itemLabel} color={tint}>
{label}
</Text>
{selected ? <Ionicons name="checkmark" size={18} color={colors.accent} /> : null}
</Pressable>
{trailing}
</View>
);
}
const styles = StyleSheet.create({
sheetBg: {
backgroundColor: colors.bgSecondary,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
},
handle: {
backgroundColor: colors.glassBorder,
width: 38,
},
content: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.xs,
},
section: {
color: colors.textTertiary,
letterSpacing: 1,
marginTop: spacing.md,
marginBottom: spacing.xs,
},
itemRow: {
flexDirection: 'row',
alignItems: 'center',
},
item: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
},
itemPressed: {
opacity: 0.6,
},
itemLabel: {
flex: 1,
},
});
export const EqSheet = AppSheet;
export const EqSheetSection = AppSheetSection;
export const EqSheetItem = AppSheetItem;
export type EqSheetItemProps = AppSheetItemProps;
export default EqSheet;
+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,
},
});
+131
View File
@@ -0,0 +1,131 @@
import { useCallback, type ReactNode } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetView,
type BottomSheetBackdropProps,
} from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
export function AppSheet({ onClose, children }: { onClose: () => void; children: ReactNode }) {
const insets = useSafeAreaInsets();
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
appearsOnIndex={0}
disappearsOnIndex={-1}
pressBehavior="close"
opacity={0.58}
/>
),
[]
);
return (
<BottomSheet
index={0}
enableDynamicSizing
enablePanDownToClose
onClose={onClose}
backdropComponent={renderBackdrop}
backgroundStyle={styles.sheetBg}
handleIndicatorStyle={styles.handle}
>
<BottomSheetView style={[styles.content, { paddingBottom: insets.bottom + spacing.md }]}>
{children}
</BottomSheetView>
</BottomSheet>
);
}
export function AppSheetSection({ label }: { label: string }) {
return (
<Text variant="caption" style={styles.section}>
{label}
</Text>
);
}
export interface AppSheetItemProps {
label: string;
icon?: keyof typeof Ionicons.glyphMap;
selected?: boolean;
destructive?: boolean;
onPress: () => void;
trailing?: ReactNode;
}
export function AppSheetItem({
label,
icon,
selected,
destructive,
onPress,
trailing,
}: AppSheetItemProps) {
const tint = destructive ? colors.warning : selected ? colors.accentTextStrong : colors.textPrimary;
return (
<View style={styles.itemRow}>
<Pressable
style={({ pressed }) => [styles.item, pressed && styles.itemPressed]}
onPress={onPress}
accessibilityRole="button"
>
{icon ? (
<Ionicons name={icon} size={20} color={destructive ? colors.warning : colors.textSecondary} />
) : null}
<Text variant="body" numberOfLines={1} style={styles.itemLabel} color={tint}>
{label}
</Text>
{selected ? <Ionicons name="checkmark" size={18} color={colors.accent} /> : null}
</Pressable>
{trailing}
</View>
);
}
const styles = StyleSheet.create({
sheetBg: {
backgroundColor: colors.bgSecondary,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
},
handle: {
backgroundColor: colors.glassBorder,
width: 38,
},
content: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.xs,
},
section: {
color: colors.textTertiary,
letterSpacing: 1,
marginTop: spacing.md,
marginBottom: spacing.xs,
},
itemRow: {
flexDirection: 'row',
alignItems: 'center',
},
item: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
},
itemPressed: {
opacity: 0.6,
},
itemLabel: {
flex: 1,
},
});
export default AppSheet;