mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-28 17:11:19 +02:00
more landscape stuff
This commit is contained in:
+55
-49
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user