Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cee322dc31 |
@@ -88,8 +88,3 @@ build/android/overlay.json
|
|||||||
# Written by @semantic-release/changelog purely to carry the release notes
|
# Written by @semantic-release/changelog purely to carry the release notes
|
||||||
# into scripts/gitea-release.sh; the release page is the changelog.
|
# into scripts/gitea-release.sh; the release page is the changelog.
|
||||||
.release-notes.md
|
.release-notes.md
|
||||||
|
|
||||||
# Agent session log: local scratch, not repo memory (that is CLAUDE.md
|
|
||||||
# and .planning/). Written by the scheduled backlog runs.
|
|
||||||
.pi/journal.md
|
|
||||||
.pi/schedule-prompts.json
|
|
||||||
|
|||||||
@@ -1909,33 +1909,6 @@ every desktop button from 33×21 to 36×24, silently. The sizes are
|
|||||||
asserted as `'33x21'` rather than as a range, because the regression
|
asserted as `'33x21'` rather than as a range, because the regression
|
||||||
was three pixels.
|
was three pixels.
|
||||||
|
|
||||||
**What that bar lost is how far through the song it is, and
|
|
||||||
`<player-progress-line>` is where it went** (#58). Plan 016 B2 took the
|
|
||||||
seek bar off the phone's transport, so the one thing a mini player is
|
|
||||||
expected to say without being opened had nowhere left to be said. It is
|
|
||||||
a 2px line on the border between the mini player and the tab bar: the
|
|
||||||
**shell's** element and its own `auto` grid row between `bottom-bar`
|
|
||||||
and `bottom-nav`, because those two are separate components and either
|
|
||||||
one drawing it means reaching into the other's box for two pixels.
|
|
||||||
|
|
||||||
Four things about it are load-bearing. **It never counts** — the fill is
|
|
||||||
`scaleX()` off the same `PlaybackPositionChanged` the seek bar renders,
|
|
||||||
with the same `trackChangeId` and `seq` guards and an interval that is
|
|
||||||
stopped and restarted by every report, which is the rule that exists
|
|
||||||
because a local clock drifted 30 s away across four keyboard seeks.
|
|
||||||
**It is not a control and cannot become one**: `aria-hidden` on the host
|
|
||||||
and `pointer-events: none` throughout, because 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, for
|
|
||||||
`job-band`'s reason plus one of its own — a stylesheet cannot stop a
|
|
||||||
1 Hz interval running for the life of every desktop session about a
|
|
||||||
line nobody can see. And **its phone rule sits 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 any width, silently.
|
|
||||||
|
|
||||||
**900 is the worst desktop width, not the 800×600 minimum.** The
|
**900 is the worst desktop width, not the 800×600 minimum.** The
|
||||||
sidebar collapses to icons *below* 900, so the main panel is 843px at
|
sidebar collapses to icons *below* 900, so the main panel is 843px at
|
||||||
899 and 700px at 900 — the narrowest content area any desktop width
|
899 and 700px at 900 — the narrowest content area any desktop width
|
||||||
@@ -3502,10 +3475,7 @@ would notice on 113 — the rule simply does not exist, there and nowhere
|
|||||||
else, which is how the bottom bar's `text-overflow: ellipsis` came to
|
else, which is how the bottom bar's `text-overflow: ellipsis` came to
|
||||||
have never truncated on the device. `make css-check`
|
have never truncated on the device. `make css-check`
|
||||||
(`frontend/scripts/check-css-nesting.mjs`, a pre-commit hook and a CI
|
(`frontend/scripts/check-css-nesting.mjs`, a pre-commit hook and a CI
|
||||||
step) fails on one, over every `frontend/*.css` and the `css` literals
|
step) fails on one, over `index.css` and the `css` literals alike, and
|
||||||
alike — a glob rather than `index.css` by name, because the hook fires
|
|
||||||
on `frontend/**/*.{ts,css}` and a sweep that names one file goes green
|
|
||||||
over a stylesheet it never opened — and
|
|
||||||
says the fix is a leading `&` — valid in both syntaxes, so no nested
|
says the fix is a leading `&` — valid in both syntaxes, so no nested
|
||||||
rule here has a reason to omit it. Two things it has to get right, and
|
rule here has a reason to omit it. Two things it has to get right, and
|
||||||
both follow from asking whether a *style* rule is anywhere above rather
|
both follow from asking whether a *style* rule is anywhere above rather
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Every test here starts a LONG_TRACK and the suite is workers: 1,
|
|
||||||
* fullyParallel: false against one long-lived app — so without this
|
|
||||||
* the four phone-* specs that follow alphabetically inherit a playing
|
|
||||||
* queue. phone-transport.spec.ts records where that lesson came from:
|
|
||||||
* the fault first showed up as a flake in a spec about something else.
|
|
||||||
*/
|
|
||||||
test.afterEach(async ({ app }) => {
|
|
||||||
await callBinding(app, 'queue.Queue.Clear').catch(() => {
|
|
||||||
/* already empty */
|
|
||||||
});
|
|
||||||
await app.setViewportSize(DESKTOP);
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+3
-27
@@ -426,7 +426,6 @@ body div.sidebar {
|
|||||||
"jobs-band" auto
|
"jobs-band" auto
|
||||||
"main-panel" 1fr
|
"main-panel" 1fr
|
||||||
"bottom-bar" auto
|
"bottom-bar" auto
|
||||||
"progress-line" auto
|
|
||||||
"bottom-nav" auto
|
"bottom-nav" auto
|
||||||
/ 1fr;
|
/ 1fr;
|
||||||
/* Nothing may scroll sideways here. On a desktop the shell is
|
/* Nothing may scroll sideways here. On a desktop the shell is
|
||||||
@@ -502,8 +501,7 @@ body div.sidebar {
|
|||||||
expression of the same fact is a second thing to keep in step.
|
expression of the same fact is a second thing to keep in step.
|
||||||
The view carries its own queue button, because this is where
|
The view carries its own queue button, because this is where
|
||||||
that one lived. */
|
that one lived. */
|
||||||
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar,
|
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar {
|
||||||
body:has(#main-content[data-active-view="now-playing"]) player-progress-line {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,12 +568,8 @@ body div.sidebar {
|
|||||||
/* Out of the desktop grid entirely. `job-band` renders nothing above
|
/* Out of the desktop grid entirely. `job-band` renders nothing above
|
||||||
600px anyway, but an in-flow grid child with no named area is
|
600px anyway, but an in-flow grid child with no named area is
|
||||||
auto-placed into a row of the shell -- the same trap the skip link is
|
auto-placed into a row of the shell -- the same trap the skip link is
|
||||||
absolutely positioned to avoid. `player-progress-line` (#58) is the
|
absolutely positioned to avoid. */
|
||||||
same element in the same position for the same reason: below 600px it
|
body job-band {
|
||||||
has a named row, and above it there is no border for it to sit on --
|
|
||||||
the desktop bar carries a real, interactive seek bar. */
|
|
||||||
body job-band,
|
|
||||||
body player-progress-line {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,21 +602,3 @@ body player-progress-line {
|
|||||||
background-color: var(--yj-bg-elevated, #343a40);
|
background-color: var(--yj-bg-elevated, #343a40);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* #58. How far through the song we are, in its own grid row between
|
|
||||||
the two bars -- so the line is *on* the border rather than inside
|
|
||||||
either of them, and in flow rather than over it. The row is `auto`
|
|
||||||
and the element renders nothing while no track is loaded, so it costs
|
|
||||||
no height at all until there is something to say.
|
|
||||||
|
|
||||||
**This block is below the `display: none` above and has to be**, for
|
|
||||||
the reason the band's rule is: a media query adds no specificity, so
|
|
||||||
`body player-progress-line { display: block }` written before that
|
|
||||||
rule loses to it at equal specificity and the line never appears at
|
|
||||||
any width. Nothing fails; it is simply not there. */
|
|
||||||
@media (max-width: 599px) {
|
|
||||||
body player-progress-line {
|
|
||||||
display: block;
|
|
||||||
grid-area: progress-line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -81,16 +81,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
<!-- How far through the song we are, on the border between the two
|
|
||||||
bars (#58). The shell's element rather than either bar's:
|
|
||||||
they are separate components stacked in this grid, so a line
|
|
||||||
on the border between them is a row of it, and neither one has
|
|
||||||
to reach into the other's box for two pixels. It renders
|
|
||||||
nothing above 600px and nothing with no track, is `aria-hidden`
|
|
||||||
(Now Playing's seek bar is what announces the position) and
|
|
||||||
takes no pointer events at all -- a thin line that sometimes
|
|
||||||
seeks is worse than one that never does. -->
|
|
||||||
<player-progress-line></player-progress-line>
|
|
||||||
<!-- The phone's primary navigation, hidden above 600px by
|
<!-- The phone's primary navigation, hidden above 600px by
|
||||||
index.css. Eager rather than a chunk, for the reason
|
index.css. Eager rather than a chunk, for the reason
|
||||||
notification-host is: it is the only way to move around the
|
notification-host is: it is the only way to move around the
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ import '@components/audio-player/audio-player.ts';
|
|||||||
// In the bar rather than inside `audio-player` since #42, so the shell
|
// In the bar rather than inside `audio-player` since #42, so the shell
|
||||||
// is what has to register it.
|
// is what has to register it.
|
||||||
import '@components/audio-player/volume-control/volume-control.ts';
|
import '@components/audio-player/volume-control/volume-control.ts';
|
||||||
// The phone's progress line (#58), on the border between the mini
|
|
||||||
// player and the tab bar. In the shell for the same reason the volume
|
|
||||||
// is, and eager because it is part of the bottom bar's first paint.
|
|
||||||
import '@components/audio-player/progress-line/progress-line.ts';
|
|
||||||
import '@components/track-list/track-list.ts';
|
import '@components/track-list/track-list.ts';
|
||||||
import '@components/now-playing/now-playing.ts';
|
import '@components/now-playing/now-playing.ts';
|
||||||
import '@components/sidebar/app-sidebar.ts';
|
import '@components/sidebar/app-sidebar.ts';
|
||||||
|
|||||||
@@ -18,24 +18,10 @@ import { findBareNestedRules } from './css-nesting.mjs';
|
|||||||
|
|
||||||
const problems = [];
|
const problems = [];
|
||||||
|
|
||||||
// Every stylesheet, not `index.css` by name: the hook that runs this
|
for (const { line, selector } of findBareNestedRules(
|
||||||
// fires on `frontend/**/*.{ts,css}`, so naming one file promises a
|
readFileSync('index.css', 'utf8'),
|
||||||
// coverage the sweep does not deliver -- a second stylesheet would be
|
)) {
|
||||||
// silently unswept while the hook still went green over it. There is
|
problems.push({ file: 'index.css', line, selector });
|
||||||
// only `index.css` today, which is exactly when this is free to fix.
|
|
||||||
const stylesheets = globSync('*.css', { cwd: process.cwd() });
|
|
||||||
|
|
||||||
if (stylesheets.length === 0) {
|
|
||||||
console.error('css-nesting-check: no stylesheet matched *.css');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const file of stylesheets) {
|
|
||||||
for (const { line, selector } of findBareNestedRules(
|
|
||||||
readFileSync(file, 'utf8'),
|
|
||||||
)) {
|
|
||||||
problems.push({ file, line, selector });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sources = globSync('src/**/*.ts', { cwd: process.cwd() });
|
const sources = globSync('src/**/*.ts', { cwd: process.cwd() });
|
||||||
@@ -75,5 +61,5 @@ if (problems.length > 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`css-nesting-check: ${stylesheets.length} stylesheet(s) + ${sources.length} files, no bare nested rules`,
|
`css-nesting-check: index.css + ${sources.length} files, no bare nested rules`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
import { LitElement, html, css, nothing } from 'lit';
|
|
||||||
import { customElement, state } from 'lit/decorators.js';
|
|
||||||
|
|
||||||
import { PlayerController } from '@store/controllers/player-controller';
|
|
||||||
import { designTokens } from '../../../styles/tokens.css';
|
|
||||||
import { PHONE_QUERY } from '../../../utils/breakpoints';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How far through the song we are, on the border between the mini
|
|
||||||
* player and the tab bar (#58).
|
|
||||||
*
|
|
||||||
* The phone's bottom bar carries three controls and no seek bar — plan
|
|
||||||
* 016 B2 took it out, because 4px of height is not a thumb target and the
|
|
||||||
* full-screen `now-playing-view` is where seeking belongs. What went
|
|
||||||
* with it is the one thing a mini player is expected to say without
|
|
||||||
* being opened: how far through the song it is. This is that, and
|
|
||||||
* only that.
|
|
||||||
*
|
|
||||||
* Four things about it are load-bearing.
|
|
||||||
*
|
|
||||||
* **It is the shell's element, not either bar's.** The mini player and
|
|
||||||
* `<bottom-nav>` are separate components stacked in the shell's grid,
|
|
||||||
* so a line on the border between them is a row of the grid — either
|
|
||||||
* one drawing it means reaching into the other's box for two pixels.
|
|
||||||
*
|
|
||||||
* **It never counts.** The position is pushed at 1 Hz by the backend
|
|
||||||
* (`PlaybackPositionChanged`), and the interval here interpolates
|
|
||||||
* *between* those reports and is stopped and restarted by every one of
|
|
||||||
* them — the seek bar's rule, for the reason the seek bar has it: a
|
|
||||||
* local clock drifted 30 s away from the backend across four keyboard
|
|
||||||
* seeks. The `trackChangeId` and `seq` guards come along for the same
|
|
||||||
* reason: the store is a singleton, so a report about the previous
|
|
||||||
* track must not be adopted, and the same second reported twice still
|
|
||||||
* has to reset the interpolation.
|
|
||||||
*
|
|
||||||
* **It is not a control and cannot become one.** `aria-hidden` on the
|
|
||||||
* host and `pointer-events: none` throughout: the real progress is
|
|
||||||
* announced by the seek bar on Now Playing, and a 2px strip on the top
|
|
||||||
* edge of the tab bar that sometimes seeks is worse than one that
|
|
||||||
* never does. It is also where a thumb aiming at a tab lands.
|
|
||||||
*
|
|
||||||
* **It renders nothing above 600px**, from `matchMedia` rather than a
|
|
||||||
* media query, because that decides whether the element *exists* — and
|
|
||||||
* with it whether a 1 Hz interval runs for the life of every desktop
|
|
||||||
* session about a line nobody can see. `job-band`, `search-trigger`
|
|
||||||
* and `player-controls` are the same pattern for the same reason.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The reporting cadence, matched. This is not the clock: it exists
|
|
||||||
* only so the line moves in the second between two reports, and its
|
|
||||||
* error is discarded by the next one rather than carried.
|
|
||||||
*/
|
|
||||||
const InterpolationIntervalMillis = 1000;
|
|
||||||
|
|
||||||
@customElement('player-progress-line')
|
|
||||||
export class PlayerProgressLine extends LitElement {
|
|
||||||
private player = new PlayerController(this);
|
|
||||||
|
|
||||||
/** Phone width. See the class comment: existence, not paint. */
|
|
||||||
@state() private phone = false;
|
|
||||||
|
|
||||||
/** Seconds into the track, from the last report plus interpolation. */
|
|
||||||
@state() private elapsed = 0;
|
|
||||||
|
|
||||||
private previousTrackChangeId = -1;
|
|
||||||
|
|
||||||
/** The sequence number of the last backend report applied. */
|
|
||||||
private previousPositionSeq = -1;
|
|
||||||
|
|
||||||
private timerID = -1;
|
|
||||||
|
|
||||||
private media?: MediaQueryList;
|
|
||||||
|
|
||||||
private onMedia = (e: MediaQueryListEvent) => {
|
|
||||||
this.phone = e.matches;
|
|
||||||
};
|
|
||||||
|
|
||||||
static override styles = [
|
|
||||||
designTokens,
|
|
||||||
css`
|
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
/* Not a target, at any depth. */
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track {
|
|
||||||
height: 2px;
|
|
||||||
background-color: var(--yj-bg-surface, #212529);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fill {
|
|
||||||
height: 100%;
|
|
||||||
background-color: var(--yj-accent, #ffd43b);
|
|
||||||
/* scaleX off a full-width box rather than a width in
|
|
||||||
percent, so the moving thing is a transform and the
|
|
||||||
line costs no layout once a second. */
|
|
||||||
transform-origin: left center;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
];
|
|
||||||
|
|
||||||
private get trackLength(): number {
|
|
||||||
return this.player.currentTrack?.trackLength ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
override connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
|
|
||||||
// Decorative in full: the seek bar on Now Playing is what
|
|
||||||
// announces the position, and this says the same thing without
|
|
||||||
// a name, a value or a way to act on it.
|
|
||||||
this.setAttribute('aria-hidden', 'true');
|
|
||||||
|
|
||||||
this.media = window.matchMedia(PHONE_QUERY);
|
|
||||||
this.phone = this.media.matches;
|
|
||||||
this.media.addEventListener('change', this.onMedia);
|
|
||||||
}
|
|
||||||
|
|
||||||
override disconnectedCallback(): void {
|
|
||||||
super.disconnectedCallback();
|
|
||||||
this.stopInterpolating();
|
|
||||||
this.media?.removeEventListener('change', this.onMedia);
|
|
||||||
}
|
|
||||||
|
|
||||||
override updated(): void {
|
|
||||||
// A track change resets the line, and `trackChangeId` is what
|
|
||||||
// reveals one when the same file plays twice in a row.
|
|
||||||
const currentChangeId = this.player.currentTrack?.trackChangeId ?? -1;
|
|
||||||
|
|
||||||
if (currentChangeId !== this.previousTrackChangeId) {
|
|
||||||
this.previousTrackChangeId = currentChangeId;
|
|
||||||
this.elapsed = this.player.currentTrack?.seekPosition ?? 0;
|
|
||||||
this.stopInterpolating();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The backend's own position wins over anything counted here,
|
|
||||||
// and a report for a track that is no longer loaded is stale by
|
|
||||||
// definition.
|
|
||||||
const position = this.player.position;
|
|
||||||
|
|
||||||
if (
|
|
||||||
position &&
|
|
||||||
position.trackChangeId === currentChangeId &&
|
|
||||||
position.seq !== this.previousPositionSeq
|
|
||||||
) {
|
|
||||||
this.previousPositionSeq = position.seq;
|
|
||||||
this.elapsed = position.positionSeconds;
|
|
||||||
this.stopInterpolating();
|
|
||||||
}
|
|
||||||
|
|
||||||
// One owner for the interval, as in `seek-bar`: everything that
|
|
||||||
// wants it started or stopped says so by changing state that
|
|
||||||
// brings us back here.
|
|
||||||
if (this.phone && this.player.isPlaying && currentChangeId !== -1) {
|
|
||||||
this.startInterpolating();
|
|
||||||
} else {
|
|
||||||
this.stopInterpolating();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopInterpolating(): void {
|
|
||||||
if (this.timerID !== -1) {
|
|
||||||
clearInterval(this.timerID);
|
|
||||||
this.timerID = -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private startInterpolating(): void {
|
|
||||||
if (this.timerID !== -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.timerID = window.setInterval(() => {
|
|
||||||
if (this.elapsed < this.trackLength) {
|
|
||||||
this.elapsed += 1;
|
|
||||||
}
|
|
||||||
}, InterpolationIntervalMillis);
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
// Nothing playing is nothing to say, and the grid row is `auto`
|
|
||||||
// so an empty render costs no height at all -- `job-band`'s
|
|
||||||
// rule one row down.
|
|
||||||
if (!this.phone || this.player.currentTrack === null) return nothing;
|
|
||||||
|
|
||||||
const length = this.trackLength;
|
|
||||||
const fraction =
|
|
||||||
length > 0 ? Math.min(1, Math.max(0, this.elapsed / length)) : 0;
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="track" data-testid="progress-line">
|
|
||||||
<div class="fill" style="transform: scaleX(${fraction})"></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface HTMLElementTagNameMap {
|
|
||||||
'player-progress-line': PlayerProgressLine;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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-progress-line.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);
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The reason this component asks `matchMedia` instead of letting a
|
|
||||||
* stylesheet hide it: a media query cannot stop a 1 Hz interval
|
|
||||||
* running for the life of every desktop session. That claim is
|
|
||||||
* load-bearing in CLAUDE.md, so it is asserted rather than
|
|
||||||
* described — the timer count, because a desktop render is empty
|
|
||||||
* either way and so cannot tell the two apart.
|
|
||||||
*/
|
|
||||||
it('runs no interpolation timer above the breakpoint', async () => {
|
|
||||||
pretendPhone(false);
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
const el = await fixture('player-progress-line');
|
|
||||||
|
|
||||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 9 });
|
|
||||||
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
|
||||||
emit(Events.PlaybackPositionChanged, {
|
|
||||||
positionSeconds: 3,
|
|
||||||
trackLength: 90,
|
|
||||||
trackChangeId: 9,
|
|
||||||
seq: 9,
|
|
||||||
playing: true,
|
|
||||||
});
|
|
||||||
await vi.advanceTimersByTimeAsync(5000);
|
|
||||||
await el.updateComplete;
|
|
||||||
|
|
||||||
expect(scale(el)).toBeNull();
|
|
||||||
expect(vi.getTimerCount()).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
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