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>
`;
}