fix(player): render the position the player reports, not its own

`seek-bar` renders `PlaybackPositionChanged` instead of counting: its
setInterval survives only as interpolation *between* reports, stopped
and restarted by every one of them, so its error is bounded by a
second and is discarded rather than carried. Measured after: UI 00:34
/ backend 34 across two keyboard seeks, against 00:44 / 73 before.

The bar also stops lying about smaller things: the right-hand clock
carries a minus sign and toggles to total duration on click, and the
now-playing column starts at 320 px instead of 200, which is where
"The Orchestra Of" came from.

`now-playing.updated()` used to measure and rewrite its text geometry
on every pass — six querySelectors and a read/write interleave — while
the player store notifies at 1 Hz. It now runs only when its geometry
key changes: the rendered title, the rendered artist, both scroll
flags, or the ResizeObserver reporting a resize, with every read
before every write. Over six seconds of playback: 52 forced layouts
-> 2, and 3.2 ms -> 0.9 ms inside updated().

The scroll flags are in that key because `.will-scroll .scroll-content`
carries `padding-right: 2em`, so applying the class changes the
distance the marquee travels — -128 px before it, -158 px after. A
guard on the text alone leaves every first hover scrolling short, and
nothing in any test tier would have caught it.

The resize's document listeners now attach on mousedown and detach on
mouseup, rather than running on every pointer move in the app for the
life of the process.
This commit is contained in:
2026-08-12 01:19:20 -04:00
parent 7d9e0bf2fb
commit 4ae6e13391
3 changed files with 192 additions and 56 deletions
@@ -1,13 +1,27 @@
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import './controls/player-controls';
import './seekbar/seek-bar';
import './volume-control/volume-control';
import '../notifications/inline-notice';
import { PlayerRegion } from '@store/player-store';
import { designTokens } from '../../styles/tokens.css';
/**
* The bottom bar. The transport lives here, and so does the one place
* the player admits it could not do what it was told — an
* `<inline-notice>` for the `player` region, floated above the bar
* because `.bottom-bar` is a fixed 4em grid row with no room in it.
*/
@customElement('audio-player')
export class AudioPlayer extends LitElement {
static override styles = [designTokens, css`
:host {
display: block;
position: relative;
}
.audio-player-container {
display: flex;
align-items: center;
@@ -17,10 +31,16 @@ export class AudioPlayer extends LitElement {
.player-main {
flex: 1;
}
`];
override render() {
return html`
<inline-notice
region=${PlayerRegion}
testid="player-message"
floating
></inline-notice>
<div class="audio-player-container">
<div class="player-main">
<player-controls></player-controls>
@@ -15,9 +15,16 @@ export class SeekBar extends LitElement {
private timerID: number = -1;
private previousTrackChangeId: number = -1;
/** The sequence number of the last backend report applied. */
private previousPositionSeq: number = -1;
@state()
private seekValue: number = 0;
/** Whether the right-hand clock shows time remaining or total. */
@state()
private showRemaining: boolean = true;
static override styles = [designTokens, css`
wa-slider {
--track-size: 6px;
@@ -47,6 +54,21 @@ export class SeekBar extends LitElement {
justify-content: space-between;
align-items: center;
}
.time-toggle {
background: none;
border: none;
padding: 0;
color: inherit;
font: inherit;
font-size: var(--wa-font-size-s, 0.875rem);
cursor: pointer;
}
.time-toggle:hover,
.time-toggle:focus-visible {
text-decoration: underline;
}
`];
// ===================================================================
@@ -86,6 +108,23 @@ export class SeekBar extends LitElement {
this.stopProgress();
}
// The backend's own position wins over anything counted here.
// Every report resets the interpolation, so the bar can be at most
// one tick wrong and can never accumulate — which is what made a
// keyboard seek desync it by 30 s (H-3).
// A report for a track that is no longer loaded is stale by
// definition: the change id is the only thing that distinguishes
// it, since the same file can play twice in a row.
const position = this.player.position;
const forThisTrack =
position !== null && position.trackChangeId === currentChangeId;
if (position && forThisTrack && position.seq !== this.previousPositionSeq) {
this.previousPositionSeq = position.seq;
this.seekValue = position.positionSeconds;
this.stopProgress();
}
// Start/stop progress interval based on playback state
if (this.isPlaying && this.hasTrack) {
this.startProgress();
@@ -105,6 +144,14 @@ export class SeekBar extends LitElement {
}
}
/**
* Interpolate between backend reports.
*
* This is not the clock — it exists only so the display moves
* smoothly in the second between two ticks. It is stopped and
* restarted by every report, so its error is bounded by one second
* and is discarded rather than carried.
*/
private startProgress() {
// Don't start multiple intervals
if (this.timerID !== -1) {
@@ -147,11 +194,17 @@ export class SeekBar extends LitElement {
// RENDER
// ===================================================================
/** H-16: the right-hand clock never said which number it was. */
private toggleRemaining() {
this.showRemaining = !this.showRemaining;
}
override render() {
const elapsedTime = this.hasTrack ? formatSeconds(this.seekValue) : '--:--';
const remainingTime = this.hasTrack
? formatSeconds(this.trackLength - this.seekValue)
: '--:--';
const rightLabel = this.showRemaining
? `-${formatSeconds(Math.max(0, this.trackLength - this.seekValue))}`
: formatSeconds(this.trackLength);
const rightTime = this.hasTrack ? rightLabel : '--:--';
return html`
<div id="seek-bar-container">
@@ -166,7 +219,22 @@ export class SeekBar extends LitElement {
@change="${this.handleChange}"
@input="${this.handleInput}"
></wa-slider>
<small data-testid="remaining-time">${remainingTime}</small>
<button
class="time-toggle"
type="button"
data-testid="remaining-time"
title="${this.showRemaining
? 'Time remaining — click for total duration'
: 'Total duration — click for time remaining'}"
aria-label="${this.showRemaining
? `Time remaining ${formatSeconds(
Math.max(0, this.trackLength - this.seekValue),
)}. Show total duration.`
: `Total duration ${formatSeconds(
this.trackLength,
)}. Show time remaining.`}"
@click="${this.toggleRemaining}"
>${rightTime}</button>
</div>
`;
}
@@ -12,9 +12,13 @@ import { PlayerController } from '@store/controllers/player-controller';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { designTokens } from '../../styles/tokens.css';
// H-17: at 200 px the artist truncated to "The Orchestra Of" while
// ~400 px of empty space sat between it and the transport controls.
// The panel is user-resizable, so this only moves where it starts and
// how far it can go.
const MIN_WIDTH = 120;
const MAX_WIDTH = 350;
const DEFAULT_WIDTH = 200;
const MAX_WIDTH = 500;
const DEFAULT_WIDTH = 320;
const SCROLL_STORAGE_KEY = 'yj-now-playing-scroll-mode';
const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed';
@@ -66,6 +70,10 @@ export class NowPlaying extends LitElement {
private resizeObserver?: ResizeObserver;
/** See `geometryKey()` — perf.m5. */
private lastGeometryKey = '';
private geometryDirty = true;
static override styles = [designTokens, exploreLinkStyles, css`
:host {
display: block;
@@ -237,19 +245,18 @@ export class NowPlaying extends LitElement {
super.connectedCallback();
this.loadScrollMode();
this.updateWidth(DEFAULT_WIDTH);
document.addEventListener('mousemove', this.handleMouseMove);
document.addEventListener('mouseup', this.handleMouseUp);
window.addEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent);
this.resizeObserver = new ResizeObserver(() => {
this.checkOverflows();
this.geometryDirty = true;
this.requestUpdate();
});
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
// A drag interrupted by the bar going away still has to clean up.
this.attachDragListeners(false);
window.removeEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent);
this.resizeObserver?.disconnect();
this.stopScrollCycle('title');
@@ -257,9 +264,24 @@ export class NowPlaying extends LitElement {
}
protected override updated(): void {
this.checkOverflows();
this.observeTextContainers();
this.applyScrollDistances();
// perf.m5: this used to measure and rewrite the text geometry on
// every pass, and the player store notifies while playing — so a
// component whose DOM had not changed did six querySelectors and
// a read/write interleave several times a second for no news.
// The geometry depends on exactly four things, and the observer
// covers the fifth (the panel being resized).
const key = this.geometryKey();
if (key !== this.lastGeometryKey) {
this.lastGeometryKey = key;
this.geometryDirty = true;
}
if (this.geometryDirty) {
this.geometryDirty = false;
this.measureText();
}
this.syncScrollCycles();
}
@@ -408,60 +430,73 @@ export class NowPlaying extends LitElement {
return field === 'title' ? this.titleHovered : this.artistHovered;
}
private checkOverflows(): void {
const titleEl = this.shadowRoot?.querySelector<HTMLElement>('.track-title');
const artistEl = this.shadowRoot?.querySelector<HTMLElement>('.track-artist');
/**
* Everything the measured geometry depends on, other than the panel
* width — which the ResizeObserver reports instead.
*
* The two scroll flags are in here because `.will-scroll` puts
* `padding-right: 2em` on `.scroll-content`, so toggling it changes
* the scroll distance the animation travels. A guard that only
* watched the text would leave the first hover scrolling 2em short.
*/
private geometryKey(): string {
const track = this.player.currentTrack;
const titleNow = titleEl ? titleEl.scrollWidth > titleEl.clientWidth : false;
const artistNow = artistEl ? artistEl.scrollWidth > artistEl.clientWidth : false;
// Only update state when changed to avoid infinite loops
if (titleNow !== this.titleOverflows) {
this.titleOverflows = titleNow;
}
if (artistNow !== this.artistOverflows) {
this.artistOverflows = artistNow;
}
}
private observeTextContainers(): void {
if (!this.resizeObserver) return;
const titleEl = this.shadowRoot?.querySelector('.track-title');
const artistEl = this.shadowRoot?.querySelector('.track-artist');
// ResizeObserver automatically deduplicates observed elements
if (titleEl) this.resizeObserver.observe(titleEl);
if (artistEl) this.resizeObserver.observe(artistEl);
return [
track?.title ?? '',
track?.artist ?? '',
this.shouldScroll('title') ? '1' : '0',
this.shouldScroll('artist') ? '1' : '0',
].join('\u0000');
}
/**
* Set CSS custom properties on each scroll-content span so the
* animation knows how far to travel and how long to take.
* Measure both text containers and set the CSS custom properties the
* scroll animation reads: how far to travel and how long to take.
*
* Every read happens before every write. Interleaving them is what
* makes the browser flush layout again per write, and this is the
* one place in the component that touches layout at all.
*/
private applyScrollDistances(): void {
const pairs: Array<{ container: string; overflows: boolean }> = [
{ container: '.track-title', overflows: this.titleOverflows },
{ container: '.track-artist', overflows: this.artistOverflows },
];
private measureText(): void {
const titleEl = this.shadowRoot?.querySelector<HTMLElement>('.track-title');
const artistEl = this.shadowRoot?.querySelector<HTMLElement>('.track-artist');
for (const { container, overflows } of pairs) {
if (!overflows) continue;
const measure = (el: HTMLElement | null | undefined) => {
if (!el) return { overflows: false, distance: 0 };
const el = this.shadowRoot?.querySelector<HTMLElement>(container);
const content = el?.querySelector<HTMLElement>('.scroll-content');
const content = el.querySelector<HTMLElement>('.scroll-content');
const width = el.clientWidth;
if (!el || !content) continue;
return {
overflows: el.scrollWidth > width,
distance: content ? content.scrollWidth - width : 0,
};
};
const overflow = content.scrollWidth - el.clientWidth;
const title = measure(titleEl);
const artist = measure(artistEl);
if (overflow > 0) {
const duration = Math.min(MAX_DURATION, Math.max(MIN_DURATION, overflow / SCROLL_SPEED));
el.style.setProperty('--scroll-distance', `-${overflow}px`);
el.style.setProperty('--scroll-duration', `${duration.toFixed(1)}s`);
for (const [el, m] of [[titleEl, title], [artistEl, artist]] as const) {
if (el && this.resizeObserver) {
// ResizeObserver deduplicates observed elements itself.
this.resizeObserver.observe(el);
}
if (!el || !m.overflows || m.distance <= 0) continue;
const duration = Math.min(
MAX_DURATION,
Math.max(MIN_DURATION, m.distance / SCROLL_SPEED),
);
el.style.setProperty('--scroll-distance', `-${m.distance}px`);
el.style.setProperty('--scroll-duration', `${duration.toFixed(1)}s`);
}
// Assigned last: these are @state, so they schedule the pass that
// re-measures with the scroll classes applied.
this.titleOverflows = title.overflows;
this.artistOverflows = artist.overflows;
}
// ===================================================================
@@ -574,9 +609,21 @@ export class NowPlaying extends LitElement {
// RESIZE
// ===================================================================
/** perf.m4: the resize's document listeners exist while it is
* dragging and not before — they used to run on every pointer move
* anywhere in the app, for the life of the process, to guard and
* return. */
private attachDragListeners(on: boolean): void {
const fn = on ? 'addEventListener' : 'removeEventListener';
document[fn]('mousemove', this.handleMouseMove as EventListener);
document[fn]('mouseup', this.handleMouseUp as EventListener);
}
private handleMouseDown = (e: MouseEvent) => {
e.preventDefault();
this.isDragging = true;
this.attachDragListeners(true);
};
private handleMouseMove = (e: MouseEvent) => {
@@ -591,6 +638,7 @@ export class NowPlaying extends LitElement {
private handleMouseUp = () => {
this.isDragging = false;
this.attachDragListeners(false);
};
private updateWidth(width: number) {