From 409111a9f534c107c6419a90d8939c430a56e16a Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:05:59 -0400 Subject: [PATCH] improve now playing landscape --- src/components/player/NowPlayingOverlay.tsx | 26 ++- .../player/nowPlayingLayout.test.mts | 116 +++++++++++++ src/components/player/nowPlayingLayout.ts | 159 +++++++++++++----- 3 files changed, 254 insertions(+), 47 deletions(-) diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx index d2061ea..6b379bc 100644 --- a/src/components/player/NowPlayingOverlay.tsx +++ b/src/components/player/NowPlayingOverlay.tsx @@ -135,6 +135,8 @@ const SKIP_ICON_SIZE = 32; const PLAY_ICON_SIZE = 34; const SUB_BUTTON_SIZE = NOW_PLAYING_SUB_BUTTON_SIZE; const SUB_ICON_SIZE = 20; +/** Comfortable thumb span for the transport row; see styles.transport. */ +const TRANSPORT_MAX_WIDTH = 400; const MENU_ANIMATION_IN_MS = 130; const MENU_ANIMATION_OUT_MS = 100; const MENU_ENTER_OFFSET_Y = -8; @@ -1013,7 +1015,11 @@ export function NowPlayingOverlay() { style={[ styles.stage, layout.isWide - ? { width: layout.leftPaneWidth } + ? { + width: layout.leftPaneWidth, + height: layout.stageHeight, + paddingVertical: layout.stageInset, + } : styles.stageFill, ]} > @@ -1046,7 +1052,7 @@ export function NowPlayingOverlay() { styles.deckSpread, { rowGap: deck.rowGap }, layout.isWide - ? { width: layout.rightPaneWidth } + ? { width: layout.rightPaneWidth, height: deck.height } : { height: deck.height }, ]} > @@ -1291,7 +1297,11 @@ export function NowPlayingOverlay() { style={[ styles.stage, layout.isWide - ? { width: layout.leftPaneWidth } + ? { + width: layout.leftPaneWidth, + height: layout.stageHeight, + paddingVertical: layout.stageInset, + } : styles.stageFill, ]} > @@ -1375,7 +1385,7 @@ export function NowPlayingOverlay() { /> )} - {railStyle && layout.isWide && renderScopeSurfaces && ( + {railStyle && layout.isWide && layout.scopeRailFits && renderScopeSurfaces && ( @@ -2175,6 +2185,12 @@ const useStyles = createThemedStyles((colors) => ({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + // The seek bar above wants every pixel of a wide landscape deck; the + // transport does not — past this the buttons just drift to the far corners. + // Centred under a full-width bar, which is how wide players lay this out. + width: '100%', + maxWidth: TRANSPORT_MAX_WIDTH, + alignSelf: 'center', }, transportMainBtn: { width: 48, diff --git a/src/components/player/nowPlayingLayout.test.mts b/src/components/player/nowPlayingLayout.test.mts index f3f2612..366f71d 100644 --- a/src/components/player/nowPlayingLayout.test.mts +++ b/src/components/player/nowPlayingLayout.test.mts @@ -319,6 +319,122 @@ test('caps reserved line boxes so a huge font setting cannot run away', () => { assert.ok(capped.deck.height > layoutFor(device, 1, true).deck.height); }); +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; + +test('landscape sizes its panes from their contents, not a fixed split', () => { + for (const fontScale of FONT_SCALES) { + for (const window of LANDSCAPE) { + const layout = getNowPlayingLayout( + window.width, + window.height, + true, + false, + fontScale + ); + assert.equal(layout.presentation, 'wide', `${window.name} should be wide`); + assert.equal( + layout.leftPaneWidth + 32 + layout.rightPaneWidth, + layout.contentWidth, + `${window.name}: panes + gap must equal the row width` + ); + assert.ok( + layout.contentWidth <= window.width - layout.contentPadding * 2, + `${window.name}: row ${layout.contentWidth} overflows the window` + ); + // The stage pane exists to hold the artwork and its strip; a proportional + // split used to hand a 160dp artwork a 432dp pane. + assert.equal( + layout.leftPaneWidth, + Math.max(layout.artSizeScopeOff, layout.scopeWidth), + `${window.name}: stage pane should hug its widest content` + ); + } + } +}); + +test('landscape actually uses the width it is given', () => { + // The deck cap was aliased to the portrait column width, leaving 100-150dp of + // a phone's landscape row unused. Height is scarce in landscape and the + // artwork is square, so the deck is the only pane that can spend the surplus. + for (const window of LANDSCAPE) { + if (window.width > 1000) continue; // tablets are capped by design, for now + const layout = getNowPlayingLayout(window.width, window.height, true, false, 1); + const rowSpace = Math.min(window.width - layout.contentPadding * 2, 960); + const unused = rowSpace - layout.contentWidth; + assert.ok( + unused <= 40, + `${window.name}: ${unused}dp of the row goes unused (row ${layout.contentWidth} of ${rowSpace})` + ); + } +}); + +test('landscape keeps the scope strip in proportion to the artwork', () => { + for (const window of LANDSCAPE) { + const layout = getNowPlayingLayout(window.width, window.height, true, false, 1); + if (!layout.scopeRailFits) continue; + assert.ok( + layout.scopeWidth >= layout.artSizeScopeOn, + `${window.name}: strip ${layout.scopeWidth} narrower than artwork ${layout.artSizeScopeOn}` + ); + // Was 2.7x on a Pixel in landscape, which read as a box beside the art. + assert.ok( + layout.scopeWidth <= layout.artSizeScopeOn * 1.6 + 1, + `${window.name}: strip ${layout.scopeWidth} is out of proportion to artwork ${layout.artSizeScopeOn}` + ); + assert.equal(layout.scopeHeight, getScopeHeight(layout.scopeWidth)); + } +}); + +test('landscape picks the richest deck the column can hold', () => { + const rank = { compact: 0, regular: 1, spacious: 2 } as const; + for (const fontScale of FONT_SCALES) { + for (const window of LANDSCAPE) { + const layout = getNowPlayingLayout( + window.width, + window.height, + true, + false, + fontScale + ); + const column = + window.height - + NOW_PLAYING_CONTENT_TOP_PADDING - + NOW_PLAYING_CONTENT_BOTTOM_PADDING - + NOW_PLAYING_HEADER_HEIGHT; + // Either the deck fits, or it is the leanest tier and the window is the + // one at fault. + assert.ok( + layout.deck.height <= column || layout.density === 'compact', + `${window.name} @${fontScale}: '${layout.density}' deck ${layout.deck.height} exceeds column ${column}` + ); + assert.equal(layout.stageHeight, column); + assert.ok(rank[layout.density] >= 0); + } + } +}); + +test('landscape never grows the artwork when the scope comes on', () => { + for (const fontScale of FONT_SCALES) { + for (const window of LANDSCAPE) { + const hidden = getNowPlayingLayout(window.width, window.height, false, false, fontScale); + const visible = getNowPlayingLayout(window.width, window.height, true, false, fontScale); + assert.equal(hidden.deck.height, visible.deck.height); + assert.equal(hidden.leftPaneWidth, visible.leftPaneWidth); + assert.equal(hidden.rightPaneWidth, visible.rightPaneWidth); + assert.equal(hidden.contentWidth, visible.contentWidth); + assert.ok(visible.artSizeScopeOn <= visible.artSizeScopeOff); + } + } +}); + test('adds the companion only to roomy tablet canvases', () => { for (const device of DEVICES) { assert.equal(getTabletCompanionLayout(device.width, device.height, true), null); diff --git a/src/components/player/nowPlayingLayout.ts b/src/components/player/nowPlayingLayout.ts index 2ec8769..b428363 100644 --- a/src/components/player/nowPlayingLayout.ts +++ b/src/components/player/nowPlayingLayout.ts @@ -25,10 +25,24 @@ const TABLET_ART_SIZE_MAX = 440; const WIDE_MAX_CONTENT_WIDTH = 960; export const NOW_PLAYING_WIDE_PANE_GAP = spacing.xxl; const WIDE_RIGHT_PANE_MIN = 300; -const WIDE_RIGHT_PANE_MAX = MAX_CONTENT_WIDTH; +/** + * Landscape deck pane cap. Height is the scarce resource in landscape and the + * artwork is square, so it can never spend the surplus width — the deck is the + * only pane that can, and a longer seek bar is the point. Was aliased to the + * portrait column width (408), which left 100-150dp of a phone's landscape row + * simply unused. + */ +const WIDE_RIGHT_PANE_MAX = 560; const WIDE_ART_SIZE_MAX = 400; const WIDE_ART_SIZE_MIN = 160; -const WIDE_COMPACT_HEIGHT = 480; +/** + * 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 + * a box beside it. + */ +const WIDE_SCOPE_WIDTH_RATIO = 1.5; +/** Below this the landscape artwork is too small to give the strip its share. */ +const WIDE_SCOPE_RAIL_MIN_ART = 120; const VISUALIZER_WIDTH_MAX = 448; const VISUALIZER_SIDE_PADDING = spacing.md; const VISUALIZER_TOP_GAP = spacing.lg; @@ -318,38 +332,104 @@ export function getNowPlayingLayout( ): NowPlayingLayout { const isWide = forceWide || isWideWindow(availableWidth, availableHeight); + const columnHeight = + availableHeight - + NOW_PLAYING_CONTENT_TOP_PADDING - + NOW_PLAYING_CONTENT_BOTTOM_PADDING - + NOW_PLAYING_HEADER_HEIGHT; + if (isWide) { const contentPadding = CONTENT_SIDE_PADDING; - const contentWidth = Math.max( + const rowSpace = Math.max( 0, Math.min(availableWidth - contentPadding * 2, WIDE_MAX_CONTENT_WIDTH) ); + + /** + * Solve one tier's landscape geometry. + * + * Panes sit side by side, so unlike portrait the deck costs the artwork no + * height — the constraint is that the deck fits the column at all. The + * artwork is height-bound in almost every landscape window, which is why + * the panes are sized from their *contents* here and the pair is centred: + * a proportional split gave a 160dp artwork a 432dp pane to rattle around + * in, with a scope strip nearly three times its width beneath it. + */ + const solve = (tier: DensityTier) => { + const deck = getDeckHeight(tier, fontScale); + // Budget against the *narrowest* the deck may be, so the artwork gets + // first call on the row's width; the deck reclaims whatever is left over + // once the stage has been sized (a longer waveform is worth having in + // landscape, and it is the only thing here that can use loose width). + const stageSpace = Math.max( + 0, + rowSpace - NOW_PLAYING_WIDE_PANE_GAP - WIDE_RIGHT_PANE_MIN + ); + 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))); + + // 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 + // and refine once — `getScopeHeight` is clamped to a 12dp band, so a + // second pass is enough to land on a stable answer. + let scopeHeight = VISUALIZER_HEIGHT_MAX; + let scopeWidth = 0; + let artScopeOn = 0; + for (let pass = 0; pass < 2; pass += 1) { + artScopeOn = fitArt( + inner - (tier.scopeTopGap + scopeHeight + tier.scopeBottomGap) + ); + scopeWidth = Math.round( + clamp( + artScopeOn * WIDE_SCOPE_WIDTH_RATIO, + artScopeOn, + Math.min(stageSpace, VISUALIZER_WIDTH_MAX) + ) + ); + scopeHeight = getScopeHeight(scopeWidth); + } + const artScopeOff = fitArt(inner); + const naturalScopeBlock = tier.scopeTopGap + scopeHeight + tier.scopeBottomGap; + const scopeRailFits = artScopeOn >= WIDE_SCOPE_RAIL_MIN_ART; + return { + tier, + deck, + scopeWidth, + scopeHeight, + artScopeOn: scopeRailFits ? artScopeOn : artScopeOff, + artScopeOff, + scopeBlockHeight: scopeRailFits ? naturalScopeBlock : 0, + scopeRailFits, + fits: deck.height <= columnHeight && artScopeOff >= WIDE_ART_SIZE_MIN, + }; + }; + + // Richest tier whose deck fits the column outright. In landscape the deck + // is the thing that runs out of room first, so this replaces the old raw + // `availableHeight < 480` threshold with the same ladder portrait uses. + let solved = solve(TIERS[TIERS.length - 1]); + for (const candidate of TIERS) { + const attempt = solve(candidate); + if (attempt.fits) { + solved = attempt; + break; + } + } + + const { tier, deck } = solved; + const artSize = showVisualizer ? solved.artScopeOn : solved.artScopeOff; + // Panes are content-sized and the row is centred by the shell being exactly + // this wide. The deck takes the width the stage didn't need. + const leftPaneWidth = Math.max(solved.artScopeOff, solved.scopeWidth); const rightPaneWidth = Math.round( - clamp(contentWidth * 0.46, WIDE_RIGHT_PANE_MIN, WIDE_RIGHT_PANE_MAX) - ); - const leftPaneWidth = Math.max( - 0, - contentWidth - NOW_PLAYING_WIDE_PANE_GAP - rightPaneWidth - ); - const scopeWidth = Math.min(leftPaneWidth, VISUALIZER_WIDTH_MAX); - const scopeHeight = getScopeHeight(scopeWidth); - const visualizerTopGap = showVisualizer ? VISUALIZER_TOP_GAP : 0; - const verticalBudget = - availableHeight - - NOW_PLAYING_CONTENT_TOP_PADDING - - NOW_PLAYING_CONTENT_BOTTOM_PADDING - - NOW_PLAYING_HEADER_HEIGHT - - spacing.md; - const wideArt = (budget: number) => - Math.round(clamp(Math.min(leftPaneWidth, budget), WIDE_ART_SIZE_MIN, WIDE_ART_SIZE_MAX)); - const artSizeScopeOn = wideArt(verticalBudget - (scopeHeight + VISUALIZER_TOP_GAP)); - const artSizeScopeOff = wideArt(verticalBudget); - const artSize = showVisualizer ? artSizeScopeOn : artSizeScopeOff; - // Short landscape windows get the leaner deck tokens. - const deck = getDeckHeight( - availableHeight < WIDE_COMPACT_HEIGHT ? TIERS[2] : TIERS[1], - fontScale + clamp( + rowSpace - NOW_PLAYING_WIDE_PANE_GAP - leftPaneWidth, + WIDE_RIGHT_PANE_MIN, + WIDE_RIGHT_PANE_MAX + ) ); + const contentWidth = leftPaneWidth + NOW_PLAYING_WIDE_PANE_GAP + rightPaneWidth; return { presentation: 'wide', isWide: true, @@ -359,18 +439,18 @@ export function getNowPlayingLayout( leftPaneWidth, rightPaneWidth, deck, - stageHeight: showVisualizer ? artSize + visualizerTopGap + scopeHeight : artSize, - stageInset: 0, - scopeBlockHeight: showVisualizer ? visualizerTopGap + scopeHeight : 0, + stageHeight: columnHeight, + stageInset: tier.stageInset, + scopeBlockHeight: solved.scopeBlockHeight, railBottomOffset: 0, - scopeRailFits: true, + scopeRailFits: solved.scopeRailFits, artSize, - artSizeScopeOn, - artSizeScopeOff, - scopeWidth, - scopeHeight, - visualizerTopGap, - visualizerBottomGap: 0, + artSizeScopeOn: solved.artScopeOn, + artSizeScopeOff: solved.artScopeOff, + scopeWidth: solved.scopeWidth, + scopeHeight: solved.scopeHeight, + visualizerTopGap: tier.scopeTopGap, + visualizerBottomGap: tier.scopeBottomGap, }; } @@ -386,11 +466,6 @@ export function getNowPlayingLayout( Math.min(availableWidth - VISUALIZER_SIDE_PADDING * 2, VISUALIZER_WIDTH_MAX) ); const scopeHeight = getScopeHeight(scopeWidth); - const columnHeight = - availableHeight - - NOW_PLAYING_CONTENT_TOP_PADDING - - NOW_PLAYING_CONTENT_BOTTOM_PADDING - - NOW_PLAYING_HEADER_HEIGHT; const artWidthCap = (tier: DensityTier) => Math.min(contentWidth, isTabletColumn ? TABLET_ART_SIZE_MAX : tier.artMax);