Skip to content

Improve Wave player UX and fix event listener bug#505

Open
angelday wants to merge 2 commits intoTryGhost:mainfrom
angelday:fix-wave-theme
Open

Improve Wave player UX and fix event listener bug#505
angelday wants to merge 2 commits intoTryGhost:mainfrom
angelday:fix-wave-theme

Conversation

@angelday
Copy link
Copy Markdown

@angelday angelday commented Apr 29, 2026

Summary

  • Fixed a bug where a timeupdate event listener was registered inside another timeupdate handler, causing listeners to accumulate on every frame
  • Added click-to-seek and drag-to-seek on the progress bar (mouse and touch), with scroll detection that lets vertical swipes pass through on mobile
  • Added a buffering spinner on play buttons while audio is loading, with a minimum display time to prevent flicker
  • Made the post card thumbnail clickable to trigger playback, with the full thumbnail acting as a tap target on mobile
  • Shrunk the post card play button on mobile for better proportions
  • Added hover highlights on skip/speed buttons using the accent color
  • Added proper audio event handling (playing, pause, ended, error, waiting, stalled) instead of relying solely on the play promise
  • Added accessibility: aria-label, aria-busy, aria-hidden, role="button", type="button", and prefers-reduced-motion support

Test it

  • Play/pause from the player button on a post page
  • Play/pause from a post card on the index page
  • Verify buffering spinner appears briefly when playback starts
  • Click the progress bar to seek to a position
  • Drag the progress bar to scrub through audio (also on mobile)
  • On mobile: tap the thumbnail to trigger playback and verify vertical swipe on the progress bar still scrolls the page
  • Test -10s, +30s, and speed buttons
  • Hover over skip/speed buttons on desktop, verify accent color highlight

Closes #321


Note

Medium Risk
Moderate risk because it rewrites core playback event handling and introduces new touch/mouse listeners and preventDefault logic that could affect play/seek behavior on different devices.

Overview
Improves the Wave theme’s audio player interactions by refactoring player() to rely on audio element events (fixing a timeupdate listener nesting bug), keeping play/pause state in sync across the global player and post cards, and adding buffering state tracking with a minimum spinner display time.

Adds click/drag-to-seek on the progress bar (mouse + touch with vertical-scroll passthrough), makes thumbnails act as play targets on mobile, and updates templates/CSS for a buffering spinner, mobile-sized post-card play button, hover accent styling, and basic accessibility attributes (aria-label, aria-busy, role, prefers-reduced-motion).

Reviewed by Cursor Bugbot for commit 730b617. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 29, 2026

Walkthrough

Updates to the Wave theme modify player UI, behavior, and localization. CSS changes add responsive play-button sizing and a buffering state with a spinner and animation; player controls gain pointer cursors and hover color behavior. JavaScript refactors the audio player to a single audio instance, centralizes play/buffering state handling, separates time/duration updates, adds touch/mouse click-to-seek on the track, mobile play triggers, and updates control actions. Handlebars templates add ARIA/button semantics and a spinner element. Locale JSON keys for private-site UI are adjusted.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The localization changes (context.json/en.json) updating authentication UI text appear unrelated to the player UX/event handling objectives in issue #321. Clarify whether locale key changes are intentional and required for this PR, or if they should be split into a separate issue/PR focused on authentication messaging updates.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main objectives: fixing a bug with event listeners and improving player UX with sought features like seek/buffering/accessibility.
Description check ✅ Passed The description comprehensively details all changes across player refactoring, buffering UI, accessibility, and mobile improvements, directly relating to the changeset.
Linked Issues check ✅ Passed All three objectives from issue #321 are addressed: skip button color fix via hover styling [#321], click-to-seek via progress bar listeners [#321], and play button sizing adjustments for mobile [#321].

✏️ 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
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
packages/wave/partials/player.hbs (3)

23-25: Consider adding type="button" for consistency.

The play button has type="button" (line 14), but these control buttons don't. While they're not inside a form, adding the attribute maintains consistency and is a defensive practice.

