phase 3b T6: voice-note card + VoiceNotePlayer single-instance coordinator#640
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 perdocs/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-1unplayed /--moss-2played),mm:ss / mm:sstimer in font-mono.AudioContext.decodeAudioDataover 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) — sharedRwSignal<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.WebClientHandle::fetch_blob→ Object URL on<audio>(revoked viaon_cleanup) + parallel decode for peaks. Independent paths.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)format_mm_ss,format_timer,bucket_peaks(silence floor + max-amplitude)VoiceNotePlayer::release_if_activesemanticsSpec / plan
Test plan
just check— fmt + clippy + native + WASM, zero warningsjust test-browser— 383 tests passing🤖 Generated with Claude Code