fix(web): keep the volume slider draggable under the per-frame progress loop (#111)

The 60fps requestAnimationFrame progress clock (#107) re-renders the player
every ~16ms, and Vue re-applied `el.value = storeVolume` on a range input each
time — snapping the thumb back to the stale store value mid-drag (un-draggable
on desktop, janky on mobile).

Extract the decoupling into a useDecoupledSlider composable used by both the
desktop (Player.vue) and mobile (App.vue) sliders: a local display ref tracks
the native drag via @input (so the bound value always matches the element), the
store is committed only on @change (release), and an onRelease safety-net
(pointerup/pointercancel/blur) clears the dragging guard even when the browser
skips `change` (value released at its start point). External/store changes still
flow into the display except while dragging. Adds a regression test for the
no-snap-back invariant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-06-30 21:12:05 +08:00
1 parent 4cb1da29d4
commit 5ae5168b6b
4 files changed
+187 -14

No files matched your search

+17 -5
View File
@@ -65,8 +65,12 @@
type="range"
min="0"
max="100"
:value="activeBot?.volume ?? 75"
:value="volumeDisplay"
@input="onVolumeInput"
@change="onVolumeChange"
@pointerup="onVolumeRelease"
@pointercancel="onVolumeRelease"
@blur="onVolumeRelease"
class="volume-slider"
/>
</template>
@@ -87,6 +91,7 @@ import { Icon } from '@iconify/vue';
import { useRoute, useRouter } from 'vue-router';
import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
import { useDecoupledSlider } from '../composables/useDecoupledSlider.js';
import CoverArt from './CoverArt.vue';
import Queue from './Queue.vue';
@@ -185,10 +190,17 @@ function togglePlay() {
}
}
function onVolumeChange(e: Event) {
const target = e.target as HTMLInputElement;
store.setVolume(parseInt(target.value));
}
// Volume slider is decoupled from the per-frame rAF re-render so dragging the
// thumb isn't reset every frame (#111). See useDecoupledSlider.
const {
display: volumeDisplay,
onInput: onVolumeInput,
onChange: onVolumeChange,
onRelease: onVolumeRelease,
} = useDecoupledSlider(
() => activeBot.value?.volume,
(v) => store.setVolume(v)
);
const modeOrder = ['seq', 'loop', 'random', 'rloop'] as const;
const modeIcons: Record<string, string> = {