♻️ Suggested fix
-                    <button class="player-backward">-10s</button>
-                    <button class="player-forward">+30s</button>
-                    <button class="player-speed">1x</button>
+                    <button class="player-backward" type="button">-10s</button>
+                    <button class="player-forward" type="button">+30s</button>
+                    <button class="player-speed" type="button">1x</button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wave/partials/player.hbs` around lines 23 - 25, The three control
buttons lacking explicit types should be updated to include type="button" to
match the existing play button and avoid accidental form submission; update the
elements with class names player-backward, player-forward, and player-speed to
add the type="button" attribute (e.g., modify the <button> elements that have
class="player-backward", class="player-forward", and class="player-speed").

14-14: Wrap aria-label value with translation helper.

Same as in loop.hbs, the aria-label should support i18n.

♻️ Suggested fix
-            <button class="button button-play js-play" type="button" aria-label="Play episode">
+            <button class="button button-play js-play" type="button" aria-label="{{t 'Play episode'}}">

Based on learnings: "In Ghost theme Handlebars templates, keep common keys like 'Sign in' as separate translation keys shared across UI."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wave/partials/player.hbs` at line 14, The aria-label on the play
button is hard-coded and must use the translation helper for i18n; update the
button element with class "button button-play js-play" to replace the static
aria-label value with the theme translation helper (e.g., call the t/translate
helper with a key like "Play episode") and ensure the same translation key is
used across templates and added to the locale files so the label is translated
consistently.

5-5: Consider moving inline cursor:pointer styles to CSS.

Inline styles reduce maintainability. The cursor behavior could be defined in player.css alongside the existing .player .post-media and .player-track rules.

♻️ Suggested approach

In player.hbs:

-    <div class="post-media js-play" style="cursor:pointer">
+    <div class="post-media js-play">
-        <div class="player-track hidden-xs hidden-sm hidden-md" style="cursor:pointer">
+        <div class="player-track hidden-xs hidden-sm hidden-md">

Then in player.css, add cursor: pointer; to the existing .player .post-media rule (line 11-17) and .player-track rule (line 128-132).

