diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index 5294d60..5ea365f 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -5,17 +5,21 @@ import { Easing } from 'react-native'; import { TabBar, type TabItem } from '@/components/TabBar'; import { colors } from '@/theme'; +const TAB_TRANSITION_MS = 160; + export default function TabsLayout() { return ( { diff --git a/src/app/(tabs)/eq.tsx b/src/app/(tabs)/eq.tsx index 7739bce..048e02b 100644 --- a/src/app/(tabs)/eq.tsx +++ b/src/app/(tabs)/eq.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from 'react'; -import { Pressable, StyleSheet, View, useWindowDimensions } from 'react-native'; +import { InteractionManager, Pressable, StyleSheet, View, useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useFocusEffect } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -56,9 +56,12 @@ export default function EQScreen() { // Gate the post-EQ tap to while this screen is visible. useFocusEffect( useCallback(() => { - setFocused(true); - setActivePostEqNative(true); + const task = InteractionManager.runAfterInteractions(() => { + setFocused(true); + setActivePostEqNative(true); + }); return () => { + task.cancel(); setFocused(false); setActivePostEqNative(false); }; diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index a1d762a..9589125 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -358,16 +358,7 @@ export default function HomeScreen() { const [randomAlbumKey, setRandomAlbumKey] = useState(null); const [randomSeed] = useState(() => Math.random()); const scrollTop = useScrollTopGate(); - - const tracksByAlbum = useMemo(() => { - const map = new Map(); - for (const track of tracks) { - const list = map.get(track.album_identity_key) ?? []; - list.push(track); - map.set(track.album_identity_key, list); - } - return map; - }, [tracks]); + const hasLibrary = tracks.length > 0; const recentlyAddedAlbums = useMemo( () => [...albums].sort((a, b) => b.latest_added_at - a.latest_added_at).slice(0, RECENT_ALBUM_LIMIT), @@ -394,10 +385,21 @@ export default function HomeScreen() { if (selected) return selected; return albums[Math.floor(randomSeed * albums.length) % albums.length]; }, [albums, randomAlbumKey, randomSeed]); - const randomTracks = randomAlbum ? (tracksByAlbum.get(randomAlbum.identity_key) ?? []) : []; + const randomAlbumNeedsTracks = hasLibrary && !currentTrack && randomAlbum != null; + const tracksByAlbum = useMemo(() => { + if (!randomAlbumNeedsTracks) return null; + const map = new Map(); + for (const track of tracks) { + const list = map.get(track.album_identity_key) ?? []; + list.push(track); + map.set(track.album_identity_key, list); + } + return map; + }, [randomAlbumNeedsTracks, tracks]); + const randomTracks = + randomAlbum && tracksByAlbum ? (tracksByAlbum.get(randomAlbum.identity_key) ?? []) : []; const recentTracks = recentlyPlayedTracks.slice(0, RECENT_TRACK_LIMIT); const canExpandRecentTracks = recentlyPlayedTracks.length > RECENT_TRACK_LIMIT; - const hasLibrary = tracks.length > 0; const openAlbum = (album: Album) => { router.push({ @@ -412,6 +414,7 @@ export default function HomeScreen() { }; const playAlbum = (album: Album, shuffled = false) => { + if (!tracksByAlbum) return; const albumTracks = tracksByAlbum.get(album.identity_key) ?? []; if (albumTracks.length === 0) return; if (shuffled) { diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx index 37e7c25..7cfb884 100644 --- a/src/app/(tabs)/library/index.tsx +++ b/src/app/(tabs)/library/index.tsx @@ -52,7 +52,10 @@ export default function LibraryScreen() { const isEmpty = tracks.length === 0 && folders.length === 0 && !isScanning; - const sortedTracks = useMemo(() => sortTracks(tracks, trackSort), [tracks, trackSort]); + const sortedTracks = useMemo( + () => (viewMode === 'tracks' ? sortTracks(tracks, trackSort) : []), + [trackSort, tracks, viewMode] + ); // Tap index is within sortedTracks so the tapped row is the track that plays. const playAllFrom = (index: number) => { diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx index 1f49f67..26e66fc 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(tabs)/settings.tsx @@ -1,5 +1,13 @@ import { useEffect } from 'react'; -import { Alert, View, Pressable, ScrollView, StyleSheet, Switch } from 'react-native'; +import { + Alert, + InteractionManager, + View, + Pressable, + ScrollView, + StyleSheet, + Switch, +} from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { Screen } from '@/components/Screen'; @@ -237,7 +245,10 @@ export default function SettingsScreen() { const setReplayGainMode = useAudioSettingsStore((s) => s.setReplayGainMode); useEffect(() => { - void initDesktopRemote(); + const task = InteractionManager.runAfterInteractions(() => { + void initDesktopRemote(); + }); + return () => task.cancel(); }, [initDesktopRemote]); const desktopRemoteSubtitle = desktopRemoteConnection diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index 9a9be01..303b712 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -15,12 +15,16 @@ const PILL_HEIGHT = 56; const ART = 42; const CURVE_POINTS = 64; +interface MiniPlayerProps { + visible?: boolean; +} + /** * Persistent floating mini-player (M3 redesign): a rounded pill above the tab * bar with the live filled-line spectrum drifting behind the metadata. Tapping * opens the full now-playing screen. */ -export function MiniPlayer() { +export function MiniPlayer({ visible = true }: MiniPlayerProps) { const router = useRouter(); const track = usePlayerStore((s) => s.currentTrack); const playbackState = usePlayerStore((s) => s.playbackState); @@ -35,15 +39,21 @@ export function MiniPlayer() { const isPlaying = playbackState === 'playing'; const isLoading = playbackState === 'loading'; const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0; + const liveScopeActive = visible && scopeActive; const onLayout = (e: LayoutChangeEvent) => setPillWidth(e.nativeEvent.layout.width); return ( - router.push('/now-playing')} onLayout={onLayout}> - {scopeActive && pillWidth > 0 && ( + router.push('/now-playing')} + onLayout={onLayout} + > + {liveScopeActive && pillWidth > 0 && ( )} - {scopeActive && pillWidth > 0 && } + {liveScopeActive && pillWidth > 0 && } @@ -110,6 +120,9 @@ const styles = StyleSheet.create({ overflow: 'hidden', justifyContent: 'center', }, + hidden: { + opacity: 0, + }, spectrum: { position: 'absolute', top: 0, diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 4146ff9..05e0ece 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { View, Pressable, StyleSheet, type LayoutChangeEvent } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; @@ -13,6 +13,9 @@ import { colors, fonts, layout, spacing } from '@/theme'; import { motion } from '@/theme/motion'; type IconName = keyof typeof Ionicons.glyphMap; +type MiniPlayerPhase = 'hidden' | 'reserved' | 'visible'; + +const TAB_TRANSITION_MS = 160; export const TAB_META: Record = { index: { label: 'Home', icon: 'home' }, @@ -41,6 +44,7 @@ export function TabBar({ items, onPress }: TabBarProps) { const insets = useSafeAreaInsets(); const tabs = items.filter((item) => TAB_META[item.name]); const homeFocused = items.some((item) => item.name === 'index' && item.focused); + const [settledHomeFocused, setSettledHomeFocused] = useState(homeFocused); const count = tabs.length; const activeIndex = Math.max( 0, @@ -56,6 +60,23 @@ export function TabBar({ items, onPress }: TabBarProps) { position.value = withTiming(activeIndex, motion.snap); }, [activeIndex, position]); + useEffect(() => { + if (settledHomeFocused === homeFocused) { + return; + } + + const timer = setTimeout(() => setSettledHomeFocused(homeFocused), TAB_TRANSITION_MS); + return () => clearTimeout(timer); + }, [homeFocused, settledHomeFocused]); + + const miniPlayerPhase: MiniPlayerPhase = homeFocused + ? settledHomeFocused + ? 'hidden' + : 'reserved' + : settledHomeFocused + ? 'hidden' + : 'visible'; + const indicatorStyle = useAnimatedStyle(() => { const segment = count > 0 ? barWidth.value / count : 0; return { @@ -70,7 +91,8 @@ export function TabBar({ items, onPress }: TabBarProps) { return ( - {!homeFocused ? : null} + {miniPlayerPhase === 'visible' ? : null} + {miniPlayerPhase === 'reserved' ? : null}