mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-22 13:35:06 +02:00
fix visual bug
This commit is contained in:
+1
-1
@@ -82,7 +82,7 @@
|
|||||||
"test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.test.mts",
|
"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: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: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/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts src/navigation/tabTransition.test.mts",
|
"test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test 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",
|
||||||
"test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.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",
|
"test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts",
|
||||||
"test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts",
|
"test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
HOME_GREETING_ROTATION_MS,
|
HOME_GREETING_ROTATION_MS,
|
||||||
type HomeGreetingTextMode,
|
type HomeGreetingTextMode,
|
||||||
} from '@/home/homeGreeting';
|
} from '@/home/homeGreeting';
|
||||||
|
import { useHomeLibraryNavigation } from '@/navigation/useHomeLibraryNavigation';
|
||||||
import type { Album, Artist, DbTrack } from '@/types/library';
|
import type { Album, Artist, DbTrack } from '@/types/library';
|
||||||
|
|
||||||
const RECENT_ALBUM_LIMIT = 8;
|
const RECENT_ALBUM_LIMIT = 8;
|
||||||
@@ -514,6 +515,7 @@ function EmptyHomeCard({
|
|||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const openLibrary = useHomeLibraryNavigation();
|
||||||
const totalTrackCount = useLibraryStore((s) => s.totalTrackCount);
|
const totalTrackCount = useLibraryStore((s) => s.totalTrackCount);
|
||||||
const albums = useLibraryStore((s) => s.homeAlbums);
|
const albums = useLibraryStore((s) => s.homeAlbums);
|
||||||
const artists = useLibraryStore((s) => s.homeArtists);
|
const artists = useLibraryStore((s) => s.homeArtists);
|
||||||
@@ -589,17 +591,11 @@ export default function HomeScreen() {
|
|||||||
const canExpandRecentTracks = recentlyPlayedTracks.length > RECENT_TRACK_LIMIT;
|
const canExpandRecentTracks = recentlyPlayedTracks.length > RECENT_TRACK_LIMIT;
|
||||||
|
|
||||||
const openAlbum = (album: Album) => {
|
const openAlbum = (album: Album) => {
|
||||||
router.push({
|
openLibrary({ kind: 'album', key: album.identity_key });
|
||||||
pathname: '/library/album/[key]',
|
|
||||||
params: { key: album.identity_key },
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openArtist = (artist: Artist) => {
|
const openArtist = (artist: Artist) => {
|
||||||
router.push({
|
openLibrary({ kind: 'artist', name: artist.artist });
|
||||||
pathname: '/library/artist/[name]',
|
|
||||||
params: { name: artist.artist },
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const playRecentlyPlayed = (list: DbTrack[], index = 0) => {
|
const playRecentlyPlayed = (list: DbTrack[], index = 0) => {
|
||||||
@@ -735,7 +731,7 @@ export default function HomeScreen() {
|
|||||||
trackCount={favoriteTracks.length}
|
trackCount={favoriteTracks.length}
|
||||||
coverHash={favoriteTracks[0]?.artwork_hash ?? null}
|
coverHash={favoriteTracks[0]?.artwork_hash ?? null}
|
||||||
pinned
|
pinned
|
||||||
onPress={() => router.push('/library/playlist/favorites')}
|
onPress={() => openLibrary({ kind: 'playlist', id: 'favorites' })}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{homePlaylists.map((playlist) => (
|
{homePlaylists.map((playlist) => (
|
||||||
@@ -745,7 +741,7 @@ export default function HomeScreen() {
|
|||||||
trackCount={playlist.track_count}
|
trackCount={playlist.track_count}
|
||||||
missingCount={playlist.missing_track_count}
|
missingCount={playlist.missing_track_count}
|
||||||
coverHash={playlist.auto_cover_hash}
|
coverHash={playlist.auto_cover_hash}
|
||||||
onPress={() => router.push(`/library/playlist/${playlist.id}`)}
|
onPress={() => openLibrary({ kind: 'playlist', id: playlist.id })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
|
import {
|
||||||
|
hasHomeLibraryHandoff,
|
||||||
|
withoutHomeLibraryHandoff,
|
||||||
|
} from '@/navigation/homeLibraryNavigation';
|
||||||
import { useColors } from '@/theme/themed';
|
import { useColors } from '@/theme/themed';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,10 +23,23 @@ export default function LibraryLayout() {
|
|||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={({ route }) => ({
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
contentStyle: { backgroundColor: colors.bgPrimary },
|
contentStyle: { backgroundColor: colors.bgPrimary },
|
||||||
}}
|
// The tab cross-fade is the only transition needed for a Home handoff.
|
||||||
|
// Suppressing the nested push prevents the replaced detail from being
|
||||||
|
// visible underneath the incoming screen.
|
||||||
|
animation: hasHomeLibraryHandoff(route.params) ? 'none' : undefined,
|
||||||
|
})}
|
||||||
|
screenListeners={({ route, navigation }) => ({
|
||||||
|
transitionEnd: (event) => {
|
||||||
|
if (event.data.closing || !hasHomeLibraryHandoff(route.params)) return;
|
||||||
|
// Re-enable ordinary Library push/pop animations after this arrival.
|
||||||
|
navigation.replaceParams(
|
||||||
|
withoutHomeLibraryHandoff(route.params as Record<string, unknown> | undefined)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
HOME_LIBRARY_HANDOFF_PARAM,
|
||||||
|
buildHomeLibraryResetAction,
|
||||||
|
hasHomeLibraryHandoff,
|
||||||
|
homeLibraryDetailRoute,
|
||||||
|
homeLibraryTargetHref,
|
||||||
|
withoutHomeLibraryHandoff,
|
||||||
|
type NavigationStateLike,
|
||||||
|
} from './homeLibraryNavigation.ts';
|
||||||
|
import { libraryParentLabel, parentRoute } from './libraryDetailBack.ts';
|
||||||
|
|
||||||
|
function tabsState(libraryState?: NavigationStateLike): NavigationStateLike {
|
||||||
|
return {
|
||||||
|
stale: false,
|
||||||
|
type: 'tab',
|
||||||
|
key: 'tabs-1',
|
||||||
|
index: 0,
|
||||||
|
routeNames: ['index', 'library', 'eq', 'settings'],
|
||||||
|
history: [{ type: 'route', key: 'index-1' }],
|
||||||
|
preloadedRouteKeys: ['library-1', 'eq-1'],
|
||||||
|
routes: [
|
||||||
|
{ key: 'index-1', name: 'index', state: { key: 'home-stack', routes: [] } },
|
||||||
|
{
|
||||||
|
key: 'library-1',
|
||||||
|
name: 'library',
|
||||||
|
params: { screen: 'album/[key]', params: { key: 'old' } },
|
||||||
|
state: libraryState,
|
||||||
|
},
|
||||||
|
{ key: 'eq-1', name: 'eq', params: { retained: true } },
|
||||||
|
{ key: 'settings-1', name: 'settings' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('atomically selects Library and replaces stale detail history with the requested album', () => {
|
||||||
|
const root = { key: 'library-index-1', name: 'index', params: { retained: true } };
|
||||||
|
const oldAlbum = { key: 'album-old', name: 'album/[key]', params: { key: 'old' } };
|
||||||
|
const oldArtist = { key: 'artist-old', name: 'artist/[name]', params: { name: 'Old' } };
|
||||||
|
const state = tabsState({
|
||||||
|
stale: false,
|
||||||
|
type: 'stack',
|
||||||
|
key: 'library-stack',
|
||||||
|
index: 2,
|
||||||
|
routeNames: ['index', 'album/[key]', 'artist/[name]', 'playlist/[id]'],
|
||||||
|
preloadedRoutes: [],
|
||||||
|
routes: [root, oldAlbum, oldArtist],
|
||||||
|
});
|
||||||
|
|
||||||
|
const action = buildHomeLibraryResetAction(state, { kind: 'album', key: 'new/key' });
|
||||||
|
assert.ok(action);
|
||||||
|
assert.equal(action.type, 'RESET');
|
||||||
|
assert.equal(action.target, 'tabs-1');
|
||||||
|
assert.equal(action.payload.index, 1);
|
||||||
|
assert.deepEqual(action.payload.history, [
|
||||||
|
{ type: 'route', key: 'index-1' },
|
||||||
|
{ type: 'route', key: 'library-1' },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(action.payload.preloadedRouteKeys, ['eq-1']);
|
||||||
|
|
||||||
|
const library = action.payload.routes[1];
|
||||||
|
assert.equal(library.params, undefined);
|
||||||
|
assert.equal(library.state?.stale, false);
|
||||||
|
assert.equal(library.state?.type, 'stack');
|
||||||
|
assert.equal(library.state?.key, 'library-stack');
|
||||||
|
assert.equal(library.state?.index, 1);
|
||||||
|
assert.equal(library.state?.routes[0], root);
|
||||||
|
assert.match(String(library.state?.routes[1].key), /^home-library-handoff-\d+-detail$/);
|
||||||
|
assert.equal(library.state?.routes[1].name, 'album/[key]');
|
||||||
|
assert.deepEqual(library.state?.routes[1].params, {
|
||||||
|
key: 'new/key',
|
||||||
|
[HOME_LIBRARY_HANDOFF_PARAM]: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(library.state?.preloadedRoutes, []);
|
||||||
|
assert.equal(libraryParentLabel(parentRoute(library.state)), 'Library');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports artist, regular playlist, and Favorites destinations', () => {
|
||||||
|
assert.deepEqual(homeLibraryDetailRoute({ kind: 'artist', name: 'AC/DC' }), {
|
||||||
|
name: 'artist/[name]',
|
||||||
|
params: { name: 'AC/DC', [HOME_LIBRARY_HANDOFF_PARAM]: true },
|
||||||
|
});
|
||||||
|
assert.deepEqual(homeLibraryDetailRoute({ kind: 'playlist', id: 42 }), {
|
||||||
|
name: 'playlist/[id]',
|
||||||
|
params: { id: '42', [HOME_LIBRARY_HANDOFF_PARAM]: true },
|
||||||
|
});
|
||||||
|
assert.deepEqual(homeLibraryDetailRoute({ kind: 'playlist', id: 'favorites' }), {
|
||||||
|
name: 'playlist/[id]',
|
||||||
|
params: { id: 'favorites', [HOME_LIBRARY_HANDOFF_PARAM]: true },
|
||||||
|
});
|
||||||
|
assert.equal(homeLibraryTargetHref({ kind: 'album', key: 'a/b' }), '/library/album/a%2Fb');
|
||||||
|
assert.equal(homeLibraryTargetHref({ kind: 'artist', name: 'AC/DC' }), '/library/artist/AC%2FDC');
|
||||||
|
assert.equal(
|
||||||
|
homeLibraryTargetHref({ kind: 'playlist', id: 'favorites' }),
|
||||||
|
'/library/playlist/favorites'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds a fully initialized anchored Library stack before that tab has mounted', () => {
|
||||||
|
const state = tabsState();
|
||||||
|
const action = buildHomeLibraryResetAction(state, { kind: 'playlist', id: 7 });
|
||||||
|
assert.ok(action);
|
||||||
|
const libraryState = action.payload.routes[1].state;
|
||||||
|
assert.equal(libraryState?.stale, false);
|
||||||
|
assert.equal(libraryState?.type, 'stack');
|
||||||
|
assert.equal(libraryState?.index, 1);
|
||||||
|
assert.deepEqual(libraryState?.routeNames, ['index', 'playlist/[id]']);
|
||||||
|
assert.match(String(libraryState?.key), /^home-library-handoff-\d+-stack$/);
|
||||||
|
assert.match(String(libraryState?.routes[0].key), /^home-library-handoff-\d+-root$/);
|
||||||
|
assert.match(String(libraryState?.routes[1].key), /^home-library-handoff-\d+-detail$/);
|
||||||
|
assert.deepEqual(libraryState?.routes[1].params, {
|
||||||
|
id: '7',
|
||||||
|
[HOME_LIBRARY_HANDOFF_PARAM]: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves every unrelated tab and leaves the input state immutable', () => {
|
||||||
|
const state = tabsState({
|
||||||
|
key: 'library-stack',
|
||||||
|
index: 1,
|
||||||
|
routes: [
|
||||||
|
{ key: 'library-index', name: 'index' },
|
||||||
|
{ key: 'old-playlist', name: 'playlist/[id]', params: { id: '9' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const before = structuredClone(state);
|
||||||
|
const action = buildHomeLibraryResetAction(state, { kind: 'artist', name: 'Björk' });
|
||||||
|
assert.ok(action);
|
||||||
|
assert.deepEqual(state, before);
|
||||||
|
assert.equal(action.payload.routes[0], state.routes[0]);
|
||||||
|
assert.equal(action.payload.routes[2], state.routes[2]);
|
||||||
|
assert.equal(action.payload.routes[3], state.routes[3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails safely for missing navigator identity or Library route', () => {
|
||||||
|
assert.equal(buildHomeLibraryResetAction(undefined, { kind: 'album', key: 'a' }), null);
|
||||||
|
assert.equal(
|
||||||
|
buildHomeLibraryResetAction({ key: '', routes: [] }, { kind: 'album', key: 'a' }),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
buildHomeLibraryResetAction(
|
||||||
|
{ key: 'tabs', index: 0, routes: [{ name: 'index' }] },
|
||||||
|
{ kind: 'album', key: 'a' }
|
||||||
|
),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
buildHomeLibraryResetAction(
|
||||||
|
{ key: 'tabs', index: 0, routes: [{ key: 'home', name: 'index' }, { name: 'library' }] },
|
||||||
|
{ kind: 'album', key: 'a' }
|
||||||
|
),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Home handoff transition marker is one-shot data', () => {
|
||||||
|
const params = { key: 'album', [HOME_LIBRARY_HANDOFF_PARAM]: true };
|
||||||
|
assert.equal(hasHomeLibraryHandoff(params), true);
|
||||||
|
assert.deepEqual(withoutHomeLibraryHandoff(params), { key: 'album' });
|
||||||
|
assert.equal(hasHomeLibraryHandoff({ [HOME_LIBRARY_HANDOFF_PARAM]: false }), false);
|
||||||
|
assert.deepEqual(withoutHomeLibraryHandoff({ key: 'album' }), { key: 'album' });
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* State surgery for Home -> Library detail handoffs.
|
||||||
|
*
|
||||||
|
* Expo Router normally focuses the retained Library tab before its nested
|
||||||
|
* navigation is applied. If that tab was left on another detail, the stale
|
||||||
|
* screen participates in the tab transition. Replacing the tab state and the
|
||||||
|
* Library child state in one RESET action removes that intermediate state.
|
||||||
|
*
|
||||||
|
* Kept free of framework imports so the state transformation is unit-testable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const HOME_LIBRARY_HANDOFF_PARAM = '__astra_home_library_handoff';
|
||||||
|
|
||||||
|
export type HomeLibraryTarget =
|
||||||
|
| { kind: 'album'; key: string }
|
||||||
|
| { kind: 'artist'; name: string }
|
||||||
|
| { kind: 'playlist'; id: string | number };
|
||||||
|
|
||||||
|
export interface NavigationRouteLike {
|
||||||
|
key?: string;
|
||||||
|
name: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
state?: NavigationStateLike;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavigationStateLike {
|
||||||
|
key?: string;
|
||||||
|
index?: number;
|
||||||
|
routes: NavigationRouteLike[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeLibraryResetAction {
|
||||||
|
type: 'RESET';
|
||||||
|
target: string;
|
||||||
|
payload: NavigationStateLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIBRARY_TAB_ROUTE = 'library';
|
||||||
|
const LIBRARY_ROOT_ROUTE = 'index';
|
||||||
|
let handoffSequence = 0;
|
||||||
|
|
||||||
|
function nextHandoffKeyPrefix(): string {
|
||||||
|
handoffSequence += 1;
|
||||||
|
return `home-library-handoff-${handoffSequence}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function homeLibraryDetailRoute(target: HomeLibraryTarget): NavigationRouteLike {
|
||||||
|
const handoff = { [HOME_LIBRARY_HANDOFF_PARAM]: true };
|
||||||
|
|
||||||
|
switch (target.kind) {
|
||||||
|
case 'album':
|
||||||
|
return {
|
||||||
|
name: 'album/[key]',
|
||||||
|
params: { key: target.key, ...handoff },
|
||||||
|
};
|
||||||
|
case 'artist':
|
||||||
|
return {
|
||||||
|
name: 'artist/[name]',
|
||||||
|
params: { name: target.name, ...handoff },
|
||||||
|
};
|
||||||
|
case 'playlist':
|
||||||
|
return {
|
||||||
|
name: 'playlist/[id]',
|
||||||
|
params: { id: String(target.id), ...handoff },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function homeLibraryTargetHref(target: HomeLibraryTarget): string {
|
||||||
|
switch (target.kind) {
|
||||||
|
case 'album':
|
||||||
|
return `/library/album/${encodeURIComponent(target.key)}`;
|
||||||
|
case 'artist':
|
||||||
|
return `/library/artist/${encodeURIComponent(target.name)}`;
|
||||||
|
case 'playlist':
|
||||||
|
return `/library/playlist/${encodeURIComponent(String(target.id))}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a single action that both selects Library and replaces its nested
|
||||||
|
* history with [Library root, requested detail].
|
||||||
|
*
|
||||||
|
* The child state is fully initialized before the Library tab receives focus.
|
||||||
|
* Supplying a stale/partial state here lets the nested navigator briefly render
|
||||||
|
* its initial route while it rehydrates, which is the Library-list flash this
|
||||||
|
* helper exists to prevent.
|
||||||
|
*/
|
||||||
|
export function buildHomeLibraryResetAction(
|
||||||
|
tabsState: NavigationStateLike | undefined,
|
||||||
|
target: HomeLibraryTarget
|
||||||
|
): HomeLibraryResetAction | null {
|
||||||
|
if (
|
||||||
|
!tabsState ||
|
||||||
|
typeof tabsState.key !== 'string' ||
|
||||||
|
tabsState.key.length === 0 ||
|
||||||
|
!Array.isArray(tabsState.routes)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryIndex = tabsState.routes.findIndex((route) => route.name === LIBRARY_TAB_ROUTE);
|
||||||
|
if (libraryIndex < 0) return null;
|
||||||
|
|
||||||
|
const libraryRoute = tabsState.routes[libraryIndex];
|
||||||
|
const initialTabRoute = tabsState.routes[0];
|
||||||
|
if (
|
||||||
|
typeof libraryRoute.key !== 'string' ||
|
||||||
|
typeof initialTabRoute?.key !== 'string'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const existingLibraryState =
|
||||||
|
libraryRoute.state && Array.isArray(libraryRoute.state.routes)
|
||||||
|
? libraryRoute.state
|
||||||
|
: undefined;
|
||||||
|
const existingRoot = existingLibraryState?.routes.find(
|
||||||
|
(route) => route.name === LIBRARY_ROOT_ROUTE
|
||||||
|
);
|
||||||
|
const keyPrefix = nextHandoffKeyPrefix();
|
||||||
|
const rootRoute =
|
||||||
|
existingRoot && typeof existingRoot.key === 'string'
|
||||||
|
? existingRoot
|
||||||
|
: {
|
||||||
|
...(existingRoot ?? {}),
|
||||||
|
key: `${keyPrefix}-root`,
|
||||||
|
name: LIBRARY_ROOT_ROUTE,
|
||||||
|
};
|
||||||
|
const detailRoute = {
|
||||||
|
...homeLibraryDetailRoute(target),
|
||||||
|
key: `${keyPrefix}-detail`,
|
||||||
|
};
|
||||||
|
const existingRouteNames = existingLibraryState?.routeNames;
|
||||||
|
const routeNames =
|
||||||
|
Array.isArray(existingRouteNames) &&
|
||||||
|
existingRouteNames.every((name): name is string => typeof name === 'string')
|
||||||
|
? existingRouteNames
|
||||||
|
: [LIBRARY_ROOT_ROUTE, detailRoute.name];
|
||||||
|
|
||||||
|
const nextLibraryState: NavigationStateLike = {
|
||||||
|
...(existingLibraryState ?? {}),
|
||||||
|
stale: false,
|
||||||
|
type:
|
||||||
|
typeof existingLibraryState?.type === 'string'
|
||||||
|
? existingLibraryState.type
|
||||||
|
: 'stack',
|
||||||
|
key:
|
||||||
|
typeof existingLibraryState?.key === 'string'
|
||||||
|
? existingLibraryState.key
|
||||||
|
: `${keyPrefix}-stack`,
|
||||||
|
index: 1,
|
||||||
|
routeNames,
|
||||||
|
preloadedRoutes: [],
|
||||||
|
routes: [rootRoute, detailRoute],
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextLibraryRoute: NavigationRouteLike = {
|
||||||
|
...libraryRoute,
|
||||||
|
// Clear nested-navigation command params left by earlier href navigation;
|
||||||
|
// the explicit child state above is now the sole source of truth.
|
||||||
|
params: undefined,
|
||||||
|
state: nextLibraryState,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'RESET',
|
||||||
|
target: tabsState.key,
|
||||||
|
payload: {
|
||||||
|
...tabsState,
|
||||||
|
index: libraryIndex,
|
||||||
|
// Tabs use firstRoute back behaviour, so RESET must update the history
|
||||||
|
// invariant that an ordinary navigate-to-Library action would establish.
|
||||||
|
history:
|
||||||
|
libraryIndex === 0
|
||||||
|
? [{ type: 'route', key: libraryRoute.key }]
|
||||||
|
: [
|
||||||
|
{ type: 'route', key: initialTabRoute.key },
|
||||||
|
{ type: 'route', key: libraryRoute.key },
|
||||||
|
],
|
||||||
|
...(Array.isArray(tabsState.preloadedRouteKeys)
|
||||||
|
? {
|
||||||
|
preloadedRouteKeys: tabsState.preloadedRouteKeys.filter(
|
||||||
|
(key) => key !== libraryRoute.key
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
routes: tabsState.routes.map((route, index) =>
|
||||||
|
index === libraryIndex ? nextLibraryRoute : route
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasHomeLibraryHandoff(params: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof params === 'object' &&
|
||||||
|
params !== null &&
|
||||||
|
HOME_LIBRARY_HANDOFF_PARAM in params &&
|
||||||
|
(params as Record<string, unknown>)[HOME_LIBRARY_HANDOFF_PARAM] === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withoutHomeLibraryHandoff(
|
||||||
|
params: Record<string, unknown> | undefined
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
if (!params || !hasHomeLibraryHandoff(params)) return params;
|
||||||
|
const next = { ...params };
|
||||||
|
delete next[HOME_LIBRARY_HANDOFF_PARAM];
|
||||||
|
return Object.keys(next).length > 0 ? next : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useNavigation, useRouter } from 'expo-router';
|
||||||
|
import {
|
||||||
|
buildHomeLibraryResetAction,
|
||||||
|
homeLibraryTargetHref,
|
||||||
|
type HomeLibraryTarget,
|
||||||
|
type NavigationStateLike,
|
||||||
|
} from '@/navigation/homeLibraryNavigation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a Library detail as a fresh navigation context from Home.
|
||||||
|
*
|
||||||
|
* A malformed/unready tab state falls back to ordinary href navigation so a
|
||||||
|
* state-shape mismatch never turns a content card into a dead control.
|
||||||
|
*/
|
||||||
|
export function useHomeLibraryNavigation(): (target: HomeLibraryTarget) => void {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(target: HomeLibraryTarget) => {
|
||||||
|
const action = buildHomeLibraryResetAction(
|
||||||
|
navigation.getState() as NavigationStateLike | undefined,
|
||||||
|
target
|
||||||
|
);
|
||||||
|
if (!action) {
|
||||||
|
router.push(homeLibraryTargetHref(target) as never, { withAnchor: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigation.dispatch(action as never);
|
||||||
|
},
|
||||||
|
[navigation, router]
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user