From d6b48fb3acfae0a5f734f8ab42b8a697d58fae39 Mon Sep 17 00:00:00 2001 From: Logan Date: Tue, 18 Aug 2026 11:04:21 -0400 Subject: [PATCH] fix(player): stop the seek bar resizing as its clocks count Two different things moved it and they need different answers. Digits in a proportional font are different widths, so 1:11 is narrower than 4:08 and the bar breathed once a second -- tabular figures fix that. The character *count* changes too, at the hundredth minute and whenever the right-hand clock is toggled to remaining and grows a minus sign, which a figure width cannot fix -- so each clock reserves the widest string this track can put in it. The budget is per track rather than a constant: reserving six characters on every track would push the slider in by a character at each end to buy nothing. Measured in the component tier: 4.5px of drift across three positions before, none after. Refs #13 --- .../audio-player/seekbar/seek-bar.ts | 39 ++++++++++++++++++- frontend/test/components/transport.test.ts | 35 +++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/audio-player/seekbar/seek-bar.ts b/frontend/src/components/audio-player/seekbar/seek-bar.ts index 95f38aa..5ef3ea6 100644 --- a/frontend/src/components/audio-player/seekbar/seek-bar.ts +++ b/frontend/src/components/audio-player/seekbar/seek-bar.ts @@ -68,6 +68,29 @@ export class SeekBar extends LitElement { align-items: center; } + /* The clocks must not resize as they count. + + Two things move them, and they need different answers. Digits in + a proportional font are different widths, so 1:11 is narrower + than 4:08 and the bar breathed once a second -- that is what + tabular figures fix. The character *count* changes too, at the + hundredth minute and whenever the right-hand clock is toggled to + remaining and grows a minus sign, and a figure width cannot fix + that -- so each clock also reserves the widest string this track + can put in it. The budget is per track rather than a constant + because reserving six characters on every track would push the + slider in by a character at each end for nothing. */ + #seek-bar-container small, + .time-toggle { + font-variant-numeric: tabular-nums; + flex: 0 0 auto; + min-width: calc(var(--yj-clock-chars, 5) * 1ch); + } + + #seek-bar-container small { + text-align: left; + } + .time-toggle { background: none; border: none; @@ -76,6 +99,9 @@ export class SeekBar extends LitElement { font: inherit; font-size: var(--wa-font-size-s, 0.875rem); cursor: pointer; + /* One more for the minus sign the remaining form carries. */ + min-width: calc((var(--yj-clock-chars, 5) + 1) * 1ch); + text-align: right; } .time-toggle:hover, @@ -219,8 +245,19 @@ export class SeekBar extends LitElement { : formatSeconds(this.trackLength); const rightTime = this.hasTrack ? rightLabel : '--:--'; + // The widest string either clock can hold for *this* track. The + // duration is the longest elapsed value there can be, so its length + // is the budget; `--:--` is five, which is also the floor. + const clockChars = Math.max( + 5, + this.hasTrack ? formatSeconds(this.trackLength).length : 0, + ); + return html` -
+
${elapsedTime} ', () => { expect(text(el, '[data-testid="remaining-time"]')).toBe('01:30'); }); + it('keeps the slider still as the clocks count', async () => { + const el = await fixture('seek-bar'); + + emit(Events.TrackChanged, { ...TRACK, trackChangeId: 31 }); + await flush(); + await el.updateComplete; + + const slider = () => + shadow(el, 'wa-slider')!.getBoundingClientRect(); + const before = slider(); + + // 1:11 against 4:08 is the reported jitter: different digits, and + // in a proportional font different widths. Toggling the right-hand + // clock is the other half -- the minus sign is a whole character. + for (const positionSeconds of [8, 71, 88]) { + emit(Events.PlaybackPositionChanged, { + positionSeconds, + trackLength: 90, + trackChangeId: 31, + seq: positionSeconds, + playing: true, + }); + await flush(); + await el.updateComplete; + + expect(slider().width).toBeCloseTo(before.width, 1); + expect(slider().left).toBeCloseTo(before.left, 1); + } + + await click(el, '[data-testid="remaining-time"]'); + await el.updateComplete; + + expect(slider().width).toBeCloseTo(before.width, 1); + }); + it('renders the position the backend reports rather than its own count', async () => { const el = await fixture('seek-bar');