feat(player): show progress on the phone's bar border
#59 took the seek bar off the phone's transport, so the one thing a mini player is expected to say without being opened -- how far through the song it is -- had nowhere left to be said. It is the shell's element and its own 2px grid row between `bottom-bar` and `bottom-nav`, because those two are separate components and either one drawing the line means reaching into the other's box. The fill is `scaleX()` off the same `PlaybackPositionChanged` the seek bar renders, with the same `trackChangeId`/`seq` guards and an interval that only interpolates *between* reports -- never its own clock, which is the rule that exists because a local counter drifted 30 s away from the backend across four keyboard seeks. It is `aria-hidden` and takes no pointer events at any depth: 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, because a stylesheet cannot stop a 1 Hz interval running for the life of every desktop session about a line nobody can see. Its phone rule is 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 all. Closes #58
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
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);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user