Skip to content

#508 Allow seeking audio to next/previous heading#989

Open
benpaj-cps wants to merge 12 commits into
sillsdev:mainfrom
benpaj-cps:fix/audio-seek-headings/GH-508
Open

#508 Allow seeking audio to next/previous heading#989
benpaj-cps wants to merge 12 commits into
sillsdev:mainfrom
benpaj-cps:fix/audio-seek-headings/GH-508

Conversation

@benpaj-cps

@benpaj-cps benpaj-cps commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #508.

I tested this on Mark 1 in the CUK Bible with a generated timing file from aeneas. The heading search works even with several nested HTML elements between the heading and the next verse.

image

Summary by CodeRabbit

Summary by CodeRabbit

  • Improvements
    • Enhanced skip/seek behavior with more accurate navigation around chapter/verse boundaries.
    • More intelligent handling when timing is available vs. unavailable, including safer delegation and targeted seek jumps.
    • Added lazy detection and caching of heading markers to improve repeated navigation performance.
    • Playback now tracks first-play state to ensure highlights and time updates start at the correct moment.

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5ec2c4b0-8603-495a-8cc8-ef4ce0deb99e

📥 Commits

Reviewing files that changed from the base of the PR and between 71d153c and ce7e2b4.

📒 Files selected for processing (2)
  • src/lib/data/audio.ts
  • src/lib/data/stores/audio.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lib/data/stores/audio.ts

📝 Walkthrough

Walkthrough

Adds heading-marker-based skip navigation: AudioPlayer gets hasPlayed and optional headingMarkers; skip(direction) now pauses, preserves play state, lazily computes/caches DOM-derived heading markers, uses a 2s AUDIO_SEEK_THRESHOLD to decide local seeks vs refs.skip, and resumes playback. updateTime/play use hasPlayed.

Changes

Heading-marker audio navigation

Layer / File(s) Summary
AudioPlayer contract updates
src/lib/data/stores/audio.ts
AudioPlayer adds hasPlayed: boolean and optional headingMarkers?: number[]; audioPlayerDefault initializes hasPlayed: false.
Skip() and heading marker discovery
src/lib/data/audio.ts
Adds AUDIO_SEEK_THRESHOLD and reimplements skip(direction) to pause, preserve playing state, compute/cache headingMarkers via getHeadingMarkers() and nextElementDFS(), decide between local seek() and refs.skip(direction) using marker positions and threshold, then resume and call updateTime().
Playback / time update gating
src/lib/data/audio.ts
updateTime() now gates store sync/highlight updates on currentAudioPlayer.hasPlayed; play() sets hasPlayed = true on first transition to playing.

Sequence Diagram

sequenceDiagram
  participant User
  participant Skip as skip(direction)
  participant Markers as getHeadingMarkers()
  participant DOM as DOM(div.s / data-verse)
  participant Timing as currentAudioPlayer.timing
  participant Audio as currentAudioPlayer.seek()
  User->>Skip: click prev/next
  Skip->>Skip: pause, record wasPlaying
  Skip->>Timing: check timing & loaded
  alt timing missing or audio not ready
    Skip->>Skip: decide threshold → local seek or refs.skip
  else timing available
    Skip->>Markers: compute or read cached headingMarkers
    Markers->>DOM: scan div.s and DFS to next data-verse
    DOM->>Timing: map verse tags to timing start times
    Markers->>Skip: return markers[]
    Skip->>Skip: compare progress vs AUDIO_SEEK_THRESHOLD and markers
    alt seek within chapter to marker
      Skip->>Audio: seek(targetMarker)
    else delegate to chapter skip
      Skip->>Skip: call refs.skip(direction)
    end
  end
  Skip->>Skip: updateTime()
  Skip->>User: resume playback if wasPlaying
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • chrisvire
  • FyreByrd

Poem

🐰 I nudged the player, soft and spry,
Through headings hidden, low and high,
DOM-carrots show each verse's start,
I hop and seek with ear and heart—
Playback resumes; the verses sigh.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly summarizes the main objective of the PR: implementing functionality to allow seeking audio playback to next/previous section headings.
Linked Issues check ✅ Passed The implementation addresses all coding requirements from issue #508: heading marker detection via DOM scanning, navigation logic to seek to next/previous headings with appropriate fallbacks, and proper state management.
Out of Scope Changes check ✅ Passed All changes are directly related to the linked issue #508 requirements: skip() refactoring for heading navigation, heading marker detection utilities, and audio player state additions.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@benpaj-cps

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/data/audio.ts`:
- Line 221: Remove the leftover debug console.log calls in src/lib/data/audio.ts
— specifically the lines that call console.log with template strings like `found
heading ${h.getHTML()}` and the other console.log statements around the
heading/paragraph processing (the debug prints at lines referenced in the
review). Locate the block that iterates headings/paragraphs (references to
h.getHTML(), p.getText(), etc.) and delete those console.log(...) calls or
replace them with the project's sanctioned logger if persistent logging is
required.
- Line 154: Remove the debug console.log calls in src/lib/data/audio.ts (e.g.,
the "console.log('skip')" and the other console.log occurrences) before merge;
delete these console.log lines or replace them with the project's structured
logger (e.g., processLogger.debug or logger.debug) if the information is needed,
ensuring you reference and update the surrounding functions in the file where
they appear so behavior and return flow remains correct (remove the
console.log(...) statements inside the relevant functions and use the existing
logging utility or nothing).
- Line 217: The variable headingMarkers is declared with let but never
reassigned; change its declaration to const so it remains immutable while still
allowing mutation via push()—update the headingMarkers declaration (where it is
initialized to [0.0]) to use const instead of let in src/lib/data/audio.ts to
reflect that it is only mutated, not reassigned.
- Around line 190-206: The loop that decides where to seek can perform an
out-of-bounds access when calling seek(currentAudioPlayer.headingMarkers[i +
1]); update the logic in the loop that iterates
currentAudioPlayer.headingMarkers to guard any i+1 access: before calling
seek(currentAudioPlayer.headingMarkers[i + 1]) verify i + 1 <
currentAudioPlayer.headingMarkers.length (and handle the case by seeking to the
last marker or current marker instead), and ensure play() is still resumed when
wasPlaying is true; adjust the conditions around direction,
currentAudioPlayer.progress, and AUDIO_SEEK_THRESHOLD accordingly to use the
safe alternative when i+1 would be out of range.
- Around line 241-243: The push to headingMarkers uses an unsafe non-null
assertion on currentAudioPlayer.duration; replace this with a safe fallback and
validation: compute a candidate value from
currentAudioPlayer?.timing?.at(-1)?.endtime ?? currentAudioPlayer?.duration ??
null, ensure it's a finite number (or >= 0) before pushing, and skip or push a
defined default (e.g. 0) if the candidate is null/invalid; update the code
around headingMarkers.push(...) and reference currentAudioPlayer, timing, and
duration when implementing this guard.
- Around line 179-188: The code uses an unsafe non-null assertion on
headingMarkers?.at(-2)! which can be undefined and breaks the pipeline; compute
a safe value first (e.g., const lastButOne = headingMarkers &&
headingMarkers.length >= 2 ? headingMarkers.at(-2) : undefined) and then change
the second part of the if to check lastButOne !== undefined before comparing
progress (e.g., direction >= 0 && lastButOne !== undefined &&
currentAudioPlayer.progress >= lastButOne), removing the non-null assertion and
guarding against headingMarkers being undefined or too short; keep the existing
refs.skip(...) call and adjust the log to reference lastButOne if needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7f9da34d-40fa-4eda-962f-aa70bc5dd8ee

📥 Commits

Reviewing files that changed from the base of the PR and between c4812d5 and 185a5b3.

📒 Files selected for processing (2)
  • src/lib/data/audio.ts
  • src/lib/data/stores/audio.ts

Comment thread src/lib/data/audio.ts Outdated
Comment thread src/lib/data/audio.ts
Comment thread src/lib/data/audio.ts
Comment thread src/lib/data/audio.ts Outdated
Comment thread src/lib/data/audio.ts Outdated
Comment thread src/lib/data/audio.ts Outdated
@benpaj-cps benpaj-cps force-pushed the fix/audio-seek-headings/GH-508 branch 3 times, most recently from 9223462 to 5bcee7c Compare June 12, 2026 15:19
@benpaj-cps benpaj-cps marked this pull request as ready for review June 12, 2026 17:29

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/data/audio.ts`:
- Around line 194-213: When replaying from a local heading seek the store can
remain stale because pause() writes a paused state, seek(...) writes that paused
object to audioPlayerStore, and play() changes playback without updating the
store; modify the local heading-seek path in the loop (the block that calls
seek(...) then play()) to re-sync the store after play() returns by writing the
up-to-date currentAudioPlayer back into audioPlayerStore (or call the existing
function that updates the store for the active player), ensuring
$audioPlayer.playing reflects the resumed state; reference the functions
pause(), seek(), play(), the currentAudioPlayer object, and audioPlayerStore
when making this change.
- Around line 177-186: The code treats headingMarkers.at(-2) === 0 (only
sentinels) as falsy and incorrectly falls through for forward skips; change the
logic in the block that computes and checks finalIntermediateMarker so it only
considers a real intermediate heading when headingMarkers.length > 2 (or
finalIntermediateMarker > 0). Concretely, in the computation of
finalIntermediateMarker and the subsequent if that decides whether to seek to a
marker vs. call skip(1), ensure finalIntermediateMarker is undefined/null when
headingMarkers contains only the two sentinels (e.g., set
finalIntermediateMarker only if headingMarkers.length > 2 or explicitly check
finalIntermediateMarker > 0), so next() will call skip(1) to advance to the next
chapter when there are no further headings in the current chapter.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aa36fd9b-f2b1-40ed-a990-f39d0bf09e95

📥 Commits

Reviewing files that changed from the base of the PR and between 185a5b3 and fff279c.

📒 Files selected for processing (2)
  • src/lib/data/audio.ts
  • src/lib/data/stores/audio.ts

Comment thread src/lib/data/audio.ts Outdated
Comment thread src/lib/data/audio.ts
@benpaj-cps benpaj-cps force-pushed the fix/audio-seek-headings/GH-508 branch 4 times, most recently from b5949c3 to 3d2e0b8 Compare June 12, 2026 18:17
@benpaj-cps

Copy link
Copy Markdown
Contributor Author

@chrisvire This is ready for review.

@benpaj-cps benpaj-cps requested a review from chrisvire June 12, 2026 19:53
@benpaj-cps benpaj-cps changed the title Allow seeking audio to next/previous heading #508 Allow seeking audio to next/previous heading Jun 12, 2026
@benpaj-cps benpaj-cps force-pushed the fix/audio-seek-headings/GH-508 branch from 71d153c to ce7e2b4 Compare June 17, 2026 22:06

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/data/audio.ts`:
- Around line 540-544: The `hasAudioPlayed()` function is currently checking
`progress > 0` to determine if audio has played, but this is inconsistent with
how `updateTime()` and `play()` now use the `hasPlayed` property. When
verse-number seeking resets progress to 0, the function will incorrectly return
false even though audio has already played, causing verse clicks to remain gated
in downstream components like ScriptureViewSofria.svelte. Update the
`hasAudioPlayed()` function to return the `hasPlayed` property from the current
audio player instead of checking the progress value, ensuring consistent
played-state tracking across all methods.
- Around line 344-346: The condition checking newtime uses a truthy check that
fails when newtime is 0, a valid starttime value. Change the if condition from
checking newtime truthiness to explicitly checking if newtime is not undefined
or null (using !== null or != null), while keeping the check for
currentAudioPlayer.audio, so that the seek function can be called with 0 as a
valid timestamp.
- Around line 259-273: The while loop condition checking for the data-verse
attribute stops too early when encountering title verses. Modify the while loop
at line 259 to continue past `data-verse="title"` elements by updating the
condition to skip title verses and only stop when finding numeric verses.
Additionally, update the find() call that searches the timing array to ensure it
correctly matches numeric verses by filtering or validating the verse value to
exclude title entries, so that the code properly advances next to the first
numeric verse immediately following the heading instead of stopping at the title
marker which has a starttime of 0.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5ec2c4b0-8603-495a-8cc8-ef4ce0deb99e

📥 Commits

Reviewing files that changed from the base of the PR and between 71d153c and ce7e2b4.

📒 Files selected for processing (2)
  • src/lib/data/audio.ts
  • src/lib/data/stores/audio.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lib/data/stores/audio.ts

@coderabbitai coderabbitai Bot 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.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/data/audio.ts`:
- Around line 540-544: The `hasAudioPlayed()` function is currently checking
`progress > 0` to determine if audio has played, but this is inconsistent with
how `updateTime()` and `play()` now use the `hasPlayed` property. When
verse-number seeking resets progress to 0, the function will incorrectly return
false even though audio has already played, causing verse clicks to remain gated
in downstream components like ScriptureViewSofria.svelte. Update the
`hasAudioPlayed()` function to return the `hasPlayed` property from the current
audio player instead of checking the progress value, ensuring consistent
played-state tracking across all methods.
- Around line 344-346: The condition checking newtime uses a truthy check that
fails when newtime is 0, a valid starttime value. Change the if condition from
checking newtime truthiness to explicitly checking if newtime is not undefined
or null (using !== null or != null), while keeping the check for
currentAudioPlayer.audio, so that the seek function can be called with 0 as a
valid timestamp.
- Around line 259-273: The while loop condition checking for the data-verse
attribute stops too early when encountering title verses. Modify the while loop
at line 259 to continue past `data-verse="title"` elements by updating the
condition to skip title verses and only stop when finding numeric verses.
Additionally, update the find() call that searches the timing array to ensure it
correctly matches numeric verses by filtering or validating the verse value to
exclude title entries, so that the code properly advances next to the first
numeric verse immediately following the heading instead of stopping at the title
marker which has a starttime of 0.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5ec2c4b0-8603-495a-8cc8-ef4ce0deb99e

📥 Commits

Reviewing files that changed from the base of the PR and between 71d153c and ce7e2b4.

📒 Files selected for processing (2)
  • src/lib/data/audio.ts
  • src/lib/data/stores/audio.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lib/data/stores/audio.ts
🛑 Comments failed to post (3)
src/lib/data/audio.ts (3)

259-273: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Continue past title markers when locating the verse after a heading.

data-verse="title" can appear between a section heading and the first numeric verse, so this stops too early and drops the marker because the title timing is 0. Filter for numeric verses and match timing tags exactly/prefix-style so next seeks to the verse after the heading instead of falling through to the next chapter. Based on PR objective: next should advance to the verse immediately following the next section heading.

Proposed fix
-        let next = nextElementDFS(h);
-        while (next && !next?.getAttribute('data-verse')) {
-            next = nextElementDFS(next);
-        }
-
-        // If present this is the first verse immediately after the heading
-        const verse = next?.getAttribute('data-verse');
+        let next = nextElementDFS(h);
+        let verse = next?.getAttribute('data-verse');
+        while (next && (!verse || !/^\d+$/.test(verse))) {
+            next = nextElementDFS(next);
+            verse = next?.getAttribute('data-verse');
+        }
+
+        // If present this is the first numeric verse immediately after the heading
         if (verse === null || verse === undefined) {
             return;
         }
 
         // find() always returns the first element it matches, so
         // this will locate the beginning of the first phrase of the verse
-        const marker = currentAudioPlayer?.timing?.find((v) => v.tag.includes(verse))?.starttime;
-        if (marker) {
+        const marker = currentAudioPlayer?.timing?.find((v) => v.tag === verse || v.tag === `${verse}a`)?.starttime;
+        if (typeof marker === 'number') {
             headingMarkers.push(marker);
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/data/audio.ts` around lines 259 - 273, The while loop condition
checking for the data-verse attribute stops too early when encountering title
verses. Modify the while loop at line 259 to continue past `data-verse="title"`
elements by updating the condition to skip title verses and only stop when
finding numeric verses. Additionally, update the find() call that searches the
timing array to ensure it correctly matches numeric verses by filtering or
validating the verse value to exclude title entries, so that the code properly
advances next to the first numeric verse immediately following the heading
instead of stopping at the title marker which has a starttime of 0.

344-346: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Allow seeking to timestamp 0.

0 is a valid starttime, but the truthy check skips it, so rewinding to the first timing entry can leave playback at the current position.

Proposed fix
     const newtime = currentAudioPlayer.timing?.[currentAudioPlayer.timeIndex].starttime;
-    if (newtime && currentAudioPlayer.audio) {
+    if (typeof newtime === 'number' && currentAudioPlayer.audio) {
         seek(newtime);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    const newtime = currentAudioPlayer.timing?.[currentAudioPlayer.timeIndex].starttime;
    if (typeof newtime === 'number' && currentAudioPlayer.audio) {
        seek(newtime);
    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/data/audio.ts` around lines 344 - 346, The condition checking newtime
uses a truthy check that fails when newtime is 0, a valid starttime value.
Change the if condition from checking newtime truthiness to explicitly checking
if newtime is not undefined or null (using !== null or != null), while keeping
the check for currentAudioPlayer.audio, so that the seek function can be called
with 0 as a valid timestamp.

540-544: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use hasPlayed consistently for the exported played-state check.

updateTime() and play() now use hasPlayed, but hasAudioPlayed() still derives state from progress > 0; verse-number seeking can remain blocked after audio has started if progress is still/reset to 0. Based on downstream usage in ScriptureViewSofria.svelte, verse clicks are gated by hasAudioPlayed().

Proposed fix
 export function hasAudioPlayed() {
-    return (currentAudioPlayer?.progress ?? 0) > 0;
+    return currentAudioPlayer?.hasPlayed === true;
 }

Also applies to: 607-609

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/data/audio.ts` around lines 540 - 544, The `hasAudioPlayed()`
function is currently checking `progress > 0` to determine if audio has played,
but this is inconsistent with how `updateTime()` and `play()` now use the
`hasPlayed` property. When verse-number seeking resets progress to 0, the
function will incorrectly return false even though audio has already played,
causing verse clicks to remain gated in downstream components like
ScriptureViewSofria.svelte. Update the `hasAudioPlayed()` function to return the
`hasPlayed` property from the current audio player instead of checking the
progress value, ensuring consistent played-state tracking across all methods.

@benpaj-cps

Copy link
Copy Markdown
Contributor Author

This needed to be rebased on main after the recent merges, and should be good for review again.

@benpaj-cps benpaj-cps requested a review from FyreByrd June 17, 2026 22:17
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.

Advance button in chapter with timing should go to next section heading

1 participant