mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
tablet size navbar
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user