mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
track controls
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ export default function AlbumScreen() {
|
||||
active={item.path === currentPath}
|
||||
onPress={() => playFrom(index)}
|
||||
onLongPress={() => setActionTrack(item)}
|
||||
onOpenActions={() => setActionTrack(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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." />}
|
||||
|
||||
@@ -175,6 +175,7 @@ export default function LibraryScreen() {
|
||||
active={item.path === currentPath}
|
||||
onPress={() => playAllFrom(index)}
|
||||
onLongPress={() => setActionTrack(item)}
|
||||
onOpenActions={() => setActionTrack(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user