Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deea6ad06d | ||
|
|
fba608fdbd | ||
|
|
f59490b113 | ||
|
|
6cca57f229 | ||
|
|
ea3edde697 | ||
|
|
14e3ab574c |
@@ -88,3 +88,8 @@ build/android/overlay.json
|
||||
# Written by @semantic-release/changelog purely to carry the release notes
|
||||
# into scripts/gitea-release.sh; the release page is the changelog.
|
||||
.release-notes.md
|
||||
|
||||
# Agent session log: local scratch, not repo memory (that is CLAUDE.md
|
||||
# and .planning/). Written by the scheduled backlog runs.
|
||||
.pi/journal.md
|
||||
.pi/schedule-prompts.json
|
||||
|
||||
@@ -1909,6 +1909,33 @@ every desktop button from 33×21 to 36×24, silently. The sizes are
|
||||
asserted as `'33x21'` rather than as a range, because the regression
|
||||
was three pixels.
|
||||
|
||||
**What that bar lost is how far through the song it is, and
|
||||
`<player-progress-line>` is where it went** (#58). Plan 016 B2 took the
|
||||
seek bar off the phone's transport, so the one thing a mini player is
|
||||
expected to say without being opened had nowhere left to be said. It is
|
||||
a 2px line on the border between the mini player and the tab bar: the
|
||||
**shell's** element and its own `auto` grid row between `bottom-bar`
|
||||
and `bottom-nav`, because those two are separate components and either
|
||||
one drawing it means reaching into the other's box for two pixels.
|
||||
|
||||
Four things about it are load-bearing. **It never counts** — the fill is
|
||||
`scaleX()` off the same `PlaybackPositionChanged` the seek bar renders,
|
||||
with the same `trackChangeId` and `seq` guards and an interval that is
|
||||
stopped and restarted by every report, which is the rule that exists
|
||||
because a local clock drifted 30 s away across four keyboard seeks.
|
||||
**It is not a control and cannot become one**: `aria-hidden` on the host
|
||||
and `pointer-events: none` throughout, because Now Playing's seek bar
|
||||
is what announces the position and a 2px strip on the top edge of the
|
||||
tab bar is exactly where a thumb aiming at a tab lands. **It renders
|
||||
nothing above 600px**, from `matchMedia` rather than a media query, for
|
||||
`job-band`'s reason plus one of its own — a stylesheet cannot stop a
|
||||
1 Hz interval running for the life of every desktop session about a
|
||||
line nobody can see. And **its phone rule sits at the foot of
|
||||
`index.css`, beside `job-band`'s**, not in the phone block above: a
|
||||
media query adds no specificity, so a `display: block` written before
|
||||
the `display: none` that takes it out of the desktop grid loses to it
|
||||
and the line never appears at any width, silently.
|
||||
|
||||
**900 is the worst desktop width, not the 800×600 minimum.** The
|
||||
sidebar collapses to icons *below* 900, so the main panel is 843px at
|
||||
899 and 700px at 900 — the narrowest content area any desktop width
|
||||
@@ -2489,6 +2516,31 @@ missing half; `catalogFailed` is the only route to `unavailable` now,
|
||||
and the timer is a 60 s backstop for a genuine hang rather than the
|
||||
verdict.
|
||||
|
||||
**On a phone that page is one scroll container, and the header is in
|
||||
it** (#66). It was built as a fixed header over a scrolling tracklist,
|
||||
which is the desktop arrangement: at the reference device's 424×439 the
|
||||
header owned **253 of the panel's 318px** and the list scrolled inside
|
||||
the 64 that were left. Below 600px the *host* is the scroller and
|
||||
`.content` stops being one, so the whole page moves together — which is
|
||||
only available because this tracklist is plain DOM rather than a
|
||||
virtualizer, and because `.main-panel > *` already gives the host a
|
||||
definite height.
|
||||
|
||||
Three things about it are load-bearing. **Another `min-width: 0` was
|
||||
not the fix**: `.album-info` carries one and was shrinking exactly as
|
||||
asked, to 112px beside a 200px cover — so the title drew as `G…` and
|
||||
"Shuffle album" ended at x=443 inside a 424px box, clipped by the
|
||||
component's own `overflow: hidden` and reachable by no gesture. A row
|
||||
with a fixed-size sibling has to **stack** at that width, or the column
|
||||
that must shrink has nothing to be wide with. **`layout-overflow.spec.ts`
|
||||
cannot see any of this** — `body.scrollWidth` equalled the viewport
|
||||
throughout, because the overflow was *inside* a component; the spec
|
||||
measures each header control against the host's own box, which is
|
||||
`top-bar-fit.spec.ts`'s shape for the same reason. And **the phone block
|
||||
is last in the stylesheet**, on `index.css`'s rule: a media query adds
|
||||
no specificity, so written above the plain rules it overrides every
|
||||
declaration in it is silently dead.
|
||||
|
||||
**Activating a row plays the list the row is in, from that row.** A
|
||||
double-click — and Play on a single row's context menu — queues the
|
||||
list as *displayed* with `startIndex` on that row, not a queue of one
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { test, expect } from '../support/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The album page on a phone (#66).
|
||||
*
|
||||
* Two faults, and neither was visible to `layout-overflow.spec.ts`:
|
||||
* that spec asserts the *shell* needs no sideways scrolling, and the
|
||||
* shell was correct throughout — `body.scrollWidth === clientWidth`
|
||||
* while `explore-album-details` itself measured 443 inside a 424px box
|
||||
* and clipped two of the album's three primary actions with its own
|
||||
* `overflow: hidden`. So the measurement here is **per control against
|
||||
* the component's box**, which is the same shape `top-bar-fit.spec.ts`
|
||||
* needed for the same reason.
|
||||
*
|
||||
* The other half is the scroll: the page was a fixed header over a
|
||||
* scrolling tracklist, so at the reference device's 424x439 the header
|
||||
* owned 253 of the panel's 318px and the list scrolled in the 64 that
|
||||
* were left. It is one scroll container below 600px, which is a
|
||||
* property of the *host* rather than of `.content`.
|
||||
*
|
||||
* The engine is the caveat this tier cannot close: the reference device
|
||||
* renders in Chrome 113 and this is Chromium/WebKit. A flex direction
|
||||
* and a scroll container are nowhere near that engine's documented gaps
|
||||
* (relaxed nesting, the Popover API, `light-dark()`), but "it renders
|
||||
* at that size in Chromium" is not evidence about the phone.
|
||||
*/
|
||||
|
||||
/** The phone this was measured on, in CSS pixels. */
|
||||
const DEVICE = { width: 424, height: 439 };
|
||||
|
||||
const details = (page: Page) => page.locator('explore-album-details');
|
||||
|
||||
/** The page's own boxes, read from inside its shadow root. */
|
||||
const geometry = (page: Page) =>
|
||||
page.evaluate(() => {
|
||||
const host = document.querySelector('explore-album-details');
|
||||
const sr = host?.shadowRoot;
|
||||
|
||||
if (!host || !sr) return null;
|
||||
|
||||
const box = (sel: string) => {
|
||||
const el = sr.querySelector(sel);
|
||||
|
||||
if (!el) return null;
|
||||
|
||||
const r = el.getBoundingClientRect();
|
||||
|
||||
return { width: Math.round(r.width), right: Math.round(r.right) };
|
||||
};
|
||||
|
||||
const content = sr.querySelector('.content');
|
||||
|
||||
return {
|
||||
hostWidth: host.clientWidth,
|
||||
hostScrollWidth: host.scrollWidth,
|
||||
// The host is the scroller below 600px, so the page is taller
|
||||
// than its box rather than the tracklist being a window inside it.
|
||||
hostScrolls: host.scrollHeight > host.clientHeight,
|
||||
contentScrolls: content
|
||||
? content.scrollHeight > content.clientHeight
|
||||
: null,
|
||||
header: box('.album-header'),
|
||||
play: box('[data-testid="album-play"]'),
|
||||
shuffle: box('[data-testid="album-shuffle"]'),
|
||||
queue: box('[data-testid="album-queue"]'),
|
||||
title: (() => {
|
||||
const el = sr.querySelector('.album-title-text');
|
||||
|
||||
return el ? el.scrollWidth <= el.clientWidth + 1 : null;
|
||||
})(),
|
||||
};
|
||||
});
|
||||
|
||||
test.describe('the album page on a phone', () => {
|
||||
test.beforeEach(async ({ app }) => {
|
||||
await app.setViewportSize(DEVICE);
|
||||
await openFirstAlbum(app);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ app }) => {
|
||||
await app.setViewportSize({ width: 1440, height: 900 });
|
||||
await app.getByTestId('nav-tracks').click();
|
||||
});
|
||||
|
||||
test('keeps every action inside its own box', async ({ app }) => {
|
||||
const geo = await geometry(app);
|
||||
|
||||
expect(geo).not.toBeNull();
|
||||
// "Shuffle album" ended at x=443 in a 424px component and could not
|
||||
// be reached by any gesture; "Add to queue" at 440.
|
||||
for (const action of ['play', 'shuffle', 'queue'] as const) {
|
||||
expect(
|
||||
geo?.[action],
|
||||
`${action} is rendered`,
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
geo?.[action]?.right ?? 0,
|
||||
`${action} ends inside the page`,
|
||||
).toBeLessThanOrEqual(geo?.hostWidth ?? 0);
|
||||
}
|
||||
|
||||
expect(geo?.hostScrollWidth).toBe(geo?.hostWidth);
|
||||
expect(geo?.header?.width).toBe(geo?.hostWidth);
|
||||
});
|
||||
|
||||
test('gives the title the row rather than one glyph of it', async ({
|
||||
app,
|
||||
}) => {
|
||||
// `.album-info` was squeezed to 112px beside the art, so an album
|
||||
// called *Glass Harbour* drew as `G…`. It carries `min-width: 0`
|
||||
// and was shrinking as asked — the row had to stack.
|
||||
expect(await geometry(app).then((g) => g?.title)).toBe(true);
|
||||
});
|
||||
|
||||
test('scrolls as one page, with the header scrolling away', async ({
|
||||
app,
|
||||
}) => {
|
||||
const before = await geometry(app);
|
||||
|
||||
expect(before?.hostScrolls).toBe(true);
|
||||
expect(before?.contentScrolls).toBe(false);
|
||||
|
||||
const headerTop = () =>
|
||||
app.evaluate(
|
||||
() =>
|
||||
document
|
||||
.querySelector('explore-album-details')
|
||||
?.shadowRoot?.querySelector('.album-header')
|
||||
?.getBoundingClientRect().top ?? 0,
|
||||
);
|
||||
|
||||
expect(await headerTop()).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// A wheel gesture, not `scrollTop`: `overflow: hidden` still permits
|
||||
// programmatic scrolling, so a probe that assigns it passes on the
|
||||
// build this exists to fail.
|
||||
await details(app).hover();
|
||||
await app.mouse.wheel(0, 250);
|
||||
|
||||
await expect.poll(headerTop).toBeLessThan(-100);
|
||||
});
|
||||
|
||||
test('is the desktop arrangement again above the breakpoint', async ({
|
||||
app,
|
||||
}) => {
|
||||
await app.setViewportSize({ width: 1024, height: 800 });
|
||||
|
||||
// The same element, re-laid-out: one component with two
|
||||
// arrangements, not a phone-only copy.
|
||||
await expect
|
||||
.poll(async () => (await geometry(app))?.hostScrolls)
|
||||
.toBe(false);
|
||||
|
||||
const arrangement = await app.evaluate(() => {
|
||||
const sr = document.querySelector('explore-album-details')?.shadowRoot;
|
||||
const header = sr?.querySelector('.album-header');
|
||||
const content = sr?.querySelector('.content');
|
||||
|
||||
return {
|
||||
direction: header ? getComputedStyle(header).flexDirection : null,
|
||||
contentOverflow: content ? getComputedStyle(content).overflowY : null,
|
||||
};
|
||||
});
|
||||
|
||||
expect(arrangement.direction).toBe('row');
|
||||
expect(arrangement.contentOverflow).toBe('auto');
|
||||
});
|
||||
});
|
||||
|
||||
/** Albums → the second card, which navigates to the album page. */
|
||||
async function openFirstAlbum(app: Page): Promise<void> {
|
||||
// Below 600px the sidebar is gone; the tab bar is the navigation.
|
||||
await app.getByTestId('tab-albums').click();
|
||||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||
'data-active-view',
|
||||
'albums',
|
||||
);
|
||||
|
||||
await expect.poll(() => cardCount(app)).toBeGreaterThan(1);
|
||||
|
||||
// Dispatched rather than clicked: the card lives in a virtualizer
|
||||
// inside a shadow root, and Enter expands the dropdown instead.
|
||||
await app.evaluate(() => {
|
||||
document
|
||||
.querySelector('cover-grid')
|
||||
?.shadowRoot?.querySelectorAll('.album-card')[1]
|
||||
?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, composed: true }),
|
||||
);
|
||||
});
|
||||
|
||||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||
'data-active-view',
|
||||
'explore-album-details',
|
||||
);
|
||||
await expect(
|
||||
details(app).locator('[data-testid="album-play"]'),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function cardCount(app: Page): Promise<number> {
|
||||
return app.evaluate(
|
||||
() =>
|
||||
document
|
||||
.querySelector('cover-grid')
|
||||
?.shadowRoot?.querySelectorAll('.album-card').length ?? 0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
callBinding,
|
||||
resetEvents,
|
||||
waitForEvent,
|
||||
LONG_TRACK,
|
||||
NO_QUEUE_SOURCE,
|
||||
} from '../support/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The phone's progress line (#58).
|
||||
*
|
||||
* The component tier already pins what the line *says* — that it
|
||||
* renders the backend's reported position and never a count of its own.
|
||||
* What only a real shell can answer is **where it is**: the issue asks
|
||||
* for a line on the border between the mini player and the tab bar, and
|
||||
* "on the border" is two adjacencies in a grid that no component-level
|
||||
* render has around it.
|
||||
*
|
||||
* It also asserts the line is not there on a desktop, which is the
|
||||
* other half of the same fact: above 600px there is no tab bar for it
|
||||
* to sit on the border of, and the bar carries a real seek bar.
|
||||
*/
|
||||
type Rect = { x: number; y: number; width: number; height: number };
|
||||
|
||||
/** The reference device's real viewport. */
|
||||
const DEVICE = { width: 424, height: 439 };
|
||||
const DESKTOP = { width: 1280, height: 800 };
|
||||
|
||||
async function rectOf(app: Page, selector: string): Promise<Rect | null> {
|
||||
return app.evaluate((sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
|
||||
if (!el) return null;
|
||||
|
||||
const r = el.getBoundingClientRect();
|
||||
|
||||
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
||||
}, selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the 90-second fixture on and wait for the first position report.
|
||||
*
|
||||
* The long track rather than any track: every other fixture is 2-6
|
||||
* seconds, which is shorter than the time this spec takes to measure
|
||||
* three rectangles.
|
||||
*/
|
||||
async function play(app: Page): Promise<void> {
|
||||
const tracks = await callBinding<{ FilePath: string; TrackName: string }[]>(
|
||||
app,
|
||||
'library.Library.GetTracks',
|
||||
[0],
|
||||
);
|
||||
|
||||
// `TrackName`, not `Title`: that is what the library model calls it.
|
||||
const long = tracks.find((t) => t.TrackName === LONG_TRACK);
|
||||
|
||||
expect(long, `no fixture track named ${LONG_TRACK}`).toBeTruthy();
|
||||
|
||||
await callBinding(app, 'queue.Queue.Clear');
|
||||
await resetEvents(app);
|
||||
await callBinding(app, 'queue.Queue.SetQueue', [
|
||||
[long!.FilePath],
|
||||
0,
|
||||
false,
|
||||
NO_QUEUE_SOURCE,
|
||||
]);
|
||||
await waitForEvent(app, 'QueueChanged');
|
||||
await callBinding(app, 'queue.Queue.Play');
|
||||
await waitForEvent(app, 'PlaybackPositionChanged', { timeoutMs: 15_000 });
|
||||
}
|
||||
|
||||
test.describe('the progress line sits on the border between the bars', () => {
|
||||
test.beforeEach(async ({ app }) => {
|
||||
await app.setViewportSize(DEVICE);
|
||||
await play(app);
|
||||
});
|
||||
|
||||
/*
|
||||
* Every test here starts a LONG_TRACK and the suite is workers: 1,
|
||||
* fullyParallel: false against one long-lived app — so without this
|
||||
* the four phone-* specs that follow alphabetically inherit a playing
|
||||
* queue. phone-transport.spec.ts records where that lesson came from:
|
||||
* the fault first showed up as a flake in a spec about something else.
|
||||
*/
|
||||
test.afterEach(async ({ app }) => {
|
||||
await callBinding(app, 'queue.Queue.Clear').catch(() => {
|
||||
/* already empty */
|
||||
});
|
||||
await app.setViewportSize(DESKTOP);
|
||||
});
|
||||
|
||||
test('spans the width, between the mini player and the tab bar', async ({
|
||||
app,
|
||||
}) => {
|
||||
const line = await rectOf(app, 'player-progress-line');
|
||||
const bar = await rectOf(app, '.bottom-bar');
|
||||
const nav = await rectOf(app, 'bottom-nav');
|
||||
|
||||
expect(line, 'no progress line on the phone').not.toBeNull();
|
||||
expect(bar).not.toBeNull();
|
||||
expect(nav).not.toBeNull();
|
||||
|
||||
// A border, not a band: 2px, the full width, and touching both.
|
||||
expect(line!.height).toBeCloseTo(2, 0);
|
||||
expect(line!.width).toBeCloseTo(bar!.width, 0);
|
||||
expect(line!.y).toBeCloseTo(bar!.y + bar!.height, 0);
|
||||
expect(nav!.y).toBeCloseTo(line!.y + line!.height, 0);
|
||||
});
|
||||
|
||||
/**
|
||||
* It is 2px on the top edge of the tab bar, which is exactly where a
|
||||
* thumb aiming at a tab lands. A line that sometimes seeks is worse
|
||||
* than one that never does, so it must take no part in hit testing
|
||||
* at all.
|
||||
*/
|
||||
test('takes no taps', async ({ app }) => {
|
||||
const line = await rectOf(app, 'player-progress-line');
|
||||
|
||||
const hit = await app.evaluate(
|
||||
({ x, y }) => document.elementFromPoint(x, y)?.tagName ?? '',
|
||||
{ x: line!.x + line!.width / 2, y: line!.y + 1 },
|
||||
);
|
||||
|
||||
expect(hit).not.toBe('PLAYER-PROGRESS-LINE');
|
||||
});
|
||||
|
||||
test('is not there on a desktop', async ({ app }) => {
|
||||
await app.setViewportSize(DESKTOP);
|
||||
|
||||
await expect(app.locator('player-progress-line')).toBeHidden();
|
||||
});
|
||||
});
|
||||
+27
-3
@@ -426,6 +426,7 @@ body div.sidebar {
|
||||
"jobs-band" auto
|
||||
"main-panel" 1fr
|
||||
"bottom-bar" auto
|
||||
"progress-line" auto
|
||||
"bottom-nav" auto
|
||||
/ 1fr;
|
||||
/* Nothing may scroll sideways here. On a desktop the shell is
|
||||
@@ -501,7 +502,8 @@ body div.sidebar {
|
||||
expression of the same fact is a second thing to keep in step.
|
||||
The view carries its own queue button, because this is where
|
||||
that one lived. */
|
||||
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar {
|
||||
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar,
|
||||
body:has(#main-content[data-active-view="now-playing"]) player-progress-line {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -568,8 +570,12 @@ body div.sidebar {
|
||||
/* Out of the desktop grid entirely. `job-band` renders nothing above
|
||||
600px anyway, but an in-flow grid child with no named area is
|
||||
auto-placed into a row of the shell -- the same trap the skip link is
|
||||
absolutely positioned to avoid. */
|
||||
body job-band {
|
||||
absolutely positioned to avoid. `player-progress-line` (#58) is the
|
||||
same element in the same position for the same reason: below 600px it
|
||||
has a named row, and above it there is no border for it to sit on --
|
||||
the desktop bar carries a real, interactive seek bar. */
|
||||
body job-band,
|
||||
body player-progress-line {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -602,3 +608,21 @@ body job-band {
|
||||
background-color: var(--yj-bg-elevated, #343a40);
|
||||
}
|
||||
}
|
||||
|
||||
/* #58. How far through the song we are, in its own grid row between
|
||||
the two bars -- so the line is *on* the border rather than inside
|
||||
either of them, and in flow rather than over it. The row is `auto`
|
||||
and the element renders nothing while no track is loaded, so it costs
|
||||
no height at all until there is something to say.
|
||||
|
||||
**This block is below the `display: none` above and has to be**, for
|
||||
the reason the band's rule is: a media query adds no specificity, so
|
||||
`body player-progress-line { display: block }` written before that
|
||||
rule loses to it at equal specificity and the line never appears at
|
||||
any width. Nothing fails; it is simply not there. */
|
||||
@media (max-width: 599px) {
|
||||
body player-progress-line {
|
||||
display: block;
|
||||
grid-area: progress-line;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,16 @@
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- How far through the song we are, on the border between the two
|
||||
bars (#58). The shell's element rather than either bar's:
|
||||
they are separate components stacked in this grid, so a line
|
||||
on the border between them is a row of it, and neither one has
|
||||
to reach into the other's box for two pixels. It renders
|
||||
nothing above 600px and nothing with no track, is `aria-hidden`
|
||||
(Now Playing's seek bar is what announces the position) and
|
||||
takes no pointer events at all -- a thin line that sometimes
|
||||
seeks is worse than one that never does. -->
|
||||
<player-progress-line></player-progress-line>
|
||||
<!-- The phone's primary navigation, hidden above 600px by
|
||||
index.css. Eager rather than a chunk, for the reason
|
||||
notification-host is: it is the only way to move around the
|
||||
|
||||
@@ -21,6 +21,10 @@ import '@components/audio-player/audio-player.ts';
|
||||
// In the bar rather than inside `audio-player` since #42, so the shell
|
||||
// is what has to register it.
|
||||
import '@components/audio-player/volume-control/volume-control.ts';
|
||||
// The phone's progress line (#58), on the border between the mini
|
||||
// player and the tab bar. In the shell for the same reason the volume
|
||||
// is, and eager because it is part of the bottom bar's first paint.
|
||||
import '@components/audio-player/progress-line/progress-line.ts';
|
||||
import '@components/track-list/track-list.ts';
|
||||
import '@components/now-playing/now-playing.ts';
|
||||
import '@components/sidebar/app-sidebar.ts';
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
|
||||
import { PlayerController } from '@store/controllers/player-controller';
|
||||
import { designTokens } from '../../../styles/tokens.css';
|
||||
import { PHONE_QUERY } from '../../../utils/breakpoints';
|
||||
|
||||
/**
|
||||
* How far through the song we are, on the border between the mini
|
||||
* player and the tab bar (#58).
|
||||
*
|
||||
* The phone's bottom bar carries three controls and no seek bar — plan
|
||||
* 016 B2 took it out, because 4px of height is not a thumb target and the
|
||||
* full-screen `now-playing-view` is where seeking belongs. What went
|
||||
* with it is the one thing a mini player is expected to say without
|
||||
* being opened: how far through the song it is. This is that, and
|
||||
* only that.
|
||||
*
|
||||
* Four things about it are load-bearing.
|
||||
*
|
||||
* **It is the shell's element, not either bar's.** The mini player and
|
||||
* `<bottom-nav>` are separate components stacked in the shell's grid,
|
||||
* so a line on the border between them is a row of the grid — either
|
||||
* one drawing it means reaching into the other's box for two pixels.
|
||||
*
|
||||
* **It never counts.** The position is pushed at 1 Hz by the backend
|
||||
* (`PlaybackPositionChanged`), and the interval here interpolates
|
||||
* *between* those reports and is stopped and restarted by every one of
|
||||
* them — the seek bar's rule, for the reason the seek bar has it: a
|
||||
* local clock drifted 30 s away from the backend across four keyboard
|
||||
* seeks. The `trackChangeId` and `seq` guards come along for the same
|
||||
* reason: the store is a singleton, so a report about the previous
|
||||
* track must not be adopted, and the same second reported twice still
|
||||
* has to reset the interpolation.
|
||||
*
|
||||
* **It is not a control and cannot become one.** `aria-hidden` on the
|
||||
* host and `pointer-events: none` throughout: the real progress is
|
||||
* announced by the seek bar on Now Playing, and a 2px strip on the top
|
||||
* edge of the tab bar that sometimes seeks is worse than one that
|
||||
* never does. It is also where a thumb aiming at a tab lands.
|
||||
*
|
||||
* **It renders nothing above 600px**, from `matchMedia` rather than a
|
||||
* media query, because that decides whether the element *exists* — and
|
||||
* with it whether a 1 Hz interval runs for the life of every desktop
|
||||
* session about a line nobody can see. `job-band`, `search-trigger`
|
||||
* and `player-controls` are the same pattern for the same reason.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The reporting cadence, matched. This is not the clock: it exists
|
||||
* only so the line moves in the second between two reports, and its
|
||||
* error is discarded by the next one rather than carried.
|
||||
*/
|
||||
const InterpolationIntervalMillis = 1000;
|
||||
|
||||
@customElement('player-progress-line')
|
||||
export class PlayerProgressLine extends LitElement {
|
||||
private player = new PlayerController(this);
|
||||
|
||||
/** Phone width. See the class comment: existence, not paint. */
|
||||
@state() private phone = false;
|
||||
|
||||
/** Seconds into the track, from the last report plus interpolation. */
|
||||
@state() private elapsed = 0;
|
||||
|
||||
private previousTrackChangeId = -1;
|
||||
|
||||
/** The sequence number of the last backend report applied. */
|
||||
private previousPositionSeq = -1;
|
||||
|
||||
private timerID = -1;
|
||||
|
||||
private media?: MediaQueryList;
|
||||
|
||||
private onMedia = (e: MediaQueryListEvent) => {
|
||||
this.phone = e.matches;
|
||||
};
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
/* Not a target, at any depth. */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.track {
|
||||
height: 2px;
|
||||
background-color: var(--yj-bg-surface, #212529);
|
||||
}
|
||||
|
||||
.fill {
|
||||
height: 100%;
|
||||
background-color: var(--yj-accent, #ffd43b);
|
||||
/* scaleX off a full-width box rather than a width in
|
||||
percent, so the moving thing is a transform and the
|
||||
line costs no layout once a second. */
|
||||
transform-origin: left center;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
private get trackLength(): number {
|
||||
return this.player.currentTrack?.trackLength ?? 0;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
// Decorative in full: the seek bar on Now Playing is what
|
||||
// announces the position, and this says the same thing without
|
||||
// a name, a value or a way to act on it.
|
||||
this.setAttribute('aria-hidden', 'true');
|
||||
|
||||
this.media = window.matchMedia(PHONE_QUERY);
|
||||
this.phone = this.media.matches;
|
||||
this.media.addEventListener('change', this.onMedia);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.stopInterpolating();
|
||||
this.media?.removeEventListener('change', this.onMedia);
|
||||
}
|
||||
|
||||
override updated(): void {
|
||||
// A track change resets the line, and `trackChangeId` is what
|
||||
// reveals one when the same file plays twice in a row.
|
||||
const currentChangeId = this.player.currentTrack?.trackChangeId ?? -1;
|
||||
|
||||
if (currentChangeId !== this.previousTrackChangeId) {
|
||||
this.previousTrackChangeId = currentChangeId;
|
||||
this.elapsed = this.player.currentTrack?.seekPosition ?? 0;
|
||||
this.stopInterpolating();
|
||||
}
|
||||
|
||||
// The backend's own position wins over anything counted here,
|
||||
// and a report for a track that is no longer loaded is stale by
|
||||
// definition.
|
||||
const position = this.player.position;
|
||||
|
||||
if (
|
||||
position &&
|
||||
position.trackChangeId === currentChangeId &&
|
||||
position.seq !== this.previousPositionSeq
|
||||
) {
|
||||
this.previousPositionSeq = position.seq;
|
||||
this.elapsed = position.positionSeconds;
|
||||
this.stopInterpolating();
|
||||
}
|
||||
|
||||
// One owner for the interval, as in `seek-bar`: everything that
|
||||
// wants it started or stopped says so by changing state that
|
||||
// brings us back here.
|
||||
if (this.phone && this.player.isPlaying && currentChangeId !== -1) {
|
||||
this.startInterpolating();
|
||||
} else {
|
||||
this.stopInterpolating();
|
||||
}
|
||||
}
|
||||
|
||||
private stopInterpolating(): void {
|
||||
if (this.timerID !== -1) {
|
||||
clearInterval(this.timerID);
|
||||
this.timerID = -1;
|
||||
}
|
||||
}
|
||||
|
||||
private startInterpolating(): void {
|
||||
if (this.timerID !== -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timerID = window.setInterval(() => {
|
||||
if (this.elapsed < this.trackLength) {
|
||||
this.elapsed += 1;
|
||||
}
|
||||
}, InterpolationIntervalMillis);
|
||||
}
|
||||
|
||||
override render() {
|
||||
// Nothing playing is nothing to say, and the grid row is `auto`
|
||||
// so an empty render costs no height at all -- `job-band`'s
|
||||
// rule one row down.
|
||||
if (!this.phone || this.player.currentTrack === null) return nothing;
|
||||
|
||||
const length = this.trackLength;
|
||||
const fraction =
|
||||
length > 0 ? Math.min(1, Math.max(0, this.elapsed / length)) : 0;
|
||||
|
||||
return html`
|
||||
<div class="track" data-testid="progress-line">
|
||||
<div class="fill" style="transform: scaleX(${fraction})"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'player-progress-line': PlayerProgressLine;
|
||||
}
|
||||
}
|
||||
@@ -892,6 +892,67 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
.track-row .track-request {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── The phone (#66) ──
|
||||
*
|
||||
* **This block is last on purpose**, for index.css's
|
||||
* reason: a media query adds no specificity, so a rule
|
||||
* written above the plain one it overrides loses to it and
|
||||
* every declaration here is silently dead.
|
||||
*
|
||||
* Two faults, one shape. The page is a fixed header over a
|
||||
* scrolling tracklist — the desktop arrangement — so at the
|
||||
* reference device's 424x439 the header owned 253 of the
|
||||
* panel's 318px and the tracklist scrolled inside the 64px
|
||||
* that were left. And the header's flex row squeezed
|
||||
* .album-info to 112px, so the title drew as one ellipsised
|
||||
* glyph and two of the album's three primary actions were
|
||||
* clipped by the host's own overflow: Shuffle album ended
|
||||
* at x=443 in a 424px box, unreachable by any gesture.
|
||||
*
|
||||
* .album-info carries min-width: 0 and was shrinking as
|
||||
* asked, so another one is not the fix — the row has to
|
||||
* stack, or the info column has nothing to be wide with.
|
||||
*
|
||||
* The scroller moves to the host and .content stops being
|
||||
* one, which is what makes the header scroll away; the
|
||||
* tracklist is plain DOM rather than a virtualizer, so
|
||||
* nothing inside wants a scroll window of its own. */
|
||||
@media (max-width: 599px) {
|
||||
:host {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.album-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* Stacked, the art is the whole of the header's width
|
||||
* budget and its 200px square is 45% of the reference
|
||||
* device's height. It is still what identifies the
|
||||
* album, so it shrinks rather than going. */
|
||||
.cover-art-container {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
/* A column flex item takes its content's width from
|
||||
* align-items: flex-start above, which would leave the
|
||||
* actions wrapping inside a box narrower than the row
|
||||
* they now have to themselves. */
|
||||
.album-info {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 0 0 auto;
|
||||
overflow-y: visible;
|
||||
padding: 16px 16px 24px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* The phone's progress line (#58).
|
||||
*
|
||||
* **What this tier can and cannot see.** It can see the whole of what
|
||||
* the issue asks for that is not a pixel: that the line exists only on
|
||||
* a phone and only with a track, that it renders the position the
|
||||
* backend reported rather than a count of its own, and that it is
|
||||
* neither announced nor touchable. It cannot see where it sits — that
|
||||
* is the shell's grid, and it is asserted in
|
||||
* `e2e/specs/phone-progress-line.spec.ts` where there is a real bar
|
||||
* with a real tab bar under it.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
import '@components/audio-player/progress-line/progress-line';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, flush } from '@test/support/harness';
|
||||
import { fixture, shadow } from '@test/support/render';
|
||||
|
||||
const TRACK = {
|
||||
fileName: 'song.mp3',
|
||||
filePath: '/music/song.mp3',
|
||||
trackLength: 90,
|
||||
seekPosition: 0,
|
||||
state: 'playing',
|
||||
title: 'Song',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
coverArt: '',
|
||||
coverArtSmall: '',
|
||||
coverArtMedium: '',
|
||||
coverArtLarge: '',
|
||||
trackChangeId: 1,
|
||||
artistMbid: '',
|
||||
releaseGroupMbid: '',
|
||||
recordingMbid: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Answer `matchMedia` for the phone query, since the runner's own
|
||||
* window is whatever size the browser provider gives it. Stubbed rather
|
||||
* than resized for `transport-context.test.ts`'s reason: what is under
|
||||
* test is the component's reaction to the answer.
|
||||
*/
|
||||
const realMatchMedia = window.matchMedia;
|
||||
|
||||
function pretendPhone(phone: boolean): void {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: phone && query.includes('599'),
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
/** The horizontal scale of the fill, or null if there is no line. */
|
||||
function scale(el: Element): number | null {
|
||||
const fill = shadow<HTMLElement>(el, '.fill');
|
||||
|
||||
if (!fill) return null;
|
||||
|
||||
const match = /scaleX\(([^)]+)\)/.exec(fill.style.transform);
|
||||
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
describe('<player-progress-line>', () => {
|
||||
beforeEach(() => {
|
||||
emit(Events.TrackChanged, null);
|
||||
emit(Events.PlaybackStateChanged, { state: 'stopped' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = realMatchMedia;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('draws nothing above the phone breakpoint', async () => {
|
||||
pretendPhone(false);
|
||||
|
||||
const el = await fixture('player-progress-line');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 2 });
|
||||
emit(Events.PlaybackPositionChanged, {
|
||||
positionSeconds: 45,
|
||||
trackLength: 90,
|
||||
trackChangeId: 2,
|
||||
seq: 1,
|
||||
playing: true,
|
||||
});
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
// The desktop bar carries a real seek bar, and there is no tab
|
||||
// bar for this to sit on the border of.
|
||||
expect(el.shadowRoot!.querySelector('.track')).toBeNull();
|
||||
});
|
||||
|
||||
it('draws nothing until there is a track', async () => {
|
||||
pretendPhone(true);
|
||||
|
||||
const el = await fixture('player-progress-line');
|
||||
|
||||
expect(el.shadowRoot!.querySelector('.track')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the fraction the backend reported', async () => {
|
||||
pretendPhone(true);
|
||||
|
||||
const el = await fixture('player-progress-line');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 3 });
|
||||
emit(Events.PlaybackPositionChanged, {
|
||||
positionSeconds: 45,
|
||||
trackLength: 90,
|
||||
trackChangeId: 3,
|
||||
seq: 1,
|
||||
playing: true,
|
||||
});
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(scale(el)).toBeCloseTo(0.5, 3);
|
||||
});
|
||||
|
||||
it('resumes mid-track at the position the track arrived with', async () => {
|
||||
pretendPhone(true);
|
||||
|
||||
const el = await fixture('player-progress-line');
|
||||
|
||||
emit(Events.TrackChanged, {
|
||||
...TRACK,
|
||||
seekPosition: 30,
|
||||
trackChangeId: 4,
|
||||
});
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(scale(el)).toBeCloseTo(1 / 3, 3);
|
||||
});
|
||||
|
||||
it('interpolates between reports, and every report resets it', async () => {
|
||||
pretendPhone(true);
|
||||
vi.useFakeTimers();
|
||||
|
||||
const el = await fixture('player-progress-line');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 5 });
|
||||
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(scale(el)).toBeCloseTo(3 / 90, 3);
|
||||
|
||||
// The user seeks; the backend lands somewhere else and says so.
|
||||
// The local count is discarded, never added to -- the seek
|
||||
// bar's rule, and the reason it has it.
|
||||
emit(Events.PlaybackPositionChanged, {
|
||||
positionSeconds: 40,
|
||||
trackLength: 90,
|
||||
trackChangeId: 5,
|
||||
seq: 2,
|
||||
playing: true,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(scale(el)).toBeCloseTo(41 / 90, 3);
|
||||
});
|
||||
|
||||
/*
|
||||
* The reason this component asks `matchMedia` instead of letting a
|
||||
* stylesheet hide it: a media query cannot stop a 1 Hz interval
|
||||
* running for the life of every desktop session. That claim is
|
||||
* load-bearing in CLAUDE.md, so it is asserted rather than
|
||||
* described — the timer count, because a desktop render is empty
|
||||
* either way and so cannot tell the two apart.
|
||||
*/
|
||||
it('runs no interpolation timer above the breakpoint', async () => {
|
||||
pretendPhone(false);
|
||||
vi.useFakeTimers();
|
||||
|
||||
const el = await fixture('player-progress-line');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 9 });
|
||||
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
||||
emit(Events.PlaybackPositionChanged, {
|
||||
positionSeconds: 3,
|
||||
trackLength: 90,
|
||||
trackChangeId: 9,
|
||||
seq: 9,
|
||||
playing: true,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(scale(el)).toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a report about a track that is no longer loaded', async () => {
|
||||
pretendPhone(true);
|
||||
|
||||
const el = await fixture('player-progress-line');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 6 });
|
||||
emit(Events.PlaybackPositionChanged, {
|
||||
positionSeconds: 60,
|
||||
trackLength: 90,
|
||||
trackChangeId: 5,
|
||||
seq: 3,
|
||||
playing: true,
|
||||
});
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
// The store is a singleton, so a line mounting late must not
|
||||
// adopt a report about the previous track.
|
||||
expect(scale(el)).toBe(0);
|
||||
});
|
||||
|
||||
it('counts nothing while the player is paused', async () => {
|
||||
pretendPhone(true);
|
||||
vi.useFakeTimers();
|
||||
|
||||
const el = await fixture('player-progress-line');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 7 });
|
||||
emit(Events.PlaybackPositionChanged, {
|
||||
positionSeconds: 10,
|
||||
trackLength: 90,
|
||||
trackChangeId: 7,
|
||||
seq: 1,
|
||||
playing: false,
|
||||
});
|
||||
emit(Events.PlaybackStateChanged, { state: 'paused' });
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(scale(el)).toBeCloseTo(10 / 90, 3);
|
||||
});
|
||||
|
||||
it('is decorative and cannot be touched', async () => {
|
||||
pretendPhone(true);
|
||||
|
||||
const el = await fixture('player-progress-line');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 8 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
// The seek bar on Now Playing is what announces the position;
|
||||
// this says the same thing with no name and no way to act on
|
||||
// it, and it sits exactly where a thumb aiming at a tab lands.
|
||||
expect(el.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(getComputedStyle(el).pointerEvents).toBe('none');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user