diff --git a/package.json b/package.json index 7b2296d..6c3df2c 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,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/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: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", diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index 6d6e688..03d4286 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -42,6 +42,7 @@ import { HOME_GREETING_ROTATION_MS, type HomeGreetingTextMode, } from '@/home/homeGreeting'; +import { useHomeLibraryNavigation } from '@/navigation/useHomeLibraryNavigation'; import type { Album, Artist, DbTrack } from '@/types/library'; const RECENT_ALBUM_LIMIT = 8; @@ -514,6 +515,7 @@ function EmptyHomeCard({ export default function HomeScreen() { const styles = useStyles(); const router = useRouter(); + const openLibrary = useHomeLibraryNavigation(); const totalTrackCount = useLibraryStore((s) => s.totalTrackCount); const albums = useLibraryStore((s) => s.homeAlbums); const artists = useLibraryStore((s) => s.homeArtists); @@ -589,17 +591,11 @@ export default function HomeScreen() { const canExpandRecentTracks = recentlyPlayedTracks.length > RECENT_TRACK_LIMIT; const openAlbum = (album: Album) => { - router.push({ - pathname: '/library/album/[key]', - params: { key: album.identity_key }, - }); + openLibrary({ kind: 'album', key: album.identity_key }); }; const openArtist = (artist: Artist) => { - router.push({ - pathname: '/library/artist/[name]', - params: { name: artist.artist }, - }); + openLibrary({ kind: 'artist', name: artist.artist }); }; const playRecentlyPlayed = (list: DbTrack[], index = 0) => { @@ -735,7 +731,7 @@ export default function HomeScreen() { trackCount={favoriteTracks.length} coverHash={favoriteTracks[0]?.artwork_hash ?? null} pinned - onPress={() => router.push('/library/playlist/favorites')} + onPress={() => openLibrary({ kind: 'playlist', id: 'favorites' })} /> ) : null} {homePlaylists.map((playlist) => ( @@ -745,7 +741,7 @@ export default function HomeScreen() { trackCount={playlist.track_count} missingCount={playlist.missing_track_count} coverHash={playlist.auto_cover_hash} - onPress={() => router.push(`/library/playlist/${playlist.id}`)} + onPress={() => openLibrary({ kind: 'playlist', id: playlist.id })} /> ))} diff --git a/src/app/(tabs)/library/_layout.tsx b/src/app/(tabs)/library/_layout.tsx index 7e6c891..1aace55 100644 --- a/src/app/(tabs)/library/_layout.tsx +++ b/src/app/(tabs)/library/_layout.tsx @@ -1,4 +1,8 @@ import { Stack } from 'expo-router'; +import { + hasHomeLibraryHandoff, + withoutHomeLibraryHandoff, +} from '@/navigation/homeLibraryNavigation'; import { useColors } from '@/theme/themed'; /** @@ -19,10 +23,23 @@ export default function LibraryLayout() { const colors = useColors(); return ( ({ headerShown: false, 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 | undefined) + ); + }, + })} /> ); } diff --git a/src/navigation/homeLibraryNavigation.test.mts b/src/navigation/homeLibraryNavigation.test.mts new file mode 100644 index 0000000..425f684 --- /dev/null +++ b/src/navigation/homeLibraryNavigation.test.mts @@ -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' }); +}); diff --git a/src/navigation/homeLibraryNavigation.ts b/src/navigation/homeLibraryNavigation.ts new file mode 100644 index 0000000..b2bb481 --- /dev/null +++ b/src/navigation/homeLibraryNavigation.ts @@ -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; + 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)[HOME_LIBRARY_HANDOFF_PARAM] === true + ); +} + +export function withoutHomeLibraryHandoff( + params: Record | undefined +): Record | undefined { + if (!params || !hasHomeLibraryHandoff(params)) return params; + const next = { ...params }; + delete next[HOME_LIBRARY_HANDOFF_PARAM]; + return Object.keys(next).length > 0 ? next : undefined; +} diff --git a/src/navigation/useHomeLibraryNavigation.ts b/src/navigation/useHomeLibraryNavigation.ts new file mode 100644 index 0000000..6ee052d --- /dev/null +++ b/src/navigation/useHomeLibraryNavigation.ts @@ -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] + ); +}