fix visual bug

This commit is contained in:
Boof2015
2026-07-25 21:02:08 -04:00
parent 5ea4c89303
commit ce550de0e1
6 changed files with 436 additions and 13 deletions
+1 -1
View File
@@ -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",
+6 -10
View File
@@ -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 })}
/>
))}
</View>
+19 -2
View File
@@ -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 (
<Stack
screenOptions={{
screenOptions={({ route }) => ({
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<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' });
});
+212
View File
@@ -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]
);
}