fix layout issues

This commit is contained in:
Boof2015
2026-07-01 17:56:15 -04:00
parent 76a5183dfb
commit 7f00e87823
4 changed files with 280 additions and 114 deletions
+132 -65
View File
@@ -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<SheetKind>('none');
const [editingValue, setEditingValue] = useState<EQEditableValue | null>(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 = (
<Pressable
style={[styles.presetRow, isWide && styles.sideItem]}
onPress={() => setSheet('preset')}
>
<Text variant="body" color={colors.textPrimary}>
{presetName}
</Text>
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
</Pressable>
);
const graphEl = (
<EQGraph
bands={eq.bands}
activeBandId={eq.activeBandId}
enabled={eq.enabled}
spectrumActive={scopeActive && focused}
onSelectBand={eq.selectBand}
onChangeBand={(id, updates) => eq.updateBand(id, updates)}
/>
);
const stripEl = (
<BandStrip
bands={eq.bands}
activeBandId={eq.activeBandId}
canAdd={eq.bands.length < EQ_MAX_BANDS}
onSelect={eq.selectBand}
onAdd={() => eq.addBand()}
/>
);
const detailEl = (
<BandDetailPanel
band={activeBand}
bandNumber={activeBandNumber > 0 ? activeBandNumber : 1}
onUpdate={(updates) => activeBand && eq.updateBand(activeBand.id, updates)}
onEditType={() => setSheet('type')}
onEditValue={setEditingValue}
/>
);
const bottomBarEl = (
<View style={[styles.bottomBar, isWide && styles.bottomBarWide]}>
<View style={styles.preamp}>
<EQSlider
label="Preamp"
value={eq.preamp}
min={EQ_MIN_PREAMP_DB}
max={EQ_MAX_PREAMP_DB}
format={(v) => `${formatGain(v)} dB`}
onChange={eq.setPreamp}
/>
</View>
<Pressable
style={[styles.eqToggle, eq.enabled && styles.eqToggleOn]}
onPress={eq.toggleEnabled}
>
<Ionicons
name="power"
size={16}
color={eq.enabled ? colors.accentTextStrong : colors.textSecondary}
/>
<Text variant="label" color={eq.enabled ? colors.accentTextStrong : colors.textSecondary}>
{eq.enabled ? 'EQ on' : 'EQ off'}
</Text>
</Pressable>
</View>
);
return (
<Screen padded={false}>
<View style={styles.header}>
<View
style={[
styles.header,
{ paddingLeft: spacing.lg + insets.left, paddingRight: spacing.lg + insets.right },
]}
>
<Text variant="heading">Equalizer</Text>
<View style={styles.headerActions}>
<Pressable style={styles.iconButton} onPress={() => setSheet('save')} hitSlop={8}>
@@ -91,69 +175,31 @@ export default function EQScreen() {
</View>
</View>
<Pressable style={styles.presetRow} onPress={() => setSheet('preset')}>
<Text variant="body" color={colors.textPrimary}>
{presetName}
</Text>
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
</Pressable>
<View style={styles.graphWrap}>
<EQGraph
bands={eq.bands}
activeBandId={eq.activeBandId}
enabled={eq.enabled}
spectrumActive={scopeActive && focused}
onSelectBand={eq.selectBand}
onChangeBand={(id, updates) => eq.updateBand(id, updates)}
/>
</View>
<View style={styles.section}>
<BandStrip
bands={eq.bands}
activeBandId={eq.activeBandId}
canAdd={eq.bands.length < EQ_MAX_BANDS}
onSelect={eq.selectBand}
onAdd={() => eq.addBand()}
/>
</View>
<View style={styles.section}>
<BandDetailPanel
band={activeBand}
bandNumber={activeBandNumber > 0 ? activeBandNumber : 1}
onUpdate={(updates) => activeBand && eq.updateBand(activeBand.id, updates)}
onEditType={() => setSheet('type')}
onEditValue={setEditingValue}
/>
</View>
<View style={styles.bottomBar}>
<View style={styles.preamp}>
<EQSlider
label="Preamp"
value={eq.preamp}
min={EQ_MIN_PREAMP_DB}
max={EQ_MAX_PREAMP_DB}
format={(v) => `${formatGain(v)} dB`}
onChange={eq.setPreamp}
/>
</View>
<Pressable
style={[styles.eqToggle, eq.enabled && styles.eqToggleOn]}
onPress={eq.toggleEnabled}
{isWide ? (
<View
style={[
styles.wideBody,
{ paddingLeft: spacing.lg + insets.left, paddingRight: spacing.lg + insets.right },
]}
>
<Ionicons
name="power"
size={16}
color={eq.enabled ? colors.accentTextStrong : colors.textSecondary}
/>
<Text variant="label" color={eq.enabled ? colors.accentTextStrong : colors.textSecondary}>
{eq.enabled ? 'EQ on' : 'EQ off'}
</Text>
</Pressable>
</View>
<View style={styles.wideGraphPane}>{graphEl}</View>
<View style={{ width: sidePaneWidth }}>
{presetRowEl}
{stripEl}
<View style={styles.sideDetail}>{detailEl}</View>
<View style={styles.wideSpacer} />
{bottomBarEl}
</View>
</View>
) : (
<>
{presetRowEl}
<View style={styles.graphWrap}>{graphEl}</View>
<View style={styles.section}>{stripEl}</View>
<View style={styles.section}>{detailEl}</View>
{bottomBarEl}
</>
)}
{sheet === 'preset' ? (
<PresetSheet
@@ -311,7 +357,6 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
},
@@ -349,6 +394,28 @@ const styles = StyleSheet.create({
marginHorizontal: spacing.lg,
marginTop: spacing.md,
},
wideBody: {
flex: 1,
flexDirection: 'row',
gap: spacing.lg,
paddingBottom: spacing.sm,
},
wideGraphPane: {
flex: 1,
minWidth: 0,
},
wideSpacer: {
flex: 1,
},
sideItem: {
marginHorizontal: 0,
},
sideDetail: {
marginTop: spacing.md,
},
bottomBarWide: {
paddingHorizontal: 0,
},
bottomBar: {
flexDirection: 'row',
alignItems: 'center',
+136 -48
View File
@@ -23,6 +23,7 @@ import { Visualizer } from '@/components/Visualizer';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { QueueTray } from '@/components/queue/QueueTray';
import { colors, radius, spacing } from '@/theme';
import { WIDE_MIN_WIDTH, isWideWindow } from '@/theme/adaptive';
import { motion } from '@/theme/motion';
import { resolveCanonicalBrowseArtist, resolveStrictBrowseArtist } from '@/library/artistGrouping';
import { useLibraryStore } from '@/stores/libraryStore';
@@ -46,10 +47,17 @@ const MAX_CONTENT_WIDTH = 408;
const CONTENT_SIDE_PADDING = spacing.lg;
const NARROW_CONTENT_SIDE_PADDING = spacing.md;
const MEDIA_AREA_MIN = 220;
const MEDIA_AREA_MAX = 360;
const ART_SIZE_MAX = 340;
const VISUALIZER_ART_SCALE = 0.8;
const VISUALIZER_ART_SIZE_MIN = 176;
// Tablet-portrait tier: tall windows >= 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() {
</View>
{track ? (
<View style={styles.player}>
<View style={[styles.player, layout.isWide && styles.playerWide]}>
<View
style={[
styles.middleStack,
{
marginTop: layout.mediaTopMargin,
marginBottom: layout.mediaBottomGap,
},
layout.isWide
? { width: layout.leftPaneWidth, justifyContent: 'center' }
: {
height: layout.mediaStackHeight,
marginTop: layout.mediaTopMargin,
marginBottom: layout.mediaBottomGap,
},
]}
>
<View
@@ -464,10 +544,15 @@ export default function NowPlayingScreen() {
)}
</View>
<View style={styles.spacer} />
<View style={styles.playerControls}>
<View style={styles.trackInfo}>
<View
style={[
styles.playerControls,
layout.isWide
? { width: layout.rightPaneWidth }
: styles.playerControlsFill,
]}
>
<View style={[styles.trackInfo, { marginBottom: layout.trackInfoGap }]}>
<View style={styles.trackTextStack}>
<MarqueeText
variant="heading"
@@ -508,14 +593,14 @@ export default function NowPlayingScreen() {
<WaveformSeekBar
currentTime={currentTime}
duration={duration}
height={WAVEFORM_HEIGHT}
height={layout.waveformHeight}
touchPadding={WAVEFORM_TOUCH_PADDING}
trackKey={track.id}
trackPath={track.path}
onSeek={(seconds) => void seekTo(seconds)}
/>
<View style={styles.transport}>
<View style={[styles.transport, { marginTop: layout.controlsGap }]}>
<Pressable
hitSlop={10}
style={styles.transportSideBtn}
@@ -581,7 +666,7 @@ export default function NowPlayingScreen() {
</Pressable>
</View>
<View style={styles.subRow}>
<View style={[styles.subRow, { marginTop: layout.controlsGap }]}>
<View style={styles.subBadges}>
<RemoteSourceBadge sourceType={track.sourceType} />
<FormatBadges track={track} wrap={false} />
@@ -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: {