fix internal routing, and remove softlocking

This commit is contained in:
Boof2015
2026-07-25 11:12:02 -04:00
parent 098992639d
commit 0f34ca2e8d
33 changed files with 904 additions and 157 deletions
+15 -3
View File
@@ -5,6 +5,7 @@ import {
TAB_TRANSITION_SETTLE_MS,
TAB_TRANSITION_SPEC,
} from '@/navigation/tabTransition';
import { popToTop } from '@/navigation/stackActions';
import { useColors } from '@/theme/themed';
export default function TabsLayout() {
@@ -46,10 +47,21 @@ export default function TabsLayout() {
target: item.key,
canPreventDefault: true,
});
if (!item.focused && !event.defaultPrevented) {
lastSwitchAt.current = now;
navigation.navigate(item.name);
if (event.defaultPrevented) return;
if (item.focused) {
// Re-tapping the active tab resets its nested stack. This is the
// one-tap escape from a deep library chain (artist → album →
// another artist), which is why back itself only pops one level.
const nested = state.routes[state.index]?.state;
if (nested?.key && (nested.index ?? 0) > 0) {
navigation.dispatch({ ...popToTop(), target: nested.key });
}
return;
}
lastSwitchAt.current = now;
navigation.navigate(item.name);
};
return <TabBar items={items} onPress={handlePress} />;
+10
View File
@@ -1,6 +1,16 @@
import { Stack } from 'expo-router';
import { useColors } from '@/theme/themed';
/**
* Anchor the library stack at its list. Without this, navigating straight to a
* detail route (from Home, quick search, or the now-playing overlay) builds a
* stack of just `[album]` with nothing beneath it — so popping one level had
* nowhere to go and back escaped the tab entirely.
*/
export const unstable_settings = {
initialRouteName: 'index',
};
/**
* Nested stack inside the Library tab so album/artist detail screens keep the
* tab bar + mini-player visible.
+3 -2
View File
@@ -40,7 +40,7 @@ export default function AlbumScreen() {
const { key } = useLocalSearchParams<{ key: string }>();
const { items: tracks, summary: album, totalCount, loadMore } = useNativeAlbumDetail(key);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const handleBack = useLibraryDetailBack();
const { goBack, backLabel } = useLibraryDetailBack();
const insets = useSafeAreaInsets();
const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } =
useDetailCollapse();
@@ -154,7 +154,8 @@ export default function AlbumScreen() {
</>
}
disabled={tracks.length === 0}
onBack={handleBack}
onBack={goBack}
backLabel={backLabel}
onPlay={() => playFrom(0)}
onShuffle={() => void playLibraryQuery({ kind: 'album', albumKey: key }, {
shuffle: true,
+3 -2
View File
@@ -71,7 +71,7 @@ export default function ArtistScreen() {
name: string;
credit?: string;
}>();
const handleBack = useLibraryDetailBack();
const { goBack, backLabel } = useLibraryDetailBack();
const insets = useSafeAreaInsets();
const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } =
useDetailCollapse();
@@ -246,7 +246,8 @@ export default function ArtistScreen() {
</View>
}
disabled={disabled}
onBack={handleBack}
onBack={goBack}
backLabel={backLabel}
onPlay={playArtist}
onShuffle={shuffleArtist}
scrollY={scrollY}
+16 -10
View File
@@ -1,5 +1,5 @@
import {
useEffect,
useCallback,
useMemo,
useState
} from 'react';
@@ -11,7 +11,7 @@ import {
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { useRouter } from 'expo-router';
import { useFocusEffect, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { ViewModeSwitcher } from '@/components/library/ViewModeSwitcher';
@@ -165,14 +165,20 @@ export default function LibraryScreen() {
setPlaylistPickerOpen(false);
};
useEffect(() => {
if (!selectMode) return;
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
exitSelection();
return true;
});
return () => sub.remove();
}, [selectMode]);
// Focus-gated, not a plain effect: the tabs layout keeps this screen mounted
// while blurred (`detachInactiveScreens={false}` + `freezeOnBlur: false`), so
// an ungated handler swallowed one back press anywhere in the app — any other
// tab, any settings screen — whenever selection happened to be active.
useFocusEffect(
useCallback(() => {
if (!selectMode) return undefined;
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
exitSelection();
return true;
});
return () => sub.remove();
}, [selectMode])
);
const selectedDbTracks = () => sortedTracks.filter((track) => selectedIds.has(track.id));
+4 -3
View File
@@ -89,7 +89,7 @@ export default function PlaylistScreen() {
const colors = useColors();
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const handleBack = useLibraryDetailBack();
const { goBack, backLabel } = useLibraryDetailBack();
const isFavorites = id === 'favorites';
const playlistId = isFavorites ? null : Number(id);
@@ -219,7 +219,7 @@ export default function PlaylistScreen() {
void (async () => {
try {
await deletePlaylist(target.id);
handleBack();
goBack();
} catch (err) {
Alert.alert('Delete failed', errorMessage(err));
}
@@ -340,7 +340,8 @@ export default function PlaylistScreen() {
) : null
}
disabled={playable.length === 0}
onBack={handleBack}
onBack={goBack}
backLabel={backLabel}
onMore={() => setOptionsOpen(true)}
onPlay={() => startPlayback(0)}
onShuffle={startShuffle}
+3 -1
View File
@@ -18,6 +18,7 @@ import {
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useReturnToTabs } from '@/navigation/returnToTabs';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AstraLogo } from '@/components/AstraLogo';
import { Screen } from '@/components/Screen';
@@ -225,6 +226,7 @@ export default function DesktopRemoteScreen() {
const ripple = useRipple();
const colors = useColors();
const router = useRouter();
const returnToTabs = useReturnToTabs();
const pairingParams = useLocalSearchParams<{
pair?: string;
baseUrl?: string;
@@ -620,7 +622,7 @@ export default function DesktopRemoteScreen() {
// screen so it slides in above wherever the user came from.
usePlayerUiStore.getState().openPlayer();
if (router.canGoBack()) router.back();
else router.replace('/');
else returnToTabs('/');
}}
>
<Ionicons name="musical-notes-outline" size={18} color={colors.accentTextStrong} />
+6 -3
View File
@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useLocalSearchParams } from 'expo-router';
import { useReturnToTabs } from '@/navigation/returnToTabs';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet';
@@ -17,7 +18,7 @@ export default function EQPresetImportScreen() {
const styles = useStyles();
const ripple = useRipple();
const colors = useColors();
const router = useRouter();
const returnToTabs = useReturnToTabs();
const importPreset = useEQStore((state) => state.importPreset);
const { data } = useLocalSearchParams<{ data?: string }>();
@@ -30,7 +31,9 @@ export default function EQPresetImportScreen() {
}
}, [data]);
const goToEq = () => router.replace('/eq' as never);
// `/eq` is a tab, and this screen is a root-stack sibling of `(tabs)`, so a
// bare replace here mints a second copy of the whole tab tree.
const goToEq = () => returnToTabs('/eq' as never);
if (!preset) {
return (
+4 -1
View File
@@ -11,6 +11,7 @@ import {
} from 'expo-camera';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useReturnToTabs } from '@/navigation/returnToTabs';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet';
@@ -30,6 +31,7 @@ export default function EQPresetScanScreen() {
const ripple = useRipple();
const colors = useColors();
const router = useRouter();
const returnToTabs = useReturnToTabs();
const importPreset = useEQStore((state) => state.importPreset);
const [permission, requestPermission] = useCameraPermissions();
const [pendingPreset, setPendingPreset] = useState<EQPreset | null>(null);
@@ -109,7 +111,8 @@ export default function EQPresetScanScreen() {
onConfirm={() => {
importPreset(pendingPreset);
setPendingPreset(null);
router.replace('/eq' as never);
// `/eq` is a tab; see useReturnToTabs.
returnToTabs('/eq' as never);
}}
onClose={() => setPendingPreset(null)}
/>
+8 -5
View File
@@ -1,28 +1,31 @@
import { useEffect } from 'react';
import { View } from 'react-native';
import { useRouter } from 'expo-router';
import { resolveNotificationClick } from '@/audio/notificationIntent';
import { useReturnToTabs } from '@/navigation/returnToTabs';
import { createThemedStyles } from '@/theme/themed';
export default function NotificationClickRoute() {
const styles = useStyles();
const router = useRouter();
const returnToTabs = useReturnToTabs();
// This route is a root-stack sibling of `(tabs)`, and it is entered often
// (media-notification and widget taps). A bare replace toward a tab route
// therefore left a duplicate `(tabs)` behind on every single tap.
useEffect(() => {
let cancelled = false;
resolveNotificationClick()
.then((href) => {
if (!cancelled) router.replace(href);
if (!cancelled) returnToTabs(href);
})
.catch(() => {
if (!cancelled) router.replace('/');
if (!cancelled) returnToTabs('/');
});
return () => {
cancelled = true;
};
}, [router]);
}, [returnToTabs]);
return <View style={styles.root} />;
}