smoothing

This commit is contained in:
Boof2015
2026-07-01 19:08:32 -04:00
parent 2016a0037d
commit cab67fbc05
7 changed files with 85 additions and 26 deletions
+5 -1
View File
@@ -5,17 +5,21 @@ import { Easing } from 'react-native';
import { TabBar, type TabItem } from '@/components/TabBar'; import { TabBar, type TabItem } from '@/components/TabBar';
import { colors } from '@/theme'; import { colors } from '@/theme';
const TAB_TRANSITION_MS = 160;
export default function TabsLayout() { export default function TabsLayout() {
return ( return (
<Tabs <Tabs
detachInactiveScreens
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
freezeOnBlur: true,
sceneStyle: { backgroundColor: colors.bgPrimary }, sceneStyle: { backgroundColor: colors.bgPrimary },
// Directional slide + cross-fade between tabs, following tab order. // Directional slide + cross-fade between tabs, following tab order.
animation: 'shift', animation: 'shift',
transitionSpec: { transitionSpec: {
animation: 'timing', animation: 'timing',
config: { duration: 200, easing: Easing.out(Easing.cubic) }, config: { duration: TAB_TRANSITION_MS, easing: Easing.out(Easing.cubic) },
}, },
}} }}
tabBar={({ state, navigation }) => { tabBar={({ state, navigation }) => {
+6 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useState } from 'react'; 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 { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from 'expo-router'; import { useFocusEffect } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; 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. // Gate the post-EQ tap to while this screen is visible.
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
setFocused(true); const task = InteractionManager.runAfterInteractions(() => {
setActivePostEqNative(true); setFocused(true);
setActivePostEqNative(true);
});
return () => { return () => {
task.cancel();
setFocused(false); setFocused(false);
setActivePostEqNative(false); setActivePostEqNative(false);
}; };
+15 -12
View File
@@ -358,16 +358,7 @@ export default function HomeScreen() {
const [randomAlbumKey, setRandomAlbumKey] = useState<string | null>(null); const [randomAlbumKey, setRandomAlbumKey] = useState<string | null>(null);
const [randomSeed] = useState(() => Math.random()); const [randomSeed] = useState(() => Math.random());
const scrollTop = useScrollTopGate(); const scrollTop = useScrollTopGate();
const hasLibrary = tracks.length > 0;
const tracksByAlbum = useMemo(() => {
const map = new Map<string, DbTrack[]>();
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 recentlyAddedAlbums = useMemo( const recentlyAddedAlbums = useMemo(
() => [...albums].sort((a, b) => b.latest_added_at - a.latest_added_at).slice(0, RECENT_ALBUM_LIMIT), () => [...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; if (selected) return selected;
return albums[Math.floor(randomSeed * albums.length) % albums.length]; return albums[Math.floor(randomSeed * albums.length) % albums.length];
}, [albums, randomAlbumKey, randomSeed]); }, [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<string, DbTrack[]>();
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 recentTracks = recentlyPlayedTracks.slice(0, RECENT_TRACK_LIMIT);
const canExpandRecentTracks = recentlyPlayedTracks.length > RECENT_TRACK_LIMIT; const canExpandRecentTracks = recentlyPlayedTracks.length > RECENT_TRACK_LIMIT;
const hasLibrary = tracks.length > 0;
const openAlbum = (album: Album) => { const openAlbum = (album: Album) => {
router.push({ router.push({
@@ -412,6 +414,7 @@ export default function HomeScreen() {
}; };
const playAlbum = (album: Album, shuffled = false) => { const playAlbum = (album: Album, shuffled = false) => {
if (!tracksByAlbum) return;
const albumTracks = tracksByAlbum.get(album.identity_key) ?? []; const albumTracks = tracksByAlbum.get(album.identity_key) ?? [];
if (albumTracks.length === 0) return; if (albumTracks.length === 0) return;
if (shuffled) { if (shuffled) {
+4 -1
View File
@@ -52,7 +52,10 @@ export default function LibraryScreen() {
const isEmpty = tracks.length === 0 && folders.length === 0 && !isScanning; 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. // Tap index is within sortedTracks so the tapped row is the track that plays.
const playAllFrom = (index: number) => { const playAllFrom = (index: number) => {
+13 -2
View File
@@ -1,5 +1,13 @@
import { useEffect } from 'react'; 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 { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen'; import { Screen } from '@/components/Screen';
@@ -237,7 +245,10 @@ export default function SettingsScreen() {
const setReplayGainMode = useAudioSettingsStore((s) => s.setReplayGainMode); const setReplayGainMode = useAudioSettingsStore((s) => s.setReplayGainMode);
useEffect(() => { useEffect(() => {
void initDesktopRemote(); const task = InteractionManager.runAfterInteractions(() => {
void initDesktopRemote();
});
return () => task.cancel();
}, [initDesktopRemote]); }, [initDesktopRemote]);
const desktopRemoteSubtitle = desktopRemoteConnection const desktopRemoteSubtitle = desktopRemoteConnection
+18 -5
View File
@@ -15,12 +15,16 @@ const PILL_HEIGHT = 56;
const ART = 42; const ART = 42;
const CURVE_POINTS = 64; const CURVE_POINTS = 64;
interface MiniPlayerProps {
visible?: boolean;
}
/** /**
* Persistent floating mini-player (M3 redesign): a rounded pill above the tab * Persistent floating mini-player (M3 redesign): a rounded pill above the tab
* bar with the live filled-line spectrum drifting behind the metadata. Tapping * bar with the live filled-line spectrum drifting behind the metadata. Tapping
* opens the full now-playing screen. * opens the full now-playing screen.
*/ */
export function MiniPlayer() { export function MiniPlayer({ visible = true }: MiniPlayerProps) {
const router = useRouter(); const router = useRouter();
const track = usePlayerStore((s) => s.currentTrack); const track = usePlayerStore((s) => s.currentTrack);
const playbackState = usePlayerStore((s) => s.playbackState); const playbackState = usePlayerStore((s) => s.playbackState);
@@ -35,15 +39,21 @@ export function MiniPlayer() {
const isPlaying = playbackState === 'playing'; const isPlaying = playbackState === 'playing';
const isLoading = playbackState === 'loading'; const isLoading = playbackState === 'loading';
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0; const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
const liveScopeActive = visible && scopeActive;
const onLayout = (e: LayoutChangeEvent) => setPillWidth(e.nativeEvent.layout.width); const onLayout = (e: LayoutChangeEvent) => setPillWidth(e.nativeEvent.layout.width);
return ( return (
<Pressable style={styles.pill} onPress={() => router.push('/now-playing')} onLayout={onLayout}> <Pressable
{scopeActive && pillWidth > 0 && ( pointerEvents={visible ? 'auto' : 'none'}
style={[styles.pill, !visible && styles.hidden]}
onPress={() => router.push('/now-playing')}
onLayout={onLayout}
>
{liveScopeActive && pillWidth > 0 && (
<View pointerEvents="none" style={styles.spectrum}> <View pointerEvents="none" style={styles.spectrum}>
<SpectrumCurve <SpectrumCurve
active={scopeActive} active={liveScopeActive}
pointCount={CURVE_POINTS} pointCount={CURVE_POINTS}
analysisFrameMs={0} analysisFrameMs={0}
dbMin={-84} dbMin={-84}
@@ -58,7 +68,7 @@ export function MiniPlayer() {
/> />
</View> </View>
)} )}
{scopeActive && pillWidth > 0 && <View pointerEvents="none" style={styles.spectrumVeil} />} {liveScopeActive && pillWidth > 0 && <View pointerEvents="none" style={styles.spectrumVeil} />}
<View style={styles.row}> <View style={styles.row}>
<View style={styles.art}> <View style={styles.art}>
@@ -110,6 +120,9 @@ const styles = StyleSheet.create({
overflow: 'hidden', overflow: 'hidden',
justifyContent: 'center', justifyContent: 'center',
}, },
hidden: {
opacity: 0,
},
spectrum: { spectrum: {
position: 'absolute', position: 'absolute',
top: 0, top: 0,
+24 -2
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { View, Pressable, StyleSheet, type LayoutChangeEvent } from 'react-native'; import { View, Pressable, StyleSheet, type LayoutChangeEvent } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@@ -13,6 +13,9 @@ import { colors, fonts, layout, spacing } from '@/theme';
import { motion } from '@/theme/motion'; import { motion } from '@/theme/motion';
type IconName = keyof typeof Ionicons.glyphMap; type IconName = keyof typeof Ionicons.glyphMap;
type MiniPlayerPhase = 'hidden' | 'reserved' | 'visible';
const TAB_TRANSITION_MS = 160;
export const TAB_META: Record<string, { label: string; icon: IconName }> = { export const TAB_META: Record<string, { label: string; icon: IconName }> = {
index: { label: 'Home', icon: 'home' }, index: { label: 'Home', icon: 'home' },
@@ -41,6 +44,7 @@ export function TabBar({ items, onPress }: TabBarProps) {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const tabs = items.filter((item) => TAB_META[item.name]); const tabs = items.filter((item) => TAB_META[item.name]);
const homeFocused = items.some((item) => item.name === 'index' && item.focused); const homeFocused = items.some((item) => item.name === 'index' && item.focused);
const [settledHomeFocused, setSettledHomeFocused] = useState(homeFocused);
const count = tabs.length; const count = tabs.length;
const activeIndex = Math.max( const activeIndex = Math.max(
0, 0,
@@ -56,6 +60,23 @@ export function TabBar({ items, onPress }: TabBarProps) {
position.value = withTiming(activeIndex, motion.snap); position.value = withTiming(activeIndex, motion.snap);
}, [activeIndex, position]); }, [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 indicatorStyle = useAnimatedStyle(() => {
const segment = count > 0 ? barWidth.value / count : 0; const segment = count > 0 ? barWidth.value / count : 0;
return { return {
@@ -70,7 +91,8 @@ export function TabBar({ items, onPress }: TabBarProps) {
return ( return (
<View style={styles.wrap}> <View style={styles.wrap}>
{!homeFocused ? <MiniPlayer /> : null} {miniPlayerPhase === 'visible' ? <MiniPlayer /> : null}
{miniPlayerPhase === 'reserved' ? <MiniPlayer visible={false} /> : null}
<View <View
style={[ style={[
styles.bar, styles.bar,