library nav and scroll to top on double press

This commit is contained in:
Boof2015
2026-08-02 17:19:52 -04:00
parent dfbb86706c
commit cf4f09c077
20 changed files with 619 additions and 18 deletions
+1 -1
View File
@@ -87,7 +87,7 @@
"test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.test.mts",
"test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts src/components/player/nowPlayingDismiss.test.mts src/playback/playbackTargetPresentation.test.mts",
"test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs",
"test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/navigation/shellLayout.test.mts src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts src/navigation/homeLibraryNavigation.test.mts src/navigation/tabTransition.test.mts src/navigation/statsTabState.test.mts",
"test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/navigation/shellLayout.test.mts src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts src/navigation/homeLibraryNavigation.test.mts src/navigation/tabTransition.test.mts src/navigation/statsTabState.test.mts src/navigation/tabStackReset.test.mts src/navigation/tabReselect.test.mts src/navigation/scrollToTopBehavior.test.mts src/library/libraryWindowTop.test.mts",
"test:library-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/libraryLayout.test.mts src/components/library/detailHeroLayout.test.mts",
"test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts",
"test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts",
+40 -3
View File
@@ -1,20 +1,33 @@
import { useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { Tabs } from 'expo-router';
import { TabBar, type TabItem } from '@/components/TabBar';
import { useShellLayout } from '@/navigation/useShellLayout';
import { ShellRailContext } from '@/navigation/shellRailContext';
import {
TAB_PRESS_SWALLOW_MS,
TAB_SCENE_ANIMATION,
TAB_TRANSITION_SETTLE_MS,
TAB_STACK_RESET_DELAY_MS,
TAB_TRANSITION_SPEC,
} from '@/navigation/tabTransition';
import { popToTop } from '@/navigation/stackActions';
import {
leavingStackResetTarget,
shouldApplyStackReset,
type TabsStateLike,
} from '@/navigation/tabStackReset';
import { emitTabReselect } from '@/navigation/tabReselect';
import { useColors } from '@/theme/themed';
import { isDisplayedTabFocused } from '@/navigation/statsTabState';
export default function TabsLayout() {
const colors = useColors();
const lastSwitchAt = useRef(0);
const pendingStackReset = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (pendingStackReset.current) clearTimeout(pendingStackReset.current);
pendingStackReset.current = null;
}, []);
// Landscape moves navigation to a rail down the leading edge and hands the
// scene back the ~152dp the tab bar and mini player were costing it. The
@@ -70,7 +83,7 @@ export default function TabsLayout() {
// completion frame and leave the incoming scene invisible; swallow
// taps until the current transition has finished.
const now = Date.now();
if (now - lastSwitchAt.current < TAB_TRANSITION_SETTLE_MS + 30) return;
if (now - lastSwitchAt.current < TAB_PRESS_SWALLOW_MS) return;
const event = navigation.emit({
type: 'tabPress',
target: item.key,
@@ -86,12 +99,36 @@ export default function TabsLayout() {
const nested = state.routes[state.index]?.state;
if (nested?.key && (nested.index ?? 0) > 0) {
navigation.dispatch({ ...popToTop(), target: nested.key });
// Escaping the chain is the whole press; the list underneath
// keeps the place the user left it in.
return;
}
// Already at this tab's root, so the press means "top of the
// list" — which is also the only offset where pull-to-search arms.
emitTabReselect(item.name);
return;
}
// Rewind the tab being *left*, after the fade rather than on the way
// in; see `tabStackReset` for why arrival is the wrong side of the
// transition. Read before navigating, while `state` still describes
// the tab we are leaving.
const resetTarget = leavingStackResetTarget(state);
lastSwitchAt.current = now;
navigation.navigate(item.name);
if (!resetTarget) return;
if (pendingStackReset.current) clearTimeout(pendingStackReset.current);
pendingStackReset.current = setTimeout(() => {
pendingStackReset.current = null;
const current = navigation.getState() as TabsStateLike | undefined;
if (!shouldApplyStackReset(current, resetTarget)) return;
// A targeted action bubbles to the root and back down through every
// mounted navigator, focused or not — which holds only because this
// navigator keeps inactive scenes attached and unfrozen
// (`detachInactiveScreens={false}` + `freezeOnBlur: false` below).
navigation.dispatch({ ...popToTop(), target: resetTarget });
}, TAB_STACK_RESET_DELAY_MS);
};
return <TabBar items={items} onPress={handlePress} shell={shell} />;
+22 -1
View File
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import {
AppState,
Pressable,
ScrollView,
StyleSheet,
View,
useWindowDimensions,
type GestureResponderEvent,
type LayoutChangeEvent,
type StyleProp,
@@ -55,6 +56,8 @@ import {
import { useListeningStatsStore } from '@/stores/listeningStatsStore';
import { subscribeToListeningHistory } from '@/listeningStats/events';
import { useSceneBottomInset } from '@/navigation/useShellLayout';
import { useTabReselect } from '@/navigation/useTabReselect';
import { shouldAnimateScrollToTop } from '@/navigation/scrollToTopBehavior';
const RECENT_ALBUM_LIMIT = 8;
const PLAYLIST_LIMIT = 4;
@@ -601,6 +604,23 @@ export default function HomeScreen() {
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
const scrollTop = useScrollTopGate();
const hasLibrary = totalTrackCount > 0;
const { height: windowHeight } = useWindowDimensions();
const scrollRef = useRef<ScrollView>(null);
// Stable members of the gate; the object itself is rebuilt on every `atTop` flip.
const { offsetRef: scrollOffsetRef, setScrollAtTop } = scrollTop;
// Re-tapping Home while already on it returns to the top, matching Library.
const scrollHomeToTop = useCallback(() => {
scrollRef.current?.scrollTo({
y: 0,
animated: shouldAnimateScrollToTop(scrollOffsetRef.current, windowHeight),
});
// A programmatic scroll gives the pull-to-search gate no onScroll it can
// rely on, so arm it directly.
setScrollAtTop(true);
}, [scrollOffsetRef, setScrollAtTop, windowHeight]);
useTabReselect('index', scrollHomeToTop);
// Measured, not derived from the window: the player dock takes a column out
// of the scene, so a tablet with the dock open has a phone's worth of content
@@ -824,6 +844,7 @@ export default function HomeScreen() {
<Screen>
<PullSearchGesture atTop={scrollTop.atTop} onOpen={openSearch}>
<PullSearchScrollView
ref={scrollRef}
showsVerticalScrollIndicator={false}
overScrollMode="never"
contentContainerStyle={{ paddingBottom: sceneBottomInset }}
+81 -2
View File
@@ -10,10 +10,11 @@ import {
BackHandler,
View,
Pressable,
StyleSheet
StyleSheet,
useWindowDimensions
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
import { FlashList, type FlashListRef } from '@shopify/flash-list';
import { useFocusEffect, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
@@ -45,6 +46,10 @@ import { spacing } from '@/theme';
import { useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { useSceneBottomInset, useShellShowsScreenTitle } from '@/navigation/useShellLayout';
import { useTabReselect } from '@/navigation/useTabReselect';
import { shouldAnimateScrollToTop } from '@/navigation/scrollToTopBehavior';
import type { ScrollToTopHandle } from '@/navigation/scrollToTopHandle';
import { needsWindowRewind } from '@/library/libraryWindowTop';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSearchStore } from '@/stores/searchStore';
@@ -74,6 +79,8 @@ import {
type LibraryLayout,
} from '@/library/libraryLayout';
import type {
Album,
Artist,
DbTrack
} from '@/types/library';
@@ -142,6 +149,28 @@ export default function LibraryScreen() {
// which `libraryGridColumns` reads as "use the preference".
const [gridWidth, setGridWidth] = useState(0);
const scrollTop = useScrollTopGate();
// The gate object is rebuilt whenever `atTop` flips; these two members are
// stable, so depending on them keeps the re-tap subscription from churning on
// every scroll.
const { offsetRef: scrollOffsetRef, setScrollAtTop } = scrollTop;
const { height: windowHeight } = useWindowDimensions();
// One ref per view mode so a tab re-tap can send whichever list is on screen
// back to the top. The `key` prop already remounts these on sort/layout
// changes; refs re-attach with the new instance on their own.
const albumListRef = useRef<FlashListRef<Album>>(null);
const artistListRef = useRef<FlashListRef<Artist>>(null);
const trackListRef = useRef<FlashListRef<DbTrack>>(null);
// Callback refs: these two lists are keyed on row types private to their own
// components, so they hand back the narrow handle instead.
const playlistListRef = useRef<ScrollToTopHandle | null>(null);
const folderListRef = useRef<ScrollToTopHandle | null>(null);
const setPlaylistList = useCallback((list: ScrollToTopHandle | null) => {
playlistListRef.current = list;
}, []);
const setFolderList = useCallback((list: ScrollToTopHandle | null) => {
folderListRef.current = list;
}, []);
const showLibraryStatus =
totalTrackCount === 0 &&
@@ -225,6 +254,51 @@ export default function LibraryScreen() {
pendingJump.current = null;
}, []);
// Re-tapping the Library tab while already on the list means "back to the
// top" — which is also the only offset where pull-to-search arms, so this is
// the quick way to reach it.
const scrollToLibraryTop = useCallback(() => {
// A rail scrub still inside its debounce would otherwise land after this
// and drop the list straight back down the alphabet.
if (pendingJump.current) {
clearTimeout(pendingJump.current.timer);
pendingJump.current = null;
}
const state = useLibraryStore.getState();
if (!needsWindowRewind(state.viewMode, state)) {
const list =
state.viewMode === 'albums'
? albumListRef.current
: state.viewMode === 'artists'
? artistListRef.current
: state.viewMode === 'tracks'
? trackListRef.current
: state.viewMode === 'playlists'
? playlistListRef.current
: folderListRef.current;
// Null in the empty-library state, where no list is mounted at all.
list?.scrollToTop({
animated: shouldAnimateScrollToTop(scrollOffsetRef.current, windowHeight),
});
// A programmatic scroll gives the gate no onScroll it can rely on, so arm
// it directly — the same thing a rail jump (:200), a layout change and a
// view-mode change already do.
setScrollAtTop(true);
return;
}
// Rows exist above the loaded window (an A-Z jump put them there), so offset
// 0 is not the top of the library. Rebuild from page 1 and let the forced
// remount bring the list back at row 0. Arming the gate only once that
// resolves matters: doing it up front would let a pull open search while the
// list is still showing mid-catalog rows at a non-zero offset.
void useLibraryStore.getState().rewindToHead().then((applied) => {
if (applied) setScrollAtTop(true);
});
}, [scrollOffsetRef, setScrollAtTop, windowHeight]);
useTabReselect('library', scrollToLibraryTop);
// Multi-select (tracks view): long-press arms it, batch actions live in the
// bottom bar, selection order follows the current display order.
const enterSelection = (track: DbTrack) => {
@@ -441,6 +515,7 @@ export default function LibraryScreen() {
>
{viewMode === 'albums' ? (
<FlashList
ref={albumListRef}
key={`albums-${albumSort}-${albumLayout}-${albumColumns}-${sectionJumpRevision}`}
data={sortedAlbums}
numColumns={albumColumns}
@@ -487,6 +562,7 @@ export default function LibraryScreen() {
{viewMode === 'artists' ? (
<FlashList
ref={artistListRef}
key={`artists-${artistSort}-${artistLayout}-${artistColumns}-${sectionJumpRevision}`}
data={sortedArtists}
numColumns={artistColumns}
@@ -533,6 +609,7 @@ export default function LibraryScreen() {
{viewMode === 'tracks' ? (
<FlashList
ref={trackListRef}
key={`tracks-${trackSort}-${sectionJumpRevision}`}
data={sortedTracks}
keyExtractor={(track) => String(track.id)}
@@ -566,6 +643,7 @@ export default function LibraryScreen() {
{viewMode === 'playlists' ? (
<PlaylistsView
listRef={setPlaylistList}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
/>
@@ -573,6 +651,7 @@ export default function LibraryScreen() {
{viewMode === 'folders' ? (
<FoldersView
listRef={setFolderList}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
/>
+5 -1
View File
@@ -33,12 +33,15 @@ import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { usePlayerStore } from '@/stores/playerStore';
import type { DbTrack } from '@/types/library';
import { useSceneBottomInset } from '@/navigation/useShellLayout';
import type { ScrollToTopHandle } from '@/navigation/scrollToTopHandle';
const PAGE_SIZE = 100;
interface FoldersViewProps {
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
scrollEventThrottle?: number;
/** Lets the Library screen send this list back to the top on a tab re-tap. */
listRef?: (list: ScrollToTopHandle | null) => void;
}
interface LoadedNode {
@@ -204,7 +207,7 @@ function FolderTrackRow({
);
}
export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps) {
export function FoldersView({ onScroll, scrollEventThrottle, listRef }: FoldersViewProps) {
const sceneBottomInset = useSceneBottomInset();
const styles = useStyles();
const colors = useColors();
@@ -329,6 +332,7 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps)
return (
<>
<FlashList
ref={listRef}
data={rows}
keyExtractor={(row) => row.id}
showsVerticalScrollIndicator={false}
+5
View File
@@ -18,6 +18,7 @@ import {
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
import { showAppDialog } from '@/components/dialogs/AppDialog';
import { PlaylistRow } from '@/components/library/PlaylistRow';
import type { ScrollToTopHandle } from '@/navigation/scrollToTopHandle';
import { PullSearchScrollView } from '@/components/search/PullSearchGesture';
import {
radius,
@@ -44,9 +45,12 @@ type Prompt = { kind: 'create' } | { kind: 'rename'; playlist: Playlist } | null
export function PlaylistsView({
onScroll,
scrollEventThrottle,
listRef,
}: {
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
scrollEventThrottle?: number;
/** Lets the Library screen send this list back to the top on a tab re-tap. */
listRef?: (list: ScrollToTopHandle | null) => void;
}) {
const sceneBottomInset = useSceneBottomInset();
const styles = useStyles();
@@ -179,6 +183,7 @@ export function PlaylistsView({
return (
<View style={styles.container}>
<FlashList
ref={listRef}
data={playlists}
keyExtractor={(playlist) => String(playlist.id)}
showsVerticalScrollIndicator={false}
+9 -2
View File
@@ -96,8 +96,13 @@ export function usePullSearchGestureRef(): PullSearchGestureRef | null {
export function useScrollTopGate(initialAtTop = true) {
const atTopRef = useRef(initialAtTop);
const [atTop, setAtTop] = useState(initialAtTop);
// A ref, not state: this only ever feeds an imperative decision (how far a
// scroll-to-top has to travel), so re-rendering the screen on every frame of
// every scroll to carry it would be pure cost.
const offsetRef = useRef(0);
const setScrollAtTop = useCallback((next: boolean) => {
if (next) offsetRef.current = 0;
if (next === atTopRef.current) return;
atTopRef.current = next;
setAtTop(next);
@@ -105,12 +110,14 @@ export function useScrollTopGate(initialAtTop = true) {
const onScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
setScrollAtTop(event.nativeEvent.contentOffset.y <= 2);
const { y } = event.nativeEvent.contentOffset;
offsetRef.current = y;
setScrollAtTop(y <= 2);
},
[setScrollAtTop]
);
return { atTop, onScroll, scrollEventThrottle: 16 as const, setScrollAtTop };
return { atTop, offsetRef, onScroll, scrollEventThrottle: 16 as const, setScrollAtTop };
}
export function PullSearchGesture({
+35
View File
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { needsWindowRewind, type LibraryWindowCursors } from './libraryWindowTop.ts';
const atHead: LibraryWindowCursors = {
trackPrevCursor: null,
albumPrevCursor: null,
artistPrevCursor: null,
};
test('a window starting at the head of the catalog only needs scrolling', () => {
assert.equal(needsWindowRewind('tracks', atHead), false);
assert.equal(needsWindowRewind('albums', atHead), false);
assert.equal(needsWindowRewind('artists', atHead), false);
});
test('each view mode reads only its own backward cursor', () => {
assert.equal(needsWindowRewind('tracks', { ...atHead, trackPrevCursor: 'cur' }), true);
assert.equal(needsWindowRewind('albums', { ...atHead, trackPrevCursor: 'cur' }), false);
assert.equal(needsWindowRewind('artists', { ...atHead, trackPrevCursor: 'cur' }), false);
assert.equal(needsWindowRewind('albums', { ...atHead, albumPrevCursor: 'cur' }), true);
assert.equal(needsWindowRewind('artists', { ...atHead, artistPrevCursor: 'cur' }), true);
});
test('the un-paged views never rebuild their window', () => {
const jumped: LibraryWindowCursors = {
trackPrevCursor: 'cur',
albumPrevCursor: 'cur',
artistPrevCursor: 'cur',
};
assert.equal(needsWindowRewind('playlists', jumped), false);
assert.equal(needsWindowRewind('folders', jumped), false);
assert.equal(needsWindowRewind('something-new', jumped), false);
});
+33
View File
@@ -0,0 +1,33 @@
/**
* Whether "back to the top" has to rebuild the library window rather than just
* scroll it.
*
* `jumpToSection` loads the jumped-to letter's page *plus* the page above it, so
* after an A-Z rail jump rendered index 0 is a row somewhere in the middle of
* the catalog and scrolling to offset 0 would stop at the top of the window, not
* the top of the library. The per-list `*PrevCursor` is non-null exactly when
* rows exist above the loaded window, which makes it the test for "offset 0 is
* a lie".
*
* Kept free of store imports so the rule stays unit-testable.
*/
export interface LibraryWindowCursors {
trackPrevCursor: string | null;
albumPrevCursor: string | null;
artistPrevCursor: string | null;
}
export function needsWindowRewind(viewMode: string, cursors: LibraryWindowCursors): boolean {
switch (viewMode) {
case 'tracks':
return cursors.trackPrevCursor !== null;
case 'albums':
return cursors.albumPrevCursor !== null;
case 'artists':
return cursors.artistPrevCursor !== null;
// Playlists and folders are rendered whole, never cursor-paged.
default:
return false;
}
}
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
ANIMATED_SCROLL_TOP_SCREENS,
shouldAnimateScrollToTop,
} from './scrollToTopBehavior.ts';
const VIEWPORT = 800;
const LIMIT = VIEWPORT * ANIMATED_SCROLL_TOP_SCREENS;
test('a list near the top animates back', () => {
assert.equal(shouldAnimateScrollToTop(0, VIEWPORT), true);
assert.equal(shouldAnimateScrollToTop(VIEWPORT, VIEWPORT), true);
});
test('a list deep down jumps instead of smearing blank cells', () => {
assert.equal(shouldAnimateScrollToTop(LIMIT + 1, VIEWPORT), false);
assert.equal(shouldAnimateScrollToTop(120_000, VIEWPORT), false);
});
test('the boundary itself still animates', () => {
assert.equal(shouldAnimateScrollToTop(LIMIT, VIEWPORT), true);
});
// The grid measures its width on first layout; height can arrive just as late.
test('an unmeasured viewport jumps rather than guessing', () => {
assert.equal(shouldAnimateScrollToTop(10, 0), false);
assert.equal(shouldAnimateScrollToTop(10, -1), false);
assert.equal(shouldAnimateScrollToTop(10, Number.NaN), false);
});
+18
View File
@@ -0,0 +1,18 @@
/**
* Whether a programmatic scroll back to the top should animate.
*
* A smooth scroll is the nicer cue that the list moved rather than reloaded, but
* flying it back from row 5000 recycles every row in between and shows a smear
* of blank cells. Close to the top there is nothing to recycle, so animate; far
* from it, jump.
*/
/** Screens' worth of travel still worth animating. */
export const ANIMATED_SCROLL_TOP_SCREENS = 3;
export function shouldAnimateScrollToTop(offsetY: number, viewportHeight: number): boolean {
// No measurement yet (0 until first layout) — a jump is the safe assumption,
// since the distance is unknown rather than known to be small.
if (!(viewportHeight > 0)) return false;
return offsetY <= viewportHeight * ANIMATED_SCROLL_TOP_SCREENS;
}
+13
View File
@@ -0,0 +1,13 @@
/**
* The only part of a list's imperative API a tab re-tap needs.
*
* Narrower than `FlashListRef<T>` on purpose: the library's child views key
* their lists on private row types (`FoldersView`'s tree rows, `PlaylistsView`'s
* playlists), and a parent holding `FlashListRef<Row>` would have to import
* them. A callback ref onto this shape is also the variance-safe direction — a
* `RefObject<Supertype>` is not assignable to `Ref<FlashListRef<Row>>`, while a
* callback accepting the supertype is.
*/
export interface ScrollToTopHandle {
scrollToTop: (params?: { animated?: boolean }) => void;
}
+65
View File
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
emitTabReselect,
subscribeTabReselect,
tabReselectListenerCount,
} from './tabReselect.ts';
test('an emit reaches every listener on that tab and none on another', () => {
const seen: string[] = [];
const offLibraryA = subscribeTabReselect('library', () => seen.push('library-a'));
const offLibraryB = subscribeTabReselect('library', () => seen.push('library-b'));
const offHome = subscribeTabReselect('index', () => seen.push('index'));
emitTabReselect('library');
assert.deepEqual(seen, ['library-a', 'library-b']);
emitTabReselect('index');
assert.deepEqual(seen, ['library-a', 'library-b', 'index']);
offLibraryA();
offLibraryB();
offHome();
});
// `eq` and `settings` never subscribe, so every press on them lands here.
test('an emit for a tab with no listeners is inert', () => {
assert.doesNotThrow(() => emitTabReselect('eq'));
});
test('unsubscribing removes only that listener and is safe to repeat', () => {
const seen: string[] = [];
const offA = subscribeTabReselect('library', () => seen.push('a'));
const offB = subscribeTabReselect('library', () => seen.push('b'));
offA();
offA();
emitTabReselect('library');
assert.deepEqual(seen, ['b']);
offB();
});
// The screen unsubscribes on unmount, which a sibling's handler can trigger.
test('a listener unsubscribing mid-dispatch does not skip its sibling', () => {
const seen: string[] = [];
let offB = () => {};
const offA = subscribeTabReselect('library', () => {
seen.push('a');
offB();
});
offB = subscribeTabReselect('library', () => seen.push('b'));
emitTabReselect('library');
assert.deepEqual(seen, ['a', 'b']);
offA();
});
test('the last listener to leave takes its bucket with it', () => {
const off = subscribeTabReselect('library', () => {});
assert.equal(tabReselectListenerCount('library'), 1);
off();
assert.equal(tabReselectListenerCount('library'), 0);
});
+47
View File
@@ -0,0 +1,47 @@
/**
* Re-tap bus for the tab bar.
*
* The bar is the only place that knows a press landed on the tab that was
* already focused — `actuallyFocused`, which the hidden `stats` route makes
* non-obvious (see `statsTabState`) — and each screen is the only place that
* knows how to get its own list back to the top. This is the one-way channel
* between them.
*
* Deliberately not `navigation.emit({ type: 'tabPress' })`: that event is
* targeted at the tab *route* key, so a screen inside the Library stack has to
* reach it through `getParent()` while Home would not, and each screen would
* have to re-derive "was I already focused" instead of leaving that one
* question with the handler that already gets the stats case right. A plain
* module is also unit-testable, which the emitter path is not.
*/
type TabReselectListener = () => void;
const listeners = new Map<string, Set<TabReselectListener>>();
export function subscribeTabReselect(
tabName: string,
listener: TabReselectListener
): () => void {
const existing = listeners.get(tabName);
const set = existing ?? new Set<TabReselectListener>();
if (!existing) listeners.set(tabName, set);
set.add(listener);
return () => {
set.delete(listener);
// Drop the bucket with its last listener so remounts can't leak entries.
if (set.size === 0) listeners.delete(tabName);
};
}
export function emitTabReselect(tabName: string): void {
const set = listeners.get(tabName);
if (!set) return;
// Snapshot: a listener is allowed to unsubscribe while the set is walked.
for (const listener of [...set]) listener();
}
/** Test seam — the bus is module state, so leaks are otherwise invisible. */
export function tabReselectListenerCount(tabName: string): number {
return listeners.get(tabName)?.size ?? 0;
}
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
leavingStackResetTarget,
shouldApplyStackReset,
type TabsStateLike,
} from './tabStackReset.ts';
/** Home, Library (nested stack), EQ, Settings, plus the hidden stats route. */
function tabsState(
index: number,
libraryStack?: { key?: string; index?: number }
): TabsStateLike {
return {
key: 'tabs-1',
index,
routes: [
{ key: 'index-1', name: 'index' },
{ key: 'library-1', name: 'library', state: libraryStack },
{ key: 'eq-1', name: 'eq' },
{ key: 'settings-1', name: 'settings' },
{ key: 'stats-1', name: 'stats' },
],
};
}
test('a focused library sitting on a detail names its stack for rewinding', () => {
assert.equal(
leavingStackResetTarget(tabsState(1, { key: 'library-stack', index: 2 })),
'library-stack'
);
});
test('nothing to rewind when the focused stack is already at its root', () => {
assert.equal(leavingStackResetTarget(tabsState(1, { key: 'library-stack', index: 0 })), null);
assert.equal(leavingStackResetTarget(tabsState(1, { key: 'library-stack' })), null);
});
test('nothing to rewind for a tab with no nested navigator', () => {
assert.equal(leavingStackResetTarget(tabsState(0)), null);
assert.equal(leavingStackResetTarget(tabsState(3)), null);
});
test('a never-visited tab has no stack key to target', () => {
assert.equal(leavingStackResetTarget(tabsState(1, { index: 2 })), null);
assert.equal(leavingStackResetTarget(tabsState(1, { key: '', index: 2 })), null);
});
test('undefined or empty state is inert', () => {
assert.equal(leavingStackResetTarget(undefined), null);
assert.equal(leavingStackResetTarget({ routes: [] }), null);
assert.equal(leavingStackResetTarget({ key: 'tabs-1' }), null);
});
// The hidden `stats` route is focused while the Home button is lit; reading the
// pressed item instead of the focused route would look at Library here.
test('reads the genuinely focused route, not the one that looks focused', () => {
const state = tabsState(4, { key: 'library-stack', index: 2 });
assert.equal(leavingStackResetTarget(state), null);
});
test('the rewind applies while its tab stays blurred', () => {
const state = tabsState(0, { key: 'library-stack', index: 2 });
assert.equal(shouldApplyStackReset(state, 'library-stack'), true);
});
// The race the delayed dispatch exists to survive: tapping a Home album card
// re-enters Library on a fresh detail before the rewind runs.
test('the rewind is abandoned when its tab was re-focused inside the delay', () => {
const state = tabsState(1, { key: 'library-stack', index: 1 });
assert.equal(shouldApplyStackReset(state, 'library-stack'), false);
});
test('the rewind is abandoned when its stack is no longer present', () => {
assert.equal(shouldApplyStackReset(tabsState(0), 'library-stack'), false);
assert.equal(shouldApplyStackReset(tabsState(0, { key: 'other-stack' }), 'library-stack'), false);
assert.equal(shouldApplyStackReset(undefined, 'library-stack'), false);
assert.equal(shouldApplyStackReset({ key: 'tabs-1' }, 'library-stack'), false);
});
+69
View File
@@ -0,0 +1,69 @@
/**
* Which nested stack a tab-bar press has to rewind, and whether that rewind is
* still valid by the time it runs.
*
* Pressing "Library" should land on the library list, not on whatever artist
* was left open — but the rewind is deliberately done when a tab is LEFT, not
* when it is re-entered. A native stack that pops while its scene is fading in
* plays the dismissal animation of the screen being *removed*, in full view: the
* artist page slides away over the library list just as the tab arrives. That
* animation is chosen by the outgoing screen, so unlike the Home->Library
* handoff — where `homeLibraryNavigation` can mark the screen it *adds* with a
* param that `library/_layout.tsx` reads as `animation: 'none'` — there is
* nothing an arrival-time state rewrite can mark to suppress it. Popping once
* the fade has settled sidesteps it: by then the scene being popped is at
* opacity 0 behind an opaque one.
*
* React Navigation's own `popToTopOnBlur` option reaches the same conclusion —
* it dispatches exactly this action from the tab transition's completion
* callback. It is not used here because it is dropped whenever the navigator
* re-renders mid-transition (its effect depends on `descriptors`, which is
* rebuilt every render, and the restarted animation reports `finished: false`),
* and because it fires on any blur rather than only on a tab-bar press.
*
* Kept free of imports so the state transformation is unit-testable.
*/
export interface TabRouteLike {
key?: string;
name?: string;
state?: { key?: string; index?: number };
}
export interface TabsStateLike {
key?: string;
index?: number;
routes?: TabRouteLike[];
}
/**
* The nested stack key of the tab being left, or null when it has nothing to
* rewind (no nested navigator, never visited, or already at its root).
*
* Reads the genuinely focused route rather than the pressed item: `stats` is
* focused while Home merely *looks* focused (see `statsTabState`), and only the
* real one can be carrying a stack.
*/
export function leavingStackResetTarget(tabsState: TabsStateLike | undefined): string | null {
const nested = tabsState?.routes?.[tabsState.index ?? 0]?.state;
if (!nested || typeof nested.key !== 'string' || nested.key.length === 0) return null;
return (nested.index ?? 0) > 0 ? nested.key : null;
}
/**
* Re-checked when the rewind runs, never decided up front: a deep link — a Home
* card, a quick-search hit, the now-playing overlay — can re-focus that tab and
* build a fresh detail inside the delay, and truncating that would dump the user
* back on the library list.
*/
export function shouldApplyStackReset(
tabsState: TabsStateLike | undefined,
stackKey: string
): boolean {
const routes = tabsState?.routes;
if (!Array.isArray(routes)) return false;
const owner = routes.findIndex((route) => route.state?.key === stackKey);
// Gone entirely (state replaced or restored), or focused again in the meantime.
if (owner < 0) return false;
return owner !== (tabsState?.index ?? 0);
}
+8
View File
@@ -1,7 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
TAB_PRESS_SWALLOW_MS,
TAB_SCENE_ANIMATION,
TAB_STACK_RESET_DELAY_MS,
TAB_TRANSITION_SETTLE_MS,
TAB_TRANSITION_SPEC,
} from './tabTransition.ts';
@@ -9,6 +11,12 @@ import {
test('tab scenes use a cross-fade and retain the short settle guard', () => {
assert.equal(TAB_SCENE_ANIMATION, 'fade');
assert.equal(TAB_TRANSITION_SETTLE_MS, 160);
assert.equal(TAB_PRESS_SWALLOW_MS, TAB_TRANSITION_SETTLE_MS + 30);
});
test('a left-behind stack is rewound after the fade but before the next press is accepted', () => {
assert.ok(TAB_STACK_RESET_DELAY_MS > TAB_TRANSITION_SETTLE_MS);
assert.ok(TAB_STACK_RESET_DELAY_MS < TAB_PRESS_SWALLOW_MS);
});
test('tab fade uses a critically damped native spring with no overshoot', () => {
+16
View File
@@ -7,6 +7,22 @@
export const TAB_TRANSITION_SETTLE_MS = 160;
export const TAB_SCENE_ANIMATION = 'fade' as const;
/**
* Presses inside this window are swallowed so the native-driver fade is never
* interrupted. Was inlined at the press handler; named here so the reset delay
* below can be stated in terms of it.
*/
export const TAB_PRESS_SWALLOW_MS = TAB_TRANSITION_SETTLE_MS + 30;
/**
* When the tab being *left* has its nested stack rewound to its root.
*
* Must stay under `TAB_PRESS_SWALLOW_MS` so a rewind can never land after the
* next press has already been accepted, and over `TAB_TRANSITION_SETTLE_MS` so
* the scene it pops is already faded out when the removal animates.
*/
export const TAB_STACK_RESET_DELAY_MS = TAB_TRANSITION_SETTLE_MS + 10;
export const TAB_TRANSITION_SPEC = {
animation: 'spring',
config: {
+13
View File
@@ -0,0 +1,13 @@
import { useEffect } from 'react';
import { subscribeTabReselect } from './tabReselect';
/**
* Run `onReselect` when the tab bar reports a press on this tab while it was
* already focused and sitting at its root.
*
* React Compiler memoizes the caller's closure, so the handler identity is
* stable across renders and no ref-latching is needed here.
*/
export function useTabReselect(tabName: string, onReselect: () => void): void {
useEffect(() => subscribeTabReselect(tabName, onReselect), [tabName, onReselect]);
}
+30 -8
View File
@@ -123,6 +123,7 @@ interface LibraryStore {
loadPreviousAlbums: () => Promise<void>;
loadPreviousArtists: () => Promise<void>;
jumpToSection: (cursor: string) => Promise<boolean>;
rewindToHead: () => Promise<boolean>;
recordTrackPlayed: (path: string) => Promise<void>;
refreshRecentlyPlayed: () => Promise<void>;
recomputeArtists: () => void;
@@ -178,9 +179,10 @@ function prependWindow<T>(
function remountIfShorter(
current: { sectionJumpRevision: number },
previousLength: number,
nextLength: number
nextLength: number,
force = false
): { sectionJumpRevision: number } | Record<string, never> {
return previousLength > nextLength
return force || previousLength > nextLength
? { sectionJumpRevision: current.sectionJumpRevision + 1 }
: {};
}
@@ -271,7 +273,11 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
const backwardAlbumSort = (sort: AlbumSort): 'artist' | 'name' | null =>
sort === 'artist' || sort === 'name' ? sort : null;
const resetTracks = async () => {
// `forceRemount` is for `rewindToHead`: the conditional remount above only
// fires when page 1 is *shorter* than the window it replaces, which happens to
// hold after a rail jump but is arithmetic the caller cannot verify — and a
// PAGE_SIZE change would break it silently, leaving the list parked mid-catalog.
const resetTracks = async (forceRemount = false) => {
const sort = get().trackSort;
const generation = ++pageGenerations.tracks;
const page = await readTrackPage(null, sort);
@@ -283,12 +289,12 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
trackPrevCursor: null,
totalTrackCount: page.totalCount ?? 0,
jumpAnchorIndex: 0,
...remountIfShorter(current, current.tracks.length, items.length),
...remountIfShorter(current, current.tracks.length, items.length, forceRemount),
}));
return true;
};
const resetAlbums = async () => {
const resetAlbums = async (forceRemount = false) => {
const sort = get().albumSort;
const includeSingles = useSettingsStore.getState().includeSingles;
const generation = ++pageGenerations.albums;
@@ -304,12 +310,12 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
albumNextCursor: page.nextCursor ?? null,
albumPrevCursor: null,
jumpAnchorIndex: 0,
...remountIfShorter(current, current.albums.length, items.length),
...remountIfShorter(current, current.albums.length, items.length, forceRemount),
}));
return true;
};
const resetArtists = async () => {
const resetArtists = async (forceRemount = false) => {
const sort = get().artistSort;
const groupingMode = useSettingsStore.getState().artistGroupingMode;
const includeCollaborations = get().includeCollabArtists;
@@ -327,7 +333,7 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
artistNextCursor: page.nextCursor ?? null,
artistPrevCursor: null,
jumpAnchorIndex: 0,
...remountIfShorter(current, current.artists.length, items.length),
...remountIfShorter(current, current.artists.length, items.length, forceRemount),
}));
return true;
};
@@ -916,6 +922,22 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
return true;
},
/**
* Rebuilds the current view's window from the head of the catalog and forces
* the list to remount at row 0.
*
* The counterpart to `jumpToSection` — same boolean contract — for getting
* back to the true top after one. Bumping the page generation also voids any
* jump still in flight, which would otherwise land after this and undo it.
*/
rewindToHead: async () => {
const viewMode = get().viewMode;
if (viewMode === 'tracks') return resetTracks(true);
if (viewMode === 'albums') return resetAlbums(true);
if (viewMode === 'artists') return resetArtists(true);
return false;
},
recordTrackPlayed: async (path) => {
await AstraLibraryData.recordTrackPlayed(path);
set({ recentlyPlayedTracks: await AstraLibraryData.getRecentlyPlayed<DbTrack>(20) });