Also applies to: 30-30

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wave/partials/player.hbs` at line 5, Remove the inline
style="cursor:pointer" from the <div class="post-media js-play"> in player.hbs
(and any other inline cursor usages) and instead add cursor: pointer to the
existing CSS selectors in player.css—specifically the .player .post-media rule
and the .player-track rule—so the clickable behavior is maintained via
stylesheet rather than inline styles.
packages/wave/partials/loop.hbs (1)

4-4: Wrap aria-label value with translation helper.

The aria-label text should use the translation helper for i18n support.

♻️ Suggested fix
-            <div class="post-play js-play" role="button" aria-label="Play episode">
+            <div class="post-play js-play" role="button" aria-label="{{t 'Play episode'}}">

Based on learnings: "In Ghost theme Handlebars templates, keep common keys like 'Sign in' as separate translation keys shared across UI."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wave/partials/loop.hbs` at line 4, Update the aria-label on the div
with classes "post-play js-play" to use the theme translation helper instead of
a hardcoded string: replace the literal aria-label value with a call to the i18n
helper (e.g. {{t "Play episode"}} or your project's translation helper) so the
label for the "post-play js-play" element is translated; ensure the translation
key ("Play episode") is added to the shared locale file if missing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/wave/assets/css/site/player.css`:
- Line 69: The CSS rule uses the keyword "currentColor" with uppercase C which
violates the value-keyword-case Stylelint rule; update the declaration "border:
2px solid currentColor" to use the lowercase keyword "currentcolor" so the
property (border: 2px solid currentcolor) complies with the linter.

---

Nitpick comments:
In `@packages/wave/partials/loop.hbs`:
- Line 4: Update the aria-label on the div with classes "post-play js-play" to
use the theme translation helper instead of a hardcoded string: replace the
literal aria-label value with a call to the i18n helper (e.g. {{t "Play
episode"}} or your project's translation helper) so the label for the "post-play
js-play" element is translated; ensure the translation key ("Play episode") is
added to the shared locale file if missing.

In `@packages/wave/partials/player.hbs`:
- Around line 23-25: The three control buttons lacking explicit types should be
updated to include type="button" to match the existing play button and avoid
accidental form submission; update the elements with class names
player-backward, player-forward, and player-speed to add the type="button"
attribute (e.g., modify the <button> elements that have class="player-backward",
class="player-forward", and class="player-speed").
- Line 14: The aria-label on the play button is hard-coded and must use the
translation helper for i18n; update the button element with class "button
button-play js-play" to replace the static aria-label value with the theme
translation helper (e.g., call the t/translate helper with a key like "Play
episode") and ensure the same translation key is used across templates and added
to the locale files so the label is translated consistently.
- Line 5: Remove the inline style="cursor:pointer" from the <div
class="post-media js-play"> in player.hbs (and any other inline cursor usages)
and instead add cursor: pointer to the existing CSS selectors in
player.css—specifically the .player .post-media rule and the .player-track
rule—so the clickable behavior is maintained via stylesheet rather than inline
styles.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 52d95efd-2fb1-4bd8-b36b-5f4138c47466

📥 Commits

Reviewing files that changed from the base of the PR and between 184a253 and 730b617.

📒 Files selected for processing (5)
  • packages/wave/assets/css/blog/post.css
  • packages/wave/assets/css/site/player.css
  • packages/wave/assets/js/main.js
  • packages/wave/partials/loop.hbs
  • packages/wave/partials/player.hbs

Comment thread packages/wave/assets/css/site/player.css Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 730b617. Configure here.


audio.addEventListener('stalled', function () {
if (playRequestTrigger && playRequestTrigger.isConnected) { setBuffering(playRequestTrigger); return; }
if (!audio.paused && playButton.length) setBuffering(playButton[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.

Player thumbnail click bypasses buffering spinner display

Medium Severity

When playback is triggered by clicking the .post-media.js-play thumbnail in the player, playRequestTrigger is set to the .post-media element. This element has no .player-spinner child and doesn't match the CSS selectors (.button-play.is-buffering / .post-play.is-buffering), so no spinner ever appears. Worse, in the waiting and stalled handlers, the playRequestTrigger check succeeds and returns early, preventing the fallback that would show the spinner on .button-play. On slow connections, users get zero visual feedback and may click again, immediately pausing the audio.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 730b617. Configure here.

document.addEventListener('touchend', function () {
if (dragging && !locked) seek(startX);
dragging = false; locked = false;
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing touchcancel handler leaves drag state stuck

Low Severity

There is no touchcancel handler to reset the dragging flag. If a touch on the player track is cancelled (e.g., by a system dialog or notification), dragging stays true. Subsequent touch interactions anywhere on the document would then enter the touchmove handler, potentially calling e.preventDefault() (blocking page scrolling) and triggering unexpected seek() calls until a vertical movement resets dragging.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 730b617. Configure here.

@cursor
Copy link
Copy Markdown

cursor Bot commented Apr 30, 2026

You have used all of your free Bugbot PR reviews.

To receive reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/wave/assets/css/site/player.css (1)

91-95: ⚡ Quick win

Disable the spinner animation for reduced-motion users.

Slowing an infinite rotation still leaves continuous motion enabled. In a prefers-reduced-motion: reduce context, the safer behavior is to stop the spinner animation entirely.

♻️ Proposed tweak
 `@media` (prefers-reduced-motion: reduce) {
     .player-spinner {
-        animation-duration: 1.5s;
+        animation: none;
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wave/assets/css/site/player.css` around lines 91 - 95, The
.player-spinner animation must be fully disabled for users who prefer reduced
motion: inside the existing `@media` (prefers-reduced-motion: reduce) rule, stop
the spinner by overriding its animation (e.g., set animation: none or
animation-play-state: paused and remove transforms) for the .player-spinner
selector so the rotation/continuous motion is no longer rendered; update the
.player-spinner rule within that media query accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/wave/assets/js/main.js`:
- Around line 163-209: The touch-seek handlers (see playerTrack, seek(), and the
touchstart/touchmove/touchend listeners in main.js) are unreachable on small
breakpoints because the .player-track element is rendered with Bootstrap utility
classes hidden-xs hidden-sm hidden-md in packages/wave/partials/player.hbs;
remove those utility classes from the .player-track markup or add an alternative
visible seek target for mobile (e.g., render a mobile .player-track-mobile
without the hidden-* classes and ensure playerTrack selector in main.js targets
it) so the touch handlers can actually reach a visible element.

---

Nitpick comments:
In `@packages/wave/assets/css/site/player.css`:
- Around line 91-95: The .player-spinner animation must be fully disabled for
users who prefer reduced motion: inside the existing `@media`
(prefers-reduced-motion: reduce) rule, stop the spinner by overriding its
animation (e.g., set animation: none or animation-play-state: paused and remove
transforms) for the .player-spinner selector so the rotation/continuous motion
is no longer rendered; update the .player-spinner rule within that media query
accordingly.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0ca85b18-d5cb-4047-950c-9ec08ba9d508

📥 Commits

Reviewing files that changed from the base of the PR and between 730b617 and 21b355c.

📒 Files selected for processing (6)
  • packages/wave/assets/css/site/player.css
  • packages/wave/assets/js/main.js
  • packages/wave/locales/context.json
  • packages/wave/locales/en.json
  • packages/wave/partials/loop.hbs
  • packages/wave/partials/player.hbs
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/wave/partials/loop.hbs
  • packages/wave/partials/player.hbs

Comment on lines +163 to +209
// Seek — mouse
if (playerTrack.length) {
var dragging = false;

function seek(clientX) {
if (isNaN(audio.duration)) return;
var rect = playerTrack[0].getBoundingClientRect();
var ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
audio.currentTime = ratio * audio.duration;
playerProgress.css('width', (ratio * 100) + '%');
}

playerTrack.on('mousedown', function (e) { dragging = true; seek(e.clientX); });
jQuery(document).on('mousemove', function (e) { if (dragging) seek(e.clientX); });
jQuery(document).on('mouseup', function () { dragging = false; });

// Seek — touch
var startX = 0, startY = 0, locked = false;

playerTrack[0].addEventListener('touchstart', function (e) {
dragging = true; locked = false;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
}, { passive: true });

document.addEventListener('touchmove', function (e) {
if (!dragging) return;
var dx = Math.abs(e.touches[0].clientX - startX);
var dy = Math.abs(e.touches[0].clientY - startY);
if (!locked) {
if (dx < 5 && dy < 5) return;
if (dy > dx) { dragging = false; return; }
locked = true;
}
e.preventDefault();
seek(e.touches[0].clientX);
}, { passive: false });

document.addEventListener('touchend', function () {
if (dragging && !locked) seek(startX);
dragging = false; locked = false;
});

document.addEventListener('touchcancel', function () {
dragging = false; locked = false;
});
}
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 30, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Expose the seek track on touch breakpoints or this mobile path stays unreachable.

This block adds touch scrubbing, but packages/wave/partials/player.hbs:30-32 still renders .player-track with hidden-xs hidden-sm hidden-md. That keeps the only seek target hidden on the same mobile/tablet breakpoints this code is trying to support, so the new touch-seek UX cannot actually be used there. Please either remove those utility classes for the player track or provide a mobile-visible seek target.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wave/assets/js/main.js` around lines 163 - 209, The touch-seek
handlers (see playerTrack, seek(), and the touchstart/touchmove/touchend
listeners in main.js) are unreachable on small breakpoints because the
.player-track element is rendered with Bootstrap utility classes hidden-xs
hidden-sm hidden-md in packages/wave/partials/player.hbs; remove those utility
classes from the .player-track markup or add an alternative visible seek target
for mobile (e.g., render a mobile .player-track-mobile without the hidden-*
classes and ensure playerTrack selector in main.js targets it) so the touch
handlers can actually reach a visible element.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

the track is intentionally hidden on small breakpoints in favor of a simpler mobile layout. The touch-seek handlers are there for tablet/desktop touch users where the track is visible.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@angelday, understood — hiding the seek track on xs/sm/md in favour of a simpler mobile layout is a deliberate design decision, and the touch-seek handlers are intended for larger touch devices (tablets/touch laptops) where the track remains visible. That makes complete sense. I'll disregard this concern.

( ˶ᵔ ᵕ ᵔ˶ )


✏️ Learnings added
Learnt from: angelday
Repo: TryGhost/Themes PR: 505
File: packages/wave/assets/js/main.js:163-209
Timestamp: 2026-04-30T06:25:39.505Z
Learning: In the Wave theme (`packages/wave/partials/player.hbs`), the `.player-track` seek bar is intentionally hidden on small breakpoints (`hidden-xs hidden-sm hidden-md`). This is a design decision to provide a simpler mobile layout on phones. The touch-seek handlers in `packages/wave/assets/js/main.js` are meant for tablet and desktop touch users where the track is visible, not for phone-sized screens.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

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.

Wave theme issues

1 participant