mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-28 17:11:19 +02:00
library nav and scroll to top on double press
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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) });
|
||||
|
||||
Reference in New Issue
Block a user