From 08fe01d361e7be5f6554c395d2605b8a2afdec23 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:15:05 -0400 Subject: [PATCH] tablet size navbar --- src/app/(tabs)/eq.tsx | 13 +- src/app/(tabs)/index.tsx | 8 +- src/app/(tabs)/library/album/[key].tsx | 6 +- src/app/(tabs)/library/artist/[name].tsx | 5 +- .../(tabs)/library/artist/[name]/albums.tsx | 9 +- .../library/artist/[name]/appearances.tsx | 9 +- .../(tabs)/library/artist/[name]/songs.tsx | 9 +- src/app/(tabs)/library/index.tsx | 15 +- src/app/(tabs)/library/playlist/[id].tsx | 6 +- src/app/(tabs)/settings.tsx | 8 +- src/app/(tabs)/stats.tsx | 7 +- src/components/MiniPlayer.tsx | 145 +++++++++++- src/components/TabBar.tsx | 98 +++++++- src/components/library/FoldersView.tsx | 9 +- src/components/library/PlaylistsView.tsx | 8 +- src/navigation/shellLayout.test.mts | 203 +++++++++++++++- src/navigation/shellLayout.ts | 224 ++++++++++++++++-- src/navigation/useShellLayout.ts | 14 ++ 18 files changed, 705 insertions(+), 91 deletions(-) diff --git a/src/app/(tabs)/eq.tsx b/src/app/(tabs)/eq.tsx index bc080ff..343b94e 100644 --- a/src/app/(tabs)/eq.tsx +++ b/src/app/(tabs)/eq.tsx @@ -37,11 +37,9 @@ import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet'; import { EQPresetQrSheet } from '@/components/eq/EQPresetQrSheet'; import { PresetDeviceAssignmentSheet } from '@/components/eq/PresetDeviceAssignmentSheet'; import { - layout, radius, spacing, } from '@/theme'; -import { useShellRailPresent } from '@/navigation/shellRailContext'; import { createThemedStyles, useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; import { hapticForToggle } from '@/lib/hapticCatalog'; @@ -75,6 +73,7 @@ import { } from '@/audio/eqShare'; import { BAND_TYPE_LABEL, formatGain } from '@/components/eq/format'; import type { EQBand, EQBandType, EQPreset } from '@/types/audio'; +import { useSceneBottomInset } from '@/navigation/useShellLayout'; type SheetKind = | 'none' @@ -115,7 +114,7 @@ export default function EQScreen() { const closeSheet = useCallback(() => setSheet('none'), []); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const insets = useSafeAreaInsets(); - const railPresent = useShellRailPresent(); + const sceneBottomInset = useSceneBottomInset(); const availableWidth = windowWidth - insets.left - insets.right; const availableHeight = windowHeight - insets.top - insets.bottom; const isWide = isWideWindow(availableWidth, availableHeight); @@ -375,10 +374,10 @@ export default function EQScreen() { !isWide && styles.bottomBarNarrow, isWide && styles.bottomBarWide, // This screen doesn't scroll — the preamp row is pinned to the bottom of - // the scene, so it can't reserve the pill's space in a content inset the - // way every list does. Only the tab-bar shape floats a pill; in rail - // mode the mini player is docked in the rail and nothing overlaps here. - railPresent ? null : { paddingBottom: spacing.sm + layout.miniPlayerFloat }, + // the scene, so it can't reserve the floating pill's space in a content + // inset the way every list does. `sceneBottomInset` is 0 in the shapes + // that seat the mini player in their own chrome. + sceneBottomInset > 0 ? { paddingBottom: spacing.sm + sceneBottomInset } : null, ]} > diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index 94d203a..d6bd0fd 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -23,7 +23,6 @@ import { useScrollTopGate } from '@/components/search/PullSearchGesture'; import { - layout, fonts, radius, spacing, @@ -48,6 +47,7 @@ import type { Album, Artist, DbTrack } from '@/types/library'; import { ListeningPreviewCard } from '@/components/listening/ListeningPreviewCard'; import { useListeningStatsStore } from '@/stores/listeningStatsStore'; import { subscribeToListeningHistory } from '@/listeningStats/events'; +import { useSceneBottomInset } from '@/navigation/useShellLayout'; const RECENT_ALBUM_LIMIT = 8; const RECENT_TRACK_LIMIT = 3; @@ -517,6 +517,7 @@ function EmptyHomeCard({ } export default function HomeScreen() { + const sceneBottomInset = useSceneBottomInset(); const styles = useStyles(); const router = useRouter(); const openLibrary = useHomeLibraryNavigation(); @@ -655,7 +656,7 @@ export default function HomeScreen() { @@ -788,9 +789,6 @@ export default function HomeScreen() { } const useStyles = createThemedStyles((colors) => ({ - content: { - paddingBottom: layout.miniPlayerFloat, - }, masthead: { minHeight: 72, flexDirection: 'row', diff --git a/src/app/(tabs)/library/album/[key].tsx b/src/app/(tabs)/library/album/[key].tsx index dc52774..7af5133 100644 --- a/src/app/(tabs)/library/album/[key].tsx +++ b/src/app/(tabs)/library/album/[key].tsx @@ -11,7 +11,7 @@ import { AstraLogo } from '@/components/AstraLogo'; import { TrackRow } from '@/components/library/TrackRow'; import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail'; -import { layout, spacing } from '@/theme'; +import { spacing } from '@/theme'; import { useColors } from '@/theme/themed'; import { usePlayerStore } from '@/stores/playerStore'; import { playLibraryQuery } from '@/audio/playbackController'; @@ -20,6 +20,7 @@ import { albumArtworkSource, artworkThumbUri, artworkUri } from '@/library/artwo import { formatDuration } from '@/lib/format'; import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack'; import type { DbTrack } from '@/types/library'; +import { useSceneBottomInset } from '@/navigation/useShellLayout'; type AlbumRow = | { kind: 'track'; track: DbTrack; index: number } @@ -36,6 +37,7 @@ function DiscHeader({ disc }: { disc: number }) { } export default function AlbumScreen() { + const sceneBottomInset = useSceneBottomInset(); const colors = useColors(); const { key } = useLocalSearchParams<{ key: string }>(); const { items: tracks, summary: album, totalCount, loadMore } = useNativeAlbumDetail(key); @@ -116,7 +118,7 @@ export default function AlbumScreen() { contentContainerStyle={{ paddingTop: insets.top + expandedHeight, paddingHorizontal: spacing.lg, - paddingBottom: layout.miniPlayerFloat, + paddingBottom: sceneBottomInset, }} renderItem={({ item }) => item.kind === 'disc' ? ( diff --git a/src/app/(tabs)/library/artist/[name].tsx b/src/app/(tabs)/library/artist/[name].tsx index 8ab4237..d258ff9 100644 --- a/src/app/(tabs)/library/artist/[name].tsx +++ b/src/app/(tabs)/library/artist/[name].tsx @@ -25,7 +25,6 @@ import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionShe import { showAppDialog } from '@/components/dialogs/AppDialog'; import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail'; import { - layout, fontSize, radius, spacing, @@ -55,6 +54,7 @@ import { selectDeezerArtistImage, selectLocalArtistImage, } from '@/library/artistImageLookup'; +import { useSceneBottomInset } from '@/navigation/useShellLayout'; type IconName = ComponentProps['name']; type ArtistSectionTarget = 'songs' | 'albums' | 'appearances'; @@ -76,6 +76,7 @@ type ArtistPageItem = | { key: 'empty'; type: 'empty' }; export default function ArtistScreen() { + const sceneBottomInset = useSceneBottomInset(); const styles = useStyles(); const colors = useColors(); const router = useRouter(); @@ -321,7 +322,7 @@ export default function ArtistScreen() { contentContainerStyle={{ paddingTop: insets.top + expandedHeight, paddingHorizontal: spacing.lg, - paddingBottom: layout.miniPlayerFloat, + paddingBottom: sceneBottomInset, }} /> )} ListEmptyComponent={} - contentContainerStyle={styles.listContent} + contentContainerStyle={{ paddingBottom: sceneBottomInset }} /> ); @@ -102,9 +104,6 @@ const styles = StyleSheet.create({ gap: spacing.xs, marginBottom: spacing.lg, }, - listContent: { - paddingBottom: layout.miniPlayerFloat, - }, gridCell: { flex: 1, paddingHorizontal: spacing.xs, diff --git a/src/app/(tabs)/library/artist/[name]/appearances.tsx b/src/app/(tabs)/library/artist/[name]/appearances.tsx index 51df4d7..cd63d6d 100644 --- a/src/app/(tabs)/library/artist/[name]/appearances.tsx +++ b/src/app/(tabs)/library/artist/[name]/appearances.tsx @@ -11,7 +11,7 @@ import { Screen } from '@/components/Screen'; import { Text } from '@/components/Text'; import { TrackRow } from '@/components/library/TrackRow'; import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; -import { layout, spacing } from '@/theme'; +import { spacing } from '@/theme'; import { useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { usePlayerStore } from '@/stores/playerStore'; @@ -19,8 +19,10 @@ import { useSettingsStore } from '@/stores/settingsStore'; import { playLibraryQuery } from '@/audio/playbackController'; import { useNativeArtistDetail } from '@/library/nativePages'; import type { DbTrack } from '@/types/library'; +import { useSceneBottomInset } from '@/navigation/useShellLayout'; export default function ArtistAppearancesScreen() { + const sceneBottomInset = useSceneBottomInset(); const colors = useColors(); const ripple = useRipple(); const router = useRouter(); @@ -85,7 +87,7 @@ export default function ArtistAppearancesScreen() { /> )} ListEmptyComponent={} - contentContainerStyle={styles.listContent} + contentContainerStyle={{ paddingBottom: sceneBottomInset }} /> setActionTrack(null)} /> @@ -123,9 +125,6 @@ const styles = StyleSheet.create({ gap: spacing.xs, marginBottom: spacing.lg, }, - listContent: { - paddingBottom: layout.miniPlayerFloat, - }, emptyState: { alignItems: 'center', justifyContent: 'center', diff --git a/src/app/(tabs)/library/artist/[name]/songs.tsx b/src/app/(tabs)/library/artist/[name]/songs.tsx index c9257f3..a96283e 100644 --- a/src/app/(tabs)/library/artist/[name]/songs.tsx +++ b/src/app/(tabs)/library/artist/[name]/songs.tsx @@ -11,7 +11,7 @@ import { Screen } from '@/components/Screen'; import { Text } from '@/components/Text'; import { TrackRow } from '@/components/library/TrackRow'; import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; -import { layout, spacing } from '@/theme'; +import { spacing } from '@/theme'; import { useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { usePlayerStore } from '@/stores/playerStore'; @@ -19,8 +19,10 @@ import { useSettingsStore } from '@/stores/settingsStore'; import { playLibraryQuery } from '@/audio/playbackController'; import { useNativeArtistDetail } from '@/library/nativePages'; import type { DbTrack } from '@/types/library'; +import { useSceneBottomInset } from '@/navigation/useShellLayout'; export default function ArtistSongsScreen() { + const sceneBottomInset = useSceneBottomInset(); const colors = useColors(); const ripple = useRipple(); const router = useRouter(); @@ -85,7 +87,7 @@ export default function ArtistSongsScreen() { /> )} ListEmptyComponent={} - contentContainerStyle={styles.listContent} + contentContainerStyle={{ paddingBottom: sceneBottomInset }} /> setActionTrack(null)} /> @@ -123,9 +125,6 @@ const styles = StyleSheet.create({ gap: spacing.xs, marginBottom: spacing.lg, }, - listContent: { - paddingBottom: layout.miniPlayerFloat, - }, emptyState: { alignItems: 'center', justifyContent: 'center', diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx index 5c13235..81febc6 100644 --- a/src/app/(tabs)/library/index.tsx +++ b/src/app/(tabs)/library/index.tsx @@ -41,10 +41,10 @@ import { PullSearchScrollView, useScrollTopGate } from '@/components/search/PullSearchGesture'; -import { layout, spacing } from '@/theme'; +import { spacing } from '@/theme'; import { useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; -import { useShellShowsScreenTitle } from '@/navigation/useShellLayout'; +import { useSceneBottomInset, useShellShowsScreenTitle } from '@/navigation/useShellLayout'; import { useLibraryStore } from '@/stores/libraryStore'; import { usePlayerStore } from '@/stores/playerStore'; import { useSearchStore } from '@/stores/searchStore'; @@ -128,6 +128,7 @@ export default function LibraryScreen() { const currentPath = usePlayerStore((s) => s.currentTrack?.path); const openQuickSearch = useSearchStore((s) => s.openQuickSearch); const showScreenTitle = useShellShowsScreenTitle(); + const sceneBottomInset = useSceneBottomInset(); const [actionTrack, setActionTrack] = useState(null); const [sortSheetOpen, setSortSheetOpen] = useState(false); @@ -446,7 +447,7 @@ export default function LibraryScreen() { keyExtractor={(album) => album.identity_key} showsVerticalScrollIndicator={false} overScrollMode="never" - contentContainerStyle={styles.listContent} + contentContainerStyle={{ paddingBottom: sceneBottomInset }} renderScrollComponent={PullSearchScrollView} onScroll={scrollTop.onScroll} scrollEventThrottle={scrollTop.scrollEventThrottle} @@ -492,7 +493,7 @@ export default function LibraryScreen() { keyExtractor={(artist) => artist.artist} showsVerticalScrollIndicator={false} overScrollMode="never" - contentContainerStyle={styles.listContent} + contentContainerStyle={{ paddingBottom: sceneBottomInset }} renderScrollComponent={PullSearchScrollView} onScroll={scrollTop.onScroll} scrollEventThrottle={scrollTop.scrollEventThrottle} @@ -537,7 +538,7 @@ export default function LibraryScreen() { keyExtractor={(track) => String(track.id)} showsVerticalScrollIndicator={false} overScrollMode="never" - contentContainerStyle={styles.listContent} + contentContainerStyle={{ paddingBottom: sceneBottomInset }} renderScrollComponent={PullSearchScrollView} onScroll={scrollTop.onScroll} scrollEventThrottle={scrollTop.scrollEventThrottle} @@ -711,10 +712,6 @@ const styles = StyleSheet.create({ listArea: { flex: 1, }, - // Clears the floating mini player so the last row is readable. - listContent: { - paddingBottom: layout.miniPlayerFloat, - }, listFooter: { paddingVertical: spacing.lg, alignItems: 'center', diff --git a/src/app/(tabs)/library/playlist/[id].tsx b/src/app/(tabs)/library/playlist/[id].tsx index 8f688da..5d93300 100644 --- a/src/app/(tabs)/library/playlist/[id].tsx +++ b/src/app/(tabs)/library/playlist/[id].tsx @@ -25,7 +25,7 @@ import { import { TextPromptModal } from '@/components/sheets/TextPromptModal'; import { showAppDialog } from '@/components/dialogs/AppDialog'; import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail'; -import { layout, spacing } from '@/theme'; +import { spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { usePlaylistStore } from '@/stores/playlistStore'; @@ -37,6 +37,7 @@ import { playHaptic } from '@/lib/haptics'; import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack'; import type { DbTrack } from '@/types/library'; import type { Playlist, PlaylistTrackEntry } from '@/types/playlist'; +import { useSceneBottomInset } from '@/navigation/useShellLayout'; function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); @@ -84,6 +85,7 @@ function MissingRow({ entry, onLongPress }: { entry: PlaylistTrackEntry; onLongP type Prompt = { kind: 'rename'; playlist: Playlist } | null; export default function PlaylistScreen() { + const sceneBottomInset = useSceneBottomInset(); const styles = useStyles(); const ripple = useRipple(); const colors = useColors(); @@ -292,7 +294,7 @@ export default function PlaylistScreen() { contentContainerStyle={{ paddingTop: insets.top + expandedHeight, paddingHorizontal: spacing.lg, - paddingBottom: layout.miniPlayerFloat, + paddingBottom: sceneBottomInset, }} renderItem={({ item }) => item.track ? ( diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx index 90853e9..f790073 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(tabs)/settings.tsx @@ -16,7 +16,7 @@ import { SettingsNavRow, SettingsSectionLabel, } from '@/components/settings/SettingsSectionScaffold'; -import { layout, spacing } from '@/theme'; +import { spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { formatRelativeTime } from '@/lib/format'; import { useAudioSettingsStore } from '@/stores/audioSettingsStore'; @@ -30,7 +30,7 @@ import { useThemeStore } from '@/stores/themeStore'; import { useSleepTimerStore } from '@/stores/sleepTimerStore'; import { formatSleepTimerStatus } from '@/audio/sleepTimerState'; import { useSettingsStore } from '@/stores/settingsStore'; -import { useShellShowsScreenTitle } from '@/navigation/useShellLayout'; +import { useSceneBottomInset, useShellShowsScreenTitle } from '@/navigation/useShellLayout'; function formatEnabled(value: boolean): string { return value ? 'On' : 'Off'; @@ -38,6 +38,7 @@ function formatEnabled(value: boolean): string { export default function SettingsScreen() { const showScreenTitle = useShellShowsScreenTitle(); + const sceneBottomInset = useSceneBottomInset(); const styles = useStyles(); const colors = useColors(); const router = useRouter(); @@ -89,7 +90,7 @@ export default function SettingsScreen() { return ( - + {/* The rail names this destination itself; repeating it would just spend a landscape window's scarce height on the same word. */} {showScreenTitle ? ( @@ -161,7 +162,6 @@ export default function SettingsScreen() { const useStyles = createThemedStyles(() => ({ content: { - paddingBottom: layout.miniPlayerFloat, gap: spacing.sm, }, heading: { diff --git a/src/app/(tabs)/stats.tsx b/src/app/(tabs)/stats.tsx index c5ede16..661c34a 100644 --- a/src/app/(tabs)/stats.tsx +++ b/src/app/(tabs)/stats.tsx @@ -20,7 +20,7 @@ import { formatListeningTime, formatRecordedSince } from '@/listeningStats/forma import { useHomeLibraryNavigation } from '@/navigation/useHomeLibraryNavigation'; import { useListeningStatsStore } from '@/stores/listeningStatsStore'; import { playLibraryQuery } from '@/audio/playbackController'; -import { layout, fonts, radius, spacing } from '@/theme'; +import { fonts, radius, spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; import type { @@ -32,6 +32,7 @@ import type { RankedListeningTrack, } from '@/types/listeningStats'; import { subscribeToListeningHistory } from '@/listeningStats/events'; +import { useSceneBottomInset } from '@/navigation/useShellLayout'; const RANGE_SEGMENTS = [ { key: '7d', label: '7D' }, @@ -221,6 +222,7 @@ function EmptyState({ } export default function ListeningStatsScreen() { + const sceneBottomInset = useSceneBottomInset(); const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); @@ -322,7 +324,7 @@ export default function ListeningStatsScreen() { ({ }, content: { paddingTop: spacing.md, - paddingBottom: layout.miniPlayerFloat, gap: spacing.lg, }, pausedBanner: { diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index c00f558..f545331 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -39,6 +39,7 @@ import { useScopeActive } from '@/scope/scopeStore'; import { RAIL_MINI_TOP_MARGIN, type RailMiniPlayerLayout, + type SplitBarLayout, } from '@/navigation/shellLayout'; import { artworkThumbFromSource } from '@/library/artwork'; import { useAnimatedPlaybackProgress } from '@/audio/useAnimatedPlaybackProgress'; @@ -70,11 +71,18 @@ const SWIPE_RESPONSE_TIMEOUT_MS = 1500; const COMMITTED_MEDIA_OPACITY = 0.18; interface MiniPlayerProps { - /** 'pill' floats above the bottom tabs; 'rail' docks at the foot of the - * landscape navigation rail, where there is ~88dp of width to work with. */ - variant?: 'pill' | 'rail'; + /** + * - `pill` floats above the bottom tabs. + * - `rail` docks at the foot of the landscape navigation rail, where there is + * ~88dp of width to work with. + * - `bar` sits *beside* the nav items in the split bottom bar on a wide + * window, in a segment the shell has already sized. + */ + variant?: 'pill' | 'rail' | 'bar'; /** Required for the rail variant; sizes come from `getShellLayout`. */ railLayout?: RailMiniPlayerLayout; + /** Required for the bar variant; sizes come from `getShellLayout`. */ + barLayout?: SplitBarLayout; } interface MiniPlayerMediaPresentation { @@ -167,7 +175,7 @@ function PhoneMiniProgress({ * bar with the live filled-line spectrum drifting behind the metadata. Tapping * opens the full now-playing screen. */ -export function MiniPlayer({ variant = 'pill', railLayout }: MiniPlayerProps = {}) { +export function MiniPlayer({ variant = 'pill', railLayout, barLayout }: MiniPlayerProps = {}) { const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); @@ -483,6 +491,117 @@ export function MiniPlayer({ variant = 'pill', railLayout }: MiniPlayerProps = { setMediaWidth(e.nativeEvent.layout.width); }; + if (variant === 'bar' && barLayout) { + const { artSize, controlSize, miniWidth, height } = barLayout; + return ( + <> + {/* Height matches the nav card beside it: they are peers, and a + mismatched pair is the difference between composed and slapped on. */} + + usePlayerUiStore.getState().openPlayer()} + onLayout={onLayout} + accessibilityRole="button" + accessibilityLabel={ + presentation.hasTrack + ? `Now playing: ${displayedMedia.title}. Open player` + : 'Open player' + } + > + {/* The bar is wide enough for the scope to read, and it's the thing + that makes this a music player's bar rather than a nav bar with a + track glued to it. */} + {liveScopeActive && pillWidth > 0 && ( + + + + )} + {liveScopeActive && pillWidth > 0 && } + + + {displayedMedia.artworkUri ? ( + + ) : ( + + )} + + + + + {displayedMedia.title} + + + {displayedMedia.subtitle} + + + + + + + + + + + {presentation.hasTrack ? ( + isDesktop ? ( + + ) : ( + + ) + ) : null} + + setTargetPickerOpen(false)} + /> + + ); + } + if (variant === 'rail' && railLayout) { const { artSize, titleLineHeight, controlSize, gap, blockHeight } = railLayout; return ( @@ -803,6 +922,24 @@ const useStyles = createThemedStyles((colors) => ({ alignItems: 'center', justifyContent: 'center', }, + // Bar variant: the pill's surface, seated in the split bottom bar beside the + // nav items rather than floating above them. Same card language as the pill so + // the two shapes read as one component in different rooms — but no margins or + // shadow, because here it sits *on* chrome instead of over content. + barBlock: { + borderRadius: radius.lg, + backgroundColor: colors.bgTertiary, + borderColor: colors.glassBorderStrong, + borderWidth: 1, + overflow: 'hidden', + justifyContent: 'center', + }, + barTap: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.sm, + gap: spacing.sm, + }, // Rail variant: a compact stack docked at the foot of the landscape rail. railBlock: { alignItems: 'center', diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 12c491e..7c4a3bb 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -16,13 +16,20 @@ import { MiniPlayer } from './MiniPlayer'; import { fonts, layout, + radius, spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; import { motion } from '@/theme/motion'; import { playHaptic } from '@/lib/haptics'; -import { RAIL_SIDE_PADDING, type ShellLayout } from '@/navigation/shellLayout'; +import { + RAIL_SIDE_PADDING, + SPLIT_BAR_MARGIN, + SPLIT_CARD_PADDING, + SPLIT_GAP, + type ShellLayout, +} from '@/navigation/shellLayout'; type IconName = keyof typeof Ionicons.glyphMap; @@ -62,6 +69,7 @@ export function TabBar({ items, onPress, shell }: TabBarProps) { const insets = useSafeAreaInsets(); const tabs = items.filter((item) => TAB_META[item.name]); const rail = shell.mode === 'rail'; + const split = shell.mode === 'split'; const buttons = tabs.map((item) => { const meta = TAB_META[item.name]; @@ -73,6 +81,10 @@ export function TabBar({ items, onPress, shell }: TabBarProps) { focused={item.focused} rail={rail} height={rail ? shell.navItemHeight : undefined} + // Split items are sized by the shell rather than sharing the row + // equally: the mini player takes the rest, and the leftover becomes the + // gap between the two groups. + width={split ? shell.splitBar.navItemWidth : undefined} onPress={() => onPress(item)} /> ); @@ -104,13 +116,46 @@ export function TabBar({ items, onPress, shell }: TabBarProps) { ); } + if (split) { + // Two peer cards floating over the scene, centred as a pair. Deliberately + // NOT a chrome slab with a player sitting on it — nav and playback carry + // the same surface here, which is what makes them read as one composed + // control rather than two unrelated things sharing an edge. + return ( + + + + {buttons} + + + + + ); + } + return ( {/* Out of the layout flow, sitting on top of the scene rather than beside it. In flow, this element's height was subtracted from the scene, so content stopped in a band above the pill and it read as a slab of chrome no matter what colour it was painted. Scrollable - content reserves `layout.miniPlayerFloat` at its bottom to clear it. */} + content reserves `shell.sceneBottomInset` at its bottom to clear it. */} @@ -133,6 +178,8 @@ interface TabButtonProps { /** Rail items are fixed-height and mark selection on their leading edge. */ rail?: boolean; height?: number; + /** Split-bar items are shell-sized rather than sharing the row equally. */ + width?: number; } /** @@ -143,7 +190,7 @@ interface TabButtonProps { * between a grey base icon and an accent one stacked on top. Spring-free per * theme/motion. */ -function TabButton({ meta, focused, onPress, rail = false, height }: TabButtonProps) { +function TabButton({ meta, focused, onPress, rail = false, height, width }: TabButtonProps) { const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); @@ -176,7 +223,13 @@ function TabButton({ meta, focused, onPress, rail = false, height }: TabButtonPr return ( { press.value = withTiming(1, motion.quick); @@ -223,6 +276,35 @@ const useStyles = createThemedStyles((colors) => ({ borderTopColor: colors.glassBorder, borderTopWidth: StyleSheet.hairlineWidth, }, + // Out of the layout flow like the pill, anchored to the bottom of the scene. + // Centred rather than `space-between`: the shell already divided the row, so + // any slack means both cards are at their caps, and a centred pair looks + // composed where opposite edges look like two things that drifted apart. + splitFloat: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'center', + gap: SPLIT_GAP, + }, + // Same surface as the mini-player card it sits beside. That equivalence is + // the point — see the split branch above. + splitCard: { + flexDirection: 'row', + alignItems: 'center', + // Vertical padding as well as horizontal: the card clips to its rounded + // edge, so the selection indicator has to sit inside this inset rather than + // flush to the card's top, where it was being shaved off. + padding: SPLIT_CARD_PADDING, + borderRadius: radius.lg, + backgroundColor: colors.bgTertiary, + borderColor: colors.glassBorderStrong, + borderWidth: 1, + overflow: 'hidden', + }, // Landscape rail: destinations at the top, mini player pushed to the foot by // `railFoot`'s auto margin. `getShellLayout` guarantees both fit. rail: { @@ -275,6 +357,14 @@ const useStyles = createThemedStyles((colors) => ({ alignSelf: 'stretch', paddingTop: 0, }, + // Sized by the shell, so it must not also try to share the row. + // Fills the card's padded box, so the indicator anchors to a known edge that + // is `SPLIT_CARD_PADDING` inside the card rather than on its clipped border. + splitTab: { + flex: 0, + alignSelf: 'stretch', + paddingTop: 0, + }, label: { marginTop: 2, fontSize: 10, diff --git a/src/components/library/FoldersView.tsx b/src/components/library/FoldersView.tsx index 61734a0..b82e4c0 100644 --- a/src/components/library/FoldersView.tsx +++ b/src/components/library/FoldersView.tsx @@ -27,11 +27,12 @@ import { } from '@/audio/playbackController'; import { formatDuration } from '@/lib/format'; import { playHaptic } from '@/lib/haptics'; -import { layout, spacing } from '@/theme'; +import { spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { usePlayerStore } from '@/stores/playerStore'; import type { DbTrack } from '@/types/library'; +import { useSceneBottomInset } from '@/navigation/useShellLayout'; const PAGE_SIZE = 100; @@ -204,6 +205,7 @@ function FolderTrackRow({ } export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps) { + const sceneBottomInset = useSceneBottomInset(); const styles = useStyles(); const colors = useColors(); const currentPath = usePlayerStore((state) => state.currentTrack?.path); @@ -334,7 +336,7 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps) renderScrollComponent={PullSearchScrollView} onScroll={onScroll} scrollEventThrottle={scrollEventThrottle} - contentContainerStyle={styles.listContent} + contentContainerStyle={{ paddingBottom: sceneBottomInset }} renderItem={({ item }) => { if (item.type === 'folder') { return ( @@ -420,9 +422,6 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps) } const useStyles = createThemedStyles((colors) => ({ - listContent: { - paddingBottom: layout.miniPlayerFloat, - }, indent: { flexShrink: 0, }, diff --git a/src/components/library/PlaylistsView.tsx b/src/components/library/PlaylistsView.tsx index 64cdb2d..f78f4ef 100644 --- a/src/components/library/PlaylistsView.tsx +++ b/src/components/library/PlaylistsView.tsx @@ -20,7 +20,6 @@ import { showAppDialog } from '@/components/dialogs/AppDialog'; import { PlaylistRow } from '@/components/library/PlaylistRow'; import { PullSearchScrollView } from '@/components/search/PullSearchGesture'; import { - layout, radius, spacing, } from '@/theme'; @@ -28,6 +27,7 @@ import { createThemedStyles, useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { usePlaylistStore } from '@/stores/playlistStore'; import type { Playlist } from '@/types/playlist'; +import { useSceneBottomInset } from '@/navigation/useShellLayout'; function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); @@ -48,6 +48,7 @@ export function PlaylistsView({ onScroll?: (event: NativeSyntheticEvent) => void; scrollEventThrottle?: number; }) { + const sceneBottomInset = useSceneBottomInset(); const styles = useStyles(); const ripple = useRipple(); const colors = useColors(); @@ -185,7 +186,7 @@ export function PlaylistsView({ renderScrollComponent={PullSearchScrollView} onScroll={onScroll} scrollEventThrottle={scrollEventThrottle} - contentContainerStyle={styles.listContent} + contentContainerStyle={{ paddingBottom: sceneBottomInset }} ListHeaderComponent={ ({ container: { flex: 1, }, - listContent: { - paddingBottom: layout.miniPlayerFloat, - }, empty: { alignItems: 'center', gap: spacing.sm, diff --git a/src/navigation/shellLayout.test.mts b/src/navigation/shellLayout.test.mts index 2fe2748..094daf6 100644 --- a/src/navigation/shellLayout.test.mts +++ b/src/navigation/shellLayout.test.mts @@ -3,9 +3,12 @@ import test from 'node:test'; import { getShellLayout, railContentsFit, + splitBarContentsFit, SHELL_NAV_ITEM_COUNT, + SPLIT_CARD_PADDING, type ShellInsets, } from './shellLayout.ts'; +import { variantLineHeight } from '../theme/typography.ts'; /** Portrait insets; landscape swaps the cutout onto the leading edge. */ const PORTRAIT: ShellInsets = { top: 40, bottom: 24, left: 0, right: 0 }; @@ -22,32 +25,82 @@ const DEVICES = [ const FONT_SCALES = [1, 1.2] as const; -test('the rail is for landscape windows and only landscape windows', () => { +test('a phone gets the rail in landscape and tabs in portrait', () => { for (const fontScale of FONT_SCALES) { for (const device of DEVICES) { - const [pw, ph] = device.portrait; - assert.equal( - getShellLayout(pw, ph, PORTRAIT, fontScale).mode, - 'tabs', - `${device.name} portrait should keep the bottom tabs` - ); + if (device.name === 'Tablet 10"') continue; // tall enough to afford a bar const [lw, lh] = device.landscape; assert.equal( getShellLayout(lw, lh, LANDSCAPE, fontScale).mode, 'rail', `${device.name} landscape should get the rail` ); + const [pw, ph] = device.portrait; + assert.equal( + getShellLayout(pw, ph, PORTRAIT, fontScale).mode, + 'tabs', + `${device.name} portrait should keep the bottom tabs` + ); } } }); -test('a window with no room for a rail keeps its tabs', () => { +test('a tablet splits in BOTH orientations rather than wearing a phone rail', () => { + // The rail answers scarce height, not landscape. A tablet in landscape is + // short relative to its width but still ~770dp tall, and paying 104dp of + // width for height it already has is the phone's solution on the wrong device. + for (const fontScale of FONT_SCALES) { + for (const [w, h, name] of [ + [768, 1024, 'Tablet 10" portrait'], + [1024, 768, 'Tablet 10" landscape'], + [1274, 796, 'large tablet landscape'], + [796, 1274, 'large tablet portrait'], + [834, 1112, 'iPad-ish portrait'], + [1112, 834, 'iPad-ish landscape'], + ] as const) { + assert.equal( + getShellLayout(w, h, PORTRAIT, fontScale).mode, + 'split', + `${name} (${w}x${h}) should share the bottom row` + ); + } + } +}); + +test('a window wide enough to seat both groups in one row gets the split bar', () => { + for (const fontScale of FONT_SCALES) { + // An unfolded foldable in portrait. + assert.equal(getShellLayout(674, 841, PORTRAIT, fontScale).mode, 'split'); + // A window too short for a rail but wide enough to share a row is better + // off splitting than stacking a bar and a floating pill. + assert.equal(getShellLayout(900, 200, LANDSCAPE, fontScale).mode, 'split'); + } +}); + +test('the rail survives exactly where height is actually scarce', () => { + // Sweep a wide window from short to tall: the rail is for the short end, the + // split bar for the tall end, and the handover happens once and stays. + let sawRail = false; + let sawSplit = false; + for (let height = 340; height <= 900; height += 4) { + const mode = getShellLayout(1000, height, LANDSCAPE, 1).mode; + if (mode === 'rail') { + assert.ok(!sawSplit, `fell back to the rail at ${height}dp after splitting`); + sawRail = true; + } else { + assert.equal(mode, 'split', `unexpected ${mode} at 1000x${height}`); + sawSplit = true; + } + } + assert.ok(sawRail && sawSplit, 'the sweep should cross the handover'); +}); + +test('a window with room for neither a rail nor a split keeps its tabs', () => { for (const [width, height, why] of [ [411, 420, 'portrait-ish split screen'], [360, 640, 'phone portrait'], [320, 480, 'small phone portrait'], [460, 400, 'landscape but too narrow for rail + content'], - [900, 200, 'landscape but too short to host the nav items'], ] as const) { assert.equal( getShellLayout(width, height, LANDSCAPE, 1).mode, @@ -57,6 +110,133 @@ test('a window with no room for a rail keeps its tabs', () => { } }); +test('the split threshold is the width both groups actually need', () => { + // Walk across the boundary: the mode may only ever turn on, and the moment it + // does the contents must already fit — a threshold that fires a pixel early + // ships a clipped bar. + const insets: ShellInsets = { top: 40, bottom: 24, left: 0, right: 0 }; + let sawTabs = false; + let sawSplit = false; + for (let width = 380; width <= 900; width += 1) { + const layout = getShellLayout(width, 1200, insets, 1); + if (layout.mode === 'tabs') { + assert.ok(!sawSplit, `mode fell back to tabs at ${width}dp after splitting`); + sawTabs = true; + } else { + assert.equal(layout.mode, 'split'); + sawSplit = true; + assert.ok( + splitBarContentsFit(layout, width, insets), + `split bar overflows at ${width}dp` + ); + } + } + assert.ok(sawTabs && sawSplit, 'the sweep should cross the threshold'); +}); + +test('split bar contents always fit their row', () => { + for (const fontScale of FONT_SCALES) { + // Portrait-shaped throughout: a wide *landscape* window gets the rail, which + // is a different shape with its own fit test. + for (const width of [600, 640, 674, 768, 834, 1024, 1366, 2000]) { + const layout = getShellLayout(width, width + 600, PORTRAIT, fontScale); + assert.equal(layout.mode, 'split', `${width}dp should split`); + assert.ok( + splitBarContentsFit(layout, width, PORTRAIT), + `${width}dp @${fontScale}: ${SHELL_NAV_ITEM_COUNT} nav items (${layout.splitBar.navItemWidth} each) + mini ${layout.splitBar.miniWidth} overflow` + ); + } + } +}); + +test('split nav items concede width before the mini player does', () => { + // At the narrowest split window the nav items should be the ones compressed, + // and the mini player should still be at a legible width — the same priority + // the rail applies to height. + const tight = getShellLayout(600, 1000, PORTRAIT, 1); + const roomy = getShellLayout(1024, 1400, PORTRAIT, 1); + assert.equal(tight.mode, 'split'); + assert.ok( + tight.splitBar.navItemWidth <= roomy.splitBar.navItemWidth, + 'nav items should be the part that gives ground' + ); + assert.ok( + tight.splitBar.miniWidth >= 280, + `mini player fell below its floor (${tight.splitBar.miniWidth}dp)` + ); + assert.ok(tight.splitBar.artSize > 0 && tight.splitBar.controlSize > 0); +}); + +test('the two cards divide the row instead of drifting to opposite edges', () => { + // The gap is fixed and small; whatever width exists goes into the cards. A + // 1274dp tablet used to leave a ~490dp chasm between them, which read as two + // unrelated things sharing an edge rather than one control. + for (const width of [640, 768, 834, 1024, 1274]) { + const bar = getShellLayout(width, width + 600, PORTRAIT, 1).splitBar; + const used = bar.navWidth + 12 + bar.miniWidth; + const available = width - 12 * 2; + const slack = available - used; + assert.ok(slack >= 0, `${width}dp overflows by ${-slack}dp`); + // Slack only appears once BOTH cards are capped; until then the row fills. + if (slack > 0) { + assert.equal(bar.miniWidth, 560, `${width}dp left ${slack}dp slack uncapped`); + assert.equal(bar.navItemWidth, 88); + } + } +}); + +test('the pair is capped so a huge window centres it rather than smearing it', () => { + const wide = getShellLayout(2000, 2600, PORTRAIT, 1).splitBar; + assert.equal(wide.miniWidth, 560, 'player card should cap'); + assert.equal(wide.navItemWidth, 88, 'nav items should sit at their ideal width'); + assert.ok(wide.navWidth + 12 + wide.miniWidth < 2000 - 24, 'should leave slack to centre'); +}); + +test('the nav card leaves room for the indicator inside its clipped edge', () => { + // The card clips to its rounded border, so anything flush to its top gets + // shaved — which is exactly what happened to the selection indicator. The + // items live in the padded box, and that box has to fit the indicator too. + const INDICATOR_BAR_HEIGHT = 3; // styles.indicatorBar in TabBar + for (const fontScale of FONT_SCALES) { + for (const width of [600, 768, 1024, 1274]) { + const layout = getShellLayout(width, width + 600, PORTRAIT, fontScale); + const paddedBox = layout.splitBar.height - SPLIT_CARD_PADDING * 2; + const needed = + layout.navIconSize + + layout.navLabelGap + + Math.ceil(variantLineHeight.caption * fontScale) + + INDICATOR_BAR_HEIGHT; + assert.ok( + paddedBox >= needed, + `${width}dp @${fontScale}: padded box ${paddedBox}dp cannot fit ${needed}dp of item + indicator` + ); + } + } +}); + +test('both cards are the same height, because they are peers', () => { + for (const width of [600, 768, 1024, 1274]) { + const bar = getShellLayout(width, width + 600, PORTRAIT, 1).splitBar; + assert.ok(bar.height > 0); + assert.equal(bar.blockHeight, bar.height + 12 * 2); + } +}); + +test('the shapes that float chrome over the scene are the ones that reserve it', () => { + // Surfaces reserve `sceneBottomInset`. Tabs floats a pill and split floats a + // pair of cards, so both owe the space back; the rail sits *beside* the scene + // and owes nothing — reserving there is dead space at the end of every list. + assert.ok(getShellLayout(411, 891, PORTRAIT, 1).sceneBottomInset > 0); + assert.equal(getShellLayout(891, 411, LANDSCAPE, 1).sceneBottomInset, 0); + + const tablet = getShellLayout(768, 1024, PORTRAIT, 1); + assert.equal( + tablet.sceneBottomInset, + tablet.splitBar.blockHeight + PORTRAIT.bottom, + 'split must reserve the floating pair plus the safe area under it' + ); +}); + test('rail contents always fit the rail', () => { for (const fontScale of FONT_SCALES) { for (const device of DEVICES) { @@ -194,6 +374,11 @@ test('keeps every dimension finite and non-negative', () => { assert.ok(Number.isFinite(value), `miniPlayer.${key} is not finite`); assert.ok(value >= 0, `miniPlayer.${key} is negative`); } + for (const [key, value] of Object.entries(layout.splitBar)) { + if (typeof value !== 'number') continue; + assert.ok(Number.isFinite(value), `splitBar.${key} is not finite`); + assert.ok(value >= 0, `splitBar.${key} is negative`); + } } } } diff --git a/src/navigation/shellLayout.ts b/src/navigation/shellLayout.ts index a131cee..fcbc841 100644 --- a/src/navigation/shellLayout.ts +++ b/src/navigation/shellLayout.ts @@ -1,21 +1,28 @@ +import { layout as layoutTokens } from '../theme/spacing.ts'; import { MAX_FONT_SCALE, variantLineHeight } from '../theme/typography.ts'; /** - * App shell navigation geometry. + * App shell navigation geometry. Three shapes, picked by what the window can + * actually seat: * - * Portrait keeps the bottom tab bar with the mini-player pill above it. A - * landscape window is only ~411dp tall, and that chrome costs it 152dp — so - * landscape moves navigation to a vertical rail down the leading edge with the - * mini player docked at its foot, handing the whole height back to the scene. + * - **tabs** — a phone. Bottom tab bar with the mini-player pill floating above + * it. The pill is out of the bar's layout flow, so the scene has to reserve + * `sceneBottomInset` for it. + * - **rail** — a landscape window, only ~411dp tall, where that chrome costs + * 152dp. Navigation moves to a vertical rail down the leading edge with the + * mini player docked at its foot, handing the height back to the scene. + * - **split** — a window wide enough to seat the nav items and the mini player + * *beside* each other in one bottom row. Costs one bar instead of a bar plus + * a pill, and gives the mini player room to be legible rather than cramped. * - * The rail is narrow, so the binding constraint is its own *height*: four nav - * items plus a mini player have to fit between the safe areas. This module owns - * that arithmetic with declared sizes rather than estimates, the same way - * `components/player/nowPlayingLayout.ts` does, and the mini-player block gives - * ground before anything is allowed to overflow. + * Each shape's binding constraint is different — the rail's is its own height, + * the split bar's is its width — so each gets its own fitting function with + * declared sizes rather than estimates, the same way + * `components/player/nowPlayingLayout.ts` does. In both cases the nav items + * concede padding before the mini player concedes content. */ -export type ShellNavigationMode = 'tabs' | 'rail'; +export type ShellNavigationMode = 'tabs' | 'rail' | 'split'; /** Rail content width, before the leading safe-area inset is added on. */ const RAIL_CONTENT_WIDTH = 104; @@ -41,6 +48,21 @@ const NAV_ITEM_COUNT = 4; */ const RAIL_MIN_WINDOW_WIDTH = 480; +/** + * Tallest window still better off with a rail than with a bottom bar. + * + * The rail answers scarce *height*, not landscape — a tablet in landscape is + * short relative to its width but still ~800dp tall, and wearing a phone's + * solution there costs it width for no reason. + * + * The number moved when the split bar arrived. Bottom chrome used to mean a + * 56dp bar *plus* a 76dp floating pill stacked above it; the split bar seats + * both in one ~64dp row. A window with this much height spends well under a + * tenth of it on that and keeps its full width, which is the better trade. A + * shorter one does not, and pays 104dp of width instead. + */ +const RAIL_MAX_WINDOW_HEIGHT = 600; + const RAIL_MINI_ART_MAX = 72; const RAIL_MINI_ART_MIN = 40; const RAIL_MINI_CONTROL_SIZE = 36; @@ -50,6 +72,55 @@ const RAIL_MINI_GAP = 6; * the renderer subtracts exactly what `blockHeight` reserved. */ export const RAIL_MINI_TOP_MARGIN = 16; +/* ── split bar ──────────────────────────────────────────────────────────── */ + +/** + * The split shape is **two peer cards floating over the scene**, not a chrome + * slab with a player dropped onto it. Both carry the same surface as the phone's + * mini-player pill, so nav and playback read as siblings rather than as one + * thing stuck to another — which is the whole difference between deliberate and + * slapped on. + * + * Because they float, the scene has to reserve `sceneBottomInset` for them, the + * same contract the pill has. + */ + +/** + * Seats a 44dp artwork square, and an icon-over-label nav item *plus* the + * selection indicator above it — the card clips to its own rounded edge, so the + * indicator has to live inside the card's padding rather than flush to its top. + */ +const SPLIT_BAR_HEIGHT = 72; +const SPLIT_NAV_ITEM_IDEAL_WIDTH = 88; +/** Floor before a nav item's label starts truncating. */ +const SPLIT_NAV_ITEM_MIN_WIDTH = 64; +/** Below this the mini player can't hold artwork, two text lines and two controls. */ +const SPLIT_MINI_MIN_WIDTH = 280; +/** A player card, not a smear. Past this the pair centres instead of stretching. */ +const SPLIT_MINI_MAX_WIDTH = 560; +/** The "split" itself: the visible gap between the two cards. */ +export const SPLIT_GAP = 12; +/** Breathing room between the cards and the window edges. */ +export const SPLIT_BAR_MARGIN = 12; +/** Inset inside the nav card, so its items aren't flush to its own corners. */ +export const SPLIT_CARD_PADDING = 8; +/** The selection indicator bar plus a little air under it. */ +const SPLIT_INDICATOR_CLEARANCE = 8; +const SPLIT_MINI_ART = 44; +const SPLIT_MINI_CONTROL = 40; + +/** + * Narrowest window that can seat both cards at their floors. Derived rather + * than declared so it can't drift from the parts it's the sum of — a phone + * (412dp) stays on tabs, a tablet in either orientation and an unfolded + * foldable clear it. + */ +const SPLIT_MIN_CONTENT_WIDTH = + SPLIT_NAV_ITEM_MIN_WIDTH * NAV_ITEM_COUNT + + SPLIT_CARD_PADDING * 2 + + SPLIT_GAP + + SPLIT_MINI_MIN_WIDTH; + export interface ShellInsets { top: number; bottom: number; @@ -57,6 +128,23 @@ export interface ShellInsets { right: number; } +export interface SplitBarLayout { + /** Height of both cards. They are peers, so they match. */ + height: number; + navItemWidth: number; + /** Nav card width: its items plus its own padding. */ + navWidth: number; + /** Player card width. */ + miniWidth: number; + artSize: number; + controlSize: number; + /** + * Vertical space the floating pair covers, margins included — what a scene + * has to reserve so its last row isn't hidden behind them. + */ + blockHeight: number; +} + export interface RailMiniPlayerLayout { /** 0 when the rail is too short to show artwork at all. */ artSize: number; @@ -81,6 +169,18 @@ export interface ShellLayout { navIconSize: number; navLabelGap: number; miniPlayer: RailMiniPlayerLayout; + splitBar: SplitBarLayout; + /** + * What a scrollable surface must reserve at its bottom so its last row isn't + * hidden by chrome that sits *outside* the layout flow. + * + * Only `tabs` floats anything over the scene: `rail` docks the mini player in + * the rail and `split` seats it in the bar, and both of those are in flow and + * reserve their own space. Surfaces read this rather than + * `layout.miniPlayerFloat` directly, or landscape pays for a pill that isn't + * there. + */ + sceneBottomInset: number; } function clamp(value: number, min: number, max: number): number { @@ -142,15 +242,31 @@ export function getShellLayout( ); const navBlock = navItemHeight * NAV_ITEM_COUNT; - // Landscape, wide enough for a rail plus a real content column, and tall - // enough to host the rail's own items at a pressable size. A window that - // fails the last test is worse off with a rail than without one. + const splitContentWidth = Math.max( + 0, + width - insets.left - insets.right - SPLIT_BAR_MARGIN * 2 + ); + const splitBar = fitSplitBar(splitContentWidth, scale); + + // A rail is for a window that is landscape, short enough to actually need the + // height back, wide enough to afford 104dp of it, and tall enough to host the + // rail's own items at a pressable size. A window failing any of those is + // worse off with a rail than without one. + // + // Split is the fallback rather than tabs whenever the width allows it: it + // costs one bar instead of a bar plus a floating pill, so it is never the + // worse choice for a window that can seat it. That catches both a tablet — + // in either orientation — and a window too short for a rail but wide enough + // to share a row. const mode: ShellNavigationMode = width > height && width >= RAIL_MIN_WINDOW_WIDTH && + height < RAIL_MAX_WINDOW_HEIGHT && navItemHeight >= NAV_ITEM_MIN_HEIGHT ? 'rail' - : 'tabs'; + : splitContentWidth >= SPLIT_MIN_CONTENT_WIDTH + ? 'split' + : 'tabs'; // Whatever the nav items don't need is the mini player's, and it shrinks to // fit rather than pushing anything out of the rail. @@ -169,6 +285,71 @@ export function getShellLayout( navIconSize: NAV_ICON_SIZE, navLabelGap: NAV_LABEL_GAP, miniPlayer, + splitBar, + // Both shapes that float chrome over the scene owe it the space back. The + // rail is the only one that doesn't, because it sits beside the scene + // rather than on top of it. + sceneBottomInset: + mode === 'tabs' + ? layoutTokens.miniPlayerFloat + : mode === 'split' + ? splitBar.blockHeight + insets.bottom + : 0, + }; +} + +/** + * Seat the two cards in one row of `availableWidth`. + * + * Same concession order as the rail: nav items give up width down to their own + * floor before the player card is allowed to drop below a legible size. + * + * The two cards **divide the row** — the player takes whatever the nav card + * doesn't, up to its cap. Only once *both* are capped is there slack, and the + * renderer centres the pair rather than pushing them to opposite edges. A + * tablet's bottom chrome should look composed, not like two things that drifted + * apart. + */ +function fitSplitBar(availableWidth: number, scale: number): SplitBarLayout { + // The card's own padding is part of the height, not on top of it: the nav + // items live in the padded box so the selection indicator clears the clipped + // rounded edge. A larger font setting grows the box rather than the overhang. + const height = Math.max( + SPLIT_BAR_HEIGHT, + NAV_ICON_SIZE + + NAV_LABEL_GAP + + Math.ceil(variantLineHeight.caption * scale) + + SPLIT_CARD_PADDING * 2 + + SPLIT_INDICATOR_CLEARANCE + ); + const cardChrome = SPLIT_CARD_PADDING * 2; + const navAtIdeal = SPLIT_NAV_ITEM_IDEAL_WIDTH * NAV_ITEM_COUNT + cardChrome; + const navItemWidth = + availableWidth - navAtIdeal - SPLIT_GAP >= SPLIT_MINI_MIN_WIDTH + ? SPLIT_NAV_ITEM_IDEAL_WIDTH + : clamp( + Math.floor( + (availableWidth - SPLIT_GAP - SPLIT_MINI_MIN_WIDTH - cardChrome) / + NAV_ITEM_COUNT + ), + SPLIT_NAV_ITEM_MIN_WIDTH, + SPLIT_NAV_ITEM_IDEAL_WIDTH + ); + const navWidth = navItemWidth * NAV_ITEM_COUNT + cardChrome; + const miniWidth = clamp( + availableWidth - navWidth - SPLIT_GAP, + 0, + SPLIT_MINI_MAX_WIDTH + ); + return { + height, + navItemWidth, + navWidth, + miniWidth, + // Artwork is square, so the card's height caps it as well as the token does. + artSize: Math.min(SPLIT_MINI_ART, height - 12), + controlSize: SPLIT_MINI_CONTROL, + blockHeight: height + SPLIT_BAR_MARGIN * 2, }; } @@ -239,4 +420,17 @@ export function railContentsFit(layout: ShellLayout): boolean { ); } +/** + * Both cards fit their row, given the window they were measured for. The + * renderer centres the pair, which is only safe while this holds. + */ +export function splitBarContentsFit( + layout: ShellLayout, + width: number, + insets: ShellInsets +): boolean { + const available = width - insets.left - insets.right - SPLIT_BAR_MARGIN * 2; + return layout.splitBar.navWidth + SPLIT_GAP + layout.splitBar.miniWidth <= available; +} + export { NAV_ITEM_COUNT as SHELL_NAV_ITEM_COUNT }; diff --git a/src/navigation/useShellLayout.ts b/src/navigation/useShellLayout.ts index 1f46a5a..51ca9d8 100644 --- a/src/navigation/useShellLayout.ts +++ b/src/navigation/useShellLayout.ts @@ -24,3 +24,17 @@ export function useShellLayout(): ShellLayout { export function useShellShowsScreenTitle(): boolean { return useShellLayout().mode !== 'rail'; } + +/** + * What a scrollable surface must reserve at its bottom to clear chrome that + * sits outside the layout flow. + * + * Only the phone shape floats a mini-player pill over the scene; the rail docks + * it and the split bar seats it, and both reserve their own height. Reserving + * the pill's footprint in those modes is dead space at the end of every list — + * which is what happened in landscape before this existed. Always use this + * rather than `layout.miniPlayerFloat` directly. + */ +export function useSceneBottomInset(): number { + return useShellLayout().sceneBottomInset; +}