mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
artist button, menu
This commit is contained in:
+197
-7
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { View, Pressable, StyleSheet, useWindowDimensions } from 'react-native';
|
import { View, Pressable, StyleSheet, useWindowDimensions } from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
|
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
@@ -18,12 +18,15 @@ import { AstraLogo } from '@/components/AstraLogo';
|
|||||||
import { FormatBadges } from '@/components/FormatBadge';
|
import { FormatBadges } from '@/components/FormatBadge';
|
||||||
import { WaveformSeekBar } from '@/components/WaveformSeekBar';
|
import { WaveformSeekBar } from '@/components/WaveformSeekBar';
|
||||||
import { Visualizer } from '@/components/Visualizer';
|
import { Visualizer } from '@/components/Visualizer';
|
||||||
|
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
|
||||||
import { QueueTray } from '@/components/queue/QueueTray';
|
import { QueueTray } from '@/components/queue/QueueTray';
|
||||||
import { colors, radius, spacing } from '@/theme';
|
import { colors, radius, spacing } from '@/theme';
|
||||||
import { motion } from '@/theme/motion';
|
import { motion } from '@/theme/motion';
|
||||||
|
import { useLibraryStore } from '@/stores/libraryStore';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
import { usePlaylistStore } from '@/stores/playlistStore';
|
import { usePlaylistStore } from '@/stores/playlistStore';
|
||||||
import { useSettingsStore } from '@/stores/settingsStore';
|
import { useSettingsStore } from '@/stores/settingsStore';
|
||||||
|
import type { DbTrack } from '@/types/library';
|
||||||
import {
|
import {
|
||||||
cycleRepeat,
|
cycleRepeat,
|
||||||
seekTo,
|
seekTo,
|
||||||
@@ -69,6 +72,9 @@ const SUB_BUTTON_SIZE = 40;
|
|||||||
const SUB_ICON_SIZE = 20;
|
const SUB_ICON_SIZE = 20;
|
||||||
const SUB_TOP_MARGIN = spacing.lg;
|
const SUB_TOP_MARGIN = spacing.lg;
|
||||||
const MIN_FLOATING_SPACE = spacing.sm;
|
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 {
|
interface NowPlayingLayout {
|
||||||
contentPadding: number;
|
contentPadding: number;
|
||||||
@@ -82,6 +88,13 @@ interface NowPlayingLayout {
|
|||||||
mediaBottomGap: number;
|
mediaBottomGap: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NowPlayingMenuItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number): number {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
return Math.min(max, Math.max(min, value));
|
return Math.min(max, Math.max(min, value));
|
||||||
}
|
}
|
||||||
@@ -159,9 +172,12 @@ export default function NowPlayingScreen() {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [playlistActionTrack, setPlaylistActionTrack] = useState<DbTrack | null>(null);
|
||||||
const scopeMode = useSettingsStore((s) => s.scopeMode);
|
const scopeMode = useSettingsStore((s) => s.scopeMode);
|
||||||
const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible);
|
const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible);
|
||||||
const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible);
|
const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible);
|
||||||
|
const libraryTracks = useLibraryStore((s) => s.tracks);
|
||||||
const track = usePlayerStore((s) => s.currentTrack);
|
const track = usePlayerStore((s) => s.currentTrack);
|
||||||
const playbackState = usePlayerStore((s) => s.playbackState);
|
const playbackState = usePlayerStore((s) => s.playbackState);
|
||||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||||
@@ -176,12 +192,86 @@ export default function NowPlayingScreen() {
|
|||||||
const availableHeight = windowHeight - insets.top - insets.bottom;
|
const availableHeight = windowHeight - insets.top - insets.bottom;
|
||||||
const layout = getNowPlayingLayout(windowWidth, availableHeight, scopeStageVisible);
|
const layout = getNowPlayingLayout(windowWidth, availableHeight, scopeStageVisible);
|
||||||
const source = track?.album?.trim() ? track.album : 'Library';
|
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
|
// 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
|
// the sheet owns one continuous enter/exit animation instead of handing off to
|
||||||
// a second native modal animation after release.
|
// a second native modal animation after release.
|
||||||
const translateY = useSharedValue(0);
|
const translateY = useSharedValue(0);
|
||||||
|
const menuProgress = useSharedValue(0);
|
||||||
const dismiss = () => router.back();
|
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) => {
|
const dismissSheet = (velocity = 0) => {
|
||||||
translateY.value = withSpring(
|
translateY.value = withSpring(
|
||||||
@@ -228,6 +318,14 @@ export default function NowPlayingScreen() {
|
|||||||
transform: [{ translateY: translateY.value }],
|
transform: [{ translateY: translateY.value }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const menuLayerStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: menuProgress.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const menuCardStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateY: MENU_ENTER_OFFSET_Y * (1 - menuProgress.value) }],
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.backdrop}>
|
<View style={styles.backdrop}>
|
||||||
<GestureDetector gesture={pan}>
|
<GestureDetector gesture={pan}>
|
||||||
@@ -256,7 +354,13 @@ export default function NowPlayingScreen() {
|
|||||||
{source}
|
{source}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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} />
|
<Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -354,9 +458,17 @@ export default function NowPlayingScreen() {
|
|||||||
{track.title}
|
{track.title}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.trackMetaRow}>
|
<View style={styles.trackMetaRow}>
|
||||||
<Text variant="body" numberOfLines={1} style={styles.artist}>
|
<Pressable
|
||||||
{track.artist}
|
onPress={navigateToArtist}
|
||||||
</Text>
|
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}>
|
<View style={styles.badges}>
|
||||||
<FormatBadges track={track} />
|
<FormatBadges track={track} />
|
||||||
</View>
|
</View>
|
||||||
@@ -493,6 +605,41 @@ export default function NowPlayingScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</GestureDetector>
|
</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)} />}
|
{queueOpen && <QueueTray onClose={() => setQueueOpen(false)} />}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -536,6 +683,47 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
marginTop: 1,
|
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: {
|
player: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
@@ -606,13 +794,15 @@ const styles = StyleSheet.create({
|
|||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
marginTop: spacing.xs,
|
marginTop: spacing.xs,
|
||||||
},
|
},
|
||||||
|
artistButton: {
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
centered: {
|
centered: {
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
artist: {
|
artist: {
|
||||||
color: colors.accentText,
|
color: colors.accentText,
|
||||||
flexShrink: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
},
|
},
|
||||||
badges: {
|
badges: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ interface TrackActionsSheetProps {
|
|||||||
/** null = hidden. */
|
/** null = hidden. */
|
||||||
track: DbTrack | null;
|
track: DbTrack | null;
|
||||||
onClose: () => void;
|
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. */
|
/** Screen-specific extras (e.g. playlist detail: remove / move). Handlers should close. */
|
||||||
extraItems?: ActionSheetItem[];
|
extraItems?: ActionSheetItem[];
|
||||||
}
|
}
|
||||||
@@ -22,9 +24,10 @@ export function TrackActionsSheet(props: TrackActionsSheetProps) {
|
|||||||
function TrackActionsSheetInner({
|
function TrackActionsSheetInner({
|
||||||
track,
|
track,
|
||||||
onClose,
|
onClose,
|
||||||
|
initialStep = 'menu',
|
||||||
extraItems = [],
|
extraItems = [],
|
||||||
}: TrackActionsSheetProps & { track: DbTrack }) {
|
}: 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 playlists = usePlaylistStore((s) => s.playlists);
|
||||||
const isFavorite = usePlaylistStore((s) => s.favoritePaths.has(track.path));
|
const isFavorite = usePlaylistStore((s) => s.favoritePaths.has(track.path));
|
||||||
const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite);
|
const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite);
|
||||||
|
|||||||
Reference in New Issue
Block a user