From 7f00e8782396e60cd0753c4e0bcc0fd2652031bc Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:56:15 -0400 Subject: [PATCH] fix layout issues --- app.json | 2 +- src/app/(tabs)/eq.tsx | 197 +++++++++++++++++++++++++++------------- src/app/now-playing.tsx | 184 +++++++++++++++++++++++++++---------- src/theme/adaptive.ts | 11 +++ 4 files changed, 280 insertions(+), 114 deletions(-) create mode 100644 src/theme/adaptive.ts diff --git a/app.json b/app.json index a0a45f2..23af8c9 100644 --- a/app.json +++ b/app.json @@ -3,7 +3,7 @@ "name": "Astra", "slug": "astra-mobile", "version": "0.1.0", - "orientation": "portrait", + "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "astra", "userInterfaceStyle": "dark", diff --git a/src/app/(tabs)/eq.tsx b/src/app/(tabs)/eq.tsx index 53a88f8..7739bce 100644 --- a/src/app/(tabs)/eq.tsx +++ b/src/app/(tabs)/eq.tsx @@ -1,7 +1,8 @@ import { useCallback, useState } from 'react'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { Pressable, StyleSheet, View, useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useFocusEffect } from 'expo-router'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as DocumentPicker from 'expo-document-picker'; import { readAsStringAsync } from 'expo-file-system/legacy'; import { Screen } from '@/components/Screen'; @@ -15,6 +16,7 @@ import { EQValueEditSheet } from '@/components/eq/EQValueEditSheet'; import { PresetSheet } from '@/components/eq/PresetSheet'; import { SavePresetSheet } from '@/components/eq/SavePresetSheet'; import { colors, radius, spacing } from '@/theme'; +import { isWideWindow } from '@/theme/adaptive'; import { useEQStore } from '@/stores/eqStore'; import { useScopeActive } from '@/scope/scopeStore'; import { setActivePostEqNative } from '@/audio/eqNative'; @@ -44,6 +46,12 @@ export default function EQScreen() { const [sheet, setSheet] = useState('none'); const [editingValue, setEditingValue] = useState(null); const closeSheet = useCallback(() => setSheet('none'), []); + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const availableWidth = windowWidth - insets.left - insets.right; + const isWide = isWideWindow(availableWidth, windowHeight - insets.top - insets.bottom); + // Editing pane keeps a phone-ish width; the graph gets everything else. + const sidePaneWidth = Math.min(360, Math.max(280, Math.round(availableWidth * 0.4))); // Gate the post-EQ tap to while this screen is visible. useFocusEffect( @@ -77,9 +85,85 @@ export default function EQScreen() { } }; + const presetRowEl = ( + setSheet('preset')} + > + + {presetName} + + + + ); + + const graphEl = ( + eq.updateBand(id, updates)} + /> + ); + + const stripEl = ( + eq.addBand()} + /> + ); + + const detailEl = ( + 0 ? activeBandNumber : 1} + onUpdate={(updates) => activeBand && eq.updateBand(activeBand.id, updates)} + onEditType={() => setSheet('type')} + onEditValue={setEditingValue} + /> + ); + + const bottomBarEl = ( + + + `${formatGain(v)} dB`} + onChange={eq.setPreamp} + /> + + + + + {eq.enabled ? 'EQ on' : 'EQ off'} + + + + ); + return ( - + Equalizer setSheet('save')} hitSlop={8}> @@ -91,69 +175,31 @@ export default function EQScreen() { - setSheet('preset')}> - - {presetName} - - - - - - eq.updateBand(id, updates)} - /> - - - - eq.addBand()} - /> - - - - 0 ? activeBandNumber : 1} - onUpdate={(updates) => activeBand && eq.updateBand(activeBand.id, updates)} - onEditType={() => setSheet('type')} - onEditValue={setEditingValue} - /> - - - - - `${formatGain(v)} dB`} - onChange={eq.setPreamp} - /> - - - - - {eq.enabled ? 'EQ on' : 'EQ off'} - - - + {graphEl} + + {presetRowEl} + {stripEl} + {detailEl} + + {bottomBarEl} + + + ) : ( + <> + {presetRowEl} + {graphEl} + {stripEl} + {detailEl} + {bottomBarEl} + + )} {sheet === 'preset' ? ( = WIDE_MIN_WIDTH keep the single column but grow it. +const TABLET_MAX_CONTENT_WIDTH = 520; +const TABLET_ART_SIZE_MAX = 440; +// Wide (landscape/desktop) tier: two panes, art left, controls right. +const WIDE_MAX_CONTENT_WIDTH = 960; +const WIDE_PANE_GAP = spacing.xxl; +const WIDE_RIGHT_PANE_MIN = 300; +const WIDE_RIGHT_PANE_MAX = MAX_CONTENT_WIDTH; +const WIDE_ART_SIZE_MAX = 400; +const WIDE_ART_SIZE_MIN = 160; +const WIDE_COMPACT_HEIGHT = 480; const VISUALIZER_WIDTH_MAX = 448; const VISUALIZER_SIDE_PADDING = spacing.md; const VISUALIZER_TOP_GAP = spacing.lg; @@ -61,7 +69,6 @@ const HEADER_HEIGHT = 32; const CONTENT_TOP_PADDING = spacing.sm; const CONTENT_BOTTOM_PADDING = spacing.lg; const MEDIA_TOP_MARGIN = spacing.lg; -const VISUALIZER_MEDIA_BOTTOM_GAP = spacing.sm; const MEDIA_BOTTOM_GAP = spacing.xl; const TRACK_INFO_ESTIMATE = 96; const WAVEFORM_HEIGHT = 58; @@ -80,8 +87,15 @@ const MENU_ANIMATION_OUT_MS = 100; const MENU_ENTER_OFFSET_Y = -8; interface NowPlayingLayout { + isWide: boolean; contentPadding: number; contentWidth: number; + leftPaneWidth: number; + rightPaneWidth: number; + controlsGap: number; + trackInfoGap: number; + waveformHeight: number; + mediaStackHeight: number; artSize: number; scopeWidth: number; scopeHeight: number; @@ -109,29 +123,76 @@ function getScopeHeight(scopeWidth: number): number { } function getNowPlayingLayout( - windowWidth: number, + availableWidth: number, availableHeight: number, showVisualizer: boolean ): NowPlayingLayout { + const isWide = isWideWindow(availableWidth, availableHeight); + + if (isWide) { + const contentPadding = CONTENT_SIDE_PADDING; + const contentWidth = Math.max( + 0, + Math.min(availableWidth - contentPadding * 2, WIDE_MAX_CONTENT_WIDTH) + ); + const rightPaneWidth = Math.round( + clamp(contentWidth * 0.46, WIDE_RIGHT_PANE_MIN, WIDE_RIGHT_PANE_MAX) + ); + const leftPaneWidth = Math.max(0, contentWidth - WIDE_PANE_GAP - rightPaneWidth); + const scopeWidth = Math.min(leftPaneWidth, VISUALIZER_WIDTH_MAX); + const scopeHeight = getScopeHeight(scopeWidth); + const visualizerTopGap = showVisualizer ? VISUALIZER_TOP_GAP : 0; + const verticalBudget = + availableHeight - CONTENT_TOP_PADDING - CONTENT_BOTTOM_PADDING - HEADER_HEIGHT - spacing.md; + const artHeightBudget = verticalBudget - (showVisualizer ? scopeHeight + visualizerTopGap : 0); + const artSize = Math.round( + clamp(Math.min(leftPaneWidth, artHeightBudget), WIDE_ART_SIZE_MIN, WIDE_ART_SIZE_MAX) + ); + const controlsGap = availableHeight < WIDE_COMPACT_HEIGHT ? spacing.sm : spacing.lg; + return { + isWide: true, + contentPadding, + contentWidth, + leftPaneWidth, + rightPaneWidth, + controlsGap, + trackInfoGap: spacing.md, + waveformHeight: WAVEFORM_HEIGHT, + mediaStackHeight: showVisualizer + ? artSize + visualizerTopGap + scopeHeight + : artSize, + artSize, + scopeWidth, + scopeHeight, + visualizerTopGap, + visualizerBottomGap: 0, + mediaTopMargin: 0, + mediaBottomGap: 0, + }; + } + + // Tall windows: single column. Tablet-width ones get a larger column and art cap. + const isTabletColumn = availableWidth >= WIDE_MIN_WIDTH; const contentPadding = - windowWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING; - const contentWidth = Math.max(0, Math.min(windowWidth - contentPadding * 2, MAX_CONTENT_WIDTH)); + availableWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING; + const maxContentWidth = isTabletColumn ? TABLET_MAX_CONTENT_WIDTH : MAX_CONTENT_WIDTH; + const contentWidth = Math.max(0, Math.min(availableWidth - contentPadding * 2, maxContentWidth)); const scopeWidth = Math.max( 0, - Math.min(windowWidth - VISUALIZER_SIDE_PADDING * 2, VISUALIZER_WIDTH_MAX) + Math.min(availableWidth - VISUALIZER_SIDE_PADDING * 2, VISUALIZER_WIDTH_MAX) ); const scopeHeight = getScopeHeight(scopeWidth); - const mediaMax = Math.min(contentWidth, MEDIA_AREA_MAX); + // Art may grow to the full column width when the height budget allows it. + const mediaMax = Math.min(contentWidth, isTabletColumn ? TABLET_ART_SIZE_MAX : contentWidth); const mediaMin = Math.min(mediaMax, MEDIA_AREA_MIN); const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN; const defaultMediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP; - const mediaBottomGap = showVisualizer ? VISUALIZER_MEDIA_BOTTOM_GAP : defaultMediaBottomGap; - const fixedHeight = + const mediaBottomGap = defaultMediaBottomGap; + const fixedHeightBase = CONTENT_TOP_PADDING + CONTENT_BOTTOM_PADDING + HEADER_HEIGHT + mediaTopMargin + - mediaBottomGap + TRACK_INFO_ESTIMATE + WAVEFORM_BLOCK_ESTIMATE + TRANSPORT_TOP_MARGIN + @@ -139,27 +200,38 @@ function getNowPlayingLayout( SUB_TOP_MARGIN + SUB_BUTTON_SIZE + MIN_FLOATING_SPACE; - const heightBoundMedia = availableHeight - fixedHeight; - const baseArtSize = Math.min( - Math.round(clamp(heightBoundMedia, mediaMin, mediaMax)), - ART_SIZE_MAX + // The Math.max(96, ...) floor lets art shrink below MEDIA_AREA_MIN in squat + // windows (split-screen halves) instead of pushing the controls off-screen. + const bound = availableHeight - fixedHeightBase - mediaBottomGap; + const scopeOffArt = Math.round( + clamp(bound, Math.min(mediaMin, Math.max(96, bound)), mediaMax) ); - const artSize = showVisualizer - ? Math.round( - clamp( - baseArtSize * VISUALIZER_ART_SCALE, - Math.min(VISUALIZER_ART_SIZE_MIN, mediaMax), - mediaMax - ) - ) - : baseArtSize; + // Roomy screens get a taller waveform; the rest of the spare space is + // distributed between the control rows by flex (space-between), so no + // height estimate error can pool as one gap above the controls. + const offSurplus = Math.max(0, bound - scopeOffArt); + const stretchUnit = Math.min(Math.floor(offSurplus / 5), spacing.md); + const waveformHeight = WAVEFORM_HEIGHT + stretchUnit * 2; + // The media stack keeps one locked height in both scope states — the scope + // steals its space from the art alone, so toggling it moves nothing else. + const scopeBlockHeight = VISUALIZER_TOP_GAP + scopeHeight + VISUALIZER_BOTTOM_GAP; + const mediaStackHeight = Math.max(scopeOffArt, 96 + scopeBlockHeight); + const scopeOnArt = mediaStackHeight - scopeBlockHeight; + const artSize = showVisualizer ? scopeOnArt : scopeOffArt; const visualizerTopGap = showVisualizer ? VISUALIZER_TOP_GAP : 0; const visualizerBottomGap = showVisualizer ? VISUALIZER_BOTTOM_GAP : 0; return { + isWide: false, contentPadding, contentWidth, + leftPaneWidth: contentWidth, + rightPaneWidth: contentWidth, + controlsGap: TRANSPORT_TOP_MARGIN, + trackInfoGap: spacing.md, + waveformHeight, + mediaStackHeight, artSize, scopeWidth, scopeHeight, @@ -194,9 +266,13 @@ export default function NowPlayingScreen() { const isPlaying = playbackState === 'playing'; const isLoading = playbackState === 'loading'; const availableHeight = windowHeight - insets.top - insets.bottom; - const layout = getNowPlayingLayout(windowWidth, availableHeight, scopeStageVisible); + const effectiveWidth = windowWidth - insets.left - insets.right; + const layout = getNowPlayingLayout(effectiveWidth, availableHeight, scopeStageVisible); const source = track?.album?.trim() ? track.album : 'Library'; - const shellRight = Math.max(layout.contentPadding, (windowWidth - layout.contentWidth) / 2); + const shellRight = + insets.right + + layout.contentPadding + + Math.max(0, (effectiveWidth - layout.contentPadding * 2 - layout.contentWidth) / 2); const menuTop = insets.top + CONTENT_TOP_PADDING + HEADER_HEIGHT + spacing.xs; const libraryTrack = useMemo( () => (track ? libraryTracks.find((entry) => entry.path === track.path) ?? null : null), @@ -350,7 +426,8 @@ export default function NowPlayingScreen() { styles.content, contentStyle, { - paddingHorizontal: layout.contentPadding, + paddingLeft: insets.left + layout.contentPadding, + paddingRight: insets.right + layout.contentPadding, paddingTop: insets.top + CONTENT_TOP_PADDING, paddingBottom: insets.bottom + CONTENT_BOTTOM_PADDING, }, @@ -381,14 +458,17 @@ export default function NowPlayingScreen() { {track ? ( - + - - - - + + void seekTo(seconds)} /> - + - + @@ -749,6 +834,12 @@ const styles = StyleSheet.create({ player: { flex: 1, }, + playerWide: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + columnGap: WIDE_PANE_GAP, + }, middleStack: { width: '100%', alignItems: 'center', @@ -791,7 +882,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: spacing.md, - marginBottom: spacing.md, }, trackTextStack: { flex: 1, @@ -828,18 +918,17 @@ const styles = StyleSheet.create({ artist: { color: colors.accentText, }, - spacer: { - flex: 1, - minHeight: MIN_FLOATING_SPACE, - }, playerControls: { width: '100%', }, + playerControlsFill: { + flex: 1, + justifyContent: 'space-between', + }, transport: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - marginTop: TRANSPORT_TOP_MARGIN, }, transportMainBtn: { width: 48, @@ -866,7 +955,6 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'space-between', gap: spacing.md, - marginTop: SUB_TOP_MARGIN, paddingHorizontal: spacing.sm, }, subBadges: { diff --git a/src/theme/adaptive.ts b/src/theme/adaptive.ts new file mode 100644 index 0000000..f25fc10 --- /dev/null +++ b/src/theme/adaptive.ts @@ -0,0 +1,11 @@ +/** Minimum width for two-pane layouts — below this, panes get too cramped to use. */ +export const WIDE_MIN_WIDTH = 600; + +/** + * True when a window should use a two-pane / wide layout: wide enough for two + * useful panes and wider than it is tall (single columns overflow short windows). + * Pass inset-adjusted dimensions (window minus safe areas). + */ +export function isWideWindow(availableWidth: number, availableHeight: number): boolean { + return availableWidth >= WIDE_MIN_WIDTH && availableWidth > availableHeight; +}