android: the phone turned sideways — the player beside itself, the chrome cut down
The phone UI was drawn portrait-first and had never been looked at in
landscape. The player sheet was the casualty: its cover is a square sized by
width, so at 915×411 it came out 879px tall and the title, the scrubber, the
transport and the face buttons all sat below the bottom of a sheet that does
not scroll — a corner of the artwork and no way to pause. Sideways the sheet
is two columns now, the cover sized by height beside the controls, with a
lyrics or queue face taking the cover's column; the nav and the mini bar come
down to 48 and 60dp, the artist hero becomes the banner the desktop's hero has
always been rather than 300px of a 411px screen, the record head puts the
cover beside the title, and the shell reads the side insets the cutout and the
gesture pill take when the phone is on its side. The walk turns the phone over
and back on its own: `{ view: [w, h] }` is a step kind now, and the steps
assert what used to be off the screen.
This commit is contained in:
1 parent
aff29aba5c
commit
9df162360e
5 files changed
+228
-3
No files matched your search
+39
-2
@@ -68,6 +68,37 @@ transform (the small art flies to the big one). The player's scrubber is the
|
||||
lane itself, no knob. Motion is Material 3 Expressive's springs, sampled into
|
||||
`linear()`. Reduce motion (You → Appearance, or the OS setting) turns it off.
|
||||
|
||||
The activity declares no `screenOrientation` and handles `orientation` and
|
||||
`screenSize` itself, so turning the phone over is a resize and nothing else —
|
||||
the audio thread, the stack and the screen's scroll all survive it, and the
|
||||
layout is the only thing that has to answer. Two media queries do that. The
|
||||
player sheet is the one surface whose portrait shape cannot survive a wide
|
||||
viewport: its artwork is a square sized by WIDTH, so at 915×411 the cover came
|
||||
out 879px tall and the title, the lane, the transport and the face buttons sat
|
||||
below the bottom of a sheet that does not scroll — a corner of the cover and no
|
||||
way to pause. Sideways it is two columns: the head across the top, the cover
|
||||
down the left sized by HEIGHT, and the track, the scrubber, the transport and
|
||||
the faces stacked on the right. With lyrics or the queue up, the face takes the
|
||||
cover's column and the thumbnail goes — the record is already the ground behind
|
||||
the whole sheet. That is an aspect problem rather than a height one, so the rule
|
||||
keys off `orientation: landscape` alone and a tablet gets the same two columns.
|
||||
|
||||
The rest is a phone-sized query (`max-height: 560px`). The nav comes down to
|
||||
Android's own 48dp minimum and the mini bar to a 60dp row over the same lane
|
||||
strip, which gives the page back 22 of a 411-pixel screen — the chrome was 46%
|
||||
of the height, it is 40%. The artist hero stops being a 300px portrait, which
|
||||
covered its own Play button with the mini bar, and becomes a 190px banner: what
|
||||
the desktop's hero has always been. The record head puts the cover beside the
|
||||
title rather than above it, again the desktop's own shape, so Play is in view
|
||||
without a scroll. A half sheet may take all but 36px of the height instead of
|
||||
76% of it. Everything else — the grids, the rails, the artwork viewer, the
|
||||
tap-to-sync room, the chat composer — already answered to width and was left
|
||||
alone. `viewport-fit=cover` means the cutout and the gesture pill come in from
|
||||
the SIDES in landscape, so the shell reads `--inset-left` / `--inset-right`
|
||||
beside the top and bottom ones now. Those rules are appended at the end of
|
||||
`shell.css` on purpose: they have to come after `.gbar`'s padding, `.mini`'s
|
||||
and `.toast`'s left/right to win at equal specificity.
|
||||
|
||||
## Search and the resolver
|
||||
|
||||
Search is the desktop's top bar: the field asks the **catalog** once the
|
||||
@@ -631,14 +662,20 @@ a catalog card's and the record page's sheets → the now-playing Save →
|
||||
a peer's sheet, a raw file's sheet, a user's shares browsed and a folder
|
||||
of theirs streamed → a buddy's sheet → You (every switch and chip saved
|
||||
and read back from the config) → recent searches and a spelling
|
||||
suggestion → the sleep timer, asserts what each step should show, screenshots every step into `tests/mobile/out/`, and fails on
|
||||
suggestion → the sleep timer → the phone turned sideways (the player's three faces, a
|
||||
record with a long sheet over it, an artist page, and the side insets faked,
|
||||
all at 915×411) and back, asserts what each step should show, screenshots every step into `tests/mobile/out/`, and fails on
|
||||
any exception or console error. The mock answers every command the phone
|
||||
uses; one it does not know is named in the report (`[mock] no mock for X`)
|
||||
rather than surfacing as a TypeError toast, console warnings carry their top
|
||||
frames (the smoke bundle is built unminified for that), and every probe
|
||||
carries the history mirror — the entry's depth against the layers the app
|
||||
says the back gesture can peel (`hd`/`hwant`; a drift is how the blank
|
||||
page after a confirmed delete was found).
|
||||
page after a confirmed delete was found). A mock step can also set the
|
||||
viewport — `{ view: [915, 411] }` sends `Emulation.setDeviceMetricsOverride`
|
||||
through the same client, which is how the walk turns the phone over and back;
|
||||
the phone walk refuses one, because a real device's orientation is not the
|
||||
harness's to set.
|
||||
|
||||
The phone walk is the same driver on the real thing (plus a step kind of
|
||||
its own: `{ adb: [...] }` runs an adb command — the share-sheet intent, a
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
<div class="amb-tint"></div>
|
||||
<div class="grain"></div>
|
||||
</div>
|
||||
<div class="np">
|
||||
<div class="np" class:small>
|
||||
<div class="np-head">
|
||||
<button class="iconbtn" onclick={closeSheet} aria-label="Close"><Icon name="chevron-down" size={24} /></button>
|
||||
<button class="np-ctx" onclick={goAlbum} aria-label="Go to the record">
|
||||
|
||||
@@ -57,6 +57,10 @@
|
||||
/* Android's own insets: the status bar above, the gesture handle below. */
|
||||
--inset-top: env(safe-area-inset-top, 0px);
|
||||
--inset-bottom: env(safe-area-inset-bottom, 0px);
|
||||
/* landscape moves the cutout and the gesture pill to the SIDES, and
|
||||
viewport-fit=cover puts our content under both */
|
||||
--inset-left: env(safe-area-inset-left, 0px);
|
||||
--inset-right: env(safe-area-inset-right, 0px);
|
||||
--top-h: calc(var(--bar-h) + var(--inset-top));
|
||||
|
||||
/* ── motion: real springs, sampled ────────────────────────────────────
|
||||
|
||||
@@ -1415,3 +1415,95 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
|
||||
.sy-foot .iconbtn { width: auto; min-width: var(--tap); padding: 0 12px; border-radius: var(--r-pill); font-size: 13px; font-weight: 750; }
|
||||
.sy-foot .iconbtn:disabled { opacity: 0.4; }
|
||||
.sy-save { flex: 1; }
|
||||
|
||||
/* ── the phone turned sideways ────────────────────────────────────────────
|
||||
The activity declares no screenOrientation and handles orientation and
|
||||
screenSize itself, so a rotation is a RESIZE: no state is lost and the
|
||||
layout is the only thing that has to answer. Two queries do it. The player
|
||||
sheet is the surface whose portrait shape cannot survive a wide viewport —
|
||||
its artwork is a square sized by WIDTH, so at 915x411 the cover came out
|
||||
879px tall and the title, the lane, the transport and the face buttons all
|
||||
sat below the bottom of a sheet that does not scroll. That is aspect, not
|
||||
height, so its rule keys off orientation alone and a tablet gets it too;
|
||||
everything under it is a phone-sized query.
|
||||
|
||||
These rules are appended, and the order is load-bearing: the side-inset
|
||||
longhands below have to come after .gbar's padding shorthand, .mini's
|
||||
left/right and .toast's left/right to win at equal specificity. */
|
||||
|
||||
/* the side insets: the cutout and the gesture pill, from the edges */
|
||||
.gbar { padding-left: calc(6px + var(--inset-left)); padding-right: calc(8px + var(--inset-right)); }
|
||||
.nav { padding-left: var(--inset-left); padding-right: var(--inset-right); }
|
||||
.mini { left: calc(8px + var(--inset-left)); right: calc(8px + var(--inset-right)); }
|
||||
.screen { padding-left: var(--inset-left); padding-right: var(--inset-right); }
|
||||
.np { padding-left: var(--inset-left); padding-right: var(--inset-right); }
|
||||
.halfsheet { padding-left: var(--inset-left); padding-right: var(--inset-right); }
|
||||
.syncroom { padding-left: var(--inset-left); padding-right: var(--inset-right); }
|
||||
.toast { left: calc(12px + var(--inset-left)); right: calc(12px + var(--inset-right)); }
|
||||
|
||||
/* ── the player sheet, beside itself ─────────────────────────────────── */
|
||||
@media (orientation: landscape) {
|
||||
.np {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 46%) minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr) auto auto auto auto;
|
||||
grid-template-areas:
|
||||
"head head"
|
||||
"left slack"
|
||||
"left meta"
|
||||
"left scrub"
|
||||
"left trans"
|
||||
"left foot";
|
||||
column-gap: 6px;
|
||||
/* insurance for a viewport shorter than the controls themselves —
|
||||
the keyboard up on a small phone — where the sheet would clip */
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.np-head { grid-area: head; }
|
||||
.np-art { grid-area: left; padding: 10px 4px 10px var(--pad); }
|
||||
/* the square is sized by whichever is smaller, the column or the height */
|
||||
.np-frame { width: min(100%, calc(100vh - var(--inset-top) - var(--inset-bottom) - 92px)); }
|
||||
.np-spacer { grid-area: slack; padding: 10px var(--pad) 0; justify-content: center; }
|
||||
.np-face { grid-area: left; }
|
||||
.np-meta { grid-area: meta; padding-top: 2px; }
|
||||
.np-scrub { grid-area: scrub; padding-top: 8px; }
|
||||
.np-transport { grid-area: trans; padding-top: 10px; justify-self: center; width: min(100%, 430px); }
|
||||
.np-foot { grid-area: foot; padding-top: 8px; }
|
||||
/* a face is up: it takes the artwork's column and the thumbnail goes —
|
||||
the record is already the ground behind the whole sheet */
|
||||
.np.small { grid-template-columns: minmax(0, 1fr) minmax(0, 430px); }
|
||||
.np.small .np-art { display: none; }
|
||||
}
|
||||
|
||||
/* ── a phone turned sideways: 411 pixels of height to spend ───────────── */
|
||||
@media (orientation: landscape) and (max-height: 560px) {
|
||||
/* the nav comes down to Android's own 48dp minimum and the mini bar to a
|
||||
60dp row over the same lane strip: 22 pixels back, 46% of the height as
|
||||
chrome down to 40% */
|
||||
:root { --nav-h: 48px; --mini-h: 60px; }
|
||||
.nav button .gl { height: 22px; }
|
||||
.nav button .lb { font-size: 9.5px; }
|
||||
.nav button .pill { top: 3px; height: 24px; }
|
||||
.mini { padding-bottom: 24px; }
|
||||
.mini .art { width: 34px; height: 34px; }
|
||||
.mini .mini-lane { bottom: 5px; }
|
||||
|
||||
/* the hero stops being a portrait and becomes the banner the desktop's
|
||||
hero has always been */
|
||||
.hero { height: 190px; }
|
||||
.hero-name { font-size: 27px; }
|
||||
.hero-logo { max-height: 64px; }
|
||||
|
||||
/* the record head puts the cover beside the title, again the desktop's
|
||||
own shape; .pl needs naming because its own rule is more specific */
|
||||
.rec-head { flex-direction: row; align-items: center; gap: 22px; padding: calc(var(--bar-h) + 12px) var(--pad) 4px; }
|
||||
.rec-head .rec-cover, .rec-head.pl .rec-cover { width: 34%; max-width: 240px; }
|
||||
.rec-meta { text-align: left; }
|
||||
.rec-title { margin-top: 0; }
|
||||
.rec-chips { justify-content: flex-start; }
|
||||
.rec-acts { justify-content: flex-start; padding: 14px 0 4px; }
|
||||
|
||||
/* 76% of 411 is 312; a half sheet may take all but a strip */
|
||||
.halfsheet { max-height: calc(100% - 36px); }
|
||||
}
|
||||
@@ -68,6 +68,11 @@ window.__settle = function () {
|
||||
/* what the page shows, read back after every still */
|
||||
const PROBE = `(function () {
|
||||
var e = document.querySelector('.screen');
|
||||
/* geometry, for the orientation steps: box() is the rect or null, and
|
||||
inside() asks whether every pixel of it is within the viewport —
|
||||
which, sideways, is the whole question */
|
||||
function box(sel) { var x = document.querySelector(sel); return x ? x.getBoundingClientRect() : null; }
|
||||
function inside(b) { return !!b && b.height > 0 && b.top >= -1 && b.bottom <= innerHeight + 1 && b.left >= -1 && b.right <= innerWidth + 1; }
|
||||
return {
|
||||
screen: e ? e.className : '',
|
||||
title: (document.querySelector('.gb-t') || {}).textContent || '',
|
||||
@@ -183,6 +188,27 @@ const PROBE = `(function () {
|
||||
halfSub: ((document.querySelector('.halfsheet .sheet-head .m') || {}).textContent || '').trim(),
|
||||
halfSw: [].filter.call(document.querySelectorAll('.halfsheet .row'), function (r) { return !!r.querySelector('.sw.on'); }).map(function (r) { return ((r.querySelector('.t') || {}).textContent || '').trim(); }).join('|'),
|
||||
contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(),
|
||||
vp: innerWidth + 'x' + innerHeight,
|
||||
/* the sheet's controls: any one of them off the viewport is the landscape bug */
|
||||
npOut: [].filter.call(document.querySelectorAll('.sheet.up .np-frame, .sheet.up .np-meta, .sheet.up .np-scrub, .sheet.up .np-transport, .sheet.up .np-foot'), function (x) { var b = x.getBoundingClientRect(); return b.height > 0 && !inside(b); }).length,
|
||||
/* stacked = the portrait column; beside = the cover left of the controls; face = a face has the column and the thumbnail is gone */
|
||||
npCols: (function () { var a = box('.sheet.up .np-frame'), b = box('.sheet.up .np-transport'); if (!a || !b) return ''; if (!a.height) return 'face'; return a.bottom <= b.top + 1 ? 'stacked' : 'beside'; })(),
|
||||
faceH: Math.round((box('.sheet.up .np-face') || { height: 0 }).height),
|
||||
/* WHICH face: the queue's own, up whether or not anything is after this one */
|
||||
qFace: !!document.querySelector('.sheet.up .q-face'),
|
||||
/* a half sheet: how tall it is allowed to be, that it is all on screen, and that its body still reaches its last row */
|
||||
halfH: Math.round((box('.halfsheet.up') || { height: 0 }).height),
|
||||
halfIn: inside(box('.halfsheet.up')),
|
||||
hsScroll: (function () { var x = document.querySelector('.halfsheet.up .hs-body'); return x ? getComputedStyle(x).overflowY : ''; })(),
|
||||
hsTail: (function () { var x = document.querySelector('.halfsheet.up .hs-body'); return x ? x.scrollHeight - x.clientHeight : -1; })(),
|
||||
heroH: Math.round((box('.screen .hero') || { height: 0 }).height),
|
||||
/* what a page offers first: Play on an artist hero, Play on a record head */
|
||||
actsIn: inside(box('.screen .hero-acts') || box('.screen .rec-acts')),
|
||||
recCols: (function () { var c = box('.screen .rec-cover'), m = box('.screen .rec-meta'); return c && m ? (c.bottom <= m.top + 1 ? 'stacked' : 'beside') : ''; })(),
|
||||
/* the glass bar above plus the floating bars below, as a share of the screen */
|
||||
chromePct: (function () { var g = box('.gbar'), low = box('.mini') || box('.nav'); return Math.round((((g ? g.height : 0) + (low ? innerHeight - low.top : 0)) / innerHeight) * 100); })(),
|
||||
miniLeft: Math.round((box('.mini') || { left: -1 }).left),
|
||||
screenPadL: (function () { var x = document.querySelector('.screen'); return x ? Math.round(parseFloat(getComputedStyle(x).paddingLeft)) : -1; })(),
|
||||
};
|
||||
})()`;
|
||||
|
||||
@@ -542,6 +568,38 @@ const MOCK_STEPS = [
|
||||
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "home-more", wait: 700 },
|
||||
{ do: `__click('.halfsheet .row', 'Sleep timer')`, name: "sleep-sheet", wait: 700 },
|
||||
{ do: `__click('.halfsheet .chip', '30 min')`, name: "sleep-set", wait: 700 },
|
||||
/* ── the phone turned over. A resize, so the app keeps playing and keeps
|
||||
its stack; what changes is the layout, and every one of these asserts
|
||||
something that was clipped or unreachable at 915x411 before. ────── */
|
||||
{ view: [915, 411], name: "landscape", wait: 900 },
|
||||
{ do: `document.querySelector('.mini').click()`, name: "landscape-sheet", wait: 1200 },
|
||||
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "landscape-lyrics", wait: 1200 },
|
||||
{ do: `document.querySelectorAll('.fbtn')[2].click()`, name: "landscape-queue", wait: 900 },
|
||||
{ do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 500 },
|
||||
{ do: `__ss.closeSheet()`, wait: 800 },
|
||||
/* a record sideways: the cover beside the title, Play in view without a
|
||||
scroll, and a long sheet over it — the track's, 473px of rows and
|
||||
facts — capped taller than portrait's 76% and still scrolling to its
|
||||
last row. (Home's own overflow sheet is 212px tall at either cap and
|
||||
would have proved nothing.) */
|
||||
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 800 },
|
||||
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 500 },
|
||||
{ do: `document.querySelectorAll('.grid2 .release')[2].click()`, name: "landscape-album", wait: 1100 },
|
||||
{ do: `document.querySelector('.trk .r').click()`, name: "landscape-half", wait: 900 },
|
||||
{ do: `document.querySelector('.scrim').click()`, wait: 700 },
|
||||
/* and the artist: the hero a banner rather than 300px of a 411px screen,
|
||||
so it stops covering its own Play button with the mini bar */
|
||||
{ do: `document.querySelector('.rec-artist').click()`, name: "landscape-artist", wait: 1400 },
|
||||
{ do: `document.querySelectorAll('.nav button')[0].click()`, wait: 800 },
|
||||
/* the side insets, faked: sideways the cutout and the gesture pill come
|
||||
in from the edges, and env() cannot be emulated here — the plumbing can */
|
||||
{ do: `document.documentElement.style.setProperty('--inset-left', '28px'); document.documentElement.style.setProperty('--inset-right', '28px'); true`, name: "landscape-insets", wait: 700 },
|
||||
{ do: `document.documentElement.style.removeProperty('--inset-left'); document.documentElement.style.removeProperty('--inset-right'); true`, wait: 500 },
|
||||
/* and back, which is also the override reset: portrait must be exactly
|
||||
what it was before any of this */
|
||||
{ view: [411, 914], wait: 800 },
|
||||
{ do: `document.querySelector('.mini').click()`, name: "portrait-again", wait: 1200 },
|
||||
{ do: `__ss.closeSheet()`, wait: 600 },
|
||||
];
|
||||
const MOCK_EXPECT = {
|
||||
tracks: (i) => i.title.startsWith("Your library"),
|
||||
@@ -588,6 +646,25 @@ const MOCK_EXPECT = {
|
||||
"search-recent-pick": (i) => i.title.startsWith("Search") && (i.songs >= 4 || !!i.top),
|
||||
"sleep-sheet": (i) => i.half.includes("up"),
|
||||
"sleep-set": (i) => !i.half.includes("up"),
|
||||
/* sideways: the chrome budget, then every surface that was clipped. The
|
||||
before-value of each is in the rig's now.json — 46% of the height as
|
||||
chrome, a 879px cover with the transport 687px below the fold, a
|
||||
lyrics face collapsed to nothing, a 300px hero, and the record's own
|
||||
Play button off the bottom of a 411px screen. */
|
||||
landscape: (i) => i.vp === "915x411" && i.mini && i.chromePct <= 42 && /^(Good |Still)/.test(i.title),
|
||||
"landscape-sheet": (i) => i.vp === "915x411" && i.sheet.includes("up") && i.npCols === "beside" && i.npOut === 0,
|
||||
"landscape-lyrics": (i) => i.sheet.includes("up") && i.npCols === "face" && i.faceH >= 240 && i.npOut === 0,
|
||||
/* the queue face by name, not by its rows: the track this walk is on is
|
||||
the LAST queueable row of Late night drive (the missing and the
|
||||
unresolved ones never reach the queue), so there is nothing up next
|
||||
and `qrows` is 0 here — the assertion is that the queue's own face has
|
||||
the cover's column, not that something is after this song */
|
||||
"landscape-queue": (i) => i.sheet.includes("up") && i.npCols === "face" && i.qFace && i.faceH >= 240 && i.npOut === 0,
|
||||
"landscape-album": (i) => i.screen.includes("tr-push") && i.recCols === "beside" && i.actsIn,
|
||||
"landscape-half": (i) => i.half.includes("up") && i.halfIn && i.halfH >= 340 && i.halfH <= 380 && i.hsScroll === "auto" && i.hsTail > 0,
|
||||
"landscape-artist": (i) => i.heroH > 0 && i.heroH <= 200 && i.actsIn,
|
||||
"landscape-insets": (i) => i.miniLeft >= 34 && i.screenPadL >= 26,
|
||||
"portrait-again": (i) => i.vp === "411x914" && i.sheet.includes("up") && i.npCols === "stacked" && i.npOut === 0 && i.chromePct <= 24,
|
||||
home: (i) => /^(Good |Still)/.test(i.title),
|
||||
"hold-sheet": (i) => i.half.includes("up") && /Re-fetch the cover/.test(i.halfText),
|
||||
"cover-refetched": (i) => !i.half.includes("up") && /Cover re-fetched/.test(i.notice),
|
||||
@@ -1088,6 +1165,21 @@ async function main() {
|
||||
if (s.adb) {
|
||||
try { adb(...s.adb); } catch (e) { problems.push("ADB " + (s.name || s.adb.join(" ")) + ": " + e.message); }
|
||||
}
|
||||
/* a mock step can turn the phone over: the viewport, through the
|
||||
same override the walk boots with. A real device's orientation
|
||||
is not the harness's to set, so a view step on the phone walk
|
||||
is a failure rather than a no-op. send() resolves with the
|
||||
whole reply and does not reject on a protocol error, so a
|
||||
refused override has to be read out of it. */
|
||||
if (s.view) {
|
||||
if (PHONE) problems.push("VIEW " + (s.name || s.view.join("x")) + ": the phone walk must not set the viewport");
|
||||
else {
|
||||
try {
|
||||
const r = await cdp.send("Emulation.setDeviceMetricsOverride", { width: s.view[0], height: s.view[1], deviceScaleFactor: 2, mobile: true });
|
||||
if (r.error) throw new Error(r.error.message || JSON.stringify(r.error));
|
||||
} catch (e) { problems.push("VIEW " + (s.name || s.view.join("x")) + ": " + e.message); }
|
||||
}
|
||||
}
|
||||
if (s.do) {
|
||||
try {
|
||||
/* the helpers live in the page; a reload step wipes them */
|
||||
|
||||
Reference in new issue
Block a user