mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-21 21:16:09 +02:00
improve now playing landscape
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
if (isWide) {
|
||||
const contentPadding = CONTENT_SIDE_PADDING;
|
||||
const contentWidth = Math.max(
|
||||
0,
|
||||
Math.min(availableWidth - contentPadding * 2, WIDE_MAX_CONTENT_WIDTH)
|
||||
);
|
||||
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 =
|
||||
const columnHeight =
|
||||
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
|
||||
NOW_PLAYING_HEADER_HEIGHT;
|
||||
|
||||
if (isWide) {
|
||||
const contentPadding = CONTENT_SIDE_PADDING;
|
||||
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(
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user