Two issues over one bar, because they are one relayout. #42's own findings say so: giving wa-slider a label grows it 6px to 14px and moves the transport, which is #23's subject, so doing them in sequence means measuring the bar twice and throwing the first set away. The bar was `320px 1fr auto`, so the transport sat in the middle of what the metadata and the queue button did not use — its centre was ~140px right of the window's at every width. The outer two tracks are the same expression now, so the middle is centred by construction. The side width is the metadata's, capped at a quarter of the bar, and the cap was measured as a regression before it was a decision: reserving the full `--now-playing-width` on both sides is perfectly centred and takes the seek bar's track from 257px to 61px at 800px, and to 0 at 200% text. The control you drag was paying for the symmetry. With the cap it is 246, which is parity. It is a `min()` rather than a breakpoint because that variable is user state — the metadata has a drag handle — and tying both sides to it is also what keeps dragging meaningful; a plain `1fr … 1fr` centres just as well and silently makes the handle a no-op. The volume moved out of `audio-player` into the bar because the transport column has to hold the transport and nothing else, and it joins the queue button in one cell rather than a second column, since the centring compares columns. It is a slider by default and a popup by setting. The stored flag names the *popup*, which is this config's polarity rule — the zero value has to be the intended answer, so an existing config.toml gets the new default with no migration. Inline, the icon is the mute toggle and is named after that action rather than the state, because with the slider beside it there is nothing to disclose; the component tier now covers both presentations rather than whichever is default. Three nested rules in this block began with a bare element selector, which Chrome 120 relaxed and the phone's Chrome 113 **silently drops** — including the ellipsis on the bar's own title and artist, which has therefore never truncated on the device. They are `&`-prefixed now. Filed as #154 for the class and for a check. `bottom-bar.spec.ts` pins both halves separately on purpose: an uncapped build is perfectly centred and fails only the seek-bar width, so a spec asserting centring alone would have passed the regression above. Both were verified by mutation. Closes #23 Closes #42
148 lines
5.1 KiB
TypeScript
148 lines
5.1 KiB
TypeScript
import { test, expect, callBinding, NO_QUEUE_SOURCE } from '../support/fixtures.js';
|
|
import type { Page } from '@playwright/test';
|
|
|
|
/**
|
|
* The bottom bar's two promises (#23, #42): the transport is centred in
|
|
* the window, and the volume is a slider rather than a popup.
|
|
*
|
|
* **"Centred" is measured against the window, not against the space
|
|
* left over**, which is the whole of #23. The bar was
|
|
* `320px 1fr auto`, so the transport sat in the middle of what the
|
|
* metadata and the queue button did not use — its centre was ~140px
|
|
* right of the window's at every size, which reads as an alignment
|
|
* mistake rather than as a layout choice.
|
|
*
|
|
* The mechanism is that the outer two columns are the same width, so
|
|
* this asserts the *outcome* (centre lines up) rather than the CSS. A
|
|
* spec that checked `grid-template-columns` would pass on any build
|
|
* that kept the declaration and broke the result.
|
|
*/
|
|
|
|
/** Where the transport sits, against where the window's centre is. */
|
|
const geometry = (app: Page) =>
|
|
app.evaluate(() => {
|
|
const bar = document.querySelector<HTMLElement>('.bottom-bar')!;
|
|
const player = document.querySelector<HTMLElement>('audio-player')!;
|
|
const b = bar.getBoundingClientRect();
|
|
const p = player.getBoundingClientRect();
|
|
|
|
const seek = player.shadowRoot
|
|
?.querySelector('seek-bar')
|
|
?.shadowRoot?.querySelector('wa-slider');
|
|
|
|
return {
|
|
offset: Math.round(p.left + p.width / 2 - (b.left + b.width / 2)),
|
|
barHeight: Math.round(b.height),
|
|
seekWidth: seek ? Math.round(seek.getBoundingClientRect().width) : -1,
|
|
};
|
|
});
|
|
|
|
/** Something has to be playing before the transport draws a seek bar. */
|
|
async function play(app: Page): Promise<void> {
|
|
const paths = await app.evaluate(async () => {
|
|
const tracks = (await window.__yjEvents.call(
|
|
'library.Library.GetTracks',
|
|
[0],
|
|
10_000,
|
|
)) as { FilePath: string }[];
|
|
|
|
return tracks.slice(0, 3).map((t) => t.FilePath);
|
|
});
|
|
|
|
await callBinding(app, 'queue.Queue.SetQueue', [
|
|
paths,
|
|
0,
|
|
false,
|
|
NO_QUEUE_SOURCE,
|
|
]);
|
|
await callBinding(app, 'queue.Queue.Play');
|
|
await expect(app.getByTestId('now-playing-title')).not.toBeEmpty();
|
|
}
|
|
|
|
test.describe('the bottom bar', () => {
|
|
test.afterEach(async ({ app }) => {
|
|
await callBinding(app, 'queue.Queue.Clear').catch(() => {
|
|
/* already empty */
|
|
});
|
|
await app.setViewportSize({ width: 1440, height: 900 });
|
|
});
|
|
|
|
/**
|
|
* Four widths, because a centring bug is a function of width: the old
|
|
* layout was off by half the difference between the two outer
|
|
* columns, so it was wrong by a different amount at each one and
|
|
* exactly right at none.
|
|
*/
|
|
for (const width of [800, 900, 1100, 1440]) {
|
|
test(`centres the transport in the window at ${width}px`, async ({
|
|
app,
|
|
}) => {
|
|
await app.setViewportSize({ width, height: 700 });
|
|
await play(app);
|
|
|
|
await expect.poll(() => geometry(app).then((g) => g.offset)).toBe(0);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The seek bar is what the centring is *paid for* with, so it is
|
|
* asserted rather than assumed.
|
|
*
|
|
* Reserving the metadata's full width on both sides centres the
|
|
* transport perfectly and squeezes the control you drag: measured
|
|
* during this work at **61px of track at 800px**, against 257 before
|
|
* the change. The side columns are capped at a quarter of the bar for
|
|
* that reason, and this is the number that says so — 246 at 800px,
|
|
* which is parity with the uncentred layout.
|
|
*/
|
|
test('does not pay for the centring with the seek bar', async ({ app }) => {
|
|
await app.setViewportSize({ width: 800, height: 700 });
|
|
await play(app);
|
|
|
|
await expect
|
|
.poll(() => geometry(app).then((g) => g.seekWidth))
|
|
.toBeGreaterThan(200);
|
|
});
|
|
|
|
/**
|
|
* #42: the slider is simply there. Three gestures — click open, drag,
|
|
* click closed — is what a bottom bar has room not to ask for.
|
|
*/
|
|
test('shows the volume slider without a click', async ({ app }) => {
|
|
await app.setViewportSize({ width: 1440, height: 900 });
|
|
|
|
const volume = app.locator('.bottom-bar volume-control');
|
|
|
|
await expect(volume).toBeVisible();
|
|
await expect(volume.locator('wa-slider')).toBeVisible();
|
|
});
|
|
|
|
/**
|
|
* And the inline icon is the mute toggle, because with the slider
|
|
* beside it there is nothing left to disclose. The name follows the
|
|
* action rather than the state for the same reason.
|
|
*/
|
|
test('names the inline icon after what it does', async ({ app }) => {
|
|
await app.setViewportSize({ width: 1440, height: 900 });
|
|
|
|
await expect(
|
|
app.locator('.bottom-bar volume-control').getByRole('button', {
|
|
name: 'Mute',
|
|
}),
|
|
).toBeVisible();
|
|
});
|
|
|
|
/**
|
|
* The bar is a fixed 4em row and the transport sits in it. A slider
|
|
* with a label grows `#slider` by 8px unless `wa-slider-label.css`
|
|
* suppresses it, which moved the whole bar the last time — so the
|
|
* height is pinned here rather than left to a screenshot.
|
|
*/
|
|
test('stays 4em tall', async ({ app }) => {
|
|
await app.setViewportSize({ width: 1440, height: 900 });
|
|
await play(app);
|
|
|
|
await expect.poll(() => geometry(app).then((g) => g.barHeight)).toBe(64);
|
|
});
|
|
});
|