feat(player): centre the transport and show the volume inline
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
This commit is contained in:
@@ -11,7 +11,7 @@ import '@components/audio-player/controls/player-controls';
|
||||
import '@components/audio-player/seekbar/seek-bar';
|
||||
import '@components/audio-player/volume-control/volume-control';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, calls, lastArgs, flush } from '@test/support/harness';
|
||||
import { emit, calls, lastArgs, flush, stub } from '@test/support/harness';
|
||||
import {
|
||||
fixture,
|
||||
shadow,
|
||||
@@ -434,13 +434,40 @@ describe('<seek-bar>', () => {
|
||||
* be driven by its own event — watching the volume number, as it used
|
||||
* to, meant pressing M visibly did nothing.
|
||||
*/
|
||||
/**
|
||||
* The volume control has two presentations (#42), and the icon button
|
||||
* means a different thing in each — so both are exercised rather than
|
||||
* whichever one happens to be the default.
|
||||
*
|
||||
* Inline is the default: the slider is simply there, which leaves the
|
||||
* icon with nothing to disclose, so it is the mute toggle and is named
|
||||
* after that action. In the popup it is a disclosure, so it is named
|
||||
* after the *state* it is showing.
|
||||
*/
|
||||
describe('volume control: mute', () => {
|
||||
beforeEach(() => {
|
||||
/**
|
||||
* Put the presentation back to the default between tests.
|
||||
*
|
||||
* `volumeStyleStore` is a singleton whose `init()` reads the setting
|
||||
* once, so stubbing the binding inside a test is too late — a
|
||||
* previous test has already loaded it. `GeneralConfigChanged` is the
|
||||
* store's own refresh trigger and the same one the Settings page
|
||||
* fires, so driving it that way exercises the real path instead of
|
||||
* reaching for a test-only reset.
|
||||
*/
|
||||
const setPresentation = async (popup: boolean) => {
|
||||
stub('config.Config.GetPopupVolume', popup);
|
||||
emit(Events.GeneralConfigChanged, {});
|
||||
await flush();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await setPresentation(false);
|
||||
emit(Events.VolumeChanged, 40);
|
||||
emit(Events.MuteChanged, false);
|
||||
});
|
||||
|
||||
it('shows a muted glyph and label once the backend reports mute', async () => {
|
||||
it('shows a muted glyph once the backend reports mute', async () => {
|
||||
const el = await fixture('volume-control');
|
||||
|
||||
expect(shadow(el, 'button')?.getAttribute('data-muted')).toBe('false');
|
||||
@@ -453,14 +480,53 @@ describe('volume control: mute', () => {
|
||||
expect(shadow(el, 'button wa-icon')?.getAttribute('name')).toBe(
|
||||
'volume-xmark',
|
||||
);
|
||||
});
|
||||
|
||||
it('names the inline icon after the action it performs', async () => {
|
||||
const el = await fixture('volume-control');
|
||||
|
||||
expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe('Mute');
|
||||
|
||||
emit(Events.MuteChanged, true);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe('Unmute');
|
||||
});
|
||||
|
||||
it('names the popup icon after the state it discloses', async () => {
|
||||
await setPresentation(true);
|
||||
|
||||
const el = await fixture('volume-control');
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe(
|
||||
'Volume 40%',
|
||||
);
|
||||
|
||||
emit(Events.MuteChanged, true);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe('Muted');
|
||||
});
|
||||
|
||||
it('shows the slider without a click when it is inline', async () => {
|
||||
const el = await fixture('volume-control');
|
||||
|
||||
// The whole point of the issue: no disclosure to operate first.
|
||||
expect(shadow<HTMLInputElement>(el, 'wa-slider')?.value).toBe(40);
|
||||
});
|
||||
|
||||
it('keeps showing the volume level while muted, because it is unchanged', async () => {
|
||||
await setPresentation(true);
|
||||
emit(Events.MuteChanged, true);
|
||||
await flush();
|
||||
|
||||
const el = await fixture('volume-control');
|
||||
|
||||
await el.updateComplete;
|
||||
await click(el, 'button');
|
||||
|
||||
expect(shadow<HTMLInputElement>(el, 'wa-slider')?.value).toBe(40);
|
||||
@@ -468,11 +534,24 @@ describe('volume control: mute', () => {
|
||||
|
||||
it('toggles mute through the backend rather than locally', async () => {
|
||||
const el = await fixture('volume-control');
|
||||
|
||||
await click(el, 'button');
|
||||
|
||||
expect(calls('player.Player.MuteToggle').length).toBe(1);
|
||||
// Nothing optimistic: the icon follows the backend's event.
|
||||
expect(shadow(el, 'button')?.getAttribute('data-muted')).toBe('false');
|
||||
});
|
||||
|
||||
it('toggles mute from inside the popup, where the icon is a disclosure', async () => {
|
||||
await setPresentation(true);
|
||||
|
||||
const el = await fixture('volume-control');
|
||||
|
||||
await el.updateComplete;
|
||||
await click(el, 'button');
|
||||
await click(el, '.mute-toggle');
|
||||
|
||||
expect(calls('player.Player.MuteToggle').length).toBe(1);
|
||||
// Nothing optimistic: the icon follows the backend's event.
|
||||
expect(shadow(el, 'button')?.getAttribute('data-muted')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user