volume-control asks the player whether there is a volume of ours to
control, and renders nothing when there is not. The decision is in the
control rather than at either mount point because there are two, and
one of them -- the bottom bar's -- lives in index.html, which has no
module scope to make it conditional.
It could not have been a width, and that is the whole design decision.
Every other stand-down rule in this app is keyed on a viewport, because
a width is what a browser can answer and what every tier can test.
This one is a property of the build: keyed on width, an Android tablet
at 600px or more draws the bar's slider over a level the backend has
pinned -- a control that cannot act, on exactly the platform the rule
exists for, which library-status-indicator settled is worse than none.
The same rule is wrong the other way below 600px, where a narrow
desktop window has no hardware keys to fall back on. index.css keeps
its phone rule, which is now about room and says so.
Rendering nothing and hiding the host are both needed and are separate
assertions: an empty shadow root is what stops a by-role or positional
query finding a button that cannot act, and :host([hidden]) is what
stops the element taking a flex item's worth of the transport. The
host rule has to be written down, since :host { display: inline-flex }
outranks the UA's [hidden].
Measured at 424x439 by flipping the constant and rebuilding: the album
art goes 39px to 68px and the transport 172px to 143px -- 29px, being
the 21px control plus the 8px gap a hidden box stops drawing. The
bar's centring is unaffected, since #23's outer columns are the same
min() expression rather than content-sized.
volume-ownership.test.ts is the tier that can exercise the Android
rendering, on an ordinary Linux runner, because the predicate is a
stubbable backend answer. Both of its tests were confirmed to fail on
the build before this.
Closes #64
Closes #172
94 lines
3.4 KiB
TypeScript
94 lines
3.4 KiB
TypeScript
/**
|
|
* Who owns the volume, and what the control does when it is not us
|
|
* (#64).
|
|
*
|
|
* **This is the tier that can exercise the Android branch**, and it is
|
|
* the reason the predicate is a backend answer rather than a build tag
|
|
* the frontend cannot see: `SystemOwnsVolume` is a stub here, so the
|
|
* "no volume" rendering is checked on an ordinary Linux CI runner with
|
|
* no device anywhere. What no tier here can check is the *constant*
|
|
* behind it, which `TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform`
|
|
* sweeps the Go source for instead.
|
|
*
|
|
* It is a file of its own because `volumeStyleStore` asks once and
|
|
* latches — the answer is a property of the binary and cannot change
|
|
* while the app runs, so there is deliberately no event that refreshes
|
|
* it. Vitest gives each file its own module registry, which is what
|
|
* lets the stub be in place before the singleton is first touched.
|
|
* The *available* case is the rest of `transport.test.ts`, which mounts
|
|
* the same element under the default stub.
|
|
*/
|
|
import { describe, expect, it, beforeEach } from 'vitest';
|
|
|
|
import '@components/audio-player/volume-control/volume-control';
|
|
import '@components/now-playing-view/now-playing-view';
|
|
import { Events } from '../../src/events';
|
|
import { emit, resetHarness, stub } from '@test/support/harness';
|
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
|
import type { TrackInfo } from '@store/player-store';
|
|
|
|
const TRACK: TrackInfo = {
|
|
fileName: 'tideline.mp3',
|
|
filePath: '/music/tideline.mp3',
|
|
trackLength: 245,
|
|
seekPosition: 0,
|
|
state: 'playing',
|
|
title: 'Tideline',
|
|
artist: 'Sea Change',
|
|
album: 'Ebb',
|
|
coverArt: '',
|
|
coverArtSmall: '',
|
|
coverArtMedium: '',
|
|
coverArtLarge: '',
|
|
trackChangeId: 1,
|
|
artistMbid: '',
|
|
releaseGroupMbid: '',
|
|
recordingMbid: '',
|
|
};
|
|
|
|
describe('a platform whose volume we do not own', () => {
|
|
beforeEach(async () => {
|
|
resetHarness();
|
|
stub('player.Player.SystemOwnsVolume', true);
|
|
stub('config.Config.GetPopupVolume', false);
|
|
|
|
// The store latches on the first mount; do it here so every test
|
|
// below sees a settled answer rather than the first frame.
|
|
const warm = await fixture('volume-control');
|
|
|
|
await warm.updateComplete;
|
|
});
|
|
|
|
it('renders no control at all, and no empty shadow root to find', async () => {
|
|
const el = await fixture('volume-control');
|
|
|
|
await el.updateComplete;
|
|
|
|
// Both halves matter. An empty shadow root is what stops a
|
|
// positional or by-role query finding a button that cannot act;
|
|
// `hidden` is what stops the host taking a flex item's worth of
|
|
// space in the transport it sits in.
|
|
expect(shadowAll(el, 'button')).toHaveLength(0);
|
|
expect(shadowAll(el, 'wa-slider')).toHaveLength(0);
|
|
expect(el.hidden, 'the host is not hidden').toBe(true);
|
|
});
|
|
|
|
it('leaves the rest of the phone transport alone', async () => {
|
|
emit(Events.TrackChanged, TRACK);
|
|
|
|
const view = await fixture('now-playing-view');
|
|
|
|
await view.updateComplete;
|
|
|
|
// Seeking and the transport buttons are not volume, and #64 is
|
|
// allowed to remove one control, not to thin the screen out.
|
|
expect(shadow(view, 'seek-bar')).not.toBeNull();
|
|
expect(shadow(view, 'player-controls')).not.toBeNull();
|
|
|
|
const volume = shadow(view, 'volume-control') as HTMLElement | null;
|
|
|
|
expect(volume, 'the element is still mounted').not.toBeNull();
|
|
expect(volume!.hidden, 'a mounted volume-control is not hidden').toBe(true);
|
|
});
|
|
});
|