artist button, menu

This commit is contained in:
Boof2015
2026-06-18 17:47:50 -04:00
parent 87e3d876a0
commit 978d001db9
2 changed files with 201 additions and 8 deletions
+197 -7
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { View, Pressable, StyleSheet, useWindowDimensions } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
@@ -18,12 +18,15 @@ import { AstraLogo } from '@/components/AstraLogo';
import { FormatBadges } from '@/components/FormatBadge';
import { WaveformSeekBar } from '@/components/WaveformSeekBar';
import { Visualizer } from '@/components/Visualizer';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { QueueTray } from '@/components/queue/QueueTray';
import { colors, radius, spacing } from '@/theme';
import { motion } from '@/theme/motion';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { useSettingsStore } from '@/stores/settingsStore';
import type { DbTrack } from '@/types/library';
import {
cycleRepeat,
seekTo,
@@ -69,6 +72,9 @@ const SUB_BUTTON_SIZE = 40;
const SUB_ICON_SIZE = 20;
const SUB_TOP_MARGIN = spacing.lg;
const MIN_FLOATING_SPACE = spacing.sm;
const MENU_ANIMATION_IN_MS = 130;
const MENU_ANIMATION_OUT_MS = 100;
const MENU_ENTER_OFFSET_Y = -8;
interface NowPlayingLayout {
contentPadding: number;
@@ -82,6 +88,13 @@ interface NowPlayingLayout {
mediaBottomGap: number;
}
interface NowPlayingMenuItem {
key: string;
label: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
@@ -159,9 +172,12 @@ export default function NowPlayingScreen() {
const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const [queueOpen, setQueueOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [playlistActionTrack, setPlaylistActionTrack] = useState<DbTrack | null>(null);
const scopeMode = useSettingsStore((s) => s.scopeMode);
const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible);
const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible);
const libraryTracks = useLibraryStore((s) => s.tracks);
const track = usePlayerStore((s) => s.currentTrack);
const playbackState = usePlayerStore((s) => s.playbackState);
const currentTime = usePlayerStore((s) => s.currentTime);
@@ -176,12 +192,86 @@ export default function NowPlayingScreen() {
const availableHeight = windowHeight - insets.top - insets.bottom;
const layout = getNowPlayingLayout(windowWidth, availableHeight, scopeStageVisible);
const source = track?.album?.trim() ? track.album : 'Library';
const shellRight = Math.max(layout.contentPadding, (windowWidth - layout.contentWidth) / 2);
const menuTop = insets.top + CONTENT_TOP_PADDING + HEADER_HEIGHT + spacing.xs;
const artistName = track?.artist.trim() ?? '';
const libraryTrack = useMemo(
() => (track ? libraryTracks.find((entry) => entry.path === track.path) ?? null : null),
[libraryTracks, track]
);
const albumKey = track?.albumIdentityKey ?? libraryTrack?.album_identity_key;
const navigateToArtist = () => {
if (!artistName) return;
router.dismissTo({
pathname: '/library/artist/[name]',
params: { name: artistName },
});
};
const navigateToAlbum = () => {
if (!albumKey) return;
router.dismissTo({
pathname: '/library/album/[key]',
params: { key: albumKey },
});
};
const menuItems: NowPlayingMenuItem[] = [];
if (artistName) {
menuItems.push({
key: 'artist',
label: 'View artist',
icon: 'person-outline',
onPress: () => {
closeMenu();
navigateToArtist();
},
});
}
if (albumKey) {
menuItems.push({
key: 'album',
label: 'View album',
icon: 'albums-outline',
onPress: () => {
closeMenu();
navigateToAlbum();
},
});
}
if (libraryTrack) {
menuItems.push({
key: 'add-to-playlist',
label: 'Add to playlist...',
icon: 'add-circle-outline',
onPress: () => {
closeMenu();
setPlaylistActionTrack(libraryTrack);
},
});
}
// Swipe down to minimize. The stack transition is disabled for this route, so
// the sheet owns one continuous enter/exit animation instead of handing off to
// a second native modal animation after release.
const translateY = useSharedValue(0);
const menuProgress = useSharedValue(0);
const dismiss = () => router.back();
const finishCloseMenu = () => setMenuOpen(false);
function openMenu() {
if (menuItems.length === 0) return;
menuProgress.value = 0;
setMenuOpen(true);
menuProgress.value = withTiming(1, { duration: MENU_ANIMATION_IN_MS });
}
function closeMenu() {
menuProgress.value = withTiming(0, { duration: MENU_ANIMATION_OUT_MS }, (finished) => {
if (finished) runOnJS(finishCloseMenu)();
});
}
const dismissSheet = (velocity = 0) => {
translateY.value = withSpring(
@@ -228,6 +318,14 @@ export default function NowPlayingScreen() {
transform: [{ translateY: translateY.value }],
}));
const menuLayerStyle = useAnimatedStyle(() => ({
opacity: menuProgress.value,
}));
const menuCardStyle = useAnimatedStyle(() => ({
transform: [{ translateY: MENU_ENTER_OFFSET_Y * (1 - menuProgress.value) }],
}));
return (
<View style={styles.backdrop}>
<GestureDetector gesture={pan}>
@@ -256,7 +354,13 @@ export default function NowPlayingScreen() {
{source}
</Text>
</View>
<Pressable style={styles.headerBtn} hitSlop={12} accessibilityLabel="More options">
<Pressable
style={styles.headerBtn}
onPress={openMenu}
disabled={menuItems.length === 0}
hitSlop={12}
accessibilityLabel="More options"
>
<Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} />
</Pressable>
</View>
@@ -354,9 +458,17 @@ export default function NowPlayingScreen() {
{track.title}
</Text>
<View style={styles.trackMetaRow}>
<Text variant="body" numberOfLines={1} style={styles.artist}>
{track.artist}
</Text>
<Pressable
onPress={navigateToArtist}
hitSlop={6}
style={styles.artistButton}
accessibilityRole="link"
accessibilityLabel={`View artist ${track.artist}`}
>
<Text variant="body" numberOfLines={1} style={styles.artist}>
{track.artist}
</Text>
</Pressable>
<View style={styles.badges}>
<FormatBadges track={track} />
</View>
@@ -493,6 +605,41 @@ export default function NowPlayingScreen() {
</View>
</Animated.View>
</GestureDetector>
{menuOpen && menuItems.length > 0 && (
<Animated.View
pointerEvents="box-none"
style={[styles.menuLayer, menuLayerStyle]}
>
<Pressable
style={styles.menuDismiss}
onPress={closeMenu}
accessibilityRole="button"
accessibilityLabel="Close menu"
/>
<Animated.View
style={[styles.menuCard, { top: menuTop, right: shellRight }, menuCardStyle]}
>
{menuItems.map((item) => (
<Pressable
key={item.key}
style={({ pressed }) => [styles.menuItem, pressed && styles.menuItemPressed]}
onPress={item.onPress}
accessibilityRole="button"
>
<Ionicons name={item.icon} size={19} color={colors.textSecondary} />
<Text variant="body" numberOfLines={1} style={styles.menuItemLabel}>
{item.label}
</Text>
</Pressable>
))}
</Animated.View>
</Animated.View>
)}
<TrackActionsSheet
track={playlistActionTrack}
initialStep="pickPlaylist"
onClose={() => setPlaylistActionTrack(null)}
/>
{queueOpen && <QueueTray onClose={() => setQueueOpen(false)} />}
</View>
);
@@ -536,6 +683,47 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
marginTop: 1,
},
menuLayer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
menuDismiss: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
menuCard: {
position: 'absolute',
width: 212,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgSecondary,
paddingVertical: spacing.xs,
shadowColor: '#000',
shadowOpacity: 0.3,
shadowRadius: 16,
shadowOffset: { width: 0, height: 8 },
elevation: 12,
},
menuItem: {
minHeight: 44,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.md,
},
menuItemPressed: {
opacity: 0.6,
},
menuItemLabel: {
flex: 1,
},
player: {
flex: 1,
},
@@ -606,13 +794,15 @@ const styles = StyleSheet.create({
gap: spacing.sm,
marginTop: spacing.xs,
},
artistButton: {
flexShrink: 1,
minWidth: 0,
},
centered: {
textAlign: 'center',
},
artist: {
color: colors.accentText,
flexShrink: 1,
minWidth: 0,
},
badges: {
flexShrink: 0,
+4 -1
View File
@@ -8,6 +8,8 @@ interface TrackActionsSheetProps {
/** null = hidden. */
track: DbTrack | null;
onClose: () => void;
/** 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[];
}
@@ -22,9 +24,10 @@ export function TrackActionsSheet(props: TrackActionsSheetProps) {
function TrackActionsSheetInner({
track,
onClose,
initialStep = 'menu',
extraItems = [],
}: TrackActionsSheetProps & { track: DbTrack }) {
const [step, setStep] = useState<'menu' | 'pickPlaylist' | 'newPlaylist'>('menu');
const [step, setStep] = useState<'menu' | 'pickPlaylist' | 'newPlaylist'>(initialStep);
const playlists = usePlaylistStore((s) => s.playlists);
const isFavorite = usePlaylistStore((s) => s.favoritePaths.has(track.path));
const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite);