diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte
index dac041d..efd6d61 100644
--- a/src/mobile/Mobile.svelte
+++ b/src/mobile/Mobile.svelte
@@ -16,6 +16,7 @@
import Sheet from "./Sheet.svelte";
import HalfSheet from "./HalfSheet.svelte";
import Viewer from "./Viewer.svelte";
+ import { heroArt } from "./heroart.svelte.js";
import SyncRoom from "./SyncRoom.svelte";
import Home from "./screens/Home.svelte";
import Search from "./screens/Search.svelte";
@@ -121,6 +122,10 @@
reading. A pushed page has one because it carries the back chevron,
and it is static — there and titled from the first frame. */
const showBar = $derived(!root);
+ /* A hero page paints its picture a second time behind the scroller
+ (heroart.svelte.js): the mask cuts the page where it meets the clock,
+ and that copy fills the cut back in — so the photograph passes under
+ the status bar and the name does not. The mask itself never moves. */
const playing = $derived(player.status === "playing" || player.status === "buffering");
const played = $derived(player.duration ? Math.min(1, player.position / player.duration) : 0);
@@ -334,10 +339,18 @@
+ {#if meta.hero && heroArt.src}
+
+
+
+
+ {/if}
{#key nav.seq}
{ scrolled = e.currentTarget.scrollTop; }}
@@ -383,14 +396,11 @@
{/key}
-
+
{#if showBar}
-
-
- {meta.title}{#if meta.kicker && meta.kicker !== meta.title}{meta.kicker} {/if}
- openHalf("more")} aria-label="More">
-
+
+
openHalf("more")} aria-label="More">
{/if}
diff --git a/src/mobile/heroart.svelte.js b/src/mobile/heroart.svelte.js
new file mode 100644
index 0000000..989b993
--- /dev/null
+++ b/src/mobile/heroart.svelte.js
@@ -0,0 +1,12 @@
+/* The artist hero's picture, published for the copy that lives BEHIND the
+ scroller.
+
+ The page meets the Android status bar and is cut off there — but the rule is
+ that backgrounds may pass under the clock and words may not, and a mask on
+ the scroller cannot tell a photograph from a sentence: it takes both. So the
+ hero is painted twice. The page's own copy is inside the masked scroller and
+ is cut away in that band along with everything else; this one sits behind
+ the scroller, unmasked, and shows through exactly where the mask took the
+ first copy away. Same image, same box, same scale, offset by the same
+ scroll — so the two are one picture, and only the name dissolves. */
+export const heroArt = $state({ src: "" });
diff --git a/src/mobile/mobile.css b/src/mobile/mobile.css
index 0d4fbe5..f75d0c0 100644
--- a/src/mobile/mobile.css
+++ b/src/mobile/mobile.css
@@ -62,6 +62,16 @@
--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));
+ /* The floating chrome a pushed page carries: a 38px disc, 6px below the
+ status bar. Both the reserve at the top of a page and the fade are
+ measured from these, so none of the three can drift apart. */
+ --chrome-d: 38px;
+ --chrome-gap: 6px;
+ /* How far the page takes to disappear where it meets the status bar.
+ It has to reach the FOOT of the chrome: any shallower and the chevron
+ and the ⋯ stand on crisp text, which is how the page's ⋯ came to sit
+ on a track row's own ⋯. Two extra pixels so it finishes just clear. */
+ --fade-top: calc(var(--chrome-gap) + var(--chrome-d) + 2px);
/* ── motion: real springs, sampled ────────────────────────────────────
Material 3 Expressive specifies motion as springs; CSS takes a sampled
diff --git a/src/mobile/screens/Artist.svelte b/src/mobile/screens/Artist.svelte
index da140f2..a671bda 100644
--- a/src/mobile/screens/Artist.svelte
+++ b/src/mobile/screens/Artist.svelte
@@ -17,6 +17,7 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { albumsBy, tracksOf, isFollowed, toggleFollow } from "../lib.svelte.js";
import { art, wantArt } from "../art.svelte.js";
+ import { heroArt } from "../heroart.svelte.js";
import { player, playEntries, playAlbum } from "../player.svelte.js";
import { push, openHalf } from "../nav.svelte.js";
import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, loadPopular, loadHeroSeed, loadOverview, picture } from "../catalog.svelte.js";
@@ -78,6 +79,8 @@
$effect(() => { const id = artistId; if (id != null) untrack(() => { loadPopular(id); loadHeroSeed(id); loadOverview(id); }); });
const seedSrc = $derived(seedPath ? art[seedPath] : "");
const heroSrc = $derived(heroUrl ? art[heroUrl] : "");
+ /* the copy behind the scroller, which is the one allowed under the clock */
+ $effect(() => { heroArt.src = heroSrc || seedSrc || ""; return () => { heroArt.src = ""; }; });
const extras = $derived(artistId != null ? catalog.extras[artistId] ?? null : null);
const socials = $derived(page?.socials?.length ? page.socials : (extras?.socials ?? []));
diff --git a/src/mobile/shell.css b/src/mobile/shell.css
index 9f53b96..57cdeb7 100644
--- a/src/mobile/shell.css
+++ b/src/mobile/shell.css
@@ -83,11 +83,17 @@
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
padding-top: var(--inset-top);
+ /* The page is cut off at the status bar: it fades out into the ground
+ over --fade-top rather than sliding under the clock. Pinned to the
+ scroller's box, not to its content, so it never moves and costs no
+ layout — and at rest it hides nothing, because padding-top has
+ already put the first pixel below it. */
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0, transparent var(--inset-top), #000 calc(var(--inset-top) + var(--fade-top)), #000 100%);
+ mask-image: linear-gradient(to bottom, transparent 0, transparent var(--inset-top), #000 calc(var(--inset-top) + var(--fade-top)), #000 100%);
}
.screen::-webkit-scrollbar { width: 0; display: none; }
/* a pushed screen whose content starts at the very top must clear the bar;
heroes bleed behind it and the record head has its own clearance */
-.screen.under-bar { padding-top: var(--top-h); }
/* the pad that clears the mini player + nav bar */
.endpad { height: calc(var(--nav-h) + var(--inset-bottom) + 24px); }
.has-mini .endpad { height: calc(var(--mini-h) + var(--nav-h) + var(--inset-bottom) + 16px); }
@@ -98,47 +104,28 @@
and no room for a hierarchy trail, so the trail collapses to a KICKER
under the title, and the arrows to one chevron that only appears when
there is somewhere to go back to. It runs up under the status bar. */
-.gbar {
+/* The chrome a pushed page carries once the bar is gone: a back chevron and
+ the page's ⋯, floating over the artwork with nothing behind them but their
+ own disc. No backdrop blur — the bar's own note measured what three blurred
+ bands did to a scroll on the Pixel 6a, and these would be a fourth. */
+.fchrome {
position: absolute;
- top: 0; left: 0; right: 0;
- z-index: 40;
- height: var(--top-h);
- padding: var(--inset-top) 8px 0 6px;
- display: flex;
- align-items: center;
- gap: 10px;
- /* SOLID by default on the phone. The desktop's glass — the 28% tint over
- a 12px backdrop blur — was measured on the Pixel 6a (tests/mobile/
- perf.mjs): every backdrop blur re-blurs its band on every frame that
- scrolls or animates under it, and the three of them (this bar, the mini
- bar, the nav) turned a 60 Hz scroll into 30 Hz and dropped every other
- frame of the player's opening. The blur is a switch (You → Appearance →
- Glass) so the two can be compared; the design was built to stand on
- the solid floor first. */
- background: rgba(18, 18, 22, 0.92);
- box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
-}
-html[data-glass="blur"] .gbar { background: rgba(20, 20, 24, 0.28); backdrop-filter: blur(12px) saturate(1.7); -webkit-backdrop-filter: blur(12px) saturate(1.7); }
-html[data-material="solid"] .gbar { background: rgba(24, 24, 24, 0.94); backdrop-filter: none; -webkit-backdrop-filter: none; }
-.gbar .gb-t {
- font-size: 15px;
- font-weight: 800;
- letter-spacing: -0.2px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- min-width: 0;
- flex: 1;
- text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
-}
-.gbar .gb-t small {
- display: block;
- font-size: 10.5px;
- font-weight: 700;
- color: rgba(255, 255, 255, 0.55);
- letter-spacing: 0.2px;
- margin-top: -1px;
+ top: calc(var(--inset-top) + var(--chrome-gap));
+ z-index: 45;
+ width: var(--chrome-d);
+ height: var(--chrome-d);
+ padding: 0;
+ border: 0;
+ border-radius: var(--r-pill);
+ display: grid;
+ place-items: center;
+ color: #fff;
+ background: rgba(0, 0, 0, 0.46);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08), 0 2px 10px rgba(0, 0, 0, 0.35);
}
+.fchrome:active { background: rgba(0, 0, 0, 0.62); }
+.fchrome.back { left: calc(10px + var(--inset-left)); }
+.fchrome.more { right: calc(10px + var(--inset-right)); color: rgba(255, 255, 255, 0.82); }
/* the round chrome button — the desktop bar's .arrow at touch size */
.iconbtn {
@@ -568,8 +555,8 @@ html[data-material="solid"] .card, html[data-material="solid"] .group { backgrou
/* ── the artist hero ──────────────────────────────────────────────────────
The desktop hero is a wide banner beside the identity block. A phone has
no beside, so the portrait goes full-bleed and the name sits ON it. */
-.hero { position: relative; height: 300px; display: flex; align-items: flex-end; overflow: hidden; margin-bottom: -6px; }
-.hero-img { position: absolute; inset: 0; background-size: cover; background-position: center 22%; transform: scale(1.04); }
+.hero { position: relative; height: 258px; display: flex; align-items: flex-end; overflow: hidden; margin-bottom: -6px; }
+.hero-img { position: absolute; inset: 0; background-size: cover; background-position: center 24%; transform: scale(1.04); }
/* the blurred SEED: the picture the library already has, soft and
oversized, until a sharp one exists */
.hero-img.seed { filter: blur(16px) saturate(1.15); transform: scale(1.12); }
@@ -579,6 +566,43 @@ html[data-material="solid"] .card, html[data-material="solid"] .group { backgrou
from { opacity: 0; filter: blur(16px) saturate(1.15); transform: scale(1.12); }
to { opacity: 1; filter: blur(0) saturate(1); transform: scale(1.04); }
}
+/* The hero's picture again, behind the scroller and outside its mask, so the
+ photograph is allowed under the clock while the words are not. Its geometry
+ has to match .hero/.hero-img exactly — 258px tall, the same 24% focal point,
+ the same 1.04 scale — or the seam shows. */
+.hero-behind {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ height: 258px;
+ z-index: 0;
+ pointer-events: none;
+ /* the clip .hero gives its own picture; without it the 1.04 below renders
+ 268px in a 258px box and five pixels of photograph appear under the
+ hero's foot, below the black the scrim fades to */
+ overflow: hidden;
+ transform: translateY(var(--hero-shift, 0px));
+}
+.hero-behind i {
+ position: absolute;
+ inset: 0;
+ display: block;
+ background-size: cover;
+ background-position: center 24%;
+ transform: scale(1.04);
+}
+/* the clock's own backing, pinned where the clock is rather than to the photo */
+.hero-wash {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ height: calc(var(--inset-top) + 26px);
+ z-index: 2;
+ pointer-events: none;
+ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.47), transparent);
+}
.hero-scrim {
position: absolute;
inset: 0;
@@ -1207,6 +1231,12 @@ html[data-material="solid"] .peer { background: var(--bg-elevated); box-shadow:
.countline { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--pad) 10px; }
/* a hero bleeds up under the status bar; the scrim keeps the chrome legible */
.screen.has-hero { padding-top: 0; }
+/* A pushed page starts BELOW the floating chevron and ⋯, or its first line is
+ under a button — which is what the bar's own padding used to prevent. Sized
+ to the chrome that is there now: the inset, its 6px of top margin, the 38px
+ disc, and 8px of air under it. A hero page is the exception and keeps none,
+ because its photograph is meant to be behind them. */
+.screen.has-chrome { padding-top: calc(var(--inset-top) + var(--chrome-gap) + var(--chrome-d) + 8px); }
/* not a stray: `.lane` is width: 100%, and the mini bar's lane is ALSO
positioned with both left: 16px and right: 16px — an over-constrained
absolute box drops `right`, so without this it comes out 32px wider than
@@ -1434,7 +1464,6 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
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); }
diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs
index 08b3768..bcd2da1 100644
--- a/tests/mobile/smoke.mjs
+++ b/tests/mobile/smoke.mjs
@@ -169,7 +169,7 @@ const PROBE = `(function () {
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 || '',
+ title: (function () { var q = document.querySelector('.screen'); return q ? (q.dataset.title || '') + (q.dataset.kicker || '') : ''; })(),
mini: !!document.querySelector('.mini'),
sheet: (document.querySelector('.sheet') || {}).className || '',
half: (document.querySelector('.halfsheet') || {}).className || '',
@@ -465,7 +465,7 @@ const PROBE = `(function () {
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); })(),
+ chromePct: (function () { var g = box('.fchrome.back'), 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),
/* ── the long session: what the app is still HOLDING ─────────────
Minutes is all a walk has ever been. These are the numbers that
@@ -707,9 +707,9 @@ const MOCK_STEPS = [
{ do: `history.back()`, wait: 900 },
/* the artist's photo: the page's ⋯ → a picture from this phone becomes
the hero, the page is asked again, then the catalog's own come back */
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more", wait: 700 },
+ { do: `document.querySelector('.fchrome.more').click()`, name: "artist-more", wait: 700 },
{ do: `__click('.halfsheet .row', 'Choose hero photo')`, name: "artist-hero-set", wait: 1400 },
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more-custom", wait: 800 },
+ { do: `document.querySelector('.fchrome.more').click()`, name: "artist-more-custom", wait: 800 },
{ do: `__click('.halfsheet .row', 'Remove custom hero')`, name: "artist-hero-cleared", wait: 1400 },
/* an artist whose exhaustive page the mock keeps for four seconds: what
is on screen a second and a half in can only be the two fast lanes */
@@ -769,7 +769,7 @@ const MOCK_STEPS = [
/* the two guest rows the record's own anchor had no count for */
{ name: "cat-album-listeners", wait: 900 },
/* the record page's own sheet from the bar */
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "cat-album-sheet", wait: 700 },
+ { do: `document.querySelector('.fchrome.more').click()`, name: "cat-album-sheet", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `document.querySelectorAll('.tracks .trk')[1].click()`, name: "cat-album-row", wait: 5500 },
{ do: `__ss.next()`, name: "cat-album-next", wait: 7000 },
@@ -833,10 +833,10 @@ const MOCK_STEPS = [
{ do: `(function () { __ssMock.fail('playlist_set_order', ''); __ss.player.error = ''; return true; })()`, wait: 400 },
/* the bulk actions from the page's sheet: Add to library files the
streamed row (its cache is complete); Download all finds it cached too */
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
+ { do: `document.querySelector('.fchrome.more').click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Add to library')`, name: "playlist-bulk-cached", wait: 1500 },
{ do: `__click('.bulkline .bulkx', null)`, wait: 400 },
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
+ { do: `document.querySelector('.fchrome.more').click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Download all tracks')`, name: "playlist-bulk-download", wait: 1500 },
{ do: `__click('.bulkline .bulkx', null)`, wait: 400 },
/* the repair: the unknown row finds a library track and takes its identity */
@@ -844,14 +844,14 @@ const MOCK_STEPS = [
{ do: `__click('.halfsheet .row', 'Find a local match')`, name: "playlist-match", wait: 500 },
{ do: `(function () { var i = document.querySelector('.plmatch input'); i.value = 'caribou track 4'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "playlist-match-results", wait: 700 },
{ do: `__click('.halfsheet .sheet-rows .row', 'Track 4')`, name: "playlist-matched", wait: 1000 },
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "playlist-more", wait: 700 },
+ { do: `document.querySelector('.fchrome.more').click()`, name: "playlist-more", wait: 700 },
{ do: `__click('.halfsheet .row', 'Unpin')`, name: "playlist-unpinned", wait: 900 },
/* the cover: the custom one removed (the mosaic), then a photo chosen */
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
+ { do: `document.querySelector('.fchrome.more').click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Remove custom cover')`, name: "cover-removed", wait: 1000 },
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
+ { do: `document.querySelector('.fchrome.more').click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Choose cover photo')`, name: "cover-set", wait: 1000 },
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
+ { do: `document.querySelector('.fchrome.more').click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Rename')`, name: "playlist-rename", wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Late night drive II'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Rename')`, name: "playlist-renamed", wait: 1000 },
@@ -865,7 +865,7 @@ const MOCK_STEPS = [
{ do: `__click('.halfsheet .row', 'New Playlist')`, name: "playlist-added", wait: 1200 },
{ do: `__click('.segtabs button', 'Playlists')`, wait: 700 },
{ do: `__click('.plgrid .release', 'New Playlist')`, name: "playlist-filled", wait: 1200 },
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
+ { do: `document.querySelector('.fchrome.more').click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Delete playlist')`, name: "playlist-delete-confirm", wait: 700 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "playlist-deleted", wait: 1200 },
/* the import doors: a pasted tracklist reviewed and created with the
@@ -1209,7 +1209,7 @@ const MOCK_STEPS = [
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 800 },
{ do: `__click('.segtabs button', 'Playlists')`, wait: 800 },
{ do: `__click('.plgrid .release', 'Late night drive')`, name: "export-open", wait: 1200 },
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 700 },
+ { do: `document.querySelector('.fchrome.more').click()`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Export')`, name: "export-sheet", wait: 800 },
{ do: `__click('.halfsheet .row', 'CSV')`, name: "export-written", wait: 1000 },
/* a sheet dismissed: the command answers null and the phone says
@@ -1218,13 +1218,13 @@ const MOCK_STEPS = [
than any wait here, and a wait that had to outlast it would be the
fragile kind. */
{ do: `__ssMock.answer('playlist_export', null); __ss.notice.text = ''; true`, wait: 300 },
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 700 },
+ { do: `document.querySelector('.fchrome.more').click()`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Export')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'M3U8')`, name: "export-cancelled", wait: 1000 },
/* and a refusal — what Android answers when the document is somewhere
the app cannot write — read back as a sentence rather than as Rust */
{ do: `__ssMock.forget('playlist_export'); __ssMock.fail('playlist_export', 'permission denied (os error 13)'); true`, wait: 300 },
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 700 },
+ { do: `document.querySelector('.fchrome.more').click()`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Export')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Backup (JSON)')`, name: "export-refused", wait: 1200 },
{ do: `__ssMock.clear(); __ss.player.error = ''; __ss.lib.error = ''; history.back(); true`, wait: 900 },
@@ -1821,7 +1821,7 @@ const PHONE_STEPS = [
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 },
{ do: `__click('.grid2 .release', ${albumText})`, name: "album", wait: 1400 },
{ do: `document.querySelector('.rec-acts .disc').click()`, name: "album-playing", wait: 3200 },
- { do: `document.querySelector('.gbar .iconbtn').click()`, name: "library-ambience", wait: 1400 },
+ { do: `document.querySelector('.fchrome.back').click()`, name: "library-ambience", wait: 1400 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "home-ambience", wait: 1200 },
{ do: `document.querySelector('.mini').click()`, name: "sheet", wait: 1400 },
{ do: `document.querySelector('.np-art').click()`, name: "viewer", wait: 1200 },
@@ -1910,11 +1910,11 @@ const PHONE_STEPS = [
cancel path is what gets walked, and NOTHING is written to this
phone's storage. The playlist is the "Walk test" one the walk itself
makes, and deletes three steps below. */
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 800 },
+ { do: `document.querySelector('.fchrome.more').click()`, wait: 800 },
{ do: `__click('.halfsheet .row', 'Export')`, name: "export-door", wait: 800 },
{ do: `__click('.halfsheet .row', 'M3U8')`, name: "export-picker", wait: 2600 },
{ adb: ["shell", "input", "keyevent", "4"], name: "export-dismissed", wait: 2000 },
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "playlist-more", wait: 800 },
+ { do: `document.querySelector('.fchrome.more').click()`, name: "playlist-more", wait: 800 },
{ do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "playlist-deleted", wait: 1500 },
/* the paste door on the real core: two lines that resolve to a record
@@ -1937,7 +1937,7 @@ const PHONE_STEPS = [
{ do: `__click('.halfsheet .row', 'Find a local match')`, wait: 600 },
{ do: `(function () { var i = document.querySelector('.plmatch input'); i.value = 'aerodynamic'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "import-match-results", wait: 1200 },
{ do: `__click('.halfsheet .sheet-rows .row', 'Aerodynamic')`, name: "import-matched", wait: 1500 },
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 800 },
+ { do: `document.querySelector('.fchrome.more').click()`, wait: 800 },
{ do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "import-deleted", wait: 1500 },
/* the pasted-links door on the real core: one track link, and the wait
@@ -2009,7 +2009,7 @@ const PHONE_STEPS = [
{ name: "artist", wait: 4500 },
/* the page's ⋯ on the real core: the hero door is there and knows the
artist's id — it opens the system picker, so the walk only looks */
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more", wait: 900 },
+ { do: `document.querySelector('.fchrome.more').click()`, name: "artist-more", wait: 900 },
{ do: `document.querySelector('.scrim').click()`, wait: 600 },
/* a discography card the library does NOT hold opens the catalog record */
{ do: `(function () { var c = [].slice.call(document.querySelectorAll('.grid2 .release')); if (!c.length) throw new Error('no discography grid'); var pick = c.find(function (x) { return x.textContent.indexOf('in your library') < 0; }) || c[c.length - 1]; pick.click(); })()`, name: "catalog-album", wait: 3500 },
@@ -2040,7 +2040,7 @@ const PHONE_STEPS = [
{ do: `__click('.screen button.row', null)`, wait: 500 },
{ wait: 2500 },
{ do: `__click('.sec .all', 'All')`, name: "dg-page", wait: 1600 },
- { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "dg-sheet", wait: 1000 },
+ { do: `document.querySelector('.fchrome.more').click()`, name: "dg-sheet", wait: 1000 },
{ do: `__click('.halfsheet .chip', 'A–Z')`, name: "dg-az", wait: 1000 },
{ do: `document.querySelector('.scrim').click()`, wait: 700 },
{ do: `history.back()`, wait: 800 },
@@ -3043,7 +3043,7 @@ async function main() {
/* the chrome that is actually on screen: a mini bar under a raised
sheet and a nav under one are translated away on purpose */
var out = [];
- ['.gbar', '.screen', '.mini:not(.hidden)', '.nav:not(.gone)'].forEach(function (sel) {
+ ['.fchrome.back', '.screen', '.mini:not(.hidden)', '.nav:not(.gone)'].forEach(function (sel) {
var b = box(sel);
if (b && b.height > 0 && !inside(b)) out.push(sel);
});
@@ -3076,7 +3076,7 @@ async function main() {
and a check of the left alone would pass by the inset being 0 */
miniLeft: Math.round((box('.mini') || { left: -1 }).left),
miniRight: Math.round(innerWidth - (box('.mini') || { right: innerWidth + 1 }).right),
- 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); })(),
+ chromePct: (function () { var g = box('.fchrome.back'), low = box('.mini') || box('.nav'); return Math.round((((g ? g.height : 0) + (low ? innerHeight - low.top : 0)) / innerHeight) * 100); })(),
outside: out.join('|'),
sheet: !!document.querySelector('.sheet.up'),
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,
@@ -3322,7 +3322,7 @@ async function main() {
if (resOk) {
/* the hero's page: an artist's (tabs, Popular) or a record's */
- const HERO = `(function () { var e = document.querySelector('.screen'); return { screen: e ? e.className : '', title: (document.querySelector('.gb-t') || {}).textContent || '', tabs: document.querySelectorAll('.disco-tabs button').length, pop: document.querySelectorAll('.row.pop').length, socs: document.querySelectorAll('.stats .soc').length, top: (document.querySelector('.top .top-name') || {}).textContent || '' }; })()`;
+ const HERO = `(function () { var e = document.querySelector('.screen'); return { screen: e ? e.className : '', title: (function () { var q = document.querySelector('.screen'); return q ? (q.dataset.title || '') + (q.dataset.kicker || '') : ''; })(), tabs: document.querySelectorAll('.disco-tabs button').length, pop: document.querySelectorAll('.row.pop').length, socs: document.querySelectorAll('.stats .soc').length, top: (document.querySelector('.top .top-name') || {}).textContent || '' }; })()`;
const topName = (await cdp.evaluate(HERO)).top;
await cdp.evaluate(HELPERS);
await cdp.evaluate(`__click('.top .btn-ghost', null)`);
@@ -3384,7 +3384,7 @@ async function main() {
await sleep(700);
await cdp.evaluate(HELPERS);
await cdp.evaluate(`__click('.halfsheet .row', 'Browse their files')`);
- const BRW = `(function () { return { title: (document.querySelector('.gb-t') || {}).textContent || '', dirs: document.querySelectorAll('.brw-dir').length, busy: !!document.querySelector('.imp-wait'), err: [].some.call(document.querySelectorAll('.screen .stub'), function (x) { return /shares/.test(x.textContent); }), head: (document.querySelector('.brw-head .m') || {}).textContent || '' }; })()`;
+ const BRW = `(function () { return { title: (function () { var q = document.querySelector('.screen'); return q ? (q.dataset.title || '') + (q.dataset.kicker || '') : ''; })(), dirs: document.querySelectorAll('.brw-dir').length, busy: !!document.querySelector('.imp-wait'), err: [].some.call(document.querySelectorAll('.screen .stub'), function (x) { return /shares/.test(x.textContent); }), head: (document.querySelector('.brw-head .m') || {}).textContent || '' }; })()`;
let brw = null;
for (let i = 0; i < 40; i++) { await sleep(500); brw = await cdp.evaluate(BRW); if (brw.dirs > 0 || brw.err) break; }
await still(path.join(OUT, `${prefix}net-browse.png`));