mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 04:30:54 +02:00
fix layout issues
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
"name": "Astra",
|
"name": "Astra",
|
||||||
"slug": "astra-mobile",
|
"slug": "astra-mobile",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"orientation": "portrait",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "astra",
|
"scheme": "astra",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
|
|||||||
+132
-65
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useState } from 'react';
|
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 { Ionicons } from '@expo/vector-icons';
|
||||||
import { useFocusEffect } from 'expo-router';
|
import { useFocusEffect } from 'expo-router';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import * as DocumentPicker from 'expo-document-picker';
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
import { readAsStringAsync } from 'expo-file-system/legacy';
|
import { readAsStringAsync } from 'expo-file-system/legacy';
|
||||||
import { Screen } from '@/components/Screen';
|
import { Screen } from '@/components/Screen';
|
||||||
@@ -15,6 +16,7 @@ import { EQValueEditSheet } from '@/components/eq/EQValueEditSheet';
|
|||||||
import { PresetSheet } from '@/components/eq/PresetSheet';
|
import { PresetSheet } from '@/components/eq/PresetSheet';
|
||||||
import { SavePresetSheet } from '@/components/eq/SavePresetSheet';
|
import { SavePresetSheet } from '@/components/eq/SavePresetSheet';
|
||||||
import { colors, radius, spacing } from '@/theme';
|
import { colors, radius, spacing } from '@/theme';
|
||||||
|
import { isWideWindow } from '@/theme/adaptive';
|
||||||
import { useEQStore } from '@/stores/eqStore';
|
import { useEQStore } from '@/stores/eqStore';
|
||||||
import { useScopeActive } from '@/scope/scopeStore';
|
import { useScopeActive } from '@/scope/scopeStore';
|
||||||
import { setActivePostEqNative } from '@/audio/eqNative';
|
import { setActivePostEqNative } from '@/audio/eqNative';
|
||||||
@@ -44,6 +46,12 @@ export default function EQScreen() {
|
|||||||
const [sheet, setSheet] = useState<SheetKind>('none');
|
const [sheet, setSheet] = useState<SheetKind>('none');
|
||||||
const [editingValue, setEditingValue] = useState<EQEditableValue | null>(null);
|
const [editingValue, setEditingValue] = useState<EQEditableValue | null>(null);
|
||||||
const closeSheet = useCallback(() => setSheet('none'), []);
|
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.
|
// Gate the post-EQ tap to while this screen is visible.
|
||||||
useFocusEffect(
|
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 (
|
return (
|
||||||
<Screen padded={false}>
|
<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>
|
<Text variant="heading">Equalizer</Text>
|
||||||
<View style={styles.headerActions}>
|
<View style={styles.headerActions}>
|
||||||
<Pressable style={styles.iconButton} onPress={() => setSheet('save')} hitSlop={8}>
|
<Pressable style={styles.iconButton} onPress={() => setSheet('save')} hitSlop={8}>
|
||||||
@@ -91,69 +175,31 @@ export default function EQScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Pressable style={styles.presetRow} onPress={() => setSheet('preset')}>
|
{isWide ? (
|
||||||
<Text variant="body" color={colors.textPrimary}>
|
<View
|
||||||
{presetName}
|
style={[
|
||||||
</Text>
|
styles.wideBody,
|
||||||
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
|
{ paddingLeft: spacing.lg + insets.left, paddingRight: spacing.lg + insets.right },
|
||||||
</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}
|
|
||||||
>
|
>
|
||||||
<Ionicons
|
<View style={styles.wideGraphPane}>{graphEl}</View>
|
||||||
name="power"
|
<View style={{ width: sidePaneWidth }}>
|
||||||
size={16}
|
{presetRowEl}
|
||||||
color={eq.enabled ? colors.accentTextStrong : colors.textSecondary}
|
{stripEl}
|
||||||
/>
|
<View style={styles.sideDetail}>{detailEl}</View>
|
||||||
<Text variant="label" color={eq.enabled ? colors.accentTextStrong : colors.textSecondary}>
|
<View style={styles.wideSpacer} />
|
||||||
{eq.enabled ? 'EQ on' : 'EQ off'}
|
{bottomBarEl}
|
||||||
</Text>
|
</View>
|
||||||
</Pressable>
|
</View>
|
||||||
</View>
|
) : (
|
||||||
|
<>
|
||||||
|
{presetRowEl}
|
||||||
|
<View style={styles.graphWrap}>{graphEl}</View>
|
||||||
|
<View style={styles.section}>{stripEl}</View>
|
||||||
|
<View style={styles.section}>{detailEl}</View>
|
||||||
|
{bottomBarEl}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{sheet === 'preset' ? (
|
{sheet === 'preset' ? (
|
||||||
<PresetSheet
|
<PresetSheet
|
||||||
@@ -311,7 +357,6 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
paddingTop: spacing.md,
|
paddingTop: spacing.md,
|
||||||
paddingBottom: spacing.sm,
|
paddingBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
@@ -349,6 +394,28 @@ const styles = StyleSheet.create({
|
|||||||
marginHorizontal: spacing.lg,
|
marginHorizontal: spacing.lg,
|
||||||
marginTop: spacing.md,
|
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: {
|
bottomBar: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
+136
-48
@@ -23,6 +23,7 @@ import { Visualizer } from '@/components/Visualizer';
|
|||||||
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
|
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
|
||||||
import { QueueTray } from '@/components/queue/QueueTray';
|
import { QueueTray } from '@/components/queue/QueueTray';
|
||||||
import { colors, radius, spacing } from '@/theme';
|
import { colors, radius, spacing } from '@/theme';
|
||||||
|
import { WIDE_MIN_WIDTH, isWideWindow } from '@/theme/adaptive';
|
||||||
import { motion } from '@/theme/motion';
|
import { motion } from '@/theme/motion';
|
||||||
import { resolveCanonicalBrowseArtist, resolveStrictBrowseArtist } from '@/library/artistGrouping';
|
import { resolveCanonicalBrowseArtist, resolveStrictBrowseArtist } from '@/library/artistGrouping';
|
||||||
import { useLibraryStore } from '@/stores/libraryStore';
|
import { useLibraryStore } from '@/stores/libraryStore';
|
||||||
@@ -46,10 +47,17 @@ const MAX_CONTENT_WIDTH = 408;
|
|||||||
const CONTENT_SIDE_PADDING = spacing.lg;
|
const CONTENT_SIDE_PADDING = spacing.lg;
|
||||||
const NARROW_CONTENT_SIDE_PADDING = spacing.md;
|
const NARROW_CONTENT_SIDE_PADDING = spacing.md;
|
||||||
const MEDIA_AREA_MIN = 220;
|
const MEDIA_AREA_MIN = 220;
|
||||||
const MEDIA_AREA_MAX = 360;
|
// Tablet-portrait tier: tall windows >= WIDE_MIN_WIDTH keep the single column but grow it.
|
||||||
const ART_SIZE_MAX = 340;
|
const TABLET_MAX_CONTENT_WIDTH = 520;
|
||||||
const VISUALIZER_ART_SCALE = 0.8;
|
const TABLET_ART_SIZE_MAX = 440;
|
||||||
const VISUALIZER_ART_SIZE_MIN = 176;
|
// 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_WIDTH_MAX = 448;
|
||||||
const VISUALIZER_SIDE_PADDING = spacing.md;
|
const VISUALIZER_SIDE_PADDING = spacing.md;
|
||||||
const VISUALIZER_TOP_GAP = spacing.lg;
|
const VISUALIZER_TOP_GAP = spacing.lg;
|
||||||
@@ -61,7 +69,6 @@ const HEADER_HEIGHT = 32;
|
|||||||
const CONTENT_TOP_PADDING = spacing.sm;
|
const CONTENT_TOP_PADDING = spacing.sm;
|
||||||
const CONTENT_BOTTOM_PADDING = spacing.lg;
|
const CONTENT_BOTTOM_PADDING = spacing.lg;
|
||||||
const MEDIA_TOP_MARGIN = spacing.lg;
|
const MEDIA_TOP_MARGIN = spacing.lg;
|
||||||
const VISUALIZER_MEDIA_BOTTOM_GAP = spacing.sm;
|
|
||||||
const MEDIA_BOTTOM_GAP = spacing.xl;
|
const MEDIA_BOTTOM_GAP = spacing.xl;
|
||||||
const TRACK_INFO_ESTIMATE = 96;
|
const TRACK_INFO_ESTIMATE = 96;
|
||||||
const WAVEFORM_HEIGHT = 58;
|
const WAVEFORM_HEIGHT = 58;
|
||||||
@@ -80,8 +87,15 @@ const MENU_ANIMATION_OUT_MS = 100;
|
|||||||
const MENU_ENTER_OFFSET_Y = -8;
|
const MENU_ENTER_OFFSET_Y = -8;
|
||||||
|
|
||||||
interface NowPlayingLayout {
|
interface NowPlayingLayout {
|
||||||
|
isWide: boolean;
|
||||||
contentPadding: number;
|
contentPadding: number;
|
||||||
contentWidth: number;
|
contentWidth: number;
|
||||||
|
leftPaneWidth: number;
|
||||||
|
rightPaneWidth: number;
|
||||||
|
controlsGap: number;
|
||||||
|
trackInfoGap: number;
|
||||||
|
waveformHeight: number;
|
||||||
|
mediaStackHeight: number;
|
||||||
artSize: number;
|
artSize: number;
|
||||||
scopeWidth: number;
|
scopeWidth: number;
|
||||||
scopeHeight: number;
|
scopeHeight: number;
|
||||||
@@ -109,29 +123,76 @@ function getScopeHeight(scopeWidth: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getNowPlayingLayout(
|
function getNowPlayingLayout(
|
||||||
windowWidth: number,
|
availableWidth: number,
|
||||||
availableHeight: number,
|
availableHeight: number,
|
||||||
showVisualizer: boolean
|
showVisualizer: boolean
|
||||||
): NowPlayingLayout {
|
): 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 =
|
const contentPadding =
|
||||||
windowWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING;
|
availableWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING;
|
||||||
const contentWidth = Math.max(0, Math.min(windowWidth - contentPadding * 2, MAX_CONTENT_WIDTH));
|
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(
|
const scopeWidth = Math.max(
|
||||||
0,
|
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 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 mediaMin = Math.min(mediaMax, MEDIA_AREA_MIN);
|
||||||
const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN;
|
const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN;
|
||||||
const defaultMediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP;
|
const defaultMediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP;
|
||||||
const mediaBottomGap = showVisualizer ? VISUALIZER_MEDIA_BOTTOM_GAP : defaultMediaBottomGap;
|
const mediaBottomGap = defaultMediaBottomGap;
|
||||||
const fixedHeight =
|
const fixedHeightBase =
|
||||||
CONTENT_TOP_PADDING +
|
CONTENT_TOP_PADDING +
|
||||||
CONTENT_BOTTOM_PADDING +
|
CONTENT_BOTTOM_PADDING +
|
||||||
HEADER_HEIGHT +
|
HEADER_HEIGHT +
|
||||||
mediaTopMargin +
|
mediaTopMargin +
|
||||||
mediaBottomGap +
|
|
||||||
TRACK_INFO_ESTIMATE +
|
TRACK_INFO_ESTIMATE +
|
||||||
WAVEFORM_BLOCK_ESTIMATE +
|
WAVEFORM_BLOCK_ESTIMATE +
|
||||||
TRANSPORT_TOP_MARGIN +
|
TRANSPORT_TOP_MARGIN +
|
||||||
@@ -139,27 +200,38 @@ function getNowPlayingLayout(
|
|||||||
SUB_TOP_MARGIN +
|
SUB_TOP_MARGIN +
|
||||||
SUB_BUTTON_SIZE +
|
SUB_BUTTON_SIZE +
|
||||||
MIN_FLOATING_SPACE;
|
MIN_FLOATING_SPACE;
|
||||||
const heightBoundMedia = availableHeight - fixedHeight;
|
// The Math.max(96, ...) floor lets art shrink below MEDIA_AREA_MIN in squat
|
||||||
const baseArtSize = Math.min(
|
// windows (split-screen halves) instead of pushing the controls off-screen.
|
||||||
Math.round(clamp(heightBoundMedia, mediaMin, mediaMax)),
|
const bound = availableHeight - fixedHeightBase - mediaBottomGap;
|
||||||
ART_SIZE_MAX
|
const scopeOffArt = Math.round(
|
||||||
|
clamp(bound, Math.min(mediaMin, Math.max(96, bound)), mediaMax)
|
||||||
);
|
);
|
||||||
const artSize = showVisualizer
|
// Roomy screens get a taller waveform; the rest of the spare space is
|
||||||
? Math.round(
|
// distributed between the control rows by flex (space-between), so no
|
||||||
clamp(
|
// height estimate error can pool as one gap above the controls.
|
||||||
baseArtSize * VISUALIZER_ART_SCALE,
|
const offSurplus = Math.max(0, bound - scopeOffArt);
|
||||||
Math.min(VISUALIZER_ART_SIZE_MIN, mediaMax),
|
const stretchUnit = Math.min(Math.floor(offSurplus / 5), spacing.md);
|
||||||
mediaMax
|
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.
|
||||||
: baseArtSize;
|
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 visualizerTopGap = showVisualizer ? VISUALIZER_TOP_GAP : 0;
|
||||||
const visualizerBottomGap = showVisualizer ? VISUALIZER_BOTTOM_GAP : 0;
|
const visualizerBottomGap = showVisualizer ? VISUALIZER_BOTTOM_GAP : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
isWide: false,
|
||||||
contentPadding,
|
contentPadding,
|
||||||
contentWidth,
|
contentWidth,
|
||||||
|
leftPaneWidth: contentWidth,
|
||||||
|
rightPaneWidth: contentWidth,
|
||||||
|
controlsGap: TRANSPORT_TOP_MARGIN,
|
||||||
|
trackInfoGap: spacing.md,
|
||||||
|
waveformHeight,
|
||||||
|
mediaStackHeight,
|
||||||
artSize,
|
artSize,
|
||||||
scopeWidth,
|
scopeWidth,
|
||||||
scopeHeight,
|
scopeHeight,
|
||||||
@@ -194,9 +266,13 @@ export default function NowPlayingScreen() {
|
|||||||
const isPlaying = playbackState === 'playing';
|
const isPlaying = playbackState === 'playing';
|
||||||
const isLoading = playbackState === 'loading';
|
const isLoading = playbackState === 'loading';
|
||||||
const availableHeight = windowHeight - insets.top - insets.bottom;
|
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 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 menuTop = insets.top + CONTENT_TOP_PADDING + HEADER_HEIGHT + spacing.xs;
|
||||||
const libraryTrack = useMemo(
|
const libraryTrack = useMemo(
|
||||||
() => (track ? libraryTracks.find((entry) => entry.path === track.path) ?? null : null),
|
() => (track ? libraryTracks.find((entry) => entry.path === track.path) ?? null : null),
|
||||||
@@ -350,7 +426,8 @@ export default function NowPlayingScreen() {
|
|||||||
styles.content,
|
styles.content,
|
||||||
contentStyle,
|
contentStyle,
|
||||||
{
|
{
|
||||||
paddingHorizontal: layout.contentPadding,
|
paddingLeft: insets.left + layout.contentPadding,
|
||||||
|
paddingRight: insets.right + layout.contentPadding,
|
||||||
paddingTop: insets.top + CONTENT_TOP_PADDING,
|
paddingTop: insets.top + CONTENT_TOP_PADDING,
|
||||||
paddingBottom: insets.bottom + CONTENT_BOTTOM_PADDING,
|
paddingBottom: insets.bottom + CONTENT_BOTTOM_PADDING,
|
||||||
},
|
},
|
||||||
@@ -381,14 +458,17 @@ export default function NowPlayingScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{track ? (
|
{track ? (
|
||||||
<View style={styles.player}>
|
<View style={[styles.player, layout.isWide && styles.playerWide]}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.middleStack,
|
styles.middleStack,
|
||||||
{
|
layout.isWide
|
||||||
marginTop: layout.mediaTopMargin,
|
? { width: layout.leftPaneWidth, justifyContent: 'center' }
|
||||||
marginBottom: layout.mediaBottomGap,
|
: {
|
||||||
},
|
height: layout.mediaStackHeight,
|
||||||
|
marginTop: layout.mediaTopMargin,
|
||||||
|
marginBottom: layout.mediaBottomGap,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@@ -464,10 +544,15 @@ export default function NowPlayingScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.spacer} />
|
<View
|
||||||
|
style={[
|
||||||
<View style={styles.playerControls}>
|
styles.playerControls,
|
||||||
<View style={styles.trackInfo}>
|
layout.isWide
|
||||||
|
? { width: layout.rightPaneWidth }
|
||||||
|
: styles.playerControlsFill,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.trackInfo, { marginBottom: layout.trackInfoGap }]}>
|
||||||
<View style={styles.trackTextStack}>
|
<View style={styles.trackTextStack}>
|
||||||
<MarqueeText
|
<MarqueeText
|
||||||
variant="heading"
|
variant="heading"
|
||||||
@@ -508,14 +593,14 @@ export default function NowPlayingScreen() {
|
|||||||
<WaveformSeekBar
|
<WaveformSeekBar
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
duration={duration}
|
duration={duration}
|
||||||
height={WAVEFORM_HEIGHT}
|
height={layout.waveformHeight}
|
||||||
touchPadding={WAVEFORM_TOUCH_PADDING}
|
touchPadding={WAVEFORM_TOUCH_PADDING}
|
||||||
trackKey={track.id}
|
trackKey={track.id}
|
||||||
trackPath={track.path}
|
trackPath={track.path}
|
||||||
onSeek={(seconds) => void seekTo(seconds)}
|
onSeek={(seconds) => void seekTo(seconds)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={styles.transport}>
|
<View style={[styles.transport, { marginTop: layout.controlsGap }]}>
|
||||||
<Pressable
|
<Pressable
|
||||||
hitSlop={10}
|
hitSlop={10}
|
||||||
style={styles.transportSideBtn}
|
style={styles.transportSideBtn}
|
||||||
@@ -581,7 +666,7 @@ export default function NowPlayingScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.subRow}>
|
<View style={[styles.subRow, { marginTop: layout.controlsGap }]}>
|
||||||
<View style={styles.subBadges}>
|
<View style={styles.subBadges}>
|
||||||
<RemoteSourceBadge sourceType={track.sourceType} />
|
<RemoteSourceBadge sourceType={track.sourceType} />
|
||||||
<FormatBadges track={track} wrap={false} />
|
<FormatBadges track={track} wrap={false} />
|
||||||
@@ -749,6 +834,12 @@ const styles = StyleSheet.create({
|
|||||||
player: {
|
player: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
playerWide: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
columnGap: WIDE_PANE_GAP,
|
||||||
|
},
|
||||||
middleStack: {
|
middleStack: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -791,7 +882,6 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
marginBottom: spacing.md,
|
|
||||||
},
|
},
|
||||||
trackTextStack: {
|
trackTextStack: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -828,18 +918,17 @@ const styles = StyleSheet.create({
|
|||||||
artist: {
|
artist: {
|
||||||
color: colors.accentText,
|
color: colors.accentText,
|
||||||
},
|
},
|
||||||
spacer: {
|
|
||||||
flex: 1,
|
|
||||||
minHeight: MIN_FLOATING_SPACE,
|
|
||||||
},
|
|
||||||
playerControls: {
|
playerControls: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
},
|
},
|
||||||
|
playerControlsFill: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
transport: {
|
transport: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginTop: TRANSPORT_TOP_MARGIN,
|
|
||||||
},
|
},
|
||||||
transportMainBtn: {
|
transportMainBtn: {
|
||||||
width: 48,
|
width: 48,
|
||||||
@@ -866,7 +955,6 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
marginTop: SUB_TOP_MARGIN,
|
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
},
|
},
|
||||||
subBadges: {
|
subBadges: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user