The paragraph said #59 took the seek bar off the phone's transport. It was plan 016 B2 — audio-player.ts says so in the comment above the rule that does it, and CLAUDE.md's own #59 paragraph says #59 removed shuffle, repeat and the queue button. Wrong provenance in the file whose whole value is being right about which change did what. Also stop tracking .pi/journal.md. It is a scheduled run's scratch log, and this repo's memory is CLAUDE.md and .planning/ — a session log arriving inside a feature PR is a new convention landing sideways.
205 lines
7.2 KiB
TypeScript
205 lines
7.2 KiB
TypeScript
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;
|
|
}
|
|
}
|