tablet size navbar

This commit is contained in:
Boof2015
2026-07-31 17:15:05 -04:00
parent 0a3b6bea85
commit 08fe01d361
18 changed files with 705 additions and 91 deletions
+6 -7
View File
@@ -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,
]}
>
<View style={styles.preamp}>
+3 -5
View File
@@ -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() {
<PullSearchScrollView
showsVerticalScrollIndicator={false}
overScrollMode="never"
contentContainerStyle={styles.content}
contentContainerStyle={{ paddingBottom: sceneBottomInset }}
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
>
@@ -788,9 +789,6 @@ export default function HomeScreen() {
}
const useStyles = createThemedStyles((colors) => ({
content: {
paddingBottom: layout.miniPlayerFloat,
},
masthead: {
minHeight: 72,
flexDirection: 'row',
+4 -2
View File
@@ -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' ? (
+3 -2
View File
@@ -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<typeof Ionicons>['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,
}}
/>
<CollapsingHeader
@@ -9,13 +9,15 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { AlbumGridItem } from '@/components/library/AlbumGridItem';
import { layout, spacing } from '@/theme';
import { spacing } from '@/theme';
import { useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { useSettingsStore } from '@/stores/settingsStore';
import { useNativeArtistAlbums } from '@/library/nativePages';
import { useSceneBottomInset } from '@/navigation/useShellLayout';
export default function ArtistAlbumsScreen() {
const sceneBottomInset = useSceneBottomInset();
const colors = useColors();
const ripple = useRipple();
const router = useRouter();
@@ -66,7 +68,7 @@ export default function ArtistAlbumsScreen() {
</View>
)}
ListEmptyComponent={<EmptyList label="No albums found for this artist." />}
contentContainerStyle={styles.listContent}
contentContainerStyle={{ paddingBottom: sceneBottomInset }}
/>
</Screen>
);
@@ -102,9 +104,6 @@ const styles = StyleSheet.create({
gap: spacing.xs,
marginBottom: spacing.lg,
},
listContent: {
paddingBottom: layout.miniPlayerFloat,
},
gridCell: {
flex: 1,
paddingHorizontal: spacing.xs,
@@ -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={<EmptyList label="No appearances found for this artist." />}
contentContainerStyle={styles.listContent}
contentContainerStyle={{ paddingBottom: sceneBottomInset }}
/>
<TrackActionsSheet track={actionTrack} onClose={() => 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',
@@ -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={<EmptyList label="No songs found for this artist." />}
contentContainerStyle={styles.listContent}
contentContainerStyle={{ paddingBottom: sceneBottomInset }}
/>
<TrackActionsSheet track={actionTrack} onClose={() => 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',
+6 -9
View File
@@ -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<DbTrack | null>(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',
+4 -2
View File
@@ -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 ? (
+4 -4
View File
@@ -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 (
<Screen>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={[styles.content, { paddingBottom: sceneBottomInset }]}>
{/* 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: {
+4 -3
View File
@@ -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() {
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.content}
contentContainerStyle={[styles.content, { paddingBottom: sceneBottomInset }]}
refreshControl={
<RefreshControl
refreshing={refreshing}
@@ -477,7 +479,6 @@ const useStyles = createThemedStyles((colors) => ({
},
content: {
paddingTop: spacing.md,
paddingBottom: layout.miniPlayerFloat,
gap: spacing.lg,
},
pausedBanner: {
+141 -4
View File
@@ -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. */}
<View style={[styles.barBlock, { width: miniWidth, height }]}>
<Pressable
android_ripple={ripple.bounded}
style={styles.barTap}
onPress={() => 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 && (
<View pointerEvents="none" style={styles.spectrum}>
<SpectrumCurve
active={liveScopeActive}
pointCount={CURVE_POINTS}
dbMin={-84}
dbMax={-20}
width={pillWidth}
height={artSize + spacing.sm * 2}
lineWidth={1.25}
lineOpacity={0.38}
fillOpacity={0.3}
glow
glowOpacity={0.06}
/>
</View>
)}
{liveScopeActive && pillWidth > 0 && <View pointerEvents="none" style={styles.spectrumVeil} />}
<View style={[styles.art, { width: artSize, height: artSize }]}>
{displayedMedia.artworkUri ? (
<Image
source={{
uri:
artworkThumbFromSource(displayedMedia.artworkUri) ??
displayedMedia.artworkUri,
}}
style={styles.artImage}
contentFit="cover"
/>
) : (
<AstraLogo size={Math.round(artSize * 0.55)} />
)}
</View>
<View style={styles.meta}>
<Text variant="body" numberOfLines={1} style={styles.title}>
{displayedMedia.title}
</Text>
<Text variant="label" numberOfLines={1}>
{displayedMedia.subtitle}
</Text>
</View>
<Pressable
hitSlop={8}
android_ripple={ripple.icon(22)}
onPress={onTogglePlay}
style={[styles.control, { width: controlSize, height: controlSize }]}
accessibilityLabel={isPlaying ? 'Pause' : 'Play'}
>
<Ionicons
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
size={24}
color={colors.accent}
/>
</Pressable>
<Pressable
hitSlop={8}
android_ripple={ripple.icon(22)}
onPress={onSkipNext}
style={[styles.control, { width: controlSize, height: controlSize }]}
accessibilityLabel="Next"
>
<Ionicons name="play-skip-forward" size={22} color={colors.textSecondary} />
</Pressable>
</Pressable>
{presentation.hasTrack ? (
isDesktop ? (
<MiniProgress
currentTime={presentation.currentTime}
duration={presentation.duration}
isPlaying={isPlaying}
active={!playerOpen}
trackKey={presentation.trackKey}
/>
) : (
<PhoneMiniProgress isPlaying={isPlaying} active={!playerOpen} />
)
) : null}
</View>
<PlaybackTargetPicker
visible={targetPickerOpen}
onClose={() => 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',
+94 -4
View File
@@ -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 (
<View style={styles.wrap}>
<View
style={[
styles.splitFloat,
{
paddingLeft: insets.left + SPLIT_BAR_MARGIN,
paddingRight: insets.right + SPLIT_BAR_MARGIN,
paddingBottom: insets.bottom + SPLIT_BAR_MARGIN,
height: shell.splitBar.blockHeight + insets.bottom,
},
]}
pointerEvents="box-none"
>
<View
style={[
styles.splitCard,
{ width: shell.splitBar.navWidth, height: shell.splitBar.height },
]}
>
{buttons}
</View>
<MiniPlayer variant="bar" barLayout={shell.splitBar} />
</View>
</View>
);
}
return (
<View style={styles.wrap}>
{/* 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. */}
<View style={styles.floatingPlayer} pointerEvents="box-none">
<MiniPlayer />
</View>
@@ -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 (
<Pressable
android_ripple={ripple.icon(26)}
style={[styles.tab, rail && styles.railTab, height ? { height } : null]}
style={[
styles.tab,
rail && styles.railTab,
width ? styles.splitTab : null,
height ? { height } : null,
width ? { width } : null,
]}
onPress={handlePress}
onPressIn={() => {
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,
+4 -5
View File
@@ -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,
},
+3 -5
View File
@@ -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<NativeScrollEvent>) => 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={
<PlaylistRow
name="Favorites"
@@ -293,9 +294,6 @@ const useStyles = createThemedStyles((colors) => ({
container: {
flex: 1,
},
listContent: {
paddingBottom: layout.miniPlayerFloat,
},
empty: {
alignItems: 'center',
gap: spacing.sm,
+194 -9
View File
@@ -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`);
}
}
}
}
+209 -15
View File
@@ -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 };
+14
View File
@@ -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;
}