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,228 @@
|
||||
/**
|
||||
* 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-transport.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);
|
||||
});
|
||||
|
||||
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