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'; import { useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { SlideInDown, runOnJS, useAnimatedStyle, useSharedValue, withSpring, withTiming, } from 'react-native-reanimated'; import { Text } from '@/components/Text'; 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, skipToNext, skipToPrevious, togglePlay, toggleShuffle, } from '@/audio/playbackController'; const DISMISS_DISTANCE = 140; const DISMISS_VELOCITY = 1000; const MAX_CONTENT_WIDTH = 408; const CONTENT_SIDE_PADDING = spacing.lg; const NARROW_CONTENT_SIDE_PADDING = spacing.md; const MEDIA_AREA_MIN = 220; const MEDIA_AREA_MAX = 360; const ART_SIZE_MAX = 340; const VISUALIZER_ART_SCALE = 0.8; const VISUALIZER_ART_SIZE_MIN = 176; const VISUALIZER_WIDTH_MAX = 448; const VISUALIZER_SIDE_PADDING = spacing.md; const VISUALIZER_TOP_GAP = spacing.lg; const VISUALIZER_BOTTOM_GAP = spacing.sm; const VISUALIZER_HEIGHT_MIN = 84; const VISUALIZER_HEIGHT_MAX = 108; const VISUALIZER_HEIGHT_RATIO = 0.28; const HEADER_HEIGHT = 32; const CONTENT_TOP_PADDING = spacing.sm; const CONTENT_BOTTOM_PADDING = spacing.lg; const MEDIA_TOP_MARGIN = spacing.lg; const VISUALIZER_MEDIA_BOTTOM_GAP = spacing.sm; const MEDIA_BOTTOM_GAP = spacing.xl; const TRACK_INFO_ESTIMATE = 96; const WAVEFORM_HEIGHT = 58; const WAVEFORM_TOUCH_PADDING = spacing.md; const WAVEFORM_BLOCK_ESTIMATE = WAVEFORM_HEIGHT + WAVEFORM_TOUCH_PADDING * 2 + 24; const PLAY_BUTTON_SIZE = 68; const SKIP_ICON_SIZE = 32; const PLAY_ICON_SIZE = 34; const TRANSPORT_TOP_MARGIN = spacing.lg; 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; contentWidth: number; artSize: number; scopeWidth: number; scopeHeight: number; visualizerTopGap: number; visualizerBottomGap: number; mediaTopMargin: 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 { return Math.min(max, Math.max(min, value)); } function getScopeHeight(scopeWidth: number): number { return Math.round( clamp(scopeWidth * VISUALIZER_HEIGHT_RATIO, VISUALIZER_HEIGHT_MIN, VISUALIZER_HEIGHT_MAX) ); } function getNowPlayingLayout( windowWidth: number, availableHeight: number, showVisualizer: boolean ): NowPlayingLayout { const contentPadding = windowWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING; const contentWidth = Math.max(0, Math.min(windowWidth - contentPadding * 2, MAX_CONTENT_WIDTH)); const scopeWidth = Math.max( 0, Math.min(windowWidth - VISUALIZER_SIDE_PADDING * 2, VISUALIZER_WIDTH_MAX) ); const scopeHeight = getScopeHeight(scopeWidth); const mediaMax = Math.min(contentWidth, MEDIA_AREA_MAX); const mediaMin = Math.min(mediaMax, MEDIA_AREA_MIN); const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN; const defaultMediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP; const mediaBottomGap = showVisualizer ? VISUALIZER_MEDIA_BOTTOM_GAP : defaultMediaBottomGap; const fixedHeight = CONTENT_TOP_PADDING + CONTENT_BOTTOM_PADDING + HEADER_HEIGHT + mediaTopMargin + mediaBottomGap + TRACK_INFO_ESTIMATE + WAVEFORM_BLOCK_ESTIMATE + TRANSPORT_TOP_MARGIN + PLAY_BUTTON_SIZE + SUB_TOP_MARGIN + SUB_BUTTON_SIZE + MIN_FLOATING_SPACE; const heightBoundMedia = availableHeight - fixedHeight; const baseArtSize = Math.min( Math.round(clamp(heightBoundMedia, mediaMin, mediaMax)), ART_SIZE_MAX ); const artSize = showVisualizer ? Math.round( clamp( baseArtSize * VISUALIZER_ART_SCALE, Math.min(VISUALIZER_ART_SIZE_MIN, mediaMax), mediaMax ) ) : baseArtSize; const visualizerTopGap = showVisualizer ? VISUALIZER_TOP_GAP : 0; const visualizerBottomGap = showVisualizer ? VISUALIZER_BOTTOM_GAP : 0; return { contentPadding, contentWidth, artSize, scopeWidth, scopeHeight, visualizerTopGap, visualizerBottomGap, mediaTopMargin, mediaBottomGap, }; } export default function NowPlayingScreen() { const router = useRouter(); const insets = useSafeAreaInsets(); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const [queueOpen, setQueueOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [playlistActionTrack, setPlaylistActionTrack] = useState(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); const duration = usePlayerStore((s) => s.duration); const shuffle = usePlayerStore((s) => s.shuffle); const repeat = usePlayerStore((s) => s.repeat); const isFavorite = usePlaylistStore((s) => (track ? s.favoritePaths.has(track.path) : false)); const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite); const isPlaying = playbackState === 'playing'; const isLoading = playbackState === 'loading'; 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( windowHeight, { damping: 28, stiffness: 240, velocity, overshootClamping: true, }, (finished) => { if (finished) runOnJS(dismiss)(); } ); }; const pan = Gesture.Pan() .activeOffsetY(14) // engage only on a downward drag .failOffsetY(-14) .failOffsetX([-24, 24]) // let the horizontal seek drag through .onUpdate((e) => { translateY.value = e.translationY > 0 ? e.translationY : 0; }) .onEnd((e) => { if (e.translationY > DISMISS_DISTANCE || e.velocityY > DISMISS_VELOCITY) { translateY.value = withSpring( windowHeight, { damping: 28, stiffness: 240, velocity: e.velocityY, overshootClamping: true, }, (finished) => { if (finished) runOnJS(dismiss)(); } ); } else { translateY.value = withTiming(0, motion.snap); } }); const contentStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], })); const menuLayerStyle = useAnimatedStyle(() => ({ opacity: menuProgress.value, })); const menuCardStyle = useAnimatedStyle(() => ({ transform: [{ translateY: MENU_ENTER_OFFSET_Y * (1 - menuProgress.value) }], })); return ( dismissSheet()} hitSlop={12}> PLAYING FROM {source} {track ? ( {track.artworkData ? ( ) : ( )} {scopeStageVisible && ( useSettingsStore .getState() .setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum') } hitSlop={12} style={styles.scopeSwap} accessibilityRole="button" accessibilityLabel={`Showing ${ scopeMode === 'spectrum' ? 'spectrum' : 'oscilloscope' }. Tap to switch.`} > {scopeMode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'} )} {track.title} {track.artist} void toggleFavorite(track)} accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'} accessibilityState={{ selected: isFavorite }} > void seekTo(seconds)} /> void toggleShuffle()} accessibilityLabel="Shuffle" accessibilityState={{ selected: shuffle }} > void cycleRepeat()} accessibilityLabel="Repeat" accessibilityState={{ selected: repeat !== 'none' }} > {repeat === 'one' ? ( ) : ( )} void setScopeStageVisible(!scopeStageVisible)} accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'} accessibilityState={{ selected: scopeStageVisible }} > setQueueOpen(true)} accessibilityLabel="Queue" > ) : ( Nothing playing Start a track from Home. )} {menuOpen && menuItems.length > 0 && ( {menuItems.map((item) => ( [styles.menuItem, pressed && styles.menuItemPressed]} onPress={item.onPress} accessibilityRole="button" > {item.label} ))} )} setPlaylistActionTrack(null)} /> {queueOpen && setQueueOpen(false)} />} ); } const styles = StyleSheet.create({ backdrop: { flex: 1, backgroundColor: 'transparent', }, content: { flex: 1, backgroundColor: colors.bgPrimary, alignItems: 'center', }, shell: { flex: 1, }, header: { height: HEADER_HEIGHT, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, headerBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', }, headerMid: { flex: 1, alignItems: 'center', }, eyebrow: { color: colors.textTertiary, letterSpacing: 1.5, fontSize: 10, }, source: { 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, }, middleStack: { width: '100%', alignItems: 'center', }, artButton: { alignItems: 'center', justifyContent: 'center', }, artCard: { borderRadius: radius.lg, backgroundColor: colors.bgTertiary, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', }, scopeRail: { alignSelf: 'center', justifyContent: 'center', overflow: 'hidden', }, scopeSwap: { position: 'absolute', top: spacing.xs, right: spacing.sm, flexDirection: 'row', alignItems: 'center', gap: spacing.xs, }, scopeSwapLabel: { color: colors.textTertiary, letterSpacing: 1.5, fontSize: 10, }, artImage: { width: '100%', height: '100%', }, trackInfo: { alignSelf: 'stretch', flexDirection: 'row', alignItems: 'center', gap: spacing.md, marginBottom: spacing.md, }, trackTextStack: { flex: 1, minWidth: 0, alignItems: 'flex-start', }, trackTitle: { alignSelf: 'stretch', textAlign: 'left', }, inlineActionBtn: { width: SUB_BUTTON_SIZE, height: SUB_BUTTON_SIZE, alignItems: 'center', justifyContent: 'center', }, trackMetaRow: { alignSelf: 'stretch', flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: spacing.sm, marginTop: spacing.xs, }, artistButton: { flexShrink: 1, minWidth: 0, }, centered: { textAlign: 'center', }, artist: { color: colors.accentText, }, badges: { flexShrink: 0, }, spacer: { flex: 1, minHeight: MIN_FLOATING_SPACE, }, playerControls: { width: '100%', }, transport: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: TRANSPORT_TOP_MARGIN, }, transportMainBtn: { width: 48, height: 48, alignItems: 'center', justifyContent: 'center', }, transportSideBtn: { width: 48, height: 48, alignItems: 'center', justifyContent: 'center', }, playButton: { width: PLAY_BUTTON_SIZE, height: PLAY_BUTTON_SIZE, borderRadius: radius.pill, backgroundColor: colors.accent, alignItems: 'center', justifyContent: 'center', }, subRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: spacing.lg, marginTop: SUB_TOP_MARGIN, paddingHorizontal: spacing.sm, }, subBtn: { width: SUB_BUTTON_SIZE, height: SUB_BUTTON_SIZE, alignItems: 'center', justifyContent: 'center', }, empty: { flex: 1, alignItems: 'center', justifyContent: 'center', }, });