feat(volume-control): scroll-to-adjust icon + live debounced slider drag

Wheel over the volume icon steps volume by 5. The slider now updates
live on drag (@input) instead of only on release, debounced 60ms to
avoid spamming SetVolume. A local pendingVolume tracks intent so rapid
events accumulate and UI stays responsive ahead of the backend echo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 14:50:34 -04:00
co-authored by Claude Opus 4.8
parent 16886c92cf
commit a181a98ce3
@@ -6,14 +6,28 @@ import type WaSlider from '@awesome.me/webawesome/dist/components/slider/slider.
import { PlayerController } from '@store/controllers/player-controller'; import { PlayerController } from '@store/controllers/player-controller';
import { designTokens } from '../../../styles/tokens.css'; import { designTokens } from '../../../styles/tokens.css';
/** Volume change (0-100) applied per scroll-wheel tick. */
const WHEEL_STEP = 5;
/** Delay before a live volume change is pushed to the backend. */
const VOLUME_DEBOUNCE_MS = 60;
@customElement('volume-control') @customElement('volume-control')
export class VolumeControl extends LitElement { export class VolumeControl extends LitElement {
private player = new PlayerController(this); private player = new PlayerController(this);
private boundHandleOutsideClick = this.handleOutsideClick.bind(this); private boundHandleOutsideClick = this.handleOutsideClick.bind(this);
private volumeDebounceTimer?: ReturnType<typeof setTimeout>;
@state() @state()
private showSlider = false; private showSlider = false;
// Locally-tracked volume while the user is actively dragging or scrolling.
// The store's volume only updates once the backend echoes VolumeChanged
// (which we debounce), so we track intent here for responsive UI and to let
// rapid events accumulate. Cleared once the store catches up.
@state()
private pendingVolume: number | null = null;
static override styles = [designTokens, css` static override styles = [designTokens, css`
:host { :host {
position: relative; position: relative;
@@ -70,8 +84,12 @@ export class VolumeControl extends LitElement {
// DERIVED STATE // DERIVED STATE
// =================================================================== // ===================================================================
private get currentVolume(): number {
return this.pendingVolume ?? this.player.volume;
}
private get volumeIcon(): string { private get volumeIcon(): string {
const vol = this.player.volume; const vol = this.currentVolume;
if (vol === 0) return 'volume-xmark'; if (vol === 0) return 'volume-xmark';
if (vol <= 50) return 'volume-low'; if (vol <= 50) return 'volume-low';
@@ -86,6 +104,15 @@ export class VolumeControl extends LitElement {
override disconnectedCallback() { override disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
document.removeEventListener('click', this.boundHandleOutsideClick); document.removeEventListener('click', this.boundHandleOutsideClick);
clearTimeout(this.volumeDebounceTimer);
}
override willUpdate() {
// Once the backend has echoed our pending change back through the store,
// drop the local override so external volume changes are reflected again.
if (this.pendingVolume !== null && this.player.volume === this.pendingVolume) {
this.pendingVolume = null;
}
} }
// =================================================================== // ===================================================================
@@ -113,21 +140,37 @@ export class VolumeControl extends LitElement {
} }
private handleInput(e: Event) { private handleInput(e: Event) {
const value = (e.target as WaSlider).value; this.changeVolume((e.target as WaSlider).value);
this.player.setVolume(value); }
private handleWheel(e: WheelEvent) {
e.preventDefault();
const direction = e.deltaY < 0 ? 1 : -1;
this.changeVolume(this.currentVolume + direction * WHEEL_STEP);
} }
private handlePopupClick(e: Event) { private handlePopupClick(e: Event) {
e.stopPropagation(); e.stopPropagation();
} }
/** Update the UI immediately and push to the backend on a short debounce. */
private changeVolume(value: number) {
const clamped = Math.max(0, Math.min(100, Math.round(value)));
this.pendingVolume = clamped;
clearTimeout(this.volumeDebounceTimer);
this.volumeDebounceTimer = setTimeout(() => {
this.player.setVolume(clamped);
}, VOLUME_DEBOUNCE_MS);
}
// =================================================================== // ===================================================================
// RENDER // RENDER
// =================================================================== // ===================================================================
override render() { override render() {
return html` return html`
<button @click="${this.toggleSlider}"> <button @click="${this.toggleSlider}" @wheel="${this.handleWheel}">
<wa-icon name=${this.volumeIcon}></wa-icon> <wa-icon name=${this.volumeIcon}></wa-icon>
</button> </button>
${this.showSlider ${this.showSlider
@@ -137,8 +180,8 @@ export class VolumeControl extends LitElement {
orientation="vertical" orientation="vertical"
min="0" min="0"
max="100" max="100"
.value="${this.player.volume}" .value="${this.currentVolume}"
@change="${this.handleInput}" @input="${this.handleInput}"
></wa-slider> ></wa-slider>
</div> </div>
` `