Skip to content

phase 3b T6: voice-note card + VoiceNotePlayer single-instance coordinator#640

Merged
intendednull merged 4 commits into
mainfrom
ui/phase-3b-voice-notes
May 9, 2026
Merged

phase 3b T6: voice-note card + VoiceNotePlayer single-instance coordinator#640
intendednull merged 4 commits into
mainfrom
ui/phase-3b-voice-notes

Conversation

@intendednull
Copy link
Copy Markdown
Owner

Summary

Closes T6, the last open task in docs/plans/2026-05-08-ui-phase-3b-files-inline.md. Phase 3b is now feature-complete (T15 spec stamp aside).

  • <AttachmentVoiceNote> — full surface per docs/specs/2026-04-19-ui-design/files-inline.md §Voice note. Card on --bg-2, 32×32 play/pause IconBtn, 24-px waveform strip with 64 bars (--moss-1 unplayed / --moss-2 played), mm:ss / mm:ss timer in font-mono.
  • Waveform extraction via AudioContext.decodeAudioData over the fetched blob. On any decode failure the strip stays at the flat baseline so the card reads as "loading" rather than "broken"; playback still works because the <audio> element handles its own decode.
  • VoiceNotePlayer (crates/web/src/voice_note_player.rs) — shared RwSignal<Option<String>> claim slot. claim(id) writes; release_if_active(id) clears only when we still hold the slot, defending against a stale paused-card racing a fresh claim.
  • Bytes flow: WebClientHandle::fetch_blob → Object URL on <audio> (revoked via on_cleanup) + parallel decode for peaks. Independent paths.
  • Icons: icon_play (triangle) + icon_pause (two bars), Lucide-style.

Tests

  • phase_3b_voice_note::voice_note_card_renders_spec_surface (AG-5)
  • phase_3b_voice_note::second_claim_pauses_first_via_shared_player (AG-6)
  • 6 unit tests for format_mm_ss, format_timer, bucket_peaks (silence floor + max-amplitude)
  • 2 unit tests for VoiceNotePlayer::release_if_active semantics

Spec / plan

  • Spec ticks AG-5 + AG-6 with a back-pointer to the implementation.
  • Plan T6 marked landed.

Test plan

  • just check — fmt + clippy + native + WASM, zero warnings
  • just test-browser — 383 tests passing
  • CI Playwright smoke (real audio decode in Chromium)
  • Local dev: send a voice-note attachment from peer A, peer B sees the card, click play → audio plays + waveform fills as progress advances; click play on a second card → first pauses

🤖 Generated with Claude Code

intendednull and others added 4 commits May 9, 2026 00:56
Replaces the `<AttachmentVoiceNote>` placeholder with the full
spec surface from `docs/specs/2026-04-19-ui-design/files-inline.md`
§Voice note:

- Card on `--bg-2` with `--line` border, radius 10 px, 10/12
  padding, max-width 420 px (100% on mobile).
- 32×32 play / pause IconBtn (`--moss-2` border, switches between
  `icon_play` / `icon_pause` glyphs added in this commit).
- Waveform strip — fixed 24 px tall SVG with 64 bars, `--moss-1`
  for unplayed and `--moss-2` for played. Peaks come from
  `AudioContext.decodeAudioData` over the fetched blob; on decode
  failure (codec unsupported, AudioContext unavailable) the strip
  stays at the flat 0.05 baseline so the chrome reads as "loading"
  rather than "broken".
- mm:ss / mm:ss timer in the spec mono font / 11 px / `--ink-3`,
  with `--:--` for the duration until `loadedmetadata` fires.

Single-instance playback (`VoiceNotePlayer` in
`crates/web/src/voice_note_player.rs`): each card claims the player
slot on play and watches the shared signal so a competing claim
pauses our `<audio>` element. `release_if_active` only clears when
the active id matches us, so a stale paused-card can't pre-empt a
fresh claim from somebody else.

Bytes flow: fetch the blob via `WebClientHandle::fetch_blob`, build
a `blob:` Object URL on the card-local `<audio>` (revoked via
`on_cleanup` so a long-running session doesn't leak URLs), and in
parallel spawn the AudioContext decode for waveform peaks. The two
paths are independent — playback works even if waveform decoding
fails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `phase_3b_voice_note::voice_note_card_renders_spec_surface`
  pins the toggle button, waveform SVG, timer container, the
  initial `0:00 / --:--` copy, the `play voice note {filename}`
  aria-label, and the embedded `<audio>` element. Covers AG-5.
- `phase_3b_voice_note::second_claim_pauses_first_via_shared_player`
  drives the `VoiceNotePlayer.claim` signal across two cards and
  asserts the active-id semantics that AG-6 turns into the
  `pause()` call in production. Real audio playback timing isn't
  drivable in the wasm-pack harness (no codec backend), so the
  test focuses on the load-bearing claim/release coordinator.
- Spec ticks AG-5 + AG-6; plan T6 marks landed with a back-pointer
  to the two browser tests + a note on the `AudioContext` fallback.

Phase 3b is now feature-complete: every plan task except T15 (the
final spec stamp) is landed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review feedback on PR #640:

- `decode_peaks_async` now calls `ctx.close()` before dropping the
  Rust handle. `drop(ctx)` alone keeps the underlying audio output
  device open until JS GC runs, and a long chat scroll can stack
  dozens of sleeping contexts past the per-tab limit (~6 on Chrome).
  Decode-only use isn't gated by the autoplay policy so this never
  throws.
- The card's pause-effect early-exits when `playing.get_untracked()`
  is already false. Previously, `on_pause` would write
  `set_playing.set(false)` *and* call `player.release_if_active`,
  which mutated `player.active` and re-fired the effect for an
  idempotent no-op write. Untracked read avoids subscribing the
  effect to its own mirror signal.
- `second_claim_pauses_first_via_shared_player` now actually
  exercises the per-card pause-effect: dispatches a synthetic
  `play` event to flip card A into the visual "playing" state,
  claims B's real id (matching the card's `vn-{hash[..12]}-{name}`
  derivation), then asserts card A's toggle aria-label flipped
  back to "play voice note". The `audio.pause()` call against a
  headless `<audio>` is verified by inspection (no codec backend
  in wasm-pack), but the load-bearing reactive wiring is now
  covered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@intendednull intendednull marked this pull request as ready for review May 9, 2026 08:54
@intendednull intendednull merged commit 1c053bc into main May 9, 2026
8 checks passed
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.

1 participant