Skip to content

GUI: the seek and volume bars can't be dragged #843

Closed

Description

@LargeModGames

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:

  1. Start the GUI with something playing (see "Done when" for a way to do this without Spotify).
  2. 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.
  3. 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:
    onClick={(event) => {
      if (disabled) return;
      const box = event.currentTarget.getBoundingClientRect();
      onPick(clamp((event.clientX - box.left) / box.width));
    }}
    The arrow keys are handled at lines 142-147 (STEP_KEYS at 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 reuses Progress, so its seek bar gets the drag for free.
  • gui/src/Chrome.css, the .meter rules at lines 413-469. The accent colour and the knob only show on :hover and 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: SeekTo and SetVolume are in src/core/action/mod.rs lines 70-77.
  • Why one value on release and not one per move: on a decoded source (Local Files, Subsonic, YouTube, Qobuz, Radio), every SetVolume becomes its own IoEvent::ChangeVolume (src/core/app/volume.rs lines 107-113), and every seek can re-decode the file (src/core/app/seek.rs lines 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.

  1. Pull the maths into a helper. Move today's clamp((event.clientX - box.left) / box.width) into a small function in Transport.tsx, for example ratioAt(event), so all the pointer handlers share it.
  2. Drag in Meter, with pointer capture.
    • onPointerDown: ignore it when disabled or when event.button !== 0. Otherwise call event.currentTarget.setPointerCapture(event.pointerId) and show the preview at the press point.
    • onPointerMove: do nothing unless event.currentTarget.hasPointerCapture(event.pointerId). Otherwise update the preview.
    • onPointerUp: also do nothing unless event.currentTarget.hasPointerCapture(event.pointerId) (and not disabled). The browser still holds the capture while pointerup runs, 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, call onPick(ratio) exactly once and clear the preview.
    • onPointerCancel and onLostPointerCapture: clear the preview and send nothing. (lostpointercapture also fires right after a normal release. That's fine: it only clears.)
    • Add touch-action: none and user-select: none to .meter, so a touch drag doesn't scroll the page and a mouse drag doesn't select the time labels.
  3. Remove the onClick. A click is a pointer-down plus a pointer-up, so the new onPointerUp already handles it.

Traps (please read these, each one is easy to fall into):

  • Keep Meter free of hooks. No useState, useRef or useEffect in Meter, Progress or Transport. The tests walk the component tree with byName from gui/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". MiniCard is split out "free of hooks" for the same reason (MiniPlayer.tsx line 48). The test "sends the transport, like and seek actions" (miniPlayer.test.tsx lines 79-105) walks right into the seek Meter, 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.width during a drag. While a song plays, usePosition re-renders the bars on every animation frame (gui/src/usePosition.ts lines 10-17, called from PlayerBar.tsx line 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 !important is 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.css lines 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] > div and .meter[data-dragging] > div::after to those two rules.
  • Don't keep onClick next to onPointerUp. If you keep both, every click sends SeekTo or SetVolume twice.
  • Don't send anything while dragging. No SeekTo and no SetVolume from onPointerMove, only one on release.
  • Keep the arrow keys (onKeyDown), role="slider" and the aria-* attributes as they are. The existing test "exposes seek and volume as focusable sliders" (bars.test.tsx lines 67-74) checks them, and the Space key rule relies on role="slider" (describe() in gui/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 edit gui/src/bindings/: it's generated from the Rust types.

Tests to add. Name tests as behavior sentences.

  • A new gui/src/meter.test.tsx that drives Meter itself through byName with a small fake element. There's no DOM in the test setup (the tests render with renderToStaticMarkup or walk the tree), so you pass the handlers plain objects:
    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();
      });
    });
    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.
  • The existing tests in miniPlayer.test.tsx and bars.test.tsx must 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 test and npm run build all pass.
  • npm test -- meter passes. 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.md has 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/dist when it's built, so build the page before every run:
    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
    
    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:
    • 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-time npx playwright install chromium firefox webkit) renders the page against the fake bridge in gui/e2e and writes PNGs to gui/shots/<browser>/. The playing and mini-player shots should look the same as before. The fake bridge ignores what the page sends, so it can't show the drag itself.

Activity

  1. spotatui commented on Oct 14, 2026

    @spotatui
    Contributor

    🎧 spotatui triage

    Auto-triaged with DeepSeek V4-Flash.

    Label: bug
    Likely 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

    • GUI player bar seek bar (under play button) and volume bar (right side) can't be dragged
    • Dragging does nothing; releasing inside the bar jumps to that point, releasing outside does nothing
    • Desired: fill follows pointer during drag, one value sent on release, click and arrow keys unchanged
    • Cause stated: Meter only has onClick and onKeyDown, no pointer handling
    • Affects gui/src/Transport.tsx Meter/Progress, gui/src/PlayerBar.tsx volume bar, and mini player via Progress

    What would help

    • OS and terminal/environment
    • spotatui version
    • Whether the issue occurs with mouse only or also touch

    Possible duplicates: none found

    Compared against the 56 most recently updated open issues.

  2. jaopdc11 commented on Oct 22, 2026

    @jaopdc11

    It looks like this was fixed by #880 (merged on 2026-10-16): Meter now uses pointer capture, previews the drag via a CSS variable, and sends one SeekTo/SetVolume on release, with tests in bars.test.tsx. I think this issue can be closed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingenhancementNew feature or requestgood first issueGood for newcomersjavascriptPull requests that update javascript code

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions