Repository navigation
GUI: the seek and volume bars can't be dragged #843
Copy link
Copy link
Closed
Labels
bugSomething isn't workingSomething isn't workingenhancementNew feature or requestNew feature or requestgood first issueGood for newcomersGood for newcomersjavascriptPull requests that update javascript codePull requests that update javascript code
Description
What
The GUI player bar has two thin sliders: the seek bar under the play button and the volume bar on the right. Right now they only react to a click and to the arrow keys.
How to see it:
- Start the GUI with something playing (see "Done when" for a way to do this without Spotify).
- Press the mouse button on the seek bar and drag it sideways. Nothing moves while you drag. If you let go inside the bar, the song jumps to where you let go. If you let go outside the bar, nothing happens at all.
- Do the same on the volume bar. Same result.
What should happen:
- While you drag either bar, its fill follows the pointer, even when the pointer leaves the bar.
- When you let go, the bar sends one value: one seek for the seek bar, one volume change for the volume bar. Nothing is sent while you drag.
- A plain click and the arrow keys keep working as they do today.
The cause: Meter only has an onClick and an onKeyDown. It has no pointer handling at all.
Where to look
gui/src/Transport.tsx,Meter(lines 110-152). It's the one slider component both bars use. The click handler is at lines 137-141:The arrow keys are handled at lines 142-147 (onClick={(event) => { if (disabled) return; const box = event.currentTarget.getBoundingClientRect(); onPick(clamp((event.clientX - box.left) / box.width)); }}
STEP_KEYSat lines 103-108). The fill is the child<div style={{ width: ... }} />at line 149.gui/src/Transport.tsx,Progress(lines 72-101). This is the seek bar. Line 94 disables it when there's no connection or no duration, and line 95 sends{ SeekTo: Math.round(ratio * duration) }.gui/src/PlayerBar.tsx, lines 171-178. This is the volume bar. Line 176 sends{ SetVolume: Math.round(ratio * 100) }. Leave the mute button just above it (lines 155-170) alone.gui/src/MiniPlayer.tsx, lines 104-109. The mini player reusesProgress, so its seek bar gets the drag for free.gui/src/Chrome.css, the.meterrules at lines 413-469. The accent colour and the knob only show on:hoverand keyboard focus (lines 453-461). Note lines 577-581: below 1000 px of window width the volume bar is hidden (.side .meter { display: none; }), so test with a wide window.- The actions already exist in Rust, so you don't need a Rust change:
SeekToandSetVolumeare insrc/core/action/mod.rslines 70-77. - Why one value on release and not one per move: on a decoded source (Local Files, Subsonic, YouTube, Qobuz, Radio), every
SetVolumebecomes its ownIoEvent::ChangeVolume(src/core/app/volume.rslines 107-113), and every seek can re-decode the file (src/core/app/seek.rslines 179-185). A drag moves dozens of times per second.
Possible solution
Keep the whole change inside gui/src (plus one CHANGELOG line). Expect around 100 lines, about half of them tests.
- Pull the maths into a helper. Move today's
clamp((event.clientX - box.left) / box.width)into a small function inTransport.tsx, for exampleratioAt(event), so all the pointer handlers share it. - Drag in
Meter, with pointer capture.onPointerDown: ignore it whendisabledor whenevent.button !== 0. Otherwise callevent.currentTarget.setPointerCapture(event.pointerId)and show the preview at the press point.onPointerMove: do nothing unlessevent.currentTarget.hasPointerCapture(event.pointerId). Otherwise update the preview.onPointerUp: also do nothing unlessevent.currentTarget.hasPointerCapture(event.pointerId)(and notdisabled). The browser still holds the capture whilepointerupruns, and pointer-down never captures on a disabled bar or for a right-click, so this one check keeps today's behavior: a disabled bar, a right-click, or a press that started somewhere else and ends over the bar sends nothing. With the capture, callonPick(ratio)exactly once and clear the preview.onPointerCancelandonLostPointerCapture: clear the preview and send nothing. (lostpointercapturealso fires right after a normal release. That's fine: it only clears.)- Add
touch-action: noneanduser-select: noneto.meter, so a touch drag doesn't scroll the page and a mouse drag doesn't select the time labels.
- Remove the
onClick. A click is a pointer-down plus a pointer-up, so the newonPointerUpalready handles it.
Traps (please read these, each one is easy to fall into):
- Keep
Meterfree of hooks. NouseState,useReforuseEffectinMeter,ProgressorTransport. The tests walk the component tree withbyNamefromgui/src/testTree.ts, which calls each component as a plain function. Its doc comment on line 6 says "it calls each component, so none may use hooks".MiniCardis split out "free of hooks" for the same reason (MiniPlayer.tsxline 48). The test "sends the transport, like and seek actions" (miniPlayer.test.tsxlines 79-105) walks right into the seekMeter, so a hook there breaks it. You don't need any state in JavaScript: the pointer capture says whether a drag is on, and the preview lives on the element (next trap). - Don't write the fill's
style.widthduring a drag. While a song plays,usePositionre-renders the bars on every animation frame (gui/src/usePosition.tslines 10-17, called fromPlayerBar.tsxline 48), and React writes its own width over yours each time. Put the preview somewhere React doesn't own: set a CSS custom property and a data attribute on the bar (el.style.setProperty("--preview", "40%"),el.dataset.dragging = ""), and add a rule such as.meter[data-dragging] > div { width: var(--preview) !important; }. The!importantis needed to beat React's inline width. Remove both when the drag ends. - Keep the drag looking like a drag. The accent colour and the knob only show on
:hover(Chrome.csslines 453-461). The bar is 16 px tall, so the pointer leaves it quickly, and then the knob vanishes and the drag looks dropped. Add.meter[data-dragging] > divand.meter[data-dragging] > div::afterto those two rules. - Don't keep
onClicknext toonPointerUp. If you keep both, every click sendsSeekToorSetVolumetwice. - Don't send anything while dragging. No
SeekToand noSetVolumefromonPointerMove, only one on release. - Keep the arrow keys (
onKeyDown),role="slider"and thearia-*attributes as they are. The existing test "exposes seek and volume as focusable sliders" (bars.test.tsxlines 67-74) checks them, and the Space key rule relies onrole="slider"(describe()ingui/src/Shell.tsx, line 626 on main today:target?.closest("button, a[href], [role=slider]")). - Leave the mouse wheel out of this issue.
- Don't add a volume bar to the mini player. That's a separate design question.
- No Rust change and no new
Action. Don't editgui/src/bindings/: it's generated from the Rust types.
Tests to add. Name tests as behavior sentences.
- A new
gui/src/meter.test.tsxthat drivesMeteritself throughbyNamewith a small fake element. There's no DOM in the test setup (the tests render withrenderToStaticMarkupor walk the tree), so you pass the handlers plain objects:Add one more for a disabled bar: a press and a release send nothing. You may need more fields on the fake, depending on how you write the handlers.import { describe, expect, it } from "vitest"; import { Meter } from "./Transport"; import { byName } from "./testTree"; type Handler = (event: unknown) => void; const fakeBar = () => { let captured = false; return { getBoundingClientRect: () => ({ left: 100, width: 200 }), setPointerCapture: () => { captured = true; }, hasPointerCapture: () => captured, style: { setProperty: () => {}, removeProperty: () => {} }, dataset: {} as Record<string, string>, }; }; const pointer = (el: ReturnType<typeof fakeBar>, clientX: number, button = 0) => ({ button, pointerId: 1, clientX, currentTarget: el, }); const seekBar = (picks: number[], disabled = false) => byName( <Meter label="Seek" ratio={0} text="" disabled={disabled} onPick={(ratio) => picks.push(ratio)} onStep={() => {}} />, "Seek", ); describe("Meter", () => { it("a drag sends one pick when the button is released", () => { const picks: number[] = []; const bar = seekBar(picks); const el = fakeBar(); (bar.props.onPointerDown as Handler)(pointer(el, 150)); (bar.props.onPointerMove as Handler)(pointer(el, 250)); expect(picks).toEqual([]); (bar.props.onPointerUp as Handler)(pointer(el, 250)); expect(picks).toEqual([0.75]); }); it("a release past the end of the bar picks the end", () => { const picks: number[] = []; const bar = seekBar(picks); const el = fakeBar(); (bar.props.onPointerDown as Handler)(pointer(el, 150)); (bar.props.onPointerUp as Handler)(pointer(el, 900)); expect(picks).toEqual([1]); }); it("a release without a left-button press on the bar sends nothing", () => { const picks: number[] = []; const bar = seekBar(picks); const el = fakeBar(); (bar.props.onPointerUp as Handler)(pointer(el, 250)); (bar.props.onPointerDown as Handler)(pointer(el, 150, 2)); (bar.props.onPointerUp as Handler)(pointer(el, 150, 2)); expect(picks).toEqual([]); }); it("a click is handled once, not by an onClick as well", () => { expect(seekBar([]).props.onClick).toBeUndefined(); }); });
- The existing tests in
miniPlayer.test.tsxandbars.test.tsxmust pass unchanged. - Leave tools/gates.count alone. New tests need no counter change.
Done when
- In
gui/:npm ci,npm run lint,npm run format:check,npm run typecheck,npm testandnpm run buildall pass. npm test -- meterpasses. Check that the output shows your test ran; a filter that matches nothing runs nothing.- You don't change any Rust, so no cargo command is needed. CI's cargo legs still run.
CHANGELOG.mdhas one line under## [Unreleased]>### Added, for example: "Drag the seek and volume bars in the desktop app: both bars follow the pointer while you drag and change once when you let go." That section changes often, so if it conflicts, rebase on main and keep both entries.- Attach a short screen recording of a seek drag and a volume drag to your PR.
- Manual check, no Spotify needed. On Linux, install the prerequisites in docs/installation.md (Building from Source > Prerequisites) first. The GUI binary embeds
gui/distwhen it's built, so build the page before every run:It opens the page in your browser. On a first launch, pick Local Files on the first-launch screen and point it at a folder with a few music files. If you've run spotatui before and get no first-launch screen, set the Music Folder row on the Settings page instead. Play a song. Then, with the window wider than 1000 px:cd gui npm ci npm run build cd .. cargo run --no-default-features --features telemetry,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,local-files,subsonic,internet-radio,youtube,qobuz --bin spotatui-gui- Drag the seek bar: the fill follows the pointer, the knob stays visible even when the pointer leaves the bar, and the song jumps once when you let go.
- Drag off the window and let go: the bar seeks to the start or the end (the pointer capture keeps the events), and nothing is left highlighted.
- Right-click the seek bar: nothing seeks.
- Drag the volume bar: the fill follows the pointer, and the volume changes once, where you let go.
- Click and the arrow keys still work on both bars.
- Open the mini player (Shift+T) and drag its seek bar.
- If you can, try two browsers (for example Chrome and Firefox) and say in the PR which ones you checked.
- Optional:
npm run shots(after a one-timenpx playwright install chromium firefox webkit) renders the page against the fake bridge ingui/e2eand writes PNGs togui/shots/<browser>/. Theplayingandmini-playershots should look the same as before. The fake bridge ignores what the page sends, so it can't show the drag itself.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
bugSomething isn't workingSomething isn't workingenhancementNew feature or requestNew feature or requestgood first issueGood for newcomersGood for newcomersjavascriptPull requests that update javascript codePull requests that update javascript code
Activity
spotatui commentedon Oct 14, 2026
🎧 spotatui triage
Auto-triaged with DeepSeek V4-Flash.
Label:
bugLikely area: GUI player bar sliders (Meter/Progress in Transport.tsx)
Summary
In the GUI player bar, the seek and volume sliders only respond to a click or the arrow keys; pressing and dragging them does nothing while the pointer is down. The reporter wants the fill to follow the pointer during a drag (even outside the bar) and a single seek/volume value to be sent on release, while keeping click and keyboard behavior unchanged. The issue includes a detailed proposed implementation and test plan.
Key points
Meteronly hasonClickandonKeyDown, no pointer handlinggui/src/Transport.tsxMeter/Progress,gui/src/PlayerBar.tsxvolume bar, and mini player viaProgressWhat would help
Possible duplicates: none found
Compared against the 56 most recently updated open issues.
jaopdc11 commentedon Oct 22, 2026
It looks like this was fixed by #880 (merged on 2026-10-16):
Meternow uses pointer capture, previews the drag via a CSS variable, and sends oneSeekTo/SetVolumeon release, with tests inbars.test.tsx. I think this issue can be closed.