update home and eq pages for tablets

This commit is contained in:
Boof2015
2026-08-01 00:27:27 -04:00
parent c7855d5c04
commit a5e23ce49a
10 changed files with 1081 additions and 138 deletions
+2 -1
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+295
View File
@@ -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;
+113
View File
@@ -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}`);
}
});
+114
View File
@@ -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,
+106
View File
@@ -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);
});
+107
View File
@@ -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,
};
}