Skip to content

feat: tap or drag voice memo waveform to seek#5916

Open
janicduplessis wants to merge 9 commits into
developfrom
@janic/voice-memo-waveform-seek
Open

feat: tap or drag voice memo waveform to seek#5916
janicduplessis wants to merge 9 commits into
developfrom
@janic/voice-memo-waveform-seek

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Tapping the waveform on a voice memo did nothing on iOS and Android — there was no way to seek within a memo. This adds tap-to-seek and drag-to-scrub on the voice memo waveform. Found while migrating audio playback away from expo-av.

Related: #5917 independently fixes the waveform highlight alignment during playback.

Changes

  • VoiceMemoBlock wraps the waveform in a GestureDetector (tap + horizontal pan) that turns the touch position into a fraction of the memo's duration and seeks the player. The seekTo API already existed on the now-playing context but was never hooked up to any UI. Pan seeks are throttled to 100ms so scrubbing doesn't spam the native player, and the pan only activates on horizontal drags so vertical channel scrolling still works.
  • The waveform's Skia canvas swallows touches on native, so it's rendered with pointerEvents: 'none' (same workaround AudioRecorder uses for its waveform).
  • NowPlayingProvider.seekTo now sends a progress update right after seeking so the timer and waveform highlight move immediately — expo-audio doesn't send progress updates while paused.
  • Touch positions are measured against the drawn candle strip rather than the container width — the waveform only draws whole candles, so the strip can be slightly narrower than the container.
  • Scrubbing a playing memo pauses it during the drag and resumes it on release (standard scrubber behavior); a plain tap keeps playing through the seek.
  • Play and seek share a single load of a source: seeking while a play-started load is still running (or the other way around) waits for that load instead of starting a second one that would cancel it and drop the pending play/seek. If the loaded duration isn't known yet (expo-audio can briefly report 0), seeks fall back to the memo's metadata duration.
  • Fixes the memo timer on web: expo-audio's web implementation reports currentTime/duration in seconds (plain HTMLMediaElement units), but the progress pipeline divided by 1000 as if they were milliseconds — so the timer always read 0:00 and seeking a loaded memo landed at ~0. The conversion is removed.

Seeking a memo that hasn't been played yet loads it (paused) with the playhead at the tapped position, so play starts from there. While the load is running, only the latest requested position is kept, so scrubbing an unloaded memo can't kick off multiple loads.

How did I test?

  • iOS simulator (iPhone 17 Pro): played a memo, tapped at ~70% of the waveform — timer jumped from 0:01 to 0:04 of a 6s memo; scrubbed left/right while paused — timer and candle highlight followed the drag; vertical scrolling over the waveform still scrolls the channel.
  • Seek before playing: without pressing play, tapped a memo's waveform at ~50% — it loaded paused with the timer at 0:02; pressing play continued from there. Scrubbing a different never-played memo loaded it at the drag's end position and unloaded the previous one.
  • Checked frame-by-frame that playback pauses when a drag starts and resumes on release.
  • Checked that taps land within one candle (±2pt) of the tap point.

Risks and impact

  • Safe to rollback without consulting PR author? Yes
  • Affects important code area:
    • Onboarding
    • State / providers
    • Message sync
    • Channel display
    • Notifications
    • Other: voice memo playback

Rollback plan

Revert these commits.

Screenshots / videos

5916-demo.mp4

@janicduplessis janicduplessis marked this pull request as ready for review June 12, 2026 01:40

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3cd9bc46b2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/app/ui/contexts/nowPlaying.tsx Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c94872c9ce

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +273 to +276
const duration =
progress?.loadState === 'loaded' && isThisSourceLoaded
? progress.duration
: block.voiceMemo.duration ?? 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fall back to memo duration when native duration is unknown

