mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-21 21:16:09 +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: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-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: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",
|
||||
@@ -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: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: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: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",
|
||||
|
||||
@@ -11,6 +11,7 @@ const TEST_SCRIPTS = [
|
||||
'test:eq-share',
|
||||
'test:signal',
|
||||
'test:eq-math',
|
||||
'test:eq-layout',
|
||||
'test:audio-startup',
|
||||
'test:seek-bar',
|
||||
'test:lyrics',
|
||||
|
||||
+92
-11
@@ -23,6 +23,8 @@ import { showAppDialog } from '@/components/dialogs/AppDialog';
|
||||
import { EQGraph } from '@/components/eq/EQGraph';
|
||||
import { BandStrip } from '@/components/eq/BandStrip';
|
||||
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 { EqSheet, EqSheetItem } from '@/components/eq/EqSheet';
|
||||
import { EQModeSwitcher } from '@/components/eq/EQModeSwitcher';
|
||||
@@ -45,7 +47,6 @@ import { useRipple } from '@/theme/ripple';
|
||||
import { hapticForToggle } from '@/lib/hapticCatalog';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
import { useAppForeground } from '@/lib/useAppForeground';
|
||||
import { isWideWindow } from '@/theme/adaptive';
|
||||
import { useEQStore } from '@/stores/eqStore';
|
||||
import { useScopeActive } from '@/scope/scopeStore';
|
||||
import { setActivePostEqNative } from '@/audio/eqNative';
|
||||
@@ -86,6 +87,12 @@ type SheetKind =
|
||||
| 'qr'
|
||||
| 'preview';
|
||||
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>;
|
||||
|
||||
const BAND_TYPES: EQBandType[] = ['lowshelf', 'peaking', 'highshelf', 'highpass', 'lowpass'];
|
||||
@@ -106,7 +113,7 @@ export default function EQScreen() {
|
||||
!foreground
|
||||
);
|
||||
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 [pendingImportPreset, setPendingImportPreset] = useState<EQPreset | null>(null);
|
||||
const [qrPreset, setQrPreset] = useState<{ name: string; value: string } | null>(null);
|
||||
@@ -116,10 +123,12 @@ export default function EQScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const sceneBottomInset = useSceneBottomInset();
|
||||
const availableWidth = windowWidth - insets.left - insets.right;
|
||||
const availableHeight = windowHeight - insets.top - insets.bottom;
|
||||
const isWide = isWideWindow(availableWidth, availableHeight);
|
||||
// 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)));
|
||||
// The floating chrome is the scene's bottom edge as far as this screen is
|
||||
// concerned — it doesn't scroll, so anything below that line is unreachable.
|
||||
const availableHeight = windowHeight - insets.top - insets.bottom - sceneBottomInset;
|
||||
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.
|
||||
useFocusEffect(
|
||||
@@ -156,7 +165,12 @@ export default function EQScreen() {
|
||||
? eq.presets.find((preset) => preset.id === actionPresetId) ?? null
|
||||
: null;
|
||||
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 () => {
|
||||
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 = (
|
||||
<BandDetailPanel
|
||||
band={activeBand}
|
||||
@@ -388,6 +433,7 @@ export default function EQScreen() {
|
||||
max={EQ_MAX_PREAMP_DB}
|
||||
format={(v) => `${formatGain(v)} dB`}
|
||||
onChange={eq.setPreamp}
|
||||
onValuePress={() => setEditingValue('preamp')}
|
||||
/>
|
||||
</View>
|
||||
<Pressable android_ripple={ripple.bounded}
|
||||
@@ -434,9 +480,18 @@ export default function EQScreen() {
|
||||
</View>
|
||||
|
||||
{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={{ width: sidePaneWidth }}>
|
||||
<View style={{ width: eqLayout.sidePaneWidth }}>
|
||||
{modeSwitcherEl}
|
||||
{presetRowEl}
|
||||
{isGraphic ? null : (
|
||||
@@ -458,10 +513,18 @@ export default function EQScreen() {
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.graphWrap}>{graphEl}</View>
|
||||
{isConsole ? (
|
||||
<View style={[styles.section, { height: eqLayout.consoleHeight }]}>
|
||||
{consoleEl}
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.section}>{stripEl}</View>
|
||||
<View style={styles.section}>{detailEl}</View>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{bottomBarEl}
|
||||
</>
|
||||
)}
|
||||
@@ -608,7 +671,7 @@ export default function EQScreen() {
|
||||
</EqSheet>
|
||||
) : null}
|
||||
|
||||
{valueEditConfig && activeBand && editingValue ? (
|
||||
{valueEditConfig && editingValue ? (
|
||||
<EQValueEditSheet
|
||||
title={valueEditConfig.title}
|
||||
initialValue={valueEditConfig.initialValue}
|
||||
@@ -617,7 +680,13 @@ export default function EQScreen() {
|
||||
placeholder={valueEditConfig.placeholder}
|
||||
keyboardType={valueEditConfig.keyboardType}
|
||||
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)}
|
||||
/>
|
||||
) : 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) {
|
||||
switch (kind) {
|
||||
case 'frequency':
|
||||
|
||||
+177
-65
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
AppState,
|
||||
Pressable,
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
StyleSheet,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type LayoutChangeEvent,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -42,15 +45,18 @@ import {
|
||||
HOME_GREETING_ROTATION_MS,
|
||||
type HomeGreetingTextMode,
|
||||
} from '@/home/homeGreeting';
|
||||
import { getHomeLayout, HOME_COLUMN_GAP } from '@/home/homeLayout';
|
||||
import { useHomeLibraryNavigation } from '@/navigation/useHomeLibraryNavigation';
|
||||
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 { subscribeToListeningHistory } from '@/listeningStats/events';
|
||||
import { useSceneBottomInset } from '@/navigation/useShellLayout';
|
||||
|
||||
const RECENT_ALBUM_LIMIT = 8;
|
||||
const RECENT_TRACK_LIMIT = 3;
|
||||
const PLAYLIST_LIMIT = 4;
|
||||
|
||||
type RandomSpotlight =
|
||||
@@ -337,16 +343,18 @@ function ArtistCover({ artist, size }: { artist: Artist; size: number }) {
|
||||
|
||||
function RecentlyAddedAlbum({
|
||||
album,
|
||||
size,
|
||||
onPress,
|
||||
}: {
|
||||
album: Album;
|
||||
size: number;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const styles = useStyles();
|
||||
const ripple = useRipple();
|
||||
return (
|
||||
<Pressable style={styles.recentAlbum} android_ripple={ripple.tile} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onPress} accessibilityRole="button">
|
||||
<AlbumCover album={album} size={112} />
|
||||
<Pressable style={[styles.recentAlbum, { width: size }]} android_ripple={ripple.tile} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onPress} accessibilityRole="button">
|
||||
<AlbumCover album={album} size={size} />
|
||||
<Text variant="body" numberOfLines={1} style={styles.recentAlbumTitle}>
|
||||
{album.album}
|
||||
</Text>
|
||||
@@ -360,6 +368,8 @@ function RecentlyAddedAlbum({
|
||||
function RandomSpotlightCard({
|
||||
spotlight,
|
||||
hasTracks,
|
||||
coverSize,
|
||||
style,
|
||||
onPlay,
|
||||
onShuffle,
|
||||
onReroll,
|
||||
@@ -367,6 +377,8 @@ function RandomSpotlightCard({
|
||||
}: {
|
||||
spotlight: { kind: 'album'; album: Album } | { kind: 'artist'; artist: Artist };
|
||||
hasTracks: boolean;
|
||||
coverSize: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
onPlay: () => void;
|
||||
onShuffle: () => void;
|
||||
onReroll: () => void;
|
||||
@@ -388,7 +400,7 @@ function RandomSpotlightCard({
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.randomCard}
|
||||
style={[styles.randomCard, style]}
|
||||
android_ripple={ripple.tile}
|
||||
unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
onPress={onOpen}
|
||||
@@ -397,9 +409,9 @@ function RandomSpotlightCard({
|
||||
>
|
||||
<View style={styles.randomMain}>
|
||||
{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}>
|
||||
<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() {
|
||||
const sceneBottomInset = useSceneBottomInset();
|
||||
const styles = useStyles();
|
||||
@@ -543,6 +602,15 @@ export default function HomeScreen() {
|
||||
const scrollTop = useScrollTopGate();
|
||||
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(
|
||||
() => [...albums].sort((a, b) => b.latest_added_at - a.latest_added_at).slice(0, RECENT_ALBUM_LIMIT),
|
||||
[albums]
|
||||
@@ -594,8 +662,8 @@ export default function HomeScreen() {
|
||||
? ({ kind: 'artist', artist: randomArtist } as const)
|
||||
: null;
|
||||
|
||||
const recentTracks = recentlyPlayedTracks.slice(0, RECENT_TRACK_LIMIT);
|
||||
const canExpandRecentTracks = recentlyPlayedTracks.length > RECENT_TRACK_LIMIT;
|
||||
const recentTracks = recentlyPlayedTracks.slice(0, home.recentTrackCount);
|
||||
const canExpandRecentTracks = recentlyPlayedTracks.length > home.recentTrackCount;
|
||||
|
||||
const openAlbum = (album: Album) => {
|
||||
openLibrary({ kind: 'album', key: album.identity_key });
|
||||
@@ -650,42 +718,7 @@ export default function HomeScreen() {
|
||||
}, [loadListeningPreview]),
|
||||
);
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<PullSearchGesture atTop={scrollTop.atTop} onOpen={openSearch}>
|
||||
<PullSearchScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
overScrollMode="never"
|
||||
contentContainerStyle={{ paddingBottom: sceneBottomInset }}
|
||||
onScroll={scrollTop.onScroll}
|
||||
scrollEventThrottle={scrollTop.scrollEventThrottle}
|
||||
>
|
||||
<HomeMasthead
|
||||
mode={homeGreetingTextMode}
|
||||
onSearch={openSearch}
|
||||
onScan={openSignalScanner}
|
||||
/>
|
||||
|
||||
<ScanProgress />
|
||||
|
||||
{!hasLibrary ? (
|
||||
<>
|
||||
<EmptyHomeCard
|
||||
scanError={scanError}
|
||||
status={libraryStatus}
|
||||
onManageFolders={() => router.push(
|
||||
libraryStatus === 'fatalUserData' ? '/settings/troubleshooting' : '/settings'
|
||||
)}
|
||||
/>
|
||||
<ListeningPreviewCard
|
||||
dashboard={listeningPreview}
|
||||
onPress={() => router.push('/stats' as never)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{spotlightContent ? (
|
||||
<View style={styles.topFeature}>
|
||||
const spotlightCard = spotlightContent ? (
|
||||
<RandomSpotlightCard
|
||||
spotlight={spotlightContent}
|
||||
hasTracks={
|
||||
@@ -693,6 +726,8 @@ export default function HomeScreen() {
|
||||
? 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)}
|
||||
@@ -700,16 +735,20 @@ export default function HomeScreen() {
|
||||
onShuffle={() => playSpotlight(true)}
|
||||
onReroll={rerollSpotlight}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
) : 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;
|
||||
|
||||
{recentTracks.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
const recentlyPlayedSection = recentTracks.length > 0 ? (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Recently Played"
|
||||
trailing={formatCount(recentlyPlayedTracks.length, 'track')}
|
||||
@@ -731,11 +770,11 @@ export default function HomeScreen() {
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
{recentlyAddedAlbums.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
const recentlyAddedSection = recentlyAddedAlbums.length > 0 ? (
|
||||
<>
|
||||
<SectionHeader title="Recently Added" />
|
||||
<ScrollView
|
||||
horizontal
|
||||
@@ -746,15 +785,16 @@ export default function HomeScreen() {
|
||||
<RecentlyAddedAlbum
|
||||
key={album.identity_key}
|
||||
album={album}
|
||||
size={home.railCoverSize}
|
||||
onPress={() => openAlbum(album)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
{favoriteTracks.length > 0 || homePlaylists.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
const playlistsSection = favoriteTracks.length > 0 || homePlaylists.length > 0 ? (
|
||||
<>
|
||||
<SectionHeader title="Favorites & Playlists" />
|
||||
<View style={styles.listBlock}>
|
||||
{favoriteTracks.length > 0 ? (
|
||||
@@ -777,8 +817,63 @@ export default function HomeScreen() {
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<PullSearchGesture atTop={scrollTop.atTop} onOpen={openSearch}>
|
||||
<PullSearchScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
overScrollMode="never"
|
||||
contentContainerStyle={{ paddingBottom: sceneBottomInset }}
|
||||
onLayout={measureContent}
|
||||
onScroll={scrollTop.onScroll}
|
||||
scrollEventThrottle={scrollTop.scrollEventThrottle}
|
||||
>
|
||||
<HomeMasthead
|
||||
mode={homeGreetingTextMode}
|
||||
onSearch={openSearch}
|
||||
onScan={openSignalScanner}
|
||||
/>
|
||||
|
||||
<ScanProgress />
|
||||
|
||||
{!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.
|
||||
<>
|
||||
<HomeBand paired stretch primary={spotlightCard} secondary={listeningCard} />
|
||||
<HomeSection>{recentlyAddedSection}</HomeSection>
|
||||
<HomeBand
|
||||
paired
|
||||
primary={recentlyPlayedSection}
|
||||
secondary={playlistsSection}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<HomeSection>{spotlightCard}</HomeSection>
|
||||
<HomeSection>{listeningCard}</HomeSection>
|
||||
<HomeSection>{recentlyPlayedSection}</HomeSection>
|
||||
<HomeSection>{recentlyAddedSection}</HomeSection>
|
||||
<HomeSection>{playlistsSection}</HomeSection>
|
||||
</>
|
||||
)}
|
||||
</PullSearchScrollView>
|
||||
@@ -828,12 +923,26 @@ const useStyles = createThemedStyles((colors) => ({
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
},
|
||||
topFeature: {
|
||||
marginTop: spacing.xl,
|
||||
},
|
||||
section: {
|
||||
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: {
|
||||
minHeight: 32,
|
||||
flexDirection: 'row',
|
||||
@@ -862,7 +971,8 @@ const useStyles = createThemedStyles((colors) => ({
|
||||
paddingRight: spacing.lg,
|
||||
},
|
||||
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: {
|
||||
marginTop: spacing.sm,
|
||||
@@ -891,6 +1001,9 @@ const useStyles = createThemedStyles((colors) => ({
|
||||
},
|
||||
randomCard: {
|
||||
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,
|
||||
backgroundColor: colors.glassBg,
|
||||
borderColor: colors.glassBorder,
|
||||
@@ -953,7 +1066,6 @@ const useStyles = createThemedStyles((colors) => ({
|
||||
backgroundColor: colors.bgPrimary,
|
||||
},
|
||||
emptyCard: {
|
||||
marginTop: spacing.xl,
|
||||
padding: spacing.lg,
|
||||
borderRadius: radius.md,
|
||||
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 { 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 { Text } from '@/components/Text';
|
||||
import { listeningArtworkSource } from '@/library/artwork';
|
||||
@@ -10,17 +10,28 @@ import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
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({
|
||||
dashboard,
|
||||
onPress,
|
||||
style,
|
||||
}: {
|
||||
dashboard: ListeningStatsDashboard | null;
|
||||
onPress: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
if (!dashboard?.status.startedAt) return null;
|
||||
if (!hasListeningPreview(dashboard) || !dashboard) return null;
|
||||
|
||||
const topTrack = dashboard.topTracks[0] ?? null;
|
||||
const artwork = topTrack ? listeningArtworkSource(topTrack, true) : null;
|
||||
@@ -28,7 +39,7 @@ export function ListeningPreviewCard({
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.card}
|
||||
style={[styles.card, style]}
|
||||
android_ripple={ripple.tile}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
@@ -98,7 +109,9 @@ export function ListeningPreviewCard({
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
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,
|
||||
gap: spacing.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