more landscape stuff

This commit is contained in:
Boof2015
2026-07-31 16:00:15 -04:00
parent 3d70634134
commit 9b056c4f86
6 changed files with 252 additions and 60 deletions
+55 -49
View File
@@ -4,6 +4,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Tabs } from 'expo-router';
import { TabBar, type TabItem } from '@/components/TabBar';
import { getShellLayout } from '@/navigation/shellLayout';
import { ShellRailContext } from '@/navigation/shellRailContext';
import {
TAB_SCENE_ANIMATION,
TAB_TRANSITION_SETTLE_MS,
@@ -43,59 +44,64 @@ export default function TabsLayout() {
[colors.bgPrimary, tabBarPosition]
);
return (
<Tabs
detachInactiveScreens={false}
screenOptions={screenOptions}
tabBar={({ state, navigation }) => {
const activeRouteName = state.routes[state.index]?.name;
const items: TabItem[] = state.routes.map((route, index) => ({
key: route.key,
name: route.name,
focused: isDisplayedTabFocused(
route.name,
index,
state.index,
activeRouteName,
),
}));
// Tells every scene below that the rail is already standing in the leading
// cutout's way, so `Screen` pays only the trailing inset. Screens outside
// this navigator get the default `false` and pay both.
<ShellRailContext.Provider value={shell.mode === 'rail'}>
<Tabs
detachInactiveScreens={false}
screenOptions={screenOptions}
tabBar={({ state, navigation }) => {
const activeRouteName = state.routes[state.index]?.name;
const items: TabItem[] = state.routes.map((route, index) => ({
key: route.key,
name: route.name,
focused: isDisplayedTabFocused(
route.name,
index,
state.index,
activeRouteName,
),
}));
const handlePress = (item: TabItem) => {
// Interrupting the native-driver scene animation can drop its
// 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;
const event = navigation.emit({
type: 'tabPress',
target: item.key,
canPreventDefault: true,
});
if (event.defaultPrevented) return;
const handlePress = (item: TabItem) => {
// Interrupting the native-driver scene animation can drop its
// 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;
const event = navigation.emit({
type: 'tabPress',
target: item.key,
canPreventDefault: true,
});
if (event.defaultPrevented) return;
const actuallyFocused = state.routes[state.index]?.key === item.key;
if (actuallyFocused) {
// Re-tapping the active tab resets its nested stack. This is the
// one-tap escape from a deep library chain (artist → album →
// another artist), which is why back itself only pops one level.
const nested = state.routes[state.index]?.state;
if (nested?.key && (nested.index ?? 0) > 0) {
navigation.dispatch({ ...popToTop(), target: nested.key });
const actuallyFocused = state.routes[state.index]?.key === item.key;
if (actuallyFocused) {
// Re-tapping the active tab resets its nested stack. This is the
// one-tap escape from a deep library chain (artist → album →
// another artist), which is why back itself only pops one level.
const nested = state.routes[state.index]?.state;
if (nested?.key && (nested.index ?? 0) > 0) {
navigation.dispatch({ ...popToTop(), target: nested.key });
}
return;
}
return;
}
lastSwitchAt.current = now;
navigation.navigate(item.name);
};
lastSwitchAt.current = now;
navigation.navigate(item.name);
};
return <TabBar items={items} onPress={handlePress} shell={shell} />;
}}
>
<Tabs.Screen name="index" />
<Tabs.Screen name="library" />
<Tabs.Screen name="eq" />
<Tabs.Screen name="settings" />
<Tabs.Screen name="stats" options={{ href: null }} />
</Tabs>
return <TabBar items={items} onPress={handlePress} shell={shell} />;
}}
>
<Tabs.Screen name="index" />
<Tabs.Screen name="library" />
<Tabs.Screen name="eq" />
<Tabs.Screen name="settings" />
<Tabs.Screen name="stats" options={{ href: null }} />
</Tabs>
</ShellRailContext.Provider>
);
}
+20 -6
View File
@@ -69,7 +69,7 @@ import {
} from '@/lib/artistSort';
import {
LIBRARY_LAYOUT_OPTIONS,
libraryLayoutColumns,
libraryGridColumns,
libraryLayoutLabel,
type LibraryLayout,
} from '@/library/libraryLayout';
@@ -135,6 +135,11 @@ export default function LibraryScreen() {
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set());
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
// Measured rather than derived from the window: the grid's width depends on
// the nav rail, the safe-area insets and the screen gutters, and measuring is
// one source of truth instead of a copy of all three. 0 until first layout,
// which `libraryGridColumns` reads as "use the preference".
const [gridWidth, setGridWidth] = useState(0);
const scrollTop = useScrollTopGate();
const showLibraryStatus =
@@ -313,6 +318,12 @@ export default function LibraryScreen() {
: null;
const activeLayoutLabel = activeLayout ? libraryLayoutLabel(activeLayout) : null;
const layoutSheetLabel = viewMode === 'albums' ? 'ALBUM LAYOUT' : 'ARTIST LAYOUT';
// The preference is a tile-size choice, so the count follows the width the
// grid actually got. Both feed the FlashList `key` as well as `numColumns` —
// the key already carried the layout because changing column count needs a
// remount, and a rotation changes the count without changing the preference.
const albumColumns = libraryGridColumns(albumLayout, gridWidth);
const artistColumns = libraryGridColumns(artistLayout, gridWidth);
const setActiveLayout = (layout: LibraryLayout) => {
scrollTop.setScrollAtTop(true);
@@ -423,12 +434,15 @@ export default function LibraryScreen() {
</View>
) : null}
<View style={styles.listArea}>
<View
style={styles.listArea}
onLayout={(e) => setGridWidth(e.nativeEvent.layout.width)}
>
{viewMode === 'albums' ? (
<FlashList
key={`albums-${albumSort}-${albumLayout}-${sectionJumpRevision}`}
key={`albums-${albumSort}-${albumLayout}-${albumColumns}-${sectionJumpRevision}`}
data={sortedAlbums}
numColumns={libraryLayoutColumns(albumLayout)}
numColumns={albumColumns}
keyExtractor={(album) => album.identity_key}
showsVerticalScrollIndicator={false}
overScrollMode="never"
@@ -472,9 +486,9 @@ export default function LibraryScreen() {
{viewMode === 'artists' ? (
<FlashList
key={`artists-${artistSort}-${artistLayout}-${sectionJumpRevision}`}
key={`artists-${artistSort}-${artistLayout}-${artistColumns}-${sectionJumpRevision}`}
data={sortedArtists}
numColumns={libraryLayoutColumns(artistLayout)}
numColumns={artistColumns}
keyExtractor={(artist) => artist.artist}
showsVerticalScrollIndicator={false}
overScrollMode="never"
+26 -2
View File
@@ -3,6 +3,7 @@ import {
type ViewProps
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useShellRailPresent } from '@/navigation/shellRailContext';
import { spacing } from '@/theme';
import { createThemedStyles } from '@/theme/themed';
@@ -11,12 +12,35 @@ interface ScreenProps extends ViewProps {
padded?: boolean;
}
/** Base screen container: black background + top safe-area inset. */
/**
* Base screen container: background + safe-area insets.
*
* Horizontal insets matter in landscape, where a display cutout lands on one of
* the long edges. The rail already pays the leading one when it's up — see
* `ShellRailContext` for why that can't be inferred from the window shape — so
* the leading inset is skipped only in that case. The trailing edge is always
* this screen's to pay; nothing else is over there.
*
* Insets sit on the root and the content gutter on the inner view, so the
* gutter is measured from the safe edge rather than from the cutout.
*/
export function Screen({ children, style, padded = true, ...rest }: ScreenProps) {
const styles = useStyles();
const insets = useSafeAreaInsets();
const railPresent = useShellRailPresent();
return (
<View style={[styles.root, { paddingTop: insets.top }, style]} {...rest}>
<View
style={[
styles.root,
{
paddingTop: insets.top,
paddingLeft: railPresent ? 0 : insets.left,
paddingRight: insets.right,
},
style,
]}
{...rest}
>
<View style={[styles.inner, padded && styles.padded]}>{children}</View>
</View>
);
+81
View File
@@ -3,12 +3,21 @@ import test from 'node:test';
import {
DEFAULT_LIBRARY_LAYOUT,
LIBRARY_LAYOUT_OPTIONS,
libraryGridColumns,
libraryLayoutColumns,
libraryLayoutLabel,
parseLibraryLayout,
type LibraryLayout,
} from './libraryLayout.ts';
/**
* Content widths the grid actually gets, measured off the shell:
* window width, minus the rail in landscape, minus the screen gutters.
*/
const PHONE_PORTRAIT = 380;
const PHONE_LANDSCAPE = 695;
const TABLET_LANDSCAPE = 1200;
test('defaults missing and invalid library layouts to the current three-column grid', () => {
assert.equal(parseLibraryLayout(null), DEFAULT_LIBRARY_LAYOUT);
assert.equal(parseLibraryLayout(''), DEFAULT_LIBRARY_LAYOUT);
@@ -35,3 +44,75 @@ test('maps list and grid layouts to their visible column counts and labels', ()
assert.equal(libraryLayoutLabel(layout), label);
}
});
test('a phone in portrait keeps exactly the column count the user picked', () => {
// The whole point of the reference width: this is the verified layout, and
// making the grid width-aware must not reflow it.
for (const option of LIBRARY_LAYOUT_OPTIONS) {
assert.equal(
libraryGridColumns(option.value, PHONE_PORTRAIT),
option.columns,
`${option.value} reflowed in portrait`
);
}
});
test('narrower-than-reference windows never drop below the picked count', () => {
for (const width of [200, 288, 320, 379]) {
for (const option of LIBRARY_LAYOUT_OPTIONS) {
assert.ok(
libraryGridColumns(option.value, width) >= option.columns,
`${option.value} lost columns at ${width}dp`
);
}
}
});
test('landscape spends its extra width on more tiles, not bigger ones', () => {
for (const option of LIBRARY_LAYOUT_OPTIONS) {
if (option.columns <= 1) continue;
const portrait = libraryGridColumns(option.value, PHONE_PORTRAIT);
const landscape = libraryGridColumns(option.value, PHONE_LANDSCAPE);
assert.ok(
landscape > portrait,
`${option.value} stayed at ${landscape} columns across a ${PHONE_PORTRAIT}→${PHONE_LANDSCAPE}dp change`
);
// Tiles stay near the size the preference implies rather than inflating.
const portraitTile = PHONE_PORTRAIT / portrait;
const landscapeTile = PHONE_LANDSCAPE / landscape;
assert.ok(
Math.abs(landscapeTile - portraitTile) / portraitTile < 0.2,
`${option.value} tiles went ${portraitTile.toFixed(0)}dp → ${landscapeTile.toFixed(0)}dp`
);
}
});
test('a list stays one column at every width', () => {
for (const width of [PHONE_PORTRAIT, PHONE_LANDSCAPE, TABLET_LANDSCAPE]) {
assert.equal(libraryGridColumns('list', width), 1);
}
});
test('column count is capped so a wide window cannot shred the grid', () => {
for (const option of LIBRARY_LAYOUT_OPTIONS) {
assert.ok(libraryGridColumns(option.value, 4000) <= 8);
}
});
test('falls back to the picked count before the grid has been measured', () => {
for (const width of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
for (const option of LIBRARY_LAYOUT_OPTIONS) {
assert.equal(libraryGridColumns(option.value, width), option.columns);
}
}
});
test('every width produces a usable finite column count', () => {
for (let width = 0; width <= 2000; width += 7) {
for (const option of LIBRARY_LAYOUT_OPTIONS) {
const columns = libraryGridColumns(option.value, width);
assert.ok(Number.isInteger(columns), `${option.value} @ ${width} → ${columns}`);
assert.ok(columns >= 1 && columns <= 8, `${option.value} @ ${width} → ${columns}`);
}
}
});
+46 -3
View File
@@ -21,21 +21,21 @@ export const LIBRARY_LAYOUT_OPTIONS: readonly LibraryLayoutOption[] = [
{
value: 'grid-2',
label: 'Large grid',
subtitle: '2 columns',
subtitle: 'Large tiles',
icon: 'grid-outline',
columns: 2,
},
{
value: 'grid-3',
label: 'Medium grid',
subtitle: '3 columns',
subtitle: 'Medium tiles',
icon: 'grid-outline',
columns: 3,
},
{
value: 'grid-4',
label: 'Compact grid',
subtitle: '4 columns',
subtitle: 'Small tiles',
icon: 'grid-outline',
columns: 4,
},
@@ -56,6 +56,49 @@ export function libraryLayoutColumns(layout: LibraryLayout): number {
?? LIBRARY_LAYOUT_OPTIONS.find((option) => option.value === DEFAULT_LIBRARY_LAYOUT)!.columns;
}
/**
* Content width the column counts above were chosen against — a phone in
* portrait, roughly 412dp of window minus the screen gutters.
*/
const REFERENCE_CONTENT_WIDTH = 380;
/**
* Guardrail for very wide windows. Past this the grid stops reading as a grid
* and starts reading as a filmstrip, regardless of what the arithmetic wants.
*/
const MAX_GRID_COLUMNS = 8;
/**
* Columns for a grid of `contentWidth` dp.
*
* `libraryLayoutColumns` returns what the user picked, which is a *density*
* choice, not literally "three columns forever". Honouring it as a fixed count
* meant a landscape window spent all its extra width inflating artwork: the
* same 3 tiles at roughly double the size, so rotating showed about a third as
* many artists as portrait did despite twice the pixels.
*
* So the preference sets a target tile size — the size it implies at
* `REFERENCE_CONTENT_WIDTH` — and the column count follows the window. "Medium
* grid" keeps meaning ~127dp tiles whether that works out to 3 columns or 6.
*
* Never returns fewer columns than the preference asks for: on windows narrower
* than the reference this is exactly the old behaviour, so no phone in portrait
* sees its library reflow.
*/
export function libraryGridColumns(layout: LibraryLayout, contentWidth: number): number {
const base = libraryLayoutColumns(layout);
// A list is a list at any width; widening it is a different design decision.
if (base <= 1) return 1;
// Before first layout there's nothing to divide by — fall back to the
// preference so the first frame matches what the user chose.
if (!Number.isFinite(contentWidth) || contentWidth <= 0) return base;
const targetTileWidth = REFERENCE_CONTENT_WIDTH / base;
return Math.min(
MAX_GRID_COLUMNS,
Math.max(base, Math.round(contentWidth / targetTileWidth))
);
}
export function libraryLayoutLabel(layout: LibraryLayout): string {
return LIBRARY_LAYOUT_OPTIONS.find((option) => option.value === layout)?.label
?? LIBRARY_LAYOUT_OPTIONS.find((option) => option.value === DEFAULT_LIBRARY_LAYOUT)!.label;
+24
View File
@@ -0,0 +1,24 @@
import { createContext, useContext } from 'react';
/**
* Whether a navigation rail is sitting along the leading edge of the current
* scene.
*
* `Screen` needs this to place horizontal safe-area insets. `BottomTabView`
* does not narrow the safe-area context for its scenes — every screen reads the
* full window insets whether or not the rail is already standing in the
* cutout's way — so a scene that blindly applied `insets.left` would double-pay
* in landscape while the rail was doing the job.
*
* It can't be derived from window dimensions either: `/settings/appearance` and
* the other root-stack screens are just as landscape as the tab screens are,
* but no rail is rendered beside them, so they must pay both insets themselves.
* Hence a context published by the tabs layout, defaulting to false for
* everything outside it.
*/
export const ShellRailContext = createContext(false);
/** False outside the tabs navigator, and in portrait. */
export function useShellRailPresent(): boolean {
return useContext(ShellRailContext);
}