improve now playing landscape

This commit is contained in:
Boof2015
2026-07-31 12:05:59 -04:00
parent 0fb7b39395
commit 409111a9f5
3 changed files with 254 additions and 47 deletions
+21 -5
View File
@@ -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() {
/>
</Animated.View>
)}
{railStyle && layout.isWide && renderScopeSurfaces && (
{railStyle && layout.isWide && layout.scopeRailFits && renderScopeSurfaces && (
<View
style={[
styles.scopeRail,
@@ -1404,7 +1414,7 @@ export function NowPlayingOverlay() {
styles.deck,
{ rowGap: deck.rowGap },
layout.isWide
? { width: layout.rightPaneWidth }
? { width: layout.rightPaneWidth, height: deck.height }
: { height: deck.height },
]}
>
@@ -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,
@@ -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);
+117 -42
View File
@@ -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);