When expo-audio reports a loaded status before it has determined the duration (duration === 0), this branch chooses progress.duration even though block.voiceMemo.duration is available, and the zero-duration guard immediately returns. In that scenario, tapping or dragging the waveform stops seeking as soon as the memo is loaded; the unloaded path already uses the metadata duration, so it should also be used as a fallback here.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 63fcc96 — the loaded path now uses progress.duration only when it's > 0 and otherwise falls back to block.voiceMemo.duration, same as the unloaded path. (Defensive fix verified by review; the transient loaded-with-zero-duration state isn't deterministically reproducible in manual testing.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: validated on the iOS sim by temporarily forcing the loaded duration to 0 in the working tree — with the fallback in place, tapping the waveform at ~70% during playback still seeks (timer jumps accordingly); without it the zero-duration guard swallowed the seek.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 63fcc968e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/app/ui/contexts/nowPlaying.tsx Outdated
loadSource()
.then(() => {
if (pendingSeekRef.current != null) {
nowPlaying.seekTo(pendingSeekRef.current);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the seek load guard until seek finishes

When an unloaded memo finishes loading while the user is still dragging, this calls nowPlaying.seekTo(...) but does not return/await its promise, so the following .finally clears isLoadingForSeekRef and pendingSeekRef before the native seek completes and before the synthetic progress event can mark this source as loaded. A subsequent pan update in that gap still sees isThisSourceLoaded === false and can start another replace() for the same URL, reintroducing the multiple-load race this guard is meant to prevent. The fresh evidence is that the post-load path drops the seek promise, so the guard is released before the source becomes observable as loaded/seeked.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e6fc6a7, two layers: the .then now returns the nowPlaying.seekTo(...) promise so the .finally holds isLoadingForSeekRef until the post-load seek actually completes, and the direct-seek branch also accepts nowPlaying.nowPlaying?.url === sourceUri — the provider sets that synchronously when a load resolves, which covers the remaining gap before the progress event re-renders isThisSourceLoaded. Validated on the iOS sim with a temporary counter in replace(): four continuous scrub passes spanning the load of an unloaded memo produced exactly one replace(), ending parked at the final drag position.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds waveform-based seeking for voice memos (tap-to-seek and drag-to-scrub) by wiring gesture input to the existing now-playing audio controller, and fixes progress reporting so UI updates immediately after seeks (including while paused).

Changes:

  • Add tap + horizontal pan gestures to the voice memo waveform and map gesture X to a seek time.
  • Update now-playing progress handling: remove web ms→s conversion and emit synthetic progress after seekTo.
  • Deduplicate concurrent replace() loads so play/seek can share a single in-flight load.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
packages/app/ui/contexts/nowPlaying.tsx Updates progress units, emits synthetic progress on seek, and adds shared in-flight load logic plus seek/scrub controller APIs.
packages/app/ui/components/PostContent/BlockRenderer.tsx Adds gesture handling around the voice memo waveform and computes seek time from gesture position.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +363 to +376
const inFlightLoadRef = useRef<Promise<void> | null>(null);
const loadSource = useCallback(() => {
if (sourceUri == null) {
return Promise.reject(new Error('No source to load'));
}
if (inFlightLoadRef.current == null) {
inFlightLoadRef.current = nowPlaying
.replace({ url: sourceUri })
.finally(() => {
inFlightLoadRef.current = null;
});
}
return inFlightLoadRef.current;
}, [nowPlaying, sourceUri]);
Comment on lines +418 to +421
if (isThisSourceLoaded || nowPlaying.nowPlaying?.url === sourceUri) {
nowPlaying.seekTo(seconds);
return;
}
Comment on lines +406 to +409
// the Skia canvas would otherwise capture touches meant for
// the seek gesture
style={{ height: 22, pointerEvents: 'none' }}
/>
Comment on lines +287 to +289
const candleSize = WAVEFORM_CANDLE_WIDTH + WAVEFORM_CANDLE_SPACING;
const drawnExtent = Math.floor(width / candleSize) * candleSize;
if (drawnExtent <= 0) {
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