From 9764e828bda93d25d95d12fb518a5a7a359d0170 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:52:29 -0400 Subject: [PATCH] fullscreen player tablet layout --- .../astralibraryscanner/AstraQueueModule.kt | 3 + .../queue/AstraQueueView.kt | 6 + .../queue/QueueContentView.kt | 37 +++- modules/astra-library-scanner/queue.ts | 8 + src/components/lyrics/LyricsBand.tsx | 45 +++- .../player/NowPlayingCompanionPane.tsx | 78 +++---- src/components/player/NowPlayingOverlay.tsx | 67 ++++-- .../player/nowPlayingLayout.test.mts | 174 ++++++++++++++- src/components/player/nowPlayingLayout.ts | 207 +++++++++++++++--- src/stores/settingsStore.ts | 19 ++ 10 files changed, 537 insertions(+), 107 deletions(-) diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraQueueModule.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraQueueModule.kt index 4999b57..9a93b2e 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraQueueModule.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraQueueModule.kt @@ -126,6 +126,9 @@ class AstraQueueModule : Module() { Prop("palette") { view, values: Map? -> view.palette = QueuePalette.from(values) } + Prop("paneMode") { view, paneMode: Boolean? -> + view.paneMode = paneMode == true + } OnViewDidUpdateProps { view -> view.setPlaybackRequestListener { entryId, revision -> emitPlaybackRequest(entryId, revision) diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/AstraQueueView.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/AstraQueueView.kt index fe63149..2fa0931 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/AstraQueueView.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/AstraQueueView.kt @@ -22,6 +22,12 @@ class AstraQueueView( content.palette = value } + var paneMode: Boolean = false + set(value) { + field = value + content.paneMode = value + } + init { addView( content, diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueContentView.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueContentView.kt index e0bb24b..2d4059b 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueContentView.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueContentView.kt @@ -3,6 +3,7 @@ package expo.modules.astralibraryscanner.queue import android.content.Context import android.content.res.ColorStateList import android.graphics.Canvas +import android.graphics.Color import android.graphics.Paint import android.graphics.Path import android.graphics.Typeface @@ -106,6 +107,21 @@ class QueueContentView( applyPalette() } + /** + * Embedded as the now-playing companion pane. The pane is opened from a + * button that already names it, so the title and count are a second copy of + * something the screen says elsewhere. Edit is a function rather than a + * label, so it stays and simply right-aligns in the row the labels leave. + */ + var paneMode: Boolean = false + set(value) { + field = value + val labelVisibility = if (value) GONE else VISIBLE + titleView.visibility = labelVisibility + countView.visibility = labelVisibility + applyPalette() + } + var active: Boolean = true set(value) { field = value @@ -559,10 +575,14 @@ class QueueContentView( } private fun applyPalette() { - background = if (sheetMode) { - topRounded(palette.background, 16f) - } else { - ColorDrawable(palette.background) + background = when { + sheetMode -> topRounded(palette.background, 16f) + // The companion pane is a region of the player's own surface, not a panel + // on top of it. Painting `background` here drew a second slab inside the + // column — a bordered card floating in the middle of the screen, while + // the lyrics companion beside it had none. + paneMode -> null + else -> ColorDrawable(palette.background) } sheetHandle.background = rounded(palette.divider, 999f) titleView.setTextColor(palette.text) @@ -930,7 +950,14 @@ class QueueContentView( row.artwork.visibility = if (editing) GONE else VISIBLE row.handle.visibility = if (editing) GONE else VISIBLE row.setSurfaceColor( - if (item.entryId in selected) palette.selectedSurface else palette.surface, + when { + item.entryId in selected -> palette.selectedSurface + // Rows paint their own surface so a sheet reads as a stack of + // cards. In the pane that surface is a second slab per row on top + // of the player's background — the pane is the player's surface. + this@QueueContentView.paneMode -> Color.TRANSPARENT + else -> palette.surface + }, ) loadArtwork(row.artwork, item.artworkThumbPath) row.setOnClickListener { onRowClick?.invoke(item) } diff --git a/modules/astra-library-scanner/queue.ts b/modules/astra-library-scanner/queue.ts index 063b44d..b02dd16 100644 --- a/modules/astra-library-scanner/queue.ts +++ b/modules/astra-library-scanner/queue.ts @@ -70,6 +70,14 @@ declare class AstraQueueModuleType extends NativeModule { export interface AstraQueueViewProps extends ViewProps { active: boolean; palette: NativeQueuePalette; + /** + * Embedded as the now-playing companion pane rather than presented as a + * sheet. Drops the "Queue" title and its count — the pane is reached from a + * button that already names it, and the player header a few hundred dp to the + * left already says what is playing from where. Edit stays: it is a function, + * not a label. + */ + paneMode?: boolean; } function nativeColor(value: string): number { diff --git a/src/components/lyrics/LyricsBand.tsx b/src/components/lyrics/LyricsBand.tsx index e99081b..3315d3c 100644 --- a/src/components/lyrics/LyricsBand.tsx +++ b/src/components/lyrics/LyricsBand.tsx @@ -27,6 +27,32 @@ import type { Track } from '@/types/audio'; const ANCHOR_RATIO = 0.4; const H_PADDING = 22; + +/** + * Type sizing per surface. + * + * The phone body scales its type with its column, which is fine when the column + * is the whole screen — there is no other use for the width. + * + * A pane must not do that. Scaling type with width means widening the pane + * spends the new space on bigger glyphs and every line still breaks in the same + * place: going 504 → 576dp took the type 29pt → 32pt and changed nothing about + * the wrapping. So the pane declares its size and lets width buy *characters*, + * which is the only thing that actually stops a line breaking mid-phrase. + */ +interface LyricsSurfaceSpec { + /** Declared point size; `null` scales with the column instead. */ + fixedSize: number | null; + /** Ceiling for the scaled path. */ + maxSize: number; + hPadding: number; +} +const SURFACE: Record<'band' | 'panel', LyricsSurfaceSpec> = { + band: { fixedSize: null, maxSize: 24, hPadding: H_PADDING }, + panel: { fixedSize: 26, maxSize: 26, hPadding: 28 }, +}; + +export type LyricsSurface = keyof typeof SURFACE; // The displayed active line lags the audio by a fixed pipeline delay (RNTP // position reporting + poll/smoothing) that the desktop doesn't have, so advance // the lyrics clock by this much. Tune to taste — bigger = earlier highlight. @@ -37,13 +63,23 @@ interface LyricsBandProps { duration: number; isPlaying: boolean; onSeek: (seconds: number) => void; + /** `panel` is the tablet companion column; `band` is the phone body. */ + surface?: LyricsSurface; } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } -export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }: LyricsBandProps) { +export function LyricsBand({ + track, + currentTime, + duration, + isPlaying, + onSeek, + surface = 'band', +}: LyricsBandProps) { + const { fixedSize, maxSize, hPadding } = SURFACE[surface]; const colors = useColors(); const ripple = useRipple(); const entry = useLyricsStore((s) => s.byPath[track.path]); @@ -84,7 +120,8 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }: // Uniform size for every line (LyricsLine no longer scales font per tier), so pick // a comfortable reading size rather than the old oversized active value. - const baseSize = size.w > 0 ? Math.round(clamp(size.w * 0.058, 18, 24)) : 22; + const baseSize = + fixedSize ?? (size.w > 0 ? Math.round(clamp(size.w * 0.058, 18, maxSize)) : 22); // --- auto-scroll centering --- const scrollRef = useRef(null); @@ -146,7 +183,7 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }: return ( @@ -178,7 +215,7 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }: onScrollBeginDrag={() => setFollowPaused(true)} contentContainerStyle={{ paddingVertical: size.h > 0 ? Math.round(size.h * ANCHOR_RATIO) : 120, - paddingHorizontal: H_PADDING, + paddingHorizontal: hPadding, alignItems: 'stretch', }} > diff --git a/src/components/player/NowPlayingCompanionPane.tsx b/src/components/player/NowPlayingCompanionPane.tsx index 2beb1f9..1e7f2fa 100644 --- a/src/components/player/NowPlayingCompanionPane.tsx +++ b/src/components/player/NowPlayingCompanionPane.tsx @@ -1,9 +1,7 @@ import { StyleSheet, View } from 'react-native'; -import { SegmentedControl } from '@/components/SegmentedControl'; import { LyricsBand } from '@/components/lyrics/LyricsBand'; import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet'; import { seekTo } from '@/audio/playbackController'; -import { spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { getNowPlayingTrackTransitionKey, @@ -11,18 +9,12 @@ import { } from './nowPlayingTrackTransition'; import { usePlayerStore } from '@/stores/playerStore'; import { useSettingsStore } from '@/stores/settingsStore'; -import type { NowPlayingCompanion } from './nowPlayingPreferences'; import type { Track } from '@/types/audio'; import { AstraQueueView, toNativeQueuePalette, } from '../../../modules/astra-library-scanner'; -const COMPANION_SEGMENTS = [ - { key: 'queue', label: 'Queue' }, - { key: 'lyrics', label: 'Lyrics' }, -]; - const noop = () => {}; interface NowPlayingCompanionPaneProps { @@ -40,7 +32,6 @@ export function NowPlayingCompanionPane({ const styles = useStyles(); const colors = useColors(); const companion = useSettingsStore((s) => s.nowPlayingCompanion); - const setCompanion = useSettingsStore((s) => s.setNowPlayingCompanion); const currentTime = usePlayerStore((s) => (active && !desktopTarget ? s.currentTime : 0)); const duration = usePlayerStore((s) => (desktopTarget ? 0 : s.duration)); const isPlaying = usePlayerStore( @@ -51,49 +42,36 @@ export function NowPlayingCompanionPane({ track?.path ?? null ); - const selectCompanion = (next: string) => { - const value: NowPlayingCompanion = next === 'lyrics' ? 'lyrics' : 'queue'; - if (value === companion) return; - void setCompanion(value); - }; - return ( {desktopTarget ? ( ) : ( - <> - - + {companion === 'queue' ? ( + - - - {companion === 'queue' ? ( - + void seekTo(seconds)} /> - ) : track ? ( - - void seekTo(seconds)} - /> - - ) : null} - - + + ) : null} + )} ); @@ -101,17 +79,13 @@ export function NowPlayingCompanionPane({ const useStyles = createThemedStyles((colors) => ({ root: { + // No border and no card: the pane is a region of the same surface as the + // player, separated by the gap the layout already reserves. A rule down the + // middle plus a bordered slab was two frames around one thing. flex: 1, minWidth: 0, - borderLeftColor: colors.glassBorder, - borderLeftWidth: 1, - paddingLeft: spacing.lg, overflow: 'hidden', }, - switcher: { - paddingHorizontal: spacing.sm, - paddingBottom: spacing.lg, - }, content: { flex: 1, minHeight: 0, diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx index 0049b67..049610d 100644 --- a/src/components/player/NowPlayingOverlay.tsx +++ b/src/components/player/NowPlayingOverlay.tsx @@ -39,6 +39,7 @@ import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet'; import { TactilePressable } from '@/components/player/TactilePressable'; import { ScopeRack } from '@/components/player/ScopeRack'; import { NowPlayingCompanionPane } from '@/components/player/NowPlayingCompanionPane'; +import type { NowPlayingCompanion } from '@/components/player/nowPlayingPreferences'; import { PlayerStateIcon } from '@/components/player/PlayerStateIcon'; import { CachedLyricPeek } from '@/components/player/CachedLyricPeek'; import { @@ -236,6 +237,8 @@ export function NowPlayingOverlay({ const setLyricsVisible = useSettingsStore((s) => s.setLyricsVisible); const nowPlayingCompanion = useSettingsStore((s) => s.nowPlayingCompanion); const setNowPlayingCompanion = useSettingsStore((s) => s.setNowPlayingCompanion); + const companionOpen = useSettingsStore((s) => s.nowPlayingCompanionOpen); + const setCompanionOpen = useSettingsStore((s) => s.setNowPlayingCompanionOpen); const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode); const libraryTracks = useLibraryStore((s) => s.tracks); const track = usePlayerStore((s) => s.currentTrack); @@ -332,13 +335,21 @@ export function NowPlayingOverlay({ false, fontScale ); - const tabletCompanionLayout = getTabletCompanionLayout( + // Two separate facts, the same split the shell makes for the dock: whether + // this window *could* seat a pane beside the player, and whether the pane is + // actually out. The default tablet player is the full-width composition with + // nothing docked — the queue and lyrics buttons open the pane, exactly as they + // open a sheet and a takeover on a phone. + const companionFit = getTabletCompanionLayout( effectiveWidth, availableHeight, layoutScopeVisible, - fontScale + fontScale, + nowPlayingCompanion ); - const hasTabletCompanion = tabletCompanionLayout !== null; + const companionCapable = companionFit !== null && !dock; + const hasTabletCompanion = companionCapable && companionOpen; + const tabletCompanionLayout = hasTabletCompanion ? companionFit : null; const layout = tabletCompanionLayout?.playerLayout ?? standardLayout; const deck = layout.deck; // The density tier owns whether there is room for the lyric row, and it is @@ -349,8 +360,11 @@ export function NowPlayingOverlay({ const shellWidth = tabletCompanionLayout?.shellWidth ?? layout.contentWidth; // Lyrics takes over only on the phone. Roomy tablets keep the player visible // and render lyrics in the companion rail. + // Gated on *capable*, not open: a window that can seat the pane never shows + // the phone's full-body lyrics takeover, even while the pane is closed. Lyrics + // there is a pane, and `lyricsVisible` only pre-selects which tab it opens on. const lyricsMode = - !hasTabletCompanion && !isDesktopTarget && !!track && lyricsVisible; + !companionCapable && !isDesktopTarget && !!track && lyricsVisible; const source = activePresentation.sourceLabel; const shellRight = insets.right + @@ -414,30 +428,45 @@ export function NowPlayingOverlay({ }; useEffect(() => { - if (!hasTabletCompanion || isDesktopTarget) return; + if (!companionCapable || isDesktopTarget) return; + // A queue sheet reached by some other path can't coexist with the pane, so + // fold it in — that one *does* open the pane, because the user asked for the + // queue. A stale `lyricsVisible` only picks the tab; it must not force the + // pane out, or the default composition would never be what you see first. if (queueOpen) { const frame = requestAnimationFrame(() => { setQueueOpen(false); void setNowPlayingCompanion('queue'); + void setCompanionOpen(true); }); return () => cancelAnimationFrame(frame); } if (lyricsVisible) void setNowPlayingCompanion('lyrics'); return undefined; }, [ + companionCapable, isDesktopTarget, lyricsVisible, queueOpen, + setCompanionOpen, setNowPlayingCompanion, - hasTabletCompanion, ]); + /** + * Open the pane on `which`, or close it if that is already what it is showing. + * The buttons are the pane's only trigger, so each has to be able to undo + * itself the way the phone's sheet and takeover do. + */ + const toggleCompanion = (which: NowPlayingCompanion) => { + const alreadyShowing = companionOpen && nowPlayingCompanion === which; + void setNowPlayingCompanion(which); + void setCompanionOpen(!alreadyShowing); + }; + const showQueue = () => { - if (hasTabletCompanion) { - if (!isDesktopTarget) { - void setLyricsVisible(false); - void setNowPlayingCompanion('queue'); - } + if (companionCapable) { + if (!isDesktopTarget) void setLyricsVisible(false); + toggleCompanion('queue'); return; } suspendPanForChildTransition(); @@ -558,8 +587,8 @@ export function NowPlayingOverlay({ }; const showLyrics = () => { - if (hasTabletCompanion) { - void setNowPlayingCompanion('lyrics'); + if (companionCapable) { + toggleCompanion('lyrics'); return; } setPhoneLyricsVisible(!lyricsVisible); @@ -1791,11 +1820,13 @@ export function NowPlayingOverlay({ haptic="selection" onPress={showLyrics} accessibilityLabel={ - hasTabletCompanion - ? 'Show lyrics in companion' - : lyricsVisible - ? 'Hide lyrics' - : 'Show lyrics' + ( + companionCapable + ? hasTabletCompanion && nowPlayingCompanion === 'lyrics' + : lyricsVisible + ) + ? 'Hide lyrics' + : 'Show lyrics' } accessibilityState={{ selected: hasTabletCompanion diff --git a/src/components/player/nowPlayingLayout.test.mts b/src/components/player/nowPlayingLayout.test.mts index 366f71d..86b0c41 100644 --- a/src/components/player/nowPlayingLayout.test.mts +++ b/src/components/player/nowPlayingLayout.test.mts @@ -319,16 +319,125 @@ test('caps reserved line boxes so a huge font setting cannot run away', () => { assert.ok(capped.deck.height > layoutFor(device, 1, true).deck.height); }); +/** + * Windows that must use the side-by-side row: too short to stack, whatever + * their width. Tablets used to be in here because the branch was picked by + * `isWideWindow` — see `STACKED_TABLETS`. + */ const LANDSCAPE = [ { name: 'Pixel 7 Pro landscape', width: 891, height: 339 }, { name: 'S22 landscape', width: 780, height: 312 }, { name: 'Poco M5 landscape', width: 873, height: 345 }, { name: 'S25 Ultra landscape', width: 918, height: 363 }, - { name: 'Tablet 12" landscape', width: 1366, height: 1000 }, - { name: 'Foldable open landscape', width: 800, height: 650 }, { name: 'very short landscape', width: 800, height: 300 }, ] as const; +/** + * Windows with the height to stack artwork over a deck. Side-by-side is the + * phone-in-landscape compromise, not the big-screen layout, so none of these + * may take it — including a tablet in landscape. + */ +const STACKED_TABLETS = [ + { name: 'Tablet 10" landscape', width: 1248, height: 752 }, + { name: 'Tablet 12" landscape', width: 1366, height: 1000 }, + { name: 'Tablet portrait', width: 768, height: 1150 }, + { name: 'Foldable open landscape', width: 800, height: 650 }, + { name: 'Foldable open portrait', width: 808, height: 868 }, + { name: 'Tablet 10" landscape, companion out', width: 856, height: 752 }, +] as const; + +test('a window with the height to stack never uses the side-by-side row', () => { + for (const fontScale of FONT_SCALES) { + for (const window of STACKED_TABLETS) { + for (const scope of [false, true]) { + // `forceWide` is the companion tier asking for the landscape row. Even + // that must lose: the player cannot change shape because a pane slid in + // beside it. + for (const forceWide of [false, true]) { + const layout = getNowPlayingLayout( + window.width, + window.height, + scope, + forceWide, + fontScale + ); + assert.equal( + layout.presentation, + 'standard', + `${window.name} (scope ${scope}, forceWide ${forceWide}) should stack` + ); + } + } + } + } +}); + +test('a stacked tablet gives the artwork the height a phone cannot', () => { + for (const window of STACKED_TABLETS) { + const layout = getNowPlayingLayout(window.width, window.height, false, false, 1); + assert.ok( + layout.artSizeScopeOff >= 320, + `${window.name}: art ${layout.artSizeScopeOff} is a thumbnail on this screen` + ); + // Height-bound, not ceiling-bound: the artwork is square, so it can only + // ever spend height, and it must still clear the deck. + assert.ok( + layout.artSizeScopeOff <= layout.stageHeight, + `${window.name}: art ${layout.artSizeScopeOff} overflows stage ${layout.stageHeight}` + ); + } +}); + +test('a stacked tablet spends spare width on the deck, not on the artwork', () => { + // The waveform is the only control that turns width into resolution. The + // artwork is square and gains nothing, so a wider column must not inflate it. + // Both windows are below the deck's ceiling so the deck is still growing. + const wider = getNowPlayingLayout(700, 1000, false, false, 1); + const narrower = getNowPlayingLayout(620, 1000, false, false, 1); + assert.ok(wider.contentWidth > narrower.contentWidth); + assert.equal(wider.artSizeScopeOff, narrower.artSizeScopeOff); +}); + +test('the deck stops widening well before it fills a tablet', () => { + // Past about half a 10" tablet the extra width stops buying a better scrub + // and starts stretching the rows around it — title hard left, favourite hard + // right, void between. Same failure as a full-width `TrackRow`. + for (const window of STACKED_TABLETS) { + const layout = getNowPlayingLayout(window.width, window.height, false, false, 1); + assert.ok( + layout.contentWidth <= 640, + `${window.name}: deck ${layout.contentWidth} exceeds the ceiling` + ); + // A portrait tablet *should* fill its column — there is no surplus to + // leave. The margin only has to appear where the window is genuinely wide. + if (window.width < 1000) continue; + assert.ok( + layout.contentWidth <= window.width * 0.6, + `${window.name}: deck ${layout.contentWidth} of ${window.width} is a stretched row` + ); + } + // And the ceiling actually binds on a tablet, rather than the window doing it. + assert.equal(getNowPlayingLayout(1248, 752, false, false, 1).contentWidth, 640); + assert.equal(getNowPlayingLayout(1366, 1000, false, false, 1).contentWidth, 640); +}); + +test('a phone is untouched by every tablet ceiling and floor', () => { + const phones = [...LANDSCAPE, { name: 'Pixel 7 Pro portrait', width: 380, height: 850 }]; + for (const window of phones) { + for (const scope of [false, true]) { + const layout = getNowPlayingLayout(window.width, window.height, scope, false, 1); + assert.ok( + layout.artSize <= 400, + `${window.name}: art ${layout.artSize} exceeds the phone ceiling` + ); + assert.ok( + layout.contentWidth <= 960, + `${window.name}: row ${layout.contentWidth} exceeds the phone ceiling` + ); + } + } +}); + test('landscape sizes its panes from their contents, not a fixed split', () => { for (const fontScale of FONT_SCALES) { for (const window of LANDSCAPE) { @@ -435,6 +544,61 @@ test('landscape never grows the artwork when the scope comes on', () => { } }); +test('gives lyrics the majority of the shell and the queue a sidecar share', () => { + for (const [width, height] of [ + [1248, 752], + [1366, 1000], + ]) { + const queue = getTabletCompanionLayout(width, height, false, 1, 'queue'); + const lyrics = getTabletCompanionLayout(width, height, false, 1, 'lyrics'); + assert.ok(queue && lyrics, `${width}x${height} should qualify`); + // A queue row is a thumbnail and two short lines; a lyric line is a + // sentence. Sizing both the same is what left lyrics wrapping mid-phrase. + assert.ok( + queue.companionWidth / queue.shellWidth <= 0.4, + `queue took ${queue.companionWidth} of ${queue.shellWidth}` + ); + assert.ok( + lyrics.companionWidth / lyrics.shellWidth >= 0.55, + `lyrics took only ${lyrics.companionWidth} of ${lyrics.shellWidth}` + ); + } +}); + +test('never lets a companion starve the player, however wide it wants to be', () => { + for (let width = 720; width <= 2000; width += 1) { + for (const companion of ['queue', 'lyrics'] as const) { + const layout = getTabletCompanionLayout(width, 900, false, 1, companion); + if (!layout) continue; + assert.ok( + layout.playerRegionWidth >= 320, + `${companion} at ${width} left the player ${layout.playerRegionWidth}` + ); + assert.equal( + layout.playerRegionWidth + layout.gap + layout.companionWidth, + layout.shellWidth, + `${companion} at ${width} does not account for the shell` + ); + } + } +}); + +test('a companion never changes the artwork it sits beside', () => { + // The pane takes width from the deck, not from the cover: the artwork is + // height-bound, so opening or widening a companion must not shrink it. This + // is also what lets the pane animate in as a translate rather than a resize. + const closed = getNowPlayingLayout(1248, 752, false, false, 1, true); + for (const companion of ['queue', 'lyrics'] as const) { + const open = getTabletCompanionLayout(1248, 752, false, 1, companion); + assert.ok(open, `${companion} should qualify`); + assert.equal( + open.playerLayout.artSizeScopeOff, + closed.artSizeScopeOff, + `${companion} resized the artwork` + ); + } +}); + test('adds the companion only to roomy tablet canvases', () => { for (const device of DEVICES) { assert.equal(getTabletCompanionLayout(device.width, device.height, true), null); @@ -442,13 +606,17 @@ test('adds the companion only to roomy tablet canvases', () => { for (const [width, height] of [ [600, 840], [800, 600], + // A 600dp-tall tablet cannot stack artwork over a deck, and the + // side-by-side player it used to fall back to is the phone-in-landscape + // compromise rather than a tablet layout. With nothing good to show beside + // the player, it gets the full window instead of a companion. + [1024, 600], ]) { assert.equal(getTabletCompanionLayout(width, height, true), null); } for (const [width, height] of [ [768, 1024], - [1024, 600], [1024, 768], [1366, 1024], ]) { diff --git a/src/components/player/nowPlayingLayout.ts b/src/components/player/nowPlayingLayout.ts index b428363..ab75798 100644 --- a/src/components/player/nowPlayingLayout.ts +++ b/src/components/player/nowPlayingLayout.ts @@ -22,7 +22,37 @@ const MAX_CONTENT_WIDTH = 408; const CONTENT_SIDE_PADDING = spacing.lg; const TABLET_MAX_CONTENT_WIDTH = 520; const TABLET_ART_SIZE_MAX = 440; -const WIDE_MAX_CONTENT_WIDTH = 960; + +/** + * A window this size stacks — artwork over a deck — rather than putting the + * artwork beside the controls. + * + * Side-by-side exists for one reason: a phone in landscape is ~340dp tall and + * cannot stack. It is not a "big screen" layout, and routing tablets into it via + * `isWideWindow` is what made a 10" tablet read as an enlarged landscape phone. + * The question is the same one the navigation rail and the EQ both ask — is + * there height to stack — not whether the window happens to be landscape. + */ +const TABLET_STACK_MIN_WIDTH = 600; +const TABLET_STACK_MIN_HEIGHT = 620; + +/** + * Stacked-tablet ceilings. + * + * The deck runs wider than a phone's — the waveform is the one control that + * turns width into resolution — but only so far. Past roughly half a 10" tablet + * the extra width stops buying a better scrub and starts stretching the rows + * around it: the title at the far left and the favourite toggle at the far + * right with a void between them, the same failure as a full-width `TrackRow`. + * At this cap the deck also lands at the scope rail's width, so the two read as + * one column under the artwork rather than two different measures. + * + * The artwork keeps a ceiling of its own: it is square, so it can only spend + * height, and past this it stops being artwork and starts being a wall. + */ +const TABLET_STACK_MAX_CONTENT_WIDTH = 640; +const TABLET_STACK_ART_SIZE_MAX = 560; +const TABLET_STACK_SCOPE_WIDTH_MAX = 640; export const NOW_PLAYING_WIDE_PANE_GAP = spacing.xxl; const WIDE_RIGHT_PANE_MIN = 300; /** @@ -33,8 +63,45 @@ const WIDE_RIGHT_PANE_MIN = 300; * simply unused. */ const WIDE_RIGHT_PANE_MAX = 560; -const WIDE_ART_SIZE_MAX = 400; const WIDE_ART_SIZE_MIN = 160; + +/** + * Ceilings for the landscape row. Two declared sets, not one scaled number. + * + * The originals were tuned against a phone in landscape — ~411dp tall — where + * they never actually bind, because the artwork runs out of *height* long before + * it reaches 400dp. Reusing them on a tablet is what left a 10" screen with + * 400dp artwork, a 960dp row inside a 1248dp window, and ~250dp of dead space + * under the deck: the caps were doing nothing on the device they were written + * for and everything on the device they weren't. + * + * The tablet set is what lets the artwork actually be the subject of the screen. + * A window has to clear both a height and a width bar to get it — height because + * that is what the artwork is bound by, width because a tall narrow window has + * nowhere to put the deck. + */ +interface WideRowCaps { + artMax: number; + rowMax: number; + /** + * The scope strip's ceiling moves with the artwork's, or raising one alone + * inverts them: at 640dp of art against the phone's 448dp strip cap, the + * "rail under the artwork" becomes a box narrower than what it sits under. + * The tablet number is set to leave the deck ~400dp once the stage has taken + * its share, rather than to any ratio. + */ + scopeMax: number; +} +const WIDE_CAPS_PHONE: WideRowCaps = { artMax: 400, rowMax: 960, scopeMax: 448 }; +const WIDE_CAPS_TABLET: WideRowCaps = { artMax: 640, rowMax: 1160, scopeMax: 720 }; +const WIDE_TABLET_MIN_HEIGHT = 640; +const WIDE_TABLET_MIN_WIDTH = 900; + +function wideRowCaps(availableWidth: number, availableHeight: number): WideRowCaps { + return availableHeight >= WIDE_TABLET_MIN_HEIGHT && availableWidth >= WIDE_TABLET_MIN_WIDTH + ? WIDE_CAPS_TABLET + : WIDE_CAPS_PHONE; +} /** * How much wider than the artwork the scope strip runs. Mirrors the portrait * proportion (~1.5x), where the strip reads as a rail under the art rather than @@ -66,6 +133,17 @@ export const NOW_PLAYING_SUB_BUTTON_SIZE = 40; * rather than dropping a tier to buy artwork it doesn't need. */ const ART_COMFORT_MIN = 152; +/** + * The same floor for a stacked tablet, where 152dp of artwork is not "small", + * it is a thumbnail on a 10" screen. + * + * This is what stops a short, wide tablet from spending its column on the + * richest deck: at 752dp of height the spacious deck takes 360 of it and leaves + * the artwork under 300. Raising the bar makes the ladder step down to a leaner + * deck and hand the difference to the artwork — which is the whole point of + * stacking on a device this size. + */ +const TABLET_ART_COMFORT_MIN = 320; /** * Artwork the scope rail must leave behind to be worth its stage space. Below * this the rail is dropped rather than squeezing the art into a thumbnail. @@ -75,11 +153,32 @@ const SCOPE_RAIL_MIN_ART = 96; const TABLET_SHELL_MIN_WIDTH = 720; const TABLET_SHELL_MAX_WIDTH = 1200; const TABLET_COMPANION_GAP = spacing.xl; +/** + * Companion widths, per companion. + * + * A queue row is a thumbnail plus two short lines and reads fine at 360dp. A + * lyric line is a sentence, and at that width it breaks mid-phrase — the most + * visible flaw in the panel. Lyrics therefore get a wider column; it is the + * cheapest readability win available and costs the player nothing it was using. + */ const TABLET_COMPANION_MIN_WIDTH = 320; const TABLET_COMPANION_MAX_WIDTH = 400; -const TABLET_STACKED_MIN_HEIGHT = 760; -const TABLET_WIDE_PLAYER_MIN_WIDTH = 600; -const TABLET_WIDE_MIN_HEIGHT = 520; +const TABLET_COMPANION_LYRICS_MIN_WIDTH = 440; +const TABLET_COMPANION_LYRICS_MAX_WIDTH = 760; +const TABLET_COMPANION_WIDTH_RATIO = 0.34; +/** + * Lyrics take the majority of the shell, not a sidecar's share. A queue row is + * a thumbnail and two short lines; a lyric line is a sentence, and at half the + * screen it is still breaking mid-phrase. + */ +const TABLET_COMPANION_LYRICS_WIDTH_RATIO = 0.6; +/** + * The player never gives up more than this, however much the companion wants. + * It is what keeps the artwork the subject on a narrow shell — an unfolded + * foldable would otherwise hand the lyrics 60% of 776dp and leave the player a + * 280dp strip. + */ +const TABLET_PLAYER_REGION_MIN = 420; export type NowPlayingPresentation = 'standard' | 'wide'; export type NowPlayingDensity = 'spacious' | 'regular' | 'compact'; @@ -328,9 +427,28 @@ export function getNowPlayingLayout( availableHeight: number, showVisualizer: boolean, forceWide = false, - fontScale = 1 + fontScale = 1, + /** + * Treat this as a tablet column regardless of how narrow it is. + * + * The companion tier passes it, because *it* knows the window is a tablet + * even when the pane has squeezed the player into 450dp. Inferring + * tablet-ness from the region's own width would drop those columns back to + * phone ceilings and the phone comfort floor the moment the lyrics pane got + * wide — the artwork would shrink because the pane grew, which is backwards. + * It cannot be inferred by lowering `TABLET_STACK_MIN_WIDTH` either: large + * phones are 430-450dp wide in portrait and would be caught by it. + */ + forceTabletStack = false ): NowPlayingLayout { - const isWide = forceWide || isWideWindow(availableWidth, availableHeight); + // Stacking wins wherever it fits, including over `forceWide` — a tablet with + // the companion pane out still has the height to stack, and the player must + // not change shape just because a pane slid in beside it. + const stacksAsTablet = + forceTabletStack || + (availableWidth >= TABLET_STACK_MIN_WIDTH && availableHeight >= TABLET_STACK_MIN_HEIGHT); + const isWide = + !stacksAsTablet && (forceWide || isWideWindow(availableWidth, availableHeight)); const columnHeight = availableHeight - @@ -340,9 +458,10 @@ export function getNowPlayingLayout( if (isWide) { const contentPadding = CONTENT_SIDE_PADDING; + const caps = wideRowCaps(availableWidth, availableHeight); const rowSpace = Math.max( 0, - Math.min(availableWidth - contentPadding * 2, WIDE_MAX_CONTENT_WIDTH) + Math.min(availableWidth - contentPadding * 2, caps.rowMax) ); /** @@ -367,7 +486,7 @@ export function getNowPlayingLayout( ); const inner = Math.max(0, columnHeight - tier.stageInset * 2); const fitArt = (space: number) => - Math.round(Math.max(0, Math.min(space, stageSpace, WIDE_ART_SIZE_MAX))); + Math.round(Math.max(0, Math.min(space, stageSpace, caps.artMax))); // The strip's height follows its width, which follows the artwork, which // depends on the strip's height. Seed with the tallest strip it could be @@ -384,7 +503,7 @@ export function getNowPlayingLayout( clamp( artScopeOn * WIDE_SCOPE_WIDTH_RATIO, artScopeOn, - Math.min(stageSpace, VISUALIZER_WIDTH_MAX) + Math.min(stageSpace, caps.scopeMax) ) ); scopeHeight = getScopeHeight(scopeWidth); @@ -456,19 +575,37 @@ export function getNowPlayingLayout( const isTabletColumn = availableWidth >= WIDE_MIN_WIDTH; const contentPadding = CONTENT_SIDE_PADDING; - const maxContentWidth = isTabletColumn ? TABLET_MAX_CONTENT_WIDTH : MAX_CONTENT_WIDTH; + const maxContentWidth = stacksAsTablet + ? TABLET_STACK_MAX_CONTENT_WIDTH + : isTabletColumn + ? TABLET_MAX_CONTENT_WIDTH + : MAX_CONTENT_WIDTH; const contentWidth = Math.max( 0, Math.min(availableWidth - contentPadding * 2, maxContentWidth) ); const scopeWidth = Math.max( 0, - Math.min(availableWidth - VISUALIZER_SIDE_PADDING * 2, VISUALIZER_WIDTH_MAX) + Math.min( + availableWidth - VISUALIZER_SIDE_PADDING * 2, + stacksAsTablet ? TABLET_STACK_SCOPE_WIDTH_MAX : VISUALIZER_WIDTH_MAX + ) ); const scopeHeight = getScopeHeight(scopeWidth); + // The artwork is capped separately from the deck on purpose. It is square, so + // a wider column buys it nothing — letting `contentWidth` size it is what + // would turn a tablet's extra width into a wall of cover art instead of a + // longer seek bar. const artWidthCap = (tier: DensityTier) => - Math.min(contentWidth, isTabletColumn ? TABLET_ART_SIZE_MAX : tier.artMax); + Math.min( + contentWidth, + stacksAsTablet + ? TABLET_STACK_ART_SIZE_MAX + : isTabletColumn + ? TABLET_ART_SIZE_MAX + : tier.artMax + ); // Walk richest to leanest and take the first tier whose artwork lands in a // comfortable band. Deliberately measured against the scope-ON size at every @@ -483,7 +620,7 @@ export function getNowPlayingLayout( columnHeight - candidateDeck.height - candidate.stageInset * 2; const scopeBlock = candidate.scopeTopGap + scopeHeight + candidate.scopeBottomGap; const art = Math.min(inner - scopeBlock, artWidthCap(candidate)); - if (art >= ART_COMFORT_MIN) { + if (art >= (stacksAsTablet ? TABLET_ART_COMFORT_MIN : ART_COMFORT_MIN)) { tier = candidate; deck = candidateDeck; break; @@ -545,7 +682,8 @@ export function getTabletCompanionLayout( availableWidth: number, availableHeight: number, showVisualizer: boolean, - fontScale = 1 + fontScale = 1, + companion: 'queue' | 'lyrics' = 'queue' ): TabletCompanionLayout | null { const shellWidth = Math.min( Math.max(0, availableWidth - CONTENT_SIDE_PADDING * 2), @@ -553,17 +691,33 @@ export function getTabletCompanionLayout( ); if (shellWidth < TABLET_SHELL_MIN_WIDTH) return null; + const lyrics = companion === 'lyrics'; + // The player floor constrains lyrics only. Lyrics is the companion that asks + // for a majority of the shell, so it is the only one that can starve the + // player; the queue's 320-400 band never could, and applying the floor to it + // as well squeezed the queue *below* its own minimum on a small tablet. + const companionCeiling = lyrics + ? Math.min( + TABLET_COMPANION_LYRICS_MAX_WIDTH, + Math.max(0, shellWidth - TABLET_COMPANION_GAP - TABLET_PLAYER_REGION_MIN) + ) + : TABLET_COMPANION_MAX_WIDTH; const companionWidth = Math.round( - clamp(shellWidth * 0.34, TABLET_COMPANION_MIN_WIDTH, TABLET_COMPANION_MAX_WIDTH) + Math.min( + companionCeiling, + Math.max( + lyrics ? TABLET_COMPANION_LYRICS_MIN_WIDTH : TABLET_COMPANION_MIN_WIDTH, + shellWidth * (lyrics ? TABLET_COMPANION_LYRICS_WIDTH_RATIO : TABLET_COMPANION_WIDTH_RATIO) + ) + ) ); const playerRegionWidth = shellWidth - TABLET_COMPANION_GAP - companionWidth; - const canStack = availableHeight >= TABLET_STACKED_MIN_HEIGHT; - const canUseWidePlayer = - playerRegionWidth >= TABLET_WIDE_PLAYER_MIN_WIDTH && - availableHeight >= TABLET_WIDE_MIN_HEIGHT; - if (!canStack && !canUseWidePlayer) return null; - - const forceWide = canUseWidePlayer && availableWidth > availableHeight; + // The player region always stacks now, so the old pair of gates — "tall + // enough to stack (760)" *or* "wide enough to go side-by-side (600)" — asked + // a question that no longer has two answers, and rejected the case they were + // both written for: a 752dp-tall tablet whose player region the lyrics pane + // had narrowed to 456. One gate, the same height bar the player itself uses. + if (availableHeight < TABLET_STACK_MIN_HEIGHT) return null; return { presentation: 'tablet-companion', shellWidth, @@ -574,8 +728,11 @@ export function getTabletCompanionLayout( playerRegionWidth, availableHeight, showVisualizer, - forceWide, - fontScale + false, + fontScale, + // This tier only exists on a tablet, so the region keeps tablet sizing + // however narrow the companion has made it. + true ), }; } diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 29d77a9..6dc8add 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -33,6 +33,7 @@ const LISTENING_HISTORY_ENABLED_KEY = 'listening_history_enabled'; const ARTIST_IMAGE_AUTO_POLICY_KEY = 'artist_image_auto_policy'; const ARTIST_IMAGE_DISCLOSURE_KEY = 'artist_image_disclosure_seen'; const PLAYER_DOCK_KEY = 'player_dock_open'; +const NOW_PLAYING_COMPANION_OPEN_KEY = 'now_playing_companion_open'; /** Which visualizer the now-playing scope stage shows. */ export type ScopeMode = 'spectrum' | 'scope'; @@ -80,6 +81,12 @@ interface SettingsStore { /** Whether the now-playing top half shows lyrics instead of art/scope. */ lyricsVisible: boolean; nowPlayingCompanion: NowPlayingCompanion; + /** + * Whether the tablet queue/lyrics pane is out beside the player. Like + * `playerDockOpen` this is a wish: only a window that can seat a pane without + * squeezing the player honours it, and the layout arbitrates. + */ + nowPlayingCompanionOpen: boolean; homeGreetingTextMode: HomeGreetingTextMode; listeningHistoryEnabled: boolean; artistImageAutoPolicy: ArtistImageAutoPolicy; @@ -94,6 +101,7 @@ interface SettingsStore { setNowPlayingScopeStyle: (style: NowPlayingScopeStyle) => Promise; setLyricsVisible: (visible: boolean) => Promise; setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise; + setNowPlayingCompanionOpen: (open: boolean) => Promise; setHomeGreetingTextMode: (mode: HomeGreetingTextMode) => Promise; setListeningHistoryEnabled: (enabled: boolean) => Promise; setArtistImageAutoPolicy: (policy: ArtistImageAutoPolicy) => Promise; @@ -109,6 +117,7 @@ export const useSettingsStore = create((set, get) => ({ nowPlayingScopeStyle: 'rail', lyricsVisible: false, nowPlayingCompanion: 'queue', + nowPlayingCompanionOpen: false, homeGreetingTextMode: 'messages', listeningHistoryEnabled: true, artistImageAutoPolicy: 'wifi', @@ -146,6 +155,7 @@ export const useSettingsStore = create((set, get) => ({ ); const artistImageDisclosureSeen = values[ARTIST_IMAGE_DISCLOSURE_KEY] === '1'; const playerDockOpen = values[PLAYER_DOCK_KEY] ?? null; + const companionOpen = values[NOW_PLAYING_COMPANION_OPEN_KEY] ?? null; set({ artistGroupingMode: parseGroupingMode(grouping), includeSingles: parseBoolean(includeSingles), @@ -155,6 +165,7 @@ export const useSettingsStore = create((set, get) => ({ nowPlayingScopeStyle: parseScopeStyle(scopeStyle), lyricsVisible: parseBoolean(lyricsVisible), nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion), + nowPlayingCompanionOpen: parseBoolean(companionOpen), homeGreetingTextMode: parseHomeGreetingTextMode(homeGreetingTextMode), listeningHistoryEnabled, artistImageAutoPolicy, @@ -193,6 +204,14 @@ export const useSettingsStore = create((set, get) => ({ await AstraLibraryData.setSettings({ [PLAYER_DOCK_KEY]: open ? 'true' : 'false' }); }, + setNowPlayingCompanionOpen: async (open) => { + if (get().nowPlayingCompanionOpen === open) return; + set({ nowPlayingCompanionOpen: open }); + await AstraLibraryData.setSettings({ + [NOW_PLAYING_COMPANION_OPEN_KEY]: open ? 'true' : 'false', + }); + }, + setNowPlayingScopeStyle: async (style) => { if (get().nowPlayingScopeStyle === style) return; set({ nowPlayingScopeStyle: style });