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:
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user