mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 20:20:18 +02:00
update home and eq pages for tablets
This commit is contained in:
+2
-1
@@ -76,6 +76,7 @@
|
|||||||
"test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts",
|
"test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts",
|
||||||
"test:signal": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/signalShare.test.mts src/audio/signalShareIntent.test.mts src/audio/signalScanGeometry.test.mts src/audio/signalLocalMatch.test.mts",
|
"test:signal": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/signalShare.test.mts src/audio/signalShareIntent.test.mts src/audio/signalScanGeometry.test.mts src/audio/signalLocalMatch.test.mts",
|
||||||
"test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts",
|
"test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts",
|
||||||
|
"test:eq-layout": "node --experimental-strip-types --test src/components/eq/eqLayout.test.mts",
|
||||||
"test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts",
|
"test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts",
|
||||||
"test:seek-bar": "node --experimental-strip-types --test src/audio/playbackClock.test.mts src/audio/playbackNavigation.test.mts src/audio/playbackProgressProjection.test.mts src/components/waveformScrubDetents.test.mts",
|
"test:seek-bar": "node --experimental-strip-types --test src/audio/playbackClock.test.mts src/audio/playbackNavigation.test.mts src/audio/playbackProgressProjection.test.mts src/components/waveformScrubDetents.test.mts",
|
||||||
"test:recent-play": "node --experimental-strip-types --test src/audio/recentPlayTracking.test.mts",
|
"test:recent-play": "node --experimental-strip-types --test src/audio/recentPlayTracking.test.mts",
|
||||||
@@ -90,7 +91,7 @@
|
|||||||
"test:library-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/libraryLayout.test.mts src/components/library/detailHeroLayout.test.mts",
|
"test:library-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/libraryLayout.test.mts src/components/library/detailHeroLayout.test.mts",
|
||||||
"test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts",
|
"test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts",
|
||||||
"test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts",
|
"test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts",
|
||||||
"test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts",
|
"test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts src/home/homeLayout.test.mts",
|
||||||
"test:theme": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/theme/accentColors.test.mts src/theme/artworkAccentMath.test.mts",
|
"test:theme": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/theme/accentColors.test.mts src/theme/artworkAccentMath.test.mts",
|
||||||
"test:session": "node --experimental-strip-types --test src/session/sessionState.test.mts src/session/playbackMaterialization.test.mts",
|
"test:session": "node --experimental-strip-types --test src/session/sessionState.test.mts src/session/playbackMaterialization.test.mts",
|
||||||
"test:library-scan": "node --experimental-strip-types --test src/library/scanCancellation.test.mts",
|
"test:library-scan": "node --experimental-strip-types --test src/library/scanCancellation.test.mts",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const TEST_SCRIPTS = [
|
|||||||
'test:eq-share',
|
'test:eq-share',
|
||||||
'test:signal',
|
'test:signal',
|
||||||
'test:eq-math',
|
'test:eq-math',
|
||||||
|
'test:eq-layout',
|
||||||
'test:audio-startup',
|
'test:audio-startup',
|
||||||
'test:seek-bar',
|
'test:seek-bar',
|
||||||
'test:lyrics',
|
'test:lyrics',
|
||||||
|
|||||||
+94
-13
@@ -23,6 +23,8 @@ import { showAppDialog } from '@/components/dialogs/AppDialog';
|
|||||||
import { EQGraph } from '@/components/eq/EQGraph';
|
import { EQGraph } from '@/components/eq/EQGraph';
|
||||||
import { BandStrip } from '@/components/eq/BandStrip';
|
import { BandStrip } from '@/components/eq/BandStrip';
|
||||||
import { BandDetailPanel, type EQEditableValue } from '@/components/eq/BandDetailPanel';
|
import { BandDetailPanel, type EQEditableValue } from '@/components/eq/BandDetailPanel';
|
||||||
|
import { BandConsole } from '@/components/eq/BandConsole';
|
||||||
|
import { getEQLayout } from '@/components/eq/eqLayout';
|
||||||
import { EQSlider } from '@/components/eq/EQSlider';
|
import { EQSlider } from '@/components/eq/EQSlider';
|
||||||
import { EqSheet, EqSheetItem } from '@/components/eq/EqSheet';
|
import { EqSheet, EqSheetItem } from '@/components/eq/EqSheet';
|
||||||
import { EQModeSwitcher } from '@/components/eq/EQModeSwitcher';
|
import { EQModeSwitcher } from '@/components/eq/EQModeSwitcher';
|
||||||
@@ -45,7 +47,6 @@ import { useRipple } from '@/theme/ripple';
|
|||||||
import { hapticForToggle } from '@/lib/hapticCatalog';
|
import { hapticForToggle } from '@/lib/hapticCatalog';
|
||||||
import { playHaptic } from '@/lib/haptics';
|
import { playHaptic } from '@/lib/haptics';
|
||||||
import { useAppForeground } from '@/lib/useAppForeground';
|
import { useAppForeground } from '@/lib/useAppForeground';
|
||||||
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';
|
||||||
@@ -86,6 +87,12 @@ type SheetKind =
|
|||||||
| 'qr'
|
| 'qr'
|
||||||
| 'preview';
|
| 'preview';
|
||||||
type CurrentPresetAction = 'export' | 'share' | 'qr';
|
type CurrentPresetAction = 'export' | 'share' | 'qr';
|
||||||
|
/**
|
||||||
|
* What the exact-value sheet is editing. The band parameters belong to whichever
|
||||||
|
* band is active; the preamp belongs to the screen, so it is its own case rather
|
||||||
|
* than a fourth `EQEditableValue`.
|
||||||
|
*/
|
||||||
|
type EQEditTarget = EQEditableValue | 'preamp';
|
||||||
type EQState = ReturnType<typeof useEQStore.getState>;
|
type EQState = ReturnType<typeof useEQStore.getState>;
|
||||||
|
|
||||||
const BAND_TYPES: EQBandType[] = ['lowshelf', 'peaking', 'highshelf', 'highpass', 'lowpass'];
|
const BAND_TYPES: EQBandType[] = ['lowshelf', 'peaking', 'highshelf', 'highpass', 'lowpass'];
|
||||||
@@ -106,7 +113,7 @@ export default function EQScreen() {
|
|||||||
!foreground
|
!foreground
|
||||||
);
|
);
|
||||||
const [sheet, setSheet] = useState<SheetKind>('none');
|
const [sheet, setSheet] = useState<SheetKind>('none');
|
||||||
const [editingValue, setEditingValue] = useState<EQEditableValue | null>(null);
|
const [editingValue, setEditingValue] = useState<EQEditTarget | null>(null);
|
||||||
const [pendingCurrentAction, setPendingCurrentAction] = useState<CurrentPresetAction | null>(null);
|
const [pendingCurrentAction, setPendingCurrentAction] = useState<CurrentPresetAction | null>(null);
|
||||||
const [pendingImportPreset, setPendingImportPreset] = useState<EQPreset | null>(null);
|
const [pendingImportPreset, setPendingImportPreset] = useState<EQPreset | null>(null);
|
||||||
const [qrPreset, setQrPreset] = useState<{ name: string; value: string } | null>(null);
|
const [qrPreset, setQrPreset] = useState<{ name: string; value: string } | null>(null);
|
||||||
@@ -116,10 +123,12 @@ export default function EQScreen() {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const sceneBottomInset = useSceneBottomInset();
|
const sceneBottomInset = useSceneBottomInset();
|
||||||
const availableWidth = windowWidth - insets.left - insets.right;
|
const availableWidth = windowWidth - insets.left - insets.right;
|
||||||
const availableHeight = windowHeight - insets.top - insets.bottom;
|
// The floating chrome is the scene's bottom edge as far as this screen is
|
||||||
const isWide = isWideWindow(availableWidth, availableHeight);
|
// concerned — it doesn't scroll, so anything below that line is unreachable.
|
||||||
// Editing pane keeps a phone-ish width; the graph gets everything else.
|
const availableHeight = windowHeight - insets.top - insets.bottom - sceneBottomInset;
|
||||||
const sidePaneWidth = Math.min(360, Math.max(280, Math.round(availableWidth * 0.4)));
|
const eqLayout = getEQLayout(availableWidth, availableHeight);
|
||||||
|
const isWide = eqLayout.panes === 'split';
|
||||||
|
const isConsole = eqLayout.editor === 'console';
|
||||||
|
|
||||||
// Gate the post-EQ tap to while this screen is visible.
|
// Gate the post-EQ tap to while this screen is visible.
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
@@ -156,7 +165,12 @@ export default function EQScreen() {
|
|||||||
? eq.presets.find((preset) => preset.id === actionPresetId) ?? null
|
? eq.presets.find((preset) => preset.id === actionPresetId) ?? null
|
||||||
: null;
|
: null;
|
||||||
const defaultPresetName = `Preset ${eq.presets.filter((p) => p.isCustom).length + 1}`;
|
const defaultPresetName = `Preset ${eq.presets.filter((p) => p.isCustom).length + 1}`;
|
||||||
const valueEditConfig = activeBand && editingValue ? getValueEditConfig(editingValue, activeBand) : null;
|
const valueEditConfig =
|
||||||
|
editingValue === 'preamp'
|
||||||
|
? getPreampEditConfig(eq.preamp)
|
||||||
|
: activeBand && editingValue
|
||||||
|
? getValueEditConfig(editingValue, activeBand)
|
||||||
|
: null;
|
||||||
|
|
||||||
const handleImportAutoEQ = async () => {
|
const handleImportAutoEQ = async () => {
|
||||||
closeSheet();
|
closeSheet();
|
||||||
@@ -357,6 +371,37 @@ export default function EQScreen() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Wide-window editor: every band's parameters at once, so there is nothing to
|
||||||
|
// select before editing. Sheets are shared with the detail panel, and they read
|
||||||
|
// the *active* band — so a strip selects itself before opening one.
|
||||||
|
const consoleEl = (
|
||||||
|
<BandConsole
|
||||||
|
bands={eq.bands}
|
||||||
|
activeBandId={eq.activeBandId}
|
||||||
|
canAdd={eq.bands.length < EQ_MAX_BANDS}
|
||||||
|
stripWidth={eqLayout.stripWidth}
|
||||||
|
railHeight={eqLayout.railHeight}
|
||||||
|
onSelect={(id) => {
|
||||||
|
if (id === eq.activeBandId) return;
|
||||||
|
playHaptic('selection');
|
||||||
|
eq.selectBand(id);
|
||||||
|
}}
|
||||||
|
onAdd={() => {
|
||||||
|
playHaptic('action');
|
||||||
|
eq.addBand();
|
||||||
|
}}
|
||||||
|
onUpdate={(id, updates) => eq.updateBand(id, updates)}
|
||||||
|
onEditType={(id) => {
|
||||||
|
eq.selectBand(id);
|
||||||
|
setSheet('type');
|
||||||
|
}}
|
||||||
|
onEditValue={(id, value) => {
|
||||||
|
eq.selectBand(id);
|
||||||
|
setEditingValue(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
const detailEl = (
|
const detailEl = (
|
||||||
<BandDetailPanel
|
<BandDetailPanel
|
||||||
band={activeBand}
|
band={activeBand}
|
||||||
@@ -388,6 +433,7 @@ export default function EQScreen() {
|
|||||||
max={EQ_MAX_PREAMP_DB}
|
max={EQ_MAX_PREAMP_DB}
|
||||||
format={(v) => `${formatGain(v)} dB`}
|
format={(v) => `${formatGain(v)} dB`}
|
||||||
onChange={eq.setPreamp}
|
onChange={eq.setPreamp}
|
||||||
|
onValuePress={() => setEditingValue('preamp')}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Pressable android_ripple={ripple.bounded}
|
<Pressable android_ripple={ripple.bounded}
|
||||||
@@ -434,9 +480,18 @@ export default function EQScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isWide ? (
|
{isWide ? (
|
||||||
<View style={styles.wideBody}>
|
<View
|
||||||
|
style={[
|
||||||
|
styles.wideBody,
|
||||||
|
// The graph pane is `flex: 1` inside this row, so it grows into the
|
||||||
|
// floating chrome unless the row itself gives that space back — and
|
||||||
|
// `EQGraph` draws its frequency axis at its own bottom edge, which
|
||||||
|
// is what ends up underneath the bar.
|
||||||
|
sceneBottomInset > 0 ? { paddingBottom: sceneBottomInset } : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<View style={styles.wideGraphPane}>{isGraphic ? graphicEditorEl : graphEl}</View>
|
<View style={styles.wideGraphPane}>{isGraphic ? graphicEditorEl : graphEl}</View>
|
||||||
<View style={{ width: sidePaneWidth }}>
|
<View style={{ width: eqLayout.sidePaneWidth }}>
|
||||||
{modeSwitcherEl}
|
{modeSwitcherEl}
|
||||||
{presetRowEl}
|
{presetRowEl}
|
||||||
{isGraphic ? null : (
|
{isGraphic ? null : (
|
||||||
@@ -458,8 +513,16 @@ export default function EQScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<View style={styles.graphWrap}>{graphEl}</View>
|
<View style={styles.graphWrap}>{graphEl}</View>
|
||||||
<View style={styles.section}>{stripEl}</View>
|
{isConsole ? (
|
||||||
<View style={styles.section}>{detailEl}</View>
|
<View style={[styles.section, { height: eqLayout.consoleHeight }]}>
|
||||||
|
{consoleEl}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View style={styles.section}>{stripEl}</View>
|
||||||
|
<View style={styles.section}>{detailEl}</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{bottomBarEl}
|
{bottomBarEl}
|
||||||
@@ -608,7 +671,7 @@ export default function EQScreen() {
|
|||||||
</EqSheet>
|
</EqSheet>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{valueEditConfig && activeBand && editingValue ? (
|
{valueEditConfig && editingValue ? (
|
||||||
<EQValueEditSheet
|
<EQValueEditSheet
|
||||||
title={valueEditConfig.title}
|
title={valueEditConfig.title}
|
||||||
initialValue={valueEditConfig.initialValue}
|
initialValue={valueEditConfig.initialValue}
|
||||||
@@ -617,7 +680,13 @@ export default function EQScreen() {
|
|||||||
placeholder={valueEditConfig.placeholder}
|
placeholder={valueEditConfig.placeholder}
|
||||||
keyboardType={valueEditConfig.keyboardType}
|
keyboardType={valueEditConfig.keyboardType}
|
||||||
parseValue={valueEditConfig.parseValue}
|
parseValue={valueEditConfig.parseValue}
|
||||||
onApply={(value) => eq.updateBand(activeBand.id, createValueUpdate(editingValue, value))}
|
onApply={(value) => {
|
||||||
|
if (editingValue === 'preamp') {
|
||||||
|
eq.setPreamp(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeBand) eq.updateBand(activeBand.id, createValueUpdate(editingValue, value));
|
||||||
|
}}
|
||||||
onClose={() => setEditingValue(null)}
|
onClose={() => setEditingValue(null)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -625,6 +694,18 @@ export default function EQScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPreampEditConfig(preamp: number) {
|
||||||
|
return {
|
||||||
|
title: 'Edit preamp',
|
||||||
|
initialValue: preamp.toFixed(1),
|
||||||
|
unit: 'dB',
|
||||||
|
rangeLabel: `${EQ_MIN_PREAMP_DB} to +${EQ_MAX_PREAMP_DB} dB`,
|
||||||
|
placeholder: '0.0',
|
||||||
|
keyboardType: 'numbers-and-punctuation' as const,
|
||||||
|
parseValue: parseDb,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getValueEditConfig(kind: EQEditableValue, band: EQBand) {
|
function getValueEditConfig(kind: EQEditableValue, band: EQBand) {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'frequency':
|
case 'frequency':
|
||||||
|
|||||||
+232
-120
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
import {
|
import {
|
||||||
AppState,
|
AppState,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
View,
|
View,
|
||||||
type GestureResponderEvent,
|
type GestureResponderEvent,
|
||||||
|
type LayoutChangeEvent,
|
||||||
|
type StyleProp,
|
||||||
|
type ViewStyle,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -42,15 +45,18 @@ import {
|
|||||||
HOME_GREETING_ROTATION_MS,
|
HOME_GREETING_ROTATION_MS,
|
||||||
type HomeGreetingTextMode,
|
type HomeGreetingTextMode,
|
||||||
} from '@/home/homeGreeting';
|
} from '@/home/homeGreeting';
|
||||||
|
import { getHomeLayout, HOME_COLUMN_GAP } from '@/home/homeLayout';
|
||||||
import { useHomeLibraryNavigation } from '@/navigation/useHomeLibraryNavigation';
|
import { useHomeLibraryNavigation } from '@/navigation/useHomeLibraryNavigation';
|
||||||
import type { Album, Artist, DbTrack } from '@/types/library';
|
import type { Album, Artist, DbTrack } from '@/types/library';
|
||||||
import { ListeningPreviewCard } from '@/components/listening/ListeningPreviewCard';
|
import {
|
||||||
|
hasListeningPreview,
|
||||||
|
ListeningPreviewCard,
|
||||||
|
} from '@/components/listening/ListeningPreviewCard';
|
||||||
import { useListeningStatsStore } from '@/stores/listeningStatsStore';
|
import { useListeningStatsStore } from '@/stores/listeningStatsStore';
|
||||||
import { subscribeToListeningHistory } from '@/listeningStats/events';
|
import { subscribeToListeningHistory } from '@/listeningStats/events';
|
||||||
import { useSceneBottomInset } from '@/navigation/useShellLayout';
|
import { useSceneBottomInset } from '@/navigation/useShellLayout';
|
||||||
|
|
||||||
const RECENT_ALBUM_LIMIT = 8;
|
const RECENT_ALBUM_LIMIT = 8;
|
||||||
const RECENT_TRACK_LIMIT = 3;
|
|
||||||
const PLAYLIST_LIMIT = 4;
|
const PLAYLIST_LIMIT = 4;
|
||||||
|
|
||||||
type RandomSpotlight =
|
type RandomSpotlight =
|
||||||
@@ -337,16 +343,18 @@ function ArtistCover({ artist, size }: { artist: Artist; size: number }) {
|
|||||||
|
|
||||||
function RecentlyAddedAlbum({
|
function RecentlyAddedAlbum({
|
||||||
album,
|
album,
|
||||||
|
size,
|
||||||
onPress,
|
onPress,
|
||||||
}: {
|
}: {
|
||||||
album: Album;
|
album: Album;
|
||||||
|
size: number;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}) {
|
}) {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const ripple = useRipple();
|
const ripple = useRipple();
|
||||||
return (
|
return (
|
||||||
<Pressable style={styles.recentAlbum} android_ripple={ripple.tile} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onPress} accessibilityRole="button">
|
<Pressable style={[styles.recentAlbum, { width: size }]} android_ripple={ripple.tile} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onPress} accessibilityRole="button">
|
||||||
<AlbumCover album={album} size={112} />
|
<AlbumCover album={album} size={size} />
|
||||||
<Text variant="body" numberOfLines={1} style={styles.recentAlbumTitle}>
|
<Text variant="body" numberOfLines={1} style={styles.recentAlbumTitle}>
|
||||||
{album.album}
|
{album.album}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -360,6 +368,8 @@ function RecentlyAddedAlbum({
|
|||||||
function RandomSpotlightCard({
|
function RandomSpotlightCard({
|
||||||
spotlight,
|
spotlight,
|
||||||
hasTracks,
|
hasTracks,
|
||||||
|
coverSize,
|
||||||
|
style,
|
||||||
onPlay,
|
onPlay,
|
||||||
onShuffle,
|
onShuffle,
|
||||||
onReroll,
|
onReroll,
|
||||||
@@ -367,6 +377,8 @@ function RandomSpotlightCard({
|
|||||||
}: {
|
}: {
|
||||||
spotlight: { kind: 'album'; album: Album } | { kind: 'artist'; artist: Artist };
|
spotlight: { kind: 'album'; album: Album } | { kind: 'artist'; artist: Artist };
|
||||||
hasTracks: boolean;
|
hasTracks: boolean;
|
||||||
|
coverSize: number;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
onPlay: () => void;
|
onPlay: () => void;
|
||||||
onShuffle: () => void;
|
onShuffle: () => void;
|
||||||
onReroll: () => void;
|
onReroll: () => void;
|
||||||
@@ -388,7 +400,7 @@ function RandomSpotlightCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.randomCard}
|
style={[styles.randomCard, style]}
|
||||||
android_ripple={ripple.tile}
|
android_ripple={ripple.tile}
|
||||||
unstable_pressDelay={SCROLL_PRESS_DELAY}
|
unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||||
onPress={onOpen}
|
onPress={onOpen}
|
||||||
@@ -397,9 +409,9 @@ function RandomSpotlightCard({
|
|||||||
>
|
>
|
||||||
<View style={styles.randomMain}>
|
<View style={styles.randomMain}>
|
||||||
{spotlight.kind === 'album' ? (
|
{spotlight.kind === 'album' ? (
|
||||||
<AlbumCover album={spotlight.album} size={88} />
|
<AlbumCover album={spotlight.album} size={coverSize} />
|
||||||
) : (
|
) : (
|
||||||
<ArtistCover artist={spotlight.artist} size={88} />
|
<ArtistCover artist={spotlight.artist} size={coverSize} />
|
||||||
)}
|
)}
|
||||||
<View style={styles.randomMeta}>
|
<View style={styles.randomMeta}>
|
||||||
<Text variant="label" color={colors.textTertiary}>
|
<Text variant="label" color={colors.textTertiary}>
|
||||||
@@ -516,6 +528,53 @@ function EmptyHomeCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Home section and the space above it. Sections carry no margin of their
|
||||||
|
* own so the same section can be stacked or seated in a band without either
|
||||||
|
* shape double-paying for it.
|
||||||
|
*/
|
||||||
|
function HomeSection({ children }: { children: ReactNode }) {
|
||||||
|
const styles = useStyles();
|
||||||
|
if (!children) return null;
|
||||||
|
return <View style={styles.section}>{children}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two Home sections of similar weight, seated side by side when the scene is
|
||||||
|
* wide enough and stacked when it isn't.
|
||||||
|
*
|
||||||
|
* Collapses to a single full-width section whenever one half has nothing to
|
||||||
|
* show — otherwise the survivor sits in half a row beside an empty column.
|
||||||
|
*/
|
||||||
|
function HomeBand({
|
||||||
|
paired,
|
||||||
|
stretch = false,
|
||||||
|
primary,
|
||||||
|
secondary,
|
||||||
|
}: {
|
||||||
|
paired: boolean;
|
||||||
|
/** Match the two columns to the taller one. For cards; wrong for row lists. */
|
||||||
|
stretch?: boolean;
|
||||||
|
primary: ReactNode;
|
||||||
|
secondary: ReactNode;
|
||||||
|
}) {
|
||||||
|
const styles = useStyles();
|
||||||
|
if (!paired || !primary || !secondary) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HomeSection>{primary}</HomeSection>
|
||||||
|
<HomeSection>{secondary}</HomeSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View style={[styles.section, styles.band, stretch && styles.bandStretch]}>
|
||||||
|
<View style={styles.bandColumn}>{primary}</View>
|
||||||
|
<View style={styles.bandColumn}>{secondary}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const sceneBottomInset = useSceneBottomInset();
|
const sceneBottomInset = useSceneBottomInset();
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
@@ -543,6 +602,15 @@ export default function HomeScreen() {
|
|||||||
const scrollTop = useScrollTopGate();
|
const scrollTop = useScrollTopGate();
|
||||||
const hasLibrary = totalTrackCount > 0;
|
const hasLibrary = totalTrackCount > 0;
|
||||||
|
|
||||||
|
// Measured, not derived from the window: the player dock takes a column out
|
||||||
|
// of the scene, so a tablet with the dock open has a phone's worth of content
|
||||||
|
// width and has to lay out like one.
|
||||||
|
const [contentWidth, setContentWidth] = useState(0);
|
||||||
|
const measureContent = useCallback((event: LayoutChangeEvent) => {
|
||||||
|
setContentWidth(event.nativeEvent.layout.width);
|
||||||
|
}, []);
|
||||||
|
const home = getHomeLayout(contentWidth);
|
||||||
|
|
||||||
const recentlyAddedAlbums = useMemo(
|
const recentlyAddedAlbums = useMemo(
|
||||||
() => [...albums].sort((a, b) => b.latest_added_at - a.latest_added_at).slice(0, RECENT_ALBUM_LIMIT),
|
() => [...albums].sort((a, b) => b.latest_added_at - a.latest_added_at).slice(0, RECENT_ALBUM_LIMIT),
|
||||||
[albums]
|
[albums]
|
||||||
@@ -594,8 +662,8 @@ export default function HomeScreen() {
|
|||||||
? ({ kind: 'artist', artist: randomArtist } as const)
|
? ({ kind: 'artist', artist: randomArtist } as const)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const recentTracks = recentlyPlayedTracks.slice(0, RECENT_TRACK_LIMIT);
|
const recentTracks = recentlyPlayedTracks.slice(0, home.recentTrackCount);
|
||||||
const canExpandRecentTracks = recentlyPlayedTracks.length > RECENT_TRACK_LIMIT;
|
const canExpandRecentTracks = recentlyPlayedTracks.length > home.recentTrackCount;
|
||||||
|
|
||||||
const openAlbum = (album: Album) => {
|
const openAlbum = (album: Album) => {
|
||||||
openLibrary({ kind: 'album', key: album.identity_key });
|
openLibrary({ kind: 'album', key: album.identity_key });
|
||||||
@@ -650,6 +718,108 @@ export default function HomeScreen() {
|
|||||||
}, [loadListeningPreview]),
|
}, [loadListeningPreview]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const spotlightCard = spotlightContent ? (
|
||||||
|
<RandomSpotlightCard
|
||||||
|
spotlight={spotlightContent}
|
||||||
|
hasTracks={
|
||||||
|
spotlightContent.kind === 'album'
|
||||||
|
? spotlightContent.album.track_count > 0
|
||||||
|
: spotlightContent.artist.track_count > 0
|
||||||
|
}
|
||||||
|
coverSize={home.spotlightCoverSize}
|
||||||
|
style={home.paired ? styles.bandCard : undefined}
|
||||||
|
onOpen={() => spotlightContent.kind === 'album'
|
||||||
|
? openAlbum(spotlightContent.album)
|
||||||
|
: openArtist(spotlightContent.artist)}
|
||||||
|
onPlay={() => playSpotlight()}
|
||||||
|
onShuffle={() => playSpotlight(true)}
|
||||||
|
onReroll={rerollSpotlight}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
// Asked before rendering rather than after: the card returns null on its own
|
||||||
|
// when there is no history, which inside a band would leave an empty column.
|
||||||
|
const listeningCard = hasListeningPreview(listeningPreview) ? (
|
||||||
|
<ListeningPreviewCard
|
||||||
|
dashboard={listeningPreview}
|
||||||
|
style={home.paired ? styles.bandCard : undefined}
|
||||||
|
onPress={() => router.push('/stats' as never)}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const recentlyPlayedSection = recentTracks.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="Recently Played"
|
||||||
|
trailing={formatCount(recentlyPlayedTracks.length, 'track')}
|
||||||
|
actionLabel={canExpandRecentTracks ? 'See all' : undefined}
|
||||||
|
onActionPress={
|
||||||
|
canExpandRecentTracks ? () => router.push('/recently-played') : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<View style={styles.listBlock}>
|
||||||
|
{recentTracks.map((track, index) => (
|
||||||
|
<TrackRow
|
||||||
|
key={track.path}
|
||||||
|
track={track}
|
||||||
|
active={track.path === currentPath}
|
||||||
|
swipeToQueue={false}
|
||||||
|
onPress={() => playRecentlyPlayed(recentTracks, index)}
|
||||||
|
onLongPress={() => setActionTrack(track)}
|
||||||
|
onOpenActions={() => setActionTrack(track)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const recentlyAddedSection = recentlyAddedAlbums.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<SectionHeader title="Recently Added" />
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.albumRail}
|
||||||
|
>
|
||||||
|
{recentlyAddedAlbums.map((album) => (
|
||||||
|
<RecentlyAddedAlbum
|
||||||
|
key={album.identity_key}
|
||||||
|
album={album}
|
||||||
|
size={home.railCoverSize}
|
||||||
|
onPress={() => openAlbum(album)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const playlistsSection = favoriteTracks.length > 0 || homePlaylists.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<SectionHeader title="Favorites & Playlists" />
|
||||||
|
<View style={styles.listBlock}>
|
||||||
|
{favoriteTracks.length > 0 ? (
|
||||||
|
<PlaylistRow
|
||||||
|
name="Favorites"
|
||||||
|
trackCount={favoriteTracks.length}
|
||||||
|
coverHash={favoriteTracks[0]?.artwork_hash ?? null}
|
||||||
|
pinned
|
||||||
|
onPress={() => openLibrary({ kind: 'playlist', id: 'favorites' })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{homePlaylists.map((playlist) => (
|
||||||
|
<PlaylistRow
|
||||||
|
key={playlist.id}
|
||||||
|
name={playlist.name}
|
||||||
|
trackCount={playlist.track_count}
|
||||||
|
missingCount={playlist.missing_track_count}
|
||||||
|
coverHash={playlist.auto_cover_hash}
|
||||||
|
onPress={() => openLibrary({ kind: 'playlist', id: playlist.id })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<PullSearchGesture atTop={scrollTop.atTop} onOpen={openSearch}>
|
<PullSearchGesture atTop={scrollTop.atTop} onOpen={openSearch}>
|
||||||
@@ -657,6 +827,7 @@ export default function HomeScreen() {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
overScrollMode="never"
|
overScrollMode="never"
|
||||||
contentContainerStyle={{ paddingBottom: sceneBottomInset }}
|
contentContainerStyle={{ paddingBottom: sceneBottomInset }}
|
||||||
|
onLayout={measureContent}
|
||||||
onScroll={scrollTop.onScroll}
|
onScroll={scrollTop.onScroll}
|
||||||
scrollEventThrottle={scrollTop.scrollEventThrottle}
|
scrollEventThrottle={scrollTop.scrollEventThrottle}
|
||||||
>
|
>
|
||||||
@@ -669,116 +840,40 @@ export default function HomeScreen() {
|
|||||||
<ScanProgress />
|
<ScanProgress />
|
||||||
|
|
||||||
{!hasLibrary ? (
|
{!hasLibrary ? (
|
||||||
|
<HomeBand
|
||||||
|
paired={home.paired}
|
||||||
|
primary={
|
||||||
|
<EmptyHomeCard
|
||||||
|
scanError={scanError}
|
||||||
|
status={libraryStatus}
|
||||||
|
onManageFolders={() => router.push(
|
||||||
|
libraryStatus === 'fatalUserData' ? '/settings/troubleshooting' : '/settings'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
secondary={listeningCard}
|
||||||
|
/>
|
||||||
|
) : home.paired ? (
|
||||||
|
// Wide: two bands of paired sections around the one section that
|
||||||
|
// genuinely wants the full width. Recently Added moves above
|
||||||
|
// Recently Played here and only here — a rail is the natural break
|
||||||
|
// between two bands, and it has nothing to pair with.
|
||||||
<>
|
<>
|
||||||
<EmptyHomeCard
|
<HomeBand paired stretch primary={spotlightCard} secondary={listeningCard} />
|
||||||
scanError={scanError}
|
<HomeSection>{recentlyAddedSection}</HomeSection>
|
||||||
status={libraryStatus}
|
<HomeBand
|
||||||
onManageFolders={() => router.push(
|
paired
|
||||||
libraryStatus === 'fatalUserData' ? '/settings/troubleshooting' : '/settings'
|
primary={recentlyPlayedSection}
|
||||||
)}
|
secondary={playlistsSection}
|
||||||
/>
|
|
||||||
<ListeningPreviewCard
|
|
||||||
dashboard={listeningPreview}
|
|
||||||
onPress={() => router.push('/stats' as never)}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{spotlightContent ? (
|
<HomeSection>{spotlightCard}</HomeSection>
|
||||||
<View style={styles.topFeature}>
|
<HomeSection>{listeningCard}</HomeSection>
|
||||||
<RandomSpotlightCard
|
<HomeSection>{recentlyPlayedSection}</HomeSection>
|
||||||
spotlight={spotlightContent}
|
<HomeSection>{recentlyAddedSection}</HomeSection>
|
||||||
hasTracks={
|
<HomeSection>{playlistsSection}</HomeSection>
|
||||||
spotlightContent.kind === 'album'
|
|
||||||
? spotlightContent.album.track_count > 0
|
|
||||||
: spotlightContent.artist.track_count > 0
|
|
||||||
}
|
|
||||||
onOpen={() => spotlightContent.kind === 'album'
|
|
||||||
? openAlbum(spotlightContent.album)
|
|
||||||
: openArtist(spotlightContent.artist)}
|
|
||||||
onPlay={() => playSpotlight()}
|
|
||||||
onShuffle={() => playSpotlight(true)}
|
|
||||||
onReroll={rerollSpotlight}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ListeningPreviewCard
|
|
||||||
dashboard={listeningPreview}
|
|
||||||
onPress={() => router.push('/stats' as never)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{recentTracks.length > 0 ? (
|
|
||||||
<View style={styles.section}>
|
|
||||||
<SectionHeader
|
|
||||||
title="Recently Played"
|
|
||||||
trailing={formatCount(recentlyPlayedTracks.length, 'track')}
|
|
||||||
actionLabel={canExpandRecentTracks ? 'See all' : undefined}
|
|
||||||
onActionPress={
|
|
||||||
canExpandRecentTracks ? () => router.push('/recently-played') : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<View style={styles.listBlock}>
|
|
||||||
{recentTracks.map((track, index) => (
|
|
||||||
<TrackRow
|
|
||||||
key={track.path}
|
|
||||||
track={track}
|
|
||||||
active={track.path === currentPath}
|
|
||||||
swipeToQueue={false}
|
|
||||||
onPress={() => playRecentlyPlayed(recentTracks, index)}
|
|
||||||
onLongPress={() => setActionTrack(track)}
|
|
||||||
onOpenActions={() => setActionTrack(track)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{recentlyAddedAlbums.length > 0 ? (
|
|
||||||
<View style={styles.section}>
|
|
||||||
<SectionHeader title="Recently Added" />
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
contentContainerStyle={styles.albumRail}
|
|
||||||
>
|
|
||||||
{recentlyAddedAlbums.map((album) => (
|
|
||||||
<RecentlyAddedAlbum
|
|
||||||
key={album.identity_key}
|
|
||||||
album={album}
|
|
||||||
onPress={() => openAlbum(album)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{favoriteTracks.length > 0 || homePlaylists.length > 0 ? (
|
|
||||||
<View style={styles.section}>
|
|
||||||
<SectionHeader title="Favorites & Playlists" />
|
|
||||||
<View style={styles.listBlock}>
|
|
||||||
{favoriteTracks.length > 0 ? (
|
|
||||||
<PlaylistRow
|
|
||||||
name="Favorites"
|
|
||||||
trackCount={favoriteTracks.length}
|
|
||||||
coverHash={favoriteTracks[0]?.artwork_hash ?? null}
|
|
||||||
pinned
|
|
||||||
onPress={() => openLibrary({ kind: 'playlist', id: 'favorites' })}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{homePlaylists.map((playlist) => (
|
|
||||||
<PlaylistRow
|
|
||||||
key={playlist.id}
|
|
||||||
name={playlist.name}
|
|
||||||
trackCount={playlist.track_count}
|
|
||||||
missingCount={playlist.missing_track_count}
|
|
||||||
coverHash={playlist.auto_cover_hash}
|
|
||||||
onPress={() => openLibrary({ kind: 'playlist', id: playlist.id })}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</PullSearchScrollView>
|
</PullSearchScrollView>
|
||||||
@@ -828,12 +923,26 @@ const useStyles = createThemedStyles((colors) => ({
|
|||||||
borderWidth: StyleSheet.hairlineWidth,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
borderColor: colors.glassBorder,
|
borderColor: colors.glassBorder,
|
||||||
},
|
},
|
||||||
topFeature: {
|
|
||||||
marginTop: spacing.xl,
|
|
||||||
},
|
|
||||||
section: {
|
section: {
|
||||||
marginTop: spacing.xl,
|
marginTop: spacing.xl,
|
||||||
},
|
},
|
||||||
|
band: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
// Row lists end where their content ends; only cards are matched.
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: HOME_COLUMN_GAP,
|
||||||
|
},
|
||||||
|
bandStretch: {
|
||||||
|
alignItems: 'stretch',
|
||||||
|
},
|
||||||
|
bandColumn: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
/** Fills the height a stretched column hands down. */
|
||||||
|
bandCard: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
sectionHeader: {
|
sectionHeader: {
|
||||||
minHeight: 32,
|
minHeight: 32,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -862,7 +971,8 @@ const useStyles = createThemedStyles((colors) => ({
|
|||||||
paddingRight: spacing.lg,
|
paddingRight: spacing.lg,
|
||||||
},
|
},
|
||||||
recentAlbum: {
|
recentAlbum: {
|
||||||
width: 112,
|
// Width comes from the layout — the rail keeps the full scene width, so its
|
||||||
|
// tiles are what grows when the scene does.
|
||||||
},
|
},
|
||||||
recentAlbumTitle: {
|
recentAlbumTitle: {
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
@@ -891,6 +1001,9 @@ const useStyles = createThemedStyles((colors) => ({
|
|||||||
},
|
},
|
||||||
randomCard: {
|
randomCard: {
|
||||||
minHeight: 112,
|
minHeight: 112,
|
||||||
|
// Centres the cover and meta in whatever height a stretched band hands
|
||||||
|
// down; a no-op when the card is sizing itself.
|
||||||
|
justifyContent: 'center',
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
backgroundColor: colors.glassBg,
|
backgroundColor: colors.glassBg,
|
||||||
borderColor: colors.glassBorder,
|
borderColor: colors.glassBorder,
|
||||||
@@ -953,7 +1066,6 @@ const useStyles = createThemedStyles((colors) => ({
|
|||||||
backgroundColor: colors.bgPrimary,
|
backgroundColor: colors.bgPrimary,
|
||||||
},
|
},
|
||||||
emptyCard: {
|
emptyCard: {
|
||||||
marginTop: spacing.xl,
|
|
||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
backgroundColor: colors.glassBg,
|
backgroundColor: colors.glassBg,
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Text } from '@/components/Text';
|
||||||
|
import { HapticSwitch } from '@/components/HapticSwitch';
|
||||||
|
import { radius, spacing } from '@/theme';
|
||||||
|
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||||
|
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
||||||
|
import {
|
||||||
|
EQ_MAX_GAIN_DB,
|
||||||
|
isPassEQBandType,
|
||||||
|
isShelfEQBandType,
|
||||||
|
} from '@/audio/eq';
|
||||||
|
import type { EQBand } from '@/types/audio';
|
||||||
|
import { VerticalEQSlider } from './VerticalEQSlider';
|
||||||
|
import { CONSOLE_ADD_WIDTH } from './eqLayout';
|
||||||
|
import type { EQEditableValue } from './BandDetailPanel';
|
||||||
|
import {
|
||||||
|
BAND_TYPE_LABEL,
|
||||||
|
formatFreq,
|
||||||
|
formatGain,
|
||||||
|
gainColor,
|
||||||
|
} from './format';
|
||||||
|
|
||||||
|
interface BandConsoleProps {
|
||||||
|
bands: EQBand[];
|
||||||
|
activeBandId: string | null;
|
||||||
|
canAdd: boolean;
|
||||||
|
stripWidth: number;
|
||||||
|
railHeight: number;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
onUpdate: (id: string, updates: Partial<EQBand>) => void;
|
||||||
|
onEditType: (id: string) => void;
|
||||||
|
onEditValue: (id: string, value: EQEditableValue) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wide-window band editor: one vertical strip per band, in array order.
|
||||||
|
*
|
||||||
|
* This is `BandStrip` and `BandDetailPanel` welded back together. The phone
|
||||||
|
* splits one band across two components because it cannot show them at once —
|
||||||
|
* the chip carries frequency and gain, the panel below carries everything else
|
||||||
|
* for whichever chip you tapped. Given width, a strip carries the whole band,
|
||||||
|
* and every band's values are readable without selecting anything.
|
||||||
|
*
|
||||||
|
* Horizontal, not a table, because the graph directly above it is a function of
|
||||||
|
* frequency: a top-to-bottom list would fight the axis it belongs to. Strips
|
||||||
|
* stay in **array order** rather than sorted by frequency — dragging a node past
|
||||||
|
* its neighbour must never slide a control out from under your finger, and the
|
||||||
|
* band number carries the correspondence to the curve instead.
|
||||||
|
*
|
||||||
|
* Gain is a rail rather than a horizontal slider so the console shares its shape
|
||||||
|
* with the graphic-mode panel, which is already a row of `VerticalEQSlider`s.
|
||||||
|
* Frequency is dragged on the graph; frequency and Q are tap-to-edit here, which
|
||||||
|
* opens the same sheets the detail panel opens.
|
||||||
|
*/
|
||||||
|
export function BandConsole({
|
||||||
|
bands,
|
||||||
|
activeBandId,
|
||||||
|
canAdd,
|
||||||
|
stripWidth,
|
||||||
|
railHeight,
|
||||||
|
onSelect,
|
||||||
|
onAdd,
|
||||||
|
onUpdate,
|
||||||
|
onEditType,
|
||||||
|
onEditValue,
|
||||||
|
}: BandConsoleProps) {
|
||||||
|
const styles = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
const ripple = useRipple();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
>
|
||||||
|
{bands.map((band, index) => {
|
||||||
|
const isActive = band.id === activeBandId;
|
||||||
|
const isPass = isPassEQBandType(band.type);
|
||||||
|
const isShelf = isShelfEQBandType(band.type);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={band.id}
|
||||||
|
android_ripple={ripple.bounded}
|
||||||
|
unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||||
|
style={[styles.strip, { width: stripWidth }, isActive && styles.stripActive]}
|
||||||
|
// Selecting a band highlights its node on the curve. The strip is
|
||||||
|
// the whole target, the way the chip is on a phone; the controls
|
||||||
|
// inside it take precedence where they overlap.
|
||||||
|
onPress={() => onSelect(band.id)}
|
||||||
|
accessibilityLabel={`Band ${index + 1}`}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text variant="label" color={isActive ? colors.accentText : colors.textSecondary}>
|
||||||
|
{index + 1}
|
||||||
|
</Text>
|
||||||
|
<HapticSwitch
|
||||||
|
value={band.enabled}
|
||||||
|
onValueChange={(enabled) => onUpdate(band.id, { enabled })}
|
||||||
|
trackColor={{ false: colors.glassBorder, true: colors.accent }}
|
||||||
|
thumbColor={colors.textPrimary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
android_ripple={ripple.bounded}
|
||||||
|
unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||||
|
style={styles.typeButton}
|
||||||
|
onPress={() => onEditType(band.id)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Band ${index + 1} filter type, ${BAND_TYPE_LABEL[band.type]}`}
|
||||||
|
>
|
||||||
|
<Text variant="caption" color={colors.textPrimary} numberOfLines={1}>
|
||||||
|
{BAND_TYPE_LABEL[band.type]}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-down" size={12} color={colors.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
A pass filter has no gain to set. Keeping the rail in place rather
|
||||||
|
than collapsing it holds every strip's readouts on the same line —
|
||||||
|
a console whose rows drift by band type is unreadable at a glance.
|
||||||
|
*/}
|
||||||
|
<View
|
||||||
|
style={[styles.rail, { height: railHeight }, isPass && styles.railDisabled]}
|
||||||
|
pointerEvents={isPass ? 'none' : 'auto'}
|
||||||
|
onTouchStart={() => {
|
||||||
|
if (!isActive) onSelect(band.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VerticalEQSlider
|
||||||
|
label={`Band ${index + 1} gain`}
|
||||||
|
value={isPass ? 0 : band.gain}
|
||||||
|
min={-EQ_MAX_GAIN_DB}
|
||||||
|
max={EQ_MAX_GAIN_DB}
|
||||||
|
onChange={(gain) => onUpdate(band.id, { gain })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Readout
|
||||||
|
label="dB"
|
||||||
|
value={isPass ? '—' : formatGain(band.gain)}
|
||||||
|
emphasis
|
||||||
|
color={band.enabled && !isPass ? gainColor(band.gain, colors) : colors.textTertiary}
|
||||||
|
onPress={isPass ? undefined : () => onEditValue(band.id, 'gain')}
|
||||||
|
/>
|
||||||
|
<Readout
|
||||||
|
label="Hz"
|
||||||
|
value={formatFreq(band.frequency)}
|
||||||
|
onPress={() => onEditValue(band.id, 'frequency')}
|
||||||
|
/>
|
||||||
|
<Readout
|
||||||
|
label="Q"
|
||||||
|
value={isShelf ? '—' : band.Q.toFixed(2)}
|
||||||
|
onPress={isShelf ? undefined : () => onEditValue(band.id, 'Q')}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{canAdd ? (
|
||||||
|
<Pressable
|
||||||
|
android_ripple={ripple.bounded}
|
||||||
|
unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||||
|
onPress={onAdd}
|
||||||
|
style={[styles.strip, styles.addStrip, { width: CONSOLE_ADD_WIDTH }]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Add band"
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={22} color={colors.accentText} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One value line. Tapping opens the exact-value sheet, the same path the detail
|
||||||
|
* panel's value boxes take; a dash means the filter type has no such parameter.
|
||||||
|
*/
|
||||||
|
function Readout({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color,
|
||||||
|
emphasis = false,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
color?: string;
|
||||||
|
emphasis?: boolean;
|
||||||
|
onPress?: () => void;
|
||||||
|
}) {
|
||||||
|
const styles = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
const ripple = useRipple();
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
variant={emphasis ? 'body' : 'caption'}
|
||||||
|
color={color ?? colors.textPrimary}
|
||||||
|
style={styles.readoutValue}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color={colors.textTertiary}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
if (!onPress) {
|
||||||
|
return <View style={styles.readout}>{body}</View>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
android_ripple={ripple.bounded}
|
||||||
|
unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||||
|
style={styles.readout}
|
||||||
|
onPress={onPress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`${value} ${label}`}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = createThemedStyles((colors) => ({
|
||||||
|
content: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
},
|
||||||
|
strip: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
backgroundColor: colors.glassBg,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
},
|
||||||
|
stripActive: {
|
||||||
|
borderColor: colors.accent,
|
||||||
|
backgroundColor: colors.glassHighlight,
|
||||||
|
},
|
||||||
|
addStrip: {
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
typeButton: {
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
},
|
||||||
|
rail: {
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
marginVertical: spacing.xs,
|
||||||
|
},
|
||||||
|
railDisabled: {
|
||||||
|
opacity: 0.35,
|
||||||
|
},
|
||||||
|
readout: {
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
paddingVertical: 1,
|
||||||
|
},
|
||||||
|
readoutValue: {
|
||||||
|
fontVariant: ['tabular-nums'],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default BandConsole;
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { getEQLayout } from './eqLayout.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inset-adjusted windows the EQ actually runs in. The screen measures window
|
||||||
|
* minus safe areas, so these are what `getEQLayout` receives.
|
||||||
|
*/
|
||||||
|
const PHONE_PORTRAIT = { w: 412, h: 850 };
|
||||||
|
const PHONE_LANDSCAPE = { w: 915, h: 411 };
|
||||||
|
const SMALL_PHONE_LANDSCAPE = { w: 640, h: 340 };
|
||||||
|
const FOLD_COVER = { w: 340, h: 800 };
|
||||||
|
const FOLD_OPEN = { w: 840, h: 900 };
|
||||||
|
const TABLET_PORTRAIT = { w: 800, h: 1180 };
|
||||||
|
const TABLET_LANDSCAPE = { w: 1280, h: 800 };
|
||||||
|
/** A tablet in landscape with the player dock open — the dock takes 460dp. */
|
||||||
|
const TABLET_LANDSCAPE_DOCKED = { w: 820, h: 800 };
|
||||||
|
|
||||||
|
test('stacks the graph over the controls wherever there is height to do it', () => {
|
||||||
|
for (const win of [
|
||||||
|
PHONE_PORTRAIT,
|
||||||
|
FOLD_COVER,
|
||||||
|
FOLD_OPEN,
|
||||||
|
TABLET_PORTRAIT,
|
||||||
|
TABLET_LANDSCAPE,
|
||||||
|
TABLET_LANDSCAPE_DOCKED,
|
||||||
|
]) {
|
||||||
|
assert.equal(getEQLayout(win.w, win.h).panes, 'stacked', `${win.w}x${win.h}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('splits into two panes only where stacking would not fit', () => {
|
||||||
|
// The rule is scarce height, not landscape: a tablet in landscape stacks.
|
||||||
|
for (const win of [PHONE_LANDSCAPE, SMALL_PHONE_LANDSCAPE]) {
|
||||||
|
assert.equal(getEQLayout(win.w, win.h).panes, 'split', `${win.w}x${win.h}`);
|
||||||
|
}
|
||||||
|
assert.equal(getEQLayout(TABLET_LANDSCAPE.w, TABLET_LANDSCAPE.h).panes, 'stacked');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never splits into a pane too narrow to edit in', () => {
|
||||||
|
// A short *and* narrow window has no good answer; stacking cramped beats
|
||||||
|
// splitting into something unusable.
|
||||||
|
for (let width = 0; width < 600; width += 10) {
|
||||||
|
assert.equal(getEQLayout(width, 380).panes, 'stacked', `width ${width}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a phone sees exactly the editor it has today, in both orientations', () => {
|
||||||
|
assert.equal(getEQLayout(PHONE_PORTRAIT.w, PHONE_PORTRAIT.h).editor, 'strip');
|
||||||
|
assert.equal(getEQLayout(PHONE_LANDSCAPE.w, PHONE_LANDSCAPE.h).editor, 'strip');
|
||||||
|
assert.equal(getEQLayout(FOLD_COVER.w, FOLD_COVER.h).editor, 'strip');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gives tablets and unfolded foldables the console', () => {
|
||||||
|
for (const win of [FOLD_OPEN, TABLET_PORTRAIT, TABLET_LANDSCAPE, TABLET_LANDSCAPE_DOCKED]) {
|
||||||
|
assert.equal(getEQLayout(win.w, win.h).editor, 'console', `${win.w}x${win.h}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the console only appears where four strips and the add cell fit', () => {
|
||||||
|
for (let width = 0; width <= 2000; width += 1) {
|
||||||
|
for (const height of [400, 700, 1100]) {
|
||||||
|
const layout = getEQLayout(width, height);
|
||||||
|
if (layout.editor !== 'console') continue;
|
||||||
|
const editorWidth = layout.panes === 'split' ? layout.sidePaneWidth : width - 32;
|
||||||
|
const seated = (editorWidth - 56) / (layout.stripWidth + 8);
|
||||||
|
assert.ok(
|
||||||
|
seated >= 4,
|
||||||
|
`${width}x${height} seats ${seated.toFixed(2)} strips in ${editorWidth}dp`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a split pane never grows wide enough to hold a console', () => {
|
||||||
|
// The console is a stacked-layout answer by construction: the side pane is
|
||||||
|
// capped well below the console's minimum. If that ever stops being true the
|
||||||
|
// two knobs would interact, which is exactly what this module exists to avoid.
|
||||||
|
for (let width = 600; width <= 2000; width += 1) {
|
||||||
|
const layout = getEQLayout(width, 400);
|
||||||
|
assert.equal(layout.panes, 'split', `width ${width}`);
|
||||||
|
assert.equal(layout.editor, 'strip', `width ${width}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the console height is a sum of declared parts, rail included', () => {
|
||||||
|
for (const win of [FOLD_OPEN, TABLET_PORTRAIT, TABLET_LANDSCAPE]) {
|
||||||
|
const layout = getEQLayout(win.w, win.h);
|
||||||
|
assert.equal(layout.consoleHeight - layout.railHeight, 156, `${win.w}x${win.h}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shortens the gain rail rather than the graph on shorter windows', () => {
|
||||||
|
const tall = getEQLayout(TABLET_PORTRAIT.w, TABLET_PORTRAIT.h);
|
||||||
|
const short = getEQLayout(TABLET_LANDSCAPE.w, TABLET_LANDSCAPE.h);
|
||||||
|
assert.ok(short.railHeight < tall.railHeight);
|
||||||
|
assert.ok(short.consoleHeight < tall.consoleHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both knobs are monotonic — growing a window never takes capability away', () => {
|
||||||
|
let seenConsole = false;
|
||||||
|
for (let width = 0; width <= 2000; width += 1) {
|
||||||
|
const console_ = getEQLayout(width, 1000).editor === 'console';
|
||||||
|
if (console_) seenConsole = true;
|
||||||
|
assert.ok(!(seenConsole && !console_), `console lost again at width ${width}`);
|
||||||
|
}
|
||||||
|
let seenStacked = false;
|
||||||
|
for (let height = 2000; height >= 0; height -= 1) {
|
||||||
|
const stacked = getEQLayout(1280, height).panes === 'stacked';
|
||||||
|
if (!stacked) seenStacked = true;
|
||||||
|
assert.ok(!(seenStacked && stacked), `stacked again at height ${height}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { spacing } from '../../theme/spacing.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equalizer shell geometry.
|
||||||
|
*
|
||||||
|
* The screen used to make one decision — `isWideWindow`, i.e. "is this landscape
|
||||||
|
* and at least 600dp" — and hang both its layout and its editor off it. That is
|
||||||
|
* a phone-in-landscape rule, and it answered two unrelated questions with one
|
||||||
|
* boolean: *can the graph and the controls stack*, and *how much can the editor
|
||||||
|
* show at once*. A tablet got the phone-landscape layout scaled up, and a tablet
|
||||||
|
* in portrait got the phone layout stretched.
|
||||||
|
*
|
||||||
|
* They are separate questions, so they are separate knobs here:
|
||||||
|
*
|
||||||
|
* - **panes** — can the graph sit above the controls, the way it does on a
|
||||||
|
* phone? That is a question about *height*, not orientation, and it is the
|
||||||
|
* same question the navigation rail answers. A phone in landscape has ~411dp
|
||||||
|
* and genuinely cannot stack; a tablet in landscape has plenty and should look
|
||||||
|
* like the phone does.
|
||||||
|
* - **editor** — chip strip plus a detail panel for the selected band, or a
|
||||||
|
* console of full per-band strips? That depends on the width the *editor*
|
||||||
|
* gets, which is the side pane when split and the whole scene when stacked.
|
||||||
|
*/
|
||||||
|
export type EQPaneMode = 'stacked' | 'split';
|
||||||
|
export type EQEditorMode = 'strip' | 'console';
|
||||||
|
|
||||||
|
export interface EQLayout {
|
||||||
|
panes: EQPaneMode;
|
||||||
|
editor: EQEditorMode;
|
||||||
|
/** Width of the editing column. 0 when stacked — it gets the full scene. */
|
||||||
|
sidePaneWidth: number;
|
||||||
|
/** Width of one console strip. */
|
||||||
|
stripWidth: number;
|
||||||
|
/** Height of a console strip's gain rail. */
|
||||||
|
railHeight: number;
|
||||||
|
/** Total height of the console block, rail included. */
|
||||||
|
consoleHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Below this the graph and the controls cannot both be useful stacked. */
|
||||||
|
const STACK_MIN_HEIGHT = 600;
|
||||||
|
/** Below this a split pane is too cramped to edit in, whatever the height. */
|
||||||
|
const SPLIT_MIN_WIDTH = 600;
|
||||||
|
|
||||||
|
const SIDE_PANE_MIN_WIDTH = 280;
|
||||||
|
const SIDE_PANE_MAX_WIDTH = 360;
|
||||||
|
const SIDE_PANE_WIDTH_RATIO = 0.4;
|
||||||
|
|
||||||
|
/** Screen gutter either side of a stacked editor. */
|
||||||
|
const SCENE_GUTTER = spacing.lg;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One console strip: band number and enable switch, the filter type, a gain
|
||||||
|
* rail, and the three readouts under it.
|
||||||
|
*/
|
||||||
|
const STRIP_WIDTH = 152;
|
||||||
|
const STRIP_GAP = spacing.sm;
|
||||||
|
/** Trailing dashed "add band" cell. Narrower — it holds one glyph. */
|
||||||
|
export const CONSOLE_ADD_WIDTH = 56;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips the console must be able to seat before it is worth having.
|
||||||
|
*
|
||||||
|
* Below four, a console is strictly worse than the chip strip it replaces: you
|
||||||
|
* would scroll sideways to reach a band whose parameters the detail panel would
|
||||||
|
* have shown you in place. The whole point of the console is seeing several
|
||||||
|
* bands' values at once.
|
||||||
|
*/
|
||||||
|
const CONSOLE_MIN_STRIPS = 4;
|
||||||
|
|
||||||
|
const CONSOLE_MIN_WIDTH =
|
||||||
|
STRIP_WIDTH * CONSOLE_MIN_STRIPS +
|
||||||
|
STRIP_GAP * CONSOLE_MIN_STRIPS +
|
||||||
|
CONSOLE_ADD_WIDTH;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything in a strip that is not the rail: the header row, the type button,
|
||||||
|
* three readout rows, and the padding around them. Declared rather than
|
||||||
|
* measured, so the console's height is a sum of constants and the rail is the
|
||||||
|
* only thing that flexes — the rigid-deck rule.
|
||||||
|
*/
|
||||||
|
const STRIP_CHROME_HEIGHT = 156;
|
||||||
|
const RAIL_HEIGHT_REGULAR = 148;
|
||||||
|
const RAIL_HEIGHT_COMPACT = 104;
|
||||||
|
/**
|
||||||
|
* Above this the taller rail still leaves the graph a useful share.
|
||||||
|
*
|
||||||
|
* Set from the other direction: a tablet in landscape is ~800dp tall, and once
|
||||||
|
* the header, mode switcher, preset row and preamp are paid for, the regular
|
||||||
|
* rail would leave the response curve about 230dp. The rail concedes first —
|
||||||
|
* the graph is the thing you are reading.
|
||||||
|
*/
|
||||||
|
const RAIL_REGULAR_MIN_HEIGHT = 900;
|
||||||
|
|
||||||
|
export function getEQLayout(availableWidth: number, availableHeight: number): EQLayout {
|
||||||
|
const split = availableHeight < STACK_MIN_HEIGHT && availableWidth >= SPLIT_MIN_WIDTH;
|
||||||
|
const sidePaneWidth = split
|
||||||
|
? Math.min(
|
||||||
|
SIDE_PANE_MAX_WIDTH,
|
||||||
|
Math.max(SIDE_PANE_MIN_WIDTH, Math.round(availableWidth * SIDE_PANE_WIDTH_RATIO))
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
const editorWidth = split ? sidePaneWidth : availableWidth - SCENE_GUTTER * 2;
|
||||||
|
const railHeight =
|
||||||
|
availableHeight >= RAIL_REGULAR_MIN_HEIGHT ? RAIL_HEIGHT_REGULAR : RAIL_HEIGHT_COMPACT;
|
||||||
|
return {
|
||||||
|
panes: split ? 'split' : 'stacked',
|
||||||
|
editor: editorWidth >= CONSOLE_MIN_WIDTH ? 'console' : 'strip',
|
||||||
|
sidePaneWidth,
|
||||||
|
stripWidth: STRIP_WIDTH,
|
||||||
|
railHeight,
|
||||||
|
consoleHeight: STRIP_CHROME_HEIGHT + railHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { Pressable, StyleSheet, View } from 'react-native';
|
import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
|
||||||
import { AstraLogo } from '@/components/AstraLogo';
|
import { AstraLogo } from '@/components/AstraLogo';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
import { listeningArtworkSource } from '@/library/artwork';
|
import { listeningArtworkSource } from '@/library/artwork';
|
||||||
@@ -10,17 +10,28 @@ import { createThemedStyles, useColors } from '@/theme/themed';
|
|||||||
import { useRipple } from '@/theme/ripple';
|
import { useRipple } from '@/theme/ripple';
|
||||||
import type { ListeningStatsDashboard } from '@/types/listeningStats';
|
import type { ListeningStatsDashboard } from '@/types/listeningStats';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the card has something to draw. Callers seating it in a layout need
|
||||||
|
* this *before* rendering: a card that returns null still occupies a column, so
|
||||||
|
* the row beside it would sit half empty rather than closing up.
|
||||||
|
*/
|
||||||
|
export function hasListeningPreview(dashboard: ListeningStatsDashboard | null): boolean {
|
||||||
|
return Boolean(dashboard?.status.startedAt);
|
||||||
|
}
|
||||||
|
|
||||||
export function ListeningPreviewCard({
|
export function ListeningPreviewCard({
|
||||||
dashboard,
|
dashboard,
|
||||||
onPress,
|
onPress,
|
||||||
|
style,
|
||||||
}: {
|
}: {
|
||||||
dashboard: ListeningStatsDashboard | null;
|
dashboard: ListeningStatsDashboard | null;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
}) {
|
}) {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const ripple = useRipple();
|
const ripple = useRipple();
|
||||||
if (!dashboard?.status.startedAt) return null;
|
if (!hasListeningPreview(dashboard) || !dashboard) return null;
|
||||||
|
|
||||||
const topTrack = dashboard.topTracks[0] ?? null;
|
const topTrack = dashboard.topTracks[0] ?? null;
|
||||||
const artwork = topTrack ? listeningArtworkSource(topTrack, true) : null;
|
const artwork = topTrack ? listeningArtworkSource(topTrack, true) : null;
|
||||||
@@ -28,7 +39,7 @@ export function ListeningPreviewCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.card}
|
style={[styles.card, style]}
|
||||||
android_ripple={ripple.tile}
|
android_ripple={ripple.tile}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -98,7 +109,9 @@ export function ListeningPreviewCard({
|
|||||||
|
|
||||||
const useStyles = createThemedStyles((colors) => ({
|
const useStyles = createThemedStyles((colors) => ({
|
||||||
card: {
|
card: {
|
||||||
marginTop: spacing.xl,
|
// No outer margin: the card is seated by whatever lays it out (Home puts it
|
||||||
|
// in a band beside the spotlight card), and a margin of its own would
|
||||||
|
// double up against that band's spacing.
|
||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { getHomeLayout, homeColumnWidth, HOME_COLUMN_GAP } from './homeLayout.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content widths Home actually gets, measured off the shell: window width,
|
||||||
|
* minus the rail when it is up, minus the screen gutters. Same reference points
|
||||||
|
* as `library/libraryLayout.test.mts`, which is measured the same way.
|
||||||
|
*/
|
||||||
|
const PHONE_PORTRAIT = 380;
|
||||||
|
const PHONE_LANDSCAPE = 695;
|
||||||
|
const FOLD_COVER = 340;
|
||||||
|
const FOLD_OPEN_PORTRAIT = 808;
|
||||||
|
const TABLET_PORTRAIT = 768;
|
||||||
|
const TABLET_LANDSCAPE = 1200;
|
||||||
|
/** A 1200dp tablet with the player dock open: the dock takes 460 of the scene. */
|
||||||
|
const TABLET_LANDSCAPE_DOCKED = 740;
|
||||||
|
/** The narrowest a dock can leave a scene, from `DOCK_MIN_SCENE_WIDTH`. */
|
||||||
|
const DOCKED_MINIMUM = 420 - 32;
|
||||||
|
|
||||||
|
test('stacks Home full width on every phone-shaped scene', () => {
|
||||||
|
for (const width of [FOLD_COVER, PHONE_PORTRAIT, DOCKED_MINIMUM, 600]) {
|
||||||
|
assert.equal(getHomeLayout(width).paired, false, `width ${width}`);
|
||||||
|
assert.equal(homeColumnWidth(width), width, `width ${width}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pairs sections once both halves clear a phone-shaped column', () => {
|
||||||
|
for (const width of [
|
||||||
|
PHONE_LANDSCAPE,
|
||||||
|
TABLET_PORTRAIT,
|
||||||
|
FOLD_OPEN_PORTRAIT,
|
||||||
|
TABLET_LANDSCAPE_DOCKED,
|
||||||
|
TABLET_LANDSCAPE,
|
||||||
|
]) {
|
||||||
|
assert.equal(getHomeLayout(width).paired, true, `width ${width}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never pairs into a column narrower than the phone layout it reuses', () => {
|
||||||
|
// The whole premise is that a column is a phone section in a narrower box.
|
||||||
|
// If pairing can produce something narrower than the sections were drawn
|
||||||
|
// against, the threshold is wrong rather than the sections.
|
||||||
|
for (let width = 0; width <= 2000; width += 1) {
|
||||||
|
if (!getHomeLayout(width).paired) continue;
|
||||||
|
assert.ok(
|
||||||
|
homeColumnWidth(width) >= 320,
|
||||||
|
`paired at ${width} into ${homeColumnWidth(width)}dp columns`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pairing is monotonic in width — no scene loses a column by growing', () => {
|
||||||
|
let seenPaired = false;
|
||||||
|
for (let width = 0; width <= 2000; width += 1) {
|
||||||
|
const paired = getHomeLayout(width).paired;
|
||||||
|
if (paired) seenPaired = true;
|
||||||
|
assert.ok(!(seenPaired && !paired), `unpaired again at ${width}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the two columns and their gutter account for the whole scene', () => {
|
||||||
|
for (const width of [PHONE_LANDSCAPE, TABLET_PORTRAIT, TABLET_LANDSCAPE]) {
|
||||||
|
assert.equal(homeColumnWidth(width) * 2 + HOME_COLUMN_GAP, width, `width ${width}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a phone in portrait sees exactly the layout it has today', () => {
|
||||||
|
const phone = getHomeLayout(PHONE_PORTRAIT);
|
||||||
|
assert.deepEqual(phone, {
|
||||||
|
paired: false,
|
||||||
|
spotlightCoverSize: 88,
|
||||||
|
railCoverSize: 112,
|
||||||
|
recentTrackCount: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats an unmeasured scene as a phone rather than guessing wide', () => {
|
||||||
|
// First frame, before `onLayout`. Falling back to the wide shape would show a
|
||||||
|
// two-column band that collapses a frame later.
|
||||||
|
for (const width of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||||
|
assert.equal(getHomeLayout(width).paired, false, `width ${width}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grows the spotlight cover in declared tiers, never past its column', () => {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
for (let width = 0; width <= 2000; width += 1) {
|
||||||
|
const { spotlightCoverSize } = getHomeLayout(width);
|
||||||
|
seen.add(spotlightCoverSize);
|
||||||
|
// Cover, the card's own padding, and the gap before the meta. What is left
|
||||||
|
// has to seat three 36dp action buttons and their gaps.
|
||||||
|
const meta = homeColumnWidth(width) - spotlightCoverSize - 12 * 3;
|
||||||
|
if (getHomeLayout(width).paired) {
|
||||||
|
assert.ok(meta >= 124, `cover ${spotlightCoverSize} leaves ${meta}dp at ${width}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual([...seen].sort((a, b) => a - b), [88, 112, 144]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spends extra width on rail tiles and recent rows, not on stretching rows', () => {
|
||||||
|
const phone = getHomeLayout(PHONE_PORTRAIT);
|
||||||
|
const tablet = getHomeLayout(TABLET_LANDSCAPE);
|
||||||
|
assert.ok(tablet.railCoverSize > phone.railCoverSize);
|
||||||
|
assert.ok(tablet.recentTrackCount > phone.recentTrackCount);
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { spacing } from '../theme/spacing.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home's response to a wide scene.
|
||||||
|
*
|
||||||
|
* Home is a stack of sections that were all proportioned against a phone in
|
||||||
|
* portrait. Handed a tablet they don't break, they just stop meaning anything:
|
||||||
|
* a `TrackRow` spanning 1200dp puts the title at the far left and the duration
|
||||||
|
* at the far right with nothing between them, and the spotlight card becomes an
|
||||||
|
* 88dp cover marooned in a very long box.
|
||||||
|
*
|
||||||
|
* The fix is not to invent tablet sections. It is to notice that the sections
|
||||||
|
* come in pairs of similar weight — two cards, then two row lists — and to seat
|
||||||
|
* each pair side by side once the scene can give both halves a phone-shaped
|
||||||
|
* column. Every section keeps its own component, header and behaviour; only the
|
||||||
|
* column it lives in changes.
|
||||||
|
*
|
||||||
|
* Keyed on **content width, not the window**: the player dock claims up to
|
||||||
|
* 520dp of the scene, so a tablet drops back to one column the moment the dock
|
||||||
|
* opens. Callers measure and pass what they actually got.
|
||||||
|
*/
|
||||||
|
export interface HomeLayout {
|
||||||
|
/** Sections seat two-up rather than stacking full width. */
|
||||||
|
paired: boolean;
|
||||||
|
/** Square cover on the random spotlight card. */
|
||||||
|
spotlightCoverSize: number;
|
||||||
|
/** Square cover on each Recently Added rail tile. */
|
||||||
|
railCoverSize: number;
|
||||||
|
/** Rows to show under Recently Played. */
|
||||||
|
recentTrackCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrowest a column may be and still read as a home section rather than a
|
||||||
|
* squeezed one.
|
||||||
|
*
|
||||||
|
* A phone in portrait gives these sections 380dp, so 320 is the same shape with
|
||||||
|
* less air — a `TrackRow` loses nothing at that width. It is set low
|
||||||
|
* deliberately: a phone in landscape has ~695dp of content and only ~411dp of
|
||||||
|
* height, which is the window that needs two-up *most*, and a threshold tuned
|
||||||
|
* for tablets would miss it. As with the navigation rail, the trigger is a
|
||||||
|
* measurement, not a device class.
|
||||||
|
*/
|
||||||
|
const PAIR_MIN_COLUMN_WIDTH = 320;
|
||||||
|
|
||||||
|
/** Gutter between the two columns. Exported so the row and the fit agree. */
|
||||||
|
export const HOME_COLUMN_GAP = spacing.lg;
|
||||||
|
|
||||||
|
const PAIR_MIN_CONTENT_WIDTH = PAIR_MIN_COLUMN_WIDTH * 2 + HOME_COLUMN_GAP;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The spotlight cover grows when paired so the card earns the height its
|
||||||
|
* neighbour sets — left at 88 it is a short wide card beside a tall one, and
|
||||||
|
* the row reads as two unrelated things rather than one band.
|
||||||
|
*
|
||||||
|
* Two declared tiers rather than a ratio of the column: the meta beside the
|
||||||
|
* cover has to seat three action buttons (124dp) plus a readable title, so a
|
||||||
|
* cover that scales freely eats the title long before it runs out of column.
|
||||||
|
*/
|
||||||
|
const SPOTLIGHT_COVER_WIDTH = 88;
|
||||||
|
const SPOTLIGHT_COVER_WIDTH_PAIRED = 112;
|
||||||
|
const SPOTLIGHT_COVER_WIDTH_WIDE = 144;
|
||||||
|
/** Column width at which the wide cover still leaves the meta room to breathe. */
|
||||||
|
const SPOTLIGHT_WIDE_COLUMN_WIDTH = 420;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rail tiles grow too, for a different reason: the rail is the one section that
|
||||||
|
* keeps the full width, and 112dp tiles across 1200dp read as a filmstrip of
|
||||||
|
* thumbnails rather than a shelf of albums.
|
||||||
|
*/
|
||||||
|
const RAIL_COVER_WIDTH = 112;
|
||||||
|
const RAIL_COVER_WIDTH_PAIRED = 140;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recently Played sits beside Favorites & Playlists, which can show five rows.
|
||||||
|
* Three against five leaves the band visibly lopsided, and the extra rows are
|
||||||
|
* free — they come from a list already in memory.
|
||||||
|
*/
|
||||||
|
const RECENT_TRACK_COUNT = 3;
|
||||||
|
const RECENT_TRACK_COUNT_PAIRED = 5;
|
||||||
|
|
||||||
|
/** Width one column gets at `contentWidth`. Full width when unpaired. */
|
||||||
|
export function homeColumnWidth(contentWidth: number): number {
|
||||||
|
if (!isPaired(contentWidth)) return contentWidth;
|
||||||
|
return (contentWidth - HOME_COLUMN_GAP) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPaired(contentWidth: number): boolean {
|
||||||
|
// Before first layout there is nothing to measure. Fall back to the phone
|
||||||
|
// shape so the first frame is never the wide one collapsing.
|
||||||
|
return Number.isFinite(contentWidth) && contentWidth >= PAIR_MIN_CONTENT_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHomeLayout(contentWidth: number): HomeLayout {
|
||||||
|
const paired = isPaired(contentWidth);
|
||||||
|
const spotlightCoverSize = !paired
|
||||||
|
? SPOTLIGHT_COVER_WIDTH
|
||||||
|
: homeColumnWidth(contentWidth) >= SPOTLIGHT_WIDE_COLUMN_WIDTH
|
||||||
|
? SPOTLIGHT_COVER_WIDTH_WIDE
|
||||||
|
: SPOTLIGHT_COVER_WIDTH_PAIRED;
|
||||||
|
return {
|
||||||
|
paired,
|
||||||
|
spotlightCoverSize,
|
||||||
|
railCoverSize: paired ? RAIL_COVER_WIDTH_PAIRED : RAIL_COVER_WIDTH,
|
||||||
|
recentTrackCount: paired ? RECENT_TRACK_COUNT_PAIRED : RECENT_TRACK_COUNT,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user