Skip to content

feat(ui): playhead cursor on the trace tree synced to audio#83

Open
basilebong wants to merge 2 commits into
mainfrom
feat/trace-playhead-cursor
Open

feat(ui): playhead cursor on the trace tree synced to audio#83
basilebong wants to merge 2 commits into
mainfrom
feat/trace-playhead-cursor

Conversation

@basilebong
Copy link
Copy Markdown
Collaborator

Summary

As audio plays in the replay inspector, nothing on the trace tree showed where playback currently was — so it was hard to tell which span/turn produced the sound you were hearing. The two views were coupled in one direction only (clicking a span seeks the audio); there was no feedback the other way.

This adds a vertical playhead cursor on the trace tree, synced to the audio playback position, plus a small floating clock pill (e.g. 0:05.3) that follows it. The audio player already draws a white cursor; the trace line mirrors it so the two read as one shared cursor. Because clicking a span already seeks the audio, the trace cursor also jumps to a clicked span — closing the loop visually.

Scope is intentionally just the cursor. Physically aligning the spans under the waveform (shared zoom + scroll between the two views) was considered and deferred — it's a much larger redesign with real UX tradeoffs.

How it works

  • player-provider — a ref + listener playhead channel mirroring the existing controlsRef imperative-handle pattern. usePlayhead() (consumer) + usePublishPlayhead() (producer). The high-frequency position routes through a ref, so only the single cursor leaf re-renders on each frame — never the trace rows or the provider.
  • stereo-turn-player — an effect subscribing to wavesurfer's own clock events (timeupdate/seeking/play/…) publishes the position. A genuine external-system subscription, not a derived-state effect.
  • trace-tree — pure fractionOf(sec, scale) + playheadLeft(fraction), shared with TimeBar so a bar and the cursor over it can't drift. The cursor is positioned with calc(280px + f·(100% − 280px)), which lands on the matching bar at any zoom. Gated on player readiness (no audio → no cursor); decorative (aria-hidden + pointer-events-none, so it never steals a seek click).
  • format — extracted formatClockSeconds, now shared by the player's clock readout and the cursor pill (also fixes a latent float-truncation quirk, e.g. 5.3 no longer rendered as .2).

How to verify

  • Open a replay with audio in the inspector (the committed snapshot/ replay works) and press play → a white glowing line sweeps the trace tree in lockstep with the waveform cursor; the clock pill at the top tracks it.
  • Click a span or turn → both the audio cursor and the trace line jump to that span's start.
  • Zoom the tree (1×–8×) and scroll horizontally → the line stays aligned with the bars.
  • A replay with spans but no audio shows no cursor.

Verification done

  • TDD throughout — 16 new tests (positioning math, the playhead channel, cursor gating/pill/attrs), each written red→green.
  • Full client suite: no new failures (the 3 failing tests are pre-existing — an MSW handler gap in inspector.test.tsx and a cross-file leak in compare, both confirmed on a clean baseline). Server suite: 407 pass. tsc, biome, and pnpm build all clean.
  • Not verified in a live browser here (needs a real browser + audio decoding) — that part is left for a reviewer to eyeball.

🤖 Generated with Claude Code

Render a vertical cursor on the trace tree that tracks the audio
playback position, so it's clear which span/turn corresponds to the
sound being played. The audio player already drew a white cursor;
this mirrors it on the tree. Clicking a span seeks the audio, so the
trace cursor jumps there too — closing the loop in both directions.

- player-provider: a ref + listener playhead channel mirroring the
  existing controlsRef imperative-handle pattern. usePlayhead (consumer)
  + usePublishPlayhead (producer). High-frequency position routes
  through a ref, so only the cursor leaf re-renders, never the rows.
- stereo-turn-player: an effect subscribing to wavesurfer's own clock
  events publishes the position (external-system subscription, not a
  derived-state effect).
- trace-tree: pure fractionOf(sec, scale) + playheadLeft(fraction),
  shared with TimeBar so a bar and the cursor over it can't drift; plus
  a TracePlayhead leaf (white glow line + clock pill). Positioned so it
  lands on the matching bar at any zoom; gated on player readiness;
  decorative (aria-hidden + pointer-events-none).
- format: extract formatClockSeconds, shared by the player readout and
  the pill (fixes a float-truncation quirk, e.g. 5.3 no longer -> .2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The turn/span count floated above the "Span tree" title — CardTitle sets
leading-none (line-box == font size) while the adjacent text-[10px] count span
inherits a taller line-box, so flex items-center centered two mismatched boxes.
Switch the title+count group to items-baseline so both sit on a shared text
baseline. Also picks up a pre-existing biome text reflow in the AudioSection
empty-turns message (same file).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants