From a5e23ce49affbcb20e9ba926c8b2c9ae245ae436 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:27:27 -0400 Subject: [PATCH] update home and eq pages for tablets --- package.json | 3 +- scripts/run-release-tests.mjs | 1 + src/app/(tabs)/eq.tsx | 107 +++++- src/app/(tabs)/index.tsx | 352 ++++++++++++------ src/components/eq/BandConsole.tsx | 295 +++++++++++++++ src/components/eq/eqLayout.test.mts | 113 ++++++ src/components/eq/eqLayout.ts | 114 ++++++ .../listening/ListeningPreviewCard.tsx | 21 +- src/home/homeLayout.test.mts | 106 ++++++ src/home/homeLayout.ts | 107 ++++++ 10 files changed, 1081 insertions(+), 138 deletions(-) create mode 100644 src/components/eq/BandConsole.tsx create mode 100644 src/components/eq/eqLayout.test.mts create mode 100644 src/components/eq/eqLayout.ts create mode 100644 src/home/homeLayout.test.mts create mode 100644 src/home/homeLayout.ts diff --git a/package.json b/package.json index 4c3b785..70c9cdb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/run-release-tests.mjs b/scripts/run-release-tests.mjs index 1111074..5da0685 100644 --- a/scripts/run-release-tests.mjs +++ b/scripts/run-release-tests.mjs @@ -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', diff --git a/src/app/(tabs)/eq.tsx b/src/app/(tabs)/eq.tsx index 343b94e..31ffb81 100644 --- a/src/app/(tabs)/eq.tsx +++ b/src/app/(tabs)/eq.tsx @@ -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; const BAND_TYPES: EQBandType[] = ['lowshelf', 'peaking', 'highshelf', 'highpass', 'lowpass']; @@ -106,7 +113,7 @@ export default function EQScreen() { !foreground ); const [sheet, setSheet] = useState('none'); - const [editingValue, setEditingValue] = useState(null); + const [editingValue, setEditingValue] = useState(null); const [pendingCurrentAction, setPendingCurrentAction] = useState(null); const [pendingImportPreset, setPendingImportPreset] = useState(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 = ( + { + 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 = ( `${formatGain(v)} dB`} onChange={eq.setPreamp} + onValuePress={() => setEditingValue('preamp')} /> {isWide ? ( - + 0 ? { paddingBottom: sceneBottomInset } : null, + ]} + > {isGraphic ? graphicEditorEl : graphEl} - + {modeSwitcherEl} {presetRowEl} {isGraphic ? null : ( @@ -458,8 +513,16 @@ export default function EQScreen() { ) : ( <> {graphEl} - {stripEl} - {detailEl} + {isConsole ? ( + + {consoleEl} + + ) : ( + <> + {stripEl} + {detailEl} + + )} )} {bottomBarEl} @@ -608,7 +671,7 @@ export default function EQScreen() { ) : null} - {valueEditConfig && activeBand && editingValue ? ( + {valueEditConfig && editingValue ? ( 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': diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index d6bd0fd..5cffb8f 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -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 ( - - + + {album.album} @@ -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; onPlay: () => void; onShuffle: () => void; onReroll: () => void; @@ -388,7 +400,7 @@ function RandomSpotlightCard({ return ( {spotlight.kind === 'album' ? ( - + ) : ( - + )} @@ -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 {children}; +} + +/** + * 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 ( + <> + {primary} + {secondary} + + ); + } + return ( + + {primary} + {secondary} + + ); +} + 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,6 +718,108 @@ export default function HomeScreen() { }, [loadListeningPreview]), ); + const spotlightCard = spotlightContent ? ( + 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) ? ( + router.push('/stats' as never)} + /> + ) : null; + + const recentlyPlayedSection = recentTracks.length > 0 ? ( + <> + router.push('/recently-played') : undefined + } + /> + + {recentTracks.map((track, index) => ( + playRecentlyPlayed(recentTracks, index)} + onLongPress={() => setActionTrack(track)} + onOpenActions={() => setActionTrack(track)} + /> + ))} + + + ) : null; + + const recentlyAddedSection = recentlyAddedAlbums.length > 0 ? ( + <> + + + {recentlyAddedAlbums.map((album) => ( + openAlbum(album)} + /> + ))} + + + ) : null; + + const playlistsSection = favoriteTracks.length > 0 || homePlaylists.length > 0 ? ( + <> + + + {favoriteTracks.length > 0 ? ( + openLibrary({ kind: 'playlist', id: 'favorites' })} + /> + ) : null} + {homePlaylists.map((playlist) => ( + openLibrary({ kind: 'playlist', id: playlist.id })} + /> + ))} + + + ) : null; + return ( @@ -657,6 +827,7 @@ export default function HomeScreen() { showsVerticalScrollIndicator={false} overScrollMode="never" contentContainerStyle={{ paddingBottom: sceneBottomInset }} + onLayout={measureContent} onScroll={scrollTop.onScroll} scrollEventThrottle={scrollTop.scrollEventThrottle} > @@ -669,116 +840,40 @@ export default function HomeScreen() { {!hasLibrary ? ( + 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. <> - router.push( - libraryStatus === 'fatalUserData' ? '/settings/troubleshooting' : '/settings' - )} - /> - router.push('/stats' as never)} + + {recentlyAddedSection} + ) : ( <> - {spotlightContent ? ( - - 0 - : spotlightContent.artist.track_count > 0 - } - onOpen={() => spotlightContent.kind === 'album' - ? openAlbum(spotlightContent.album) - : openArtist(spotlightContent.artist)} - onPlay={() => playSpotlight()} - onShuffle={() => playSpotlight(true)} - onReroll={rerollSpotlight} - /> - - ) : null} - - router.push('/stats' as never)} - /> - - {recentTracks.length > 0 ? ( - - router.push('/recently-played') : undefined - } - /> - - {recentTracks.map((track, index) => ( - playRecentlyPlayed(recentTracks, index)} - onLongPress={() => setActionTrack(track)} - onOpenActions={() => setActionTrack(track)} - /> - ))} - - - ) : null} - - {recentlyAddedAlbums.length > 0 ? ( - - - - {recentlyAddedAlbums.map((album) => ( - openAlbum(album)} - /> - ))} - - - ) : null} - - {favoriteTracks.length > 0 || homePlaylists.length > 0 ? ( - - - - {favoriteTracks.length > 0 ? ( - openLibrary({ kind: 'playlist', id: 'favorites' })} - /> - ) : null} - {homePlaylists.map((playlist) => ( - openLibrary({ kind: 'playlist', id: playlist.id })} - /> - ))} - - - ) : null} + {spotlightCard} + {listeningCard} + {recentlyPlayedSection} + {recentlyAddedSection} + {playlistsSection} )} @@ -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, diff --git a/src/components/eq/BandConsole.tsx b/src/components/eq/BandConsole.tsx new file mode 100644 index 0000000..98708dd --- /dev/null +++ b/src/components/eq/BandConsole.tsx @@ -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) => 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 ( + + {bands.map((band, index) => { + const isActive = band.id === activeBandId; + const isPass = isPassEQBandType(band.type); + const isShelf = isShelfEQBandType(band.type); + return ( + onSelect(band.id)} + accessibilityLabel={`Band ${index + 1}`} + > + + + {index + 1} + + onUpdate(band.id, { enabled })} + trackColor={{ false: colors.glassBorder, true: colors.accent }} + thumbColor={colors.textPrimary} + /> + + + onEditType(band.id)} + accessibilityRole="button" + accessibilityLabel={`Band ${index + 1} filter type, ${BAND_TYPE_LABEL[band.type]}`} + > + + {BAND_TYPE_LABEL[band.type]} + + + + + {/* + 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. + */} + { + if (!isActive) onSelect(band.id); + }} + > + onUpdate(band.id, { gain })} + /> + + + onEditValue(band.id, 'gain')} + /> + onEditValue(band.id, 'frequency')} + /> + onEditValue(band.id, 'Q')} + /> + + ); + })} + + {canAdd ? ( + + + + ) : null} + + ); +} + +/** + * 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 = ( + <> + + {value} + + + {label} + + + ); + if (!onPress) { + return {body}; + } + return ( + + {body} + + ); +} + +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; diff --git a/src/components/eq/eqLayout.test.mts b/src/components/eq/eqLayout.test.mts new file mode 100644 index 0000000..db6b5f1 --- /dev/null +++ b/src/components/eq/eqLayout.test.mts @@ -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}`); + } +}); diff --git a/src/components/eq/eqLayout.ts b/src/components/eq/eqLayout.ts new file mode 100644 index 0000000..210ffa8 --- /dev/null +++ b/src/components/eq/eqLayout.ts @@ -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, + }; +} diff --git a/src/components/listening/ListeningPreviewCard.tsx b/src/components/listening/ListeningPreviewCard.tsx index facdac1..21068f6 100644 --- a/src/components/listening/ListeningPreviewCard.tsx +++ b/src/components/listening/ListeningPreviewCard.tsx @@ -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; }) { 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 ( ({ 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, diff --git a/src/home/homeLayout.test.mts b/src/home/homeLayout.test.mts new file mode 100644 index 0000000..9a5ac97 --- /dev/null +++ b/src/home/homeLayout.test.mts @@ -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(); + 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); +}); diff --git a/src/home/homeLayout.ts b/src/home/homeLayout.ts new file mode 100644 index 0000000..55a56f5 --- /dev/null +++ b/src/home/homeLayout.ts @@ -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, + }; +}