mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
smoothing
This commit is contained in:
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user