Skip to content

New: Add forward scrubbing prevention (fix #323)#334

Open
joe-replin wants to merge 13 commits intomasterfrom
issue/323
Open

New: Add forward scrubbing prevention (fix #323)#334
joe-replin wants to merge 13 commits intomasterfrom
issue/323

Conversation

@joe-replin
Copy link
Contributor

@joe-replin joe-replin commented Jul 29, 2025

#320 - was not a comprehensive solution for preventing forward scrubbing. Making another attempt.

Fix

Update

  • Adjusted _preventForwardScrubbing configuration option that prevents learners from skipping ahead in audio/video content until they have viewed/listened to it completely at least once
  • Learners can still skip backwards to any previously viewed portion of the media
  • Visual blocker overlay appears on the progress bar to indicate the restricted area. Animation provides feedback when learners attempt to scrub forward beyond their max viewed position
  • Keyboard navigation (ArrowRight, End, PageDown) is also restricted when at the maximum viewed position
  • Restriction is automatically lifted once the component is marked as complete
  • Event listener management for forward scrubbing prevention to ensure proper cleanup on component removal
  • README, Schemas & Example

New

Testing

  1. Enable _preventForwardScrubbing: true in a media component
  2. Set _setCompletionOn: "ended"
  3. Play the media and verify:
  • You cannot click or drag the progress bar ahead of the current max viewed position
  • You can click/drag backwards to any previously viewed position
  • Arrow keys (ArrowRight, End, PageDown) do not skip ahead when at max viewed position
  • A visual blocker appears on the progress bar showing the restricted area
  • Flash animation displays when attempting to scrub forward
  • After reaching the end and completing the component, all restrictions are removed
  1. Test with both audio and video media
  2. Verify proper cleanup by navigating away from the page and back

@joe-replin
Copy link
Contributor Author

Noting here that this PR has been extensively tested and is currently a part of a large production release with no issues.

Copy link
Contributor

Choose a reason for hiding this comment

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

👀

Updated comment to clarify usage of _setCompletionOn.
Updated title and help text for preventing media scrubbing.
Updated title and description for _preventForwardScrubbing property to clarify its functionality.
Updated the description of the _preventForwardScrubbing setting to clarify its behavior regarding skipping ahead in media.
Added note about native player controls for scrubbing restriction.
Copy link
Contributor

Choose a reason for hiding this comment

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

👀

Copy link
Contributor

@swashbuck swashbuck left a comment

Choose a reason for hiding this comment

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

👍

Comment on lines +451 to +462
if (this._onScrubTimeUpdate) {
this.mediaElement.removeEventListener('timeupdate', this._onScrubTimeUpdate);
}
if (this._onScrubSeeking) {
this.mediaElement.removeEventListener('seeking', this._onScrubSeeking);
}
if (this._onScrubKeyDown) {
this.mediaElement.removeEventListener('keydown', this._onScrubKeyDown);
}
if (this._onScrubEnded) {
this.mediaElement.removeEventListener('ended', this._onScrubEnded);
}
Copy link
Member

Choose a reason for hiding this comment

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

These checks are all a consequence of assigning the functions to the instance at runtime rather that assigning the functions to the class at compile time.

Suggested change
if (this._onScrubTimeUpdate) {
this.mediaElement.removeEventListener('timeupdate', this._onScrubTimeUpdate);
}
if (this._onScrubSeeking) {
this.mediaElement.removeEventListener('seeking', this._onScrubSeeking);
}
if (this._onScrubKeyDown) {
this.mediaElement.removeEventListener('keydown', this._onScrubKeyDown);
}
if (this._onScrubEnded) {
this.mediaElement.removeEventListener('ended', this._onScrubEnded);
}
this.mediaElement.removeEventListener('timeupdate', this._onScrubTimeUpdate);
this.mediaElement.removeEventListener('seeking', this._onScrubSeeking);
this.mediaElement.removeEventListener('keydown', this._onScrubKeyDown);
this.mediaElement.removeEventListener('ended', this._onScrubEnded);

Comment on lines +579 to +664
this._scrubBlocker = this.createScrubBlocker($slider[0]);

this.setupScrubBlockerEvents();

// Initialize the blocker size
this.updateScrubBlocker();
}

createScrubBlocker(sliderElement) {
const scrubBlocker = document.createElement('span');
scrubBlocker.className = 'mejs__time-slider-blocker';

this._onBlockerPointerDown = (e) => {
e.preventDefault();
e.stopImmediatePropagation();
this.flashBlockedOverlay(scrubBlocker);
};

this._onSliderClick = (e) => {
const rect = sliderElement.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const sliderWidth = rect.width;
const clickPercent = clickX / sliderWidth;
const duration = this.mediaElement.duration;
const clickTime = clickPercent * duration;
const isClickingAhead = clickTime > this._maxViewed + 0.25;

if (!isClickingAhead) return;

e.preventDefault();
e.stopImmediatePropagation();
this.mediaElement.currentTime = this._maxViewed;
this.flashBlockedOverlay(scrubBlocker);
};

scrubBlocker.addEventListener('pointerdown', this._onBlockerPointerDown);
sliderElement.addEventListener('click', this._onSliderClick);

sliderElement.style.position = 'relative';
sliderElement.appendChild(scrubBlocker);
sliderElement.setAttribute('aria-disabled', 'true');

return scrubBlocker;
}

setupScrubBlockerEvents() {
// Update progress and blocker size
this._onScrubTimeUpdate = () => {
if (this._suppressSeek) return;
this._maxViewed = Math.max(this._maxViewed, this.mediaElement.currentTime);
this.model.set('_maxViewed', this._maxViewed);
this.updateScrubBlocker();
};

// Prevent forward seeking and navigate to maxViewed
this._onScrubSeeking = () => {
const isSeekingAhead = this.mediaElement.currentTime > this._maxViewed + 0.25;
if (!isSeekingAhead) return;

this._suppressSeek = true;
this.mediaElement.currentTime = this._maxViewed;
this._suppressSeek = false;

this.flashBlockedOverlay(this._scrubBlocker);
this._showBlockedScrubMessage?.();
};

// Prevent keyboard forward navigation
this._onScrubKeyDown = (e) => {
const isForwardKey = FORWARD_SCRUBBING_KEYS.includes(e.code);
const isAtMaxViewed = this.mediaElement.currentTime >= this._maxViewed;
const shouldPrevent = isForwardKey && isAtMaxViewed;

if (!shouldPrevent) return;

e.preventDefault();
this.mediaElement.currentTime = this._maxViewed;
this.flashBlockedOverlay(this._scrubBlocker);
};

this._onScrubEnded = () => {
this.model.set('_isComplete', true);
this._scrubBlocker?.remove();
};

this.mediaElement.addEventListener('timeupdate', this._onScrubTimeUpdate);
Copy link
Member

@oliverfoster oliverfoster Jan 9, 2026

Choose a reason for hiding this comment

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

All of these nested functions should be assigned to the media prototype at compile time rather than run-time.

Suggested change
this._scrubBlocker = this.createScrubBlocker($slider[0]);
this.setupScrubBlockerEvents();
// Initialize the blocker size
this.updateScrubBlocker();
}
createScrubBlocker(sliderElement) {
const scrubBlocker = document.createElement('span');
scrubBlocker.className = 'mejs__time-slider-blocker';
this._onBlockerPointerDown = (e) => {
e.preventDefault();
e.stopImmediatePropagation();
this.flashBlockedOverlay(scrubBlocker);
};
this._onSliderClick = (e) => {
const rect = sliderElement.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const sliderWidth = rect.width;
const clickPercent = clickX / sliderWidth;
const duration = this.mediaElement.duration;
const clickTime = clickPercent * duration;
const isClickingAhead = clickTime > this._maxViewed + 0.25;
if (!isClickingAhead) return;
e.preventDefault();
e.stopImmediatePropagation();
this.mediaElement.currentTime = this._maxViewed;
this.flashBlockedOverlay(scrubBlocker);
};
scrubBlocker.addEventListener('pointerdown', this._onBlockerPointerDown);
sliderElement.addEventListener('click', this._onSliderClick);
sliderElement.style.position = 'relative';
sliderElement.appendChild(scrubBlocker);
sliderElement.setAttribute('aria-disabled', 'true');
return scrubBlocker;
}
setupScrubBlockerEvents() {
// Update progress and blocker size
this._onScrubTimeUpdate = () => {
if (this._suppressSeek) return;
this._maxViewed = Math.max(this._maxViewed, this.mediaElement.currentTime);
this.model.set('_maxViewed', this._maxViewed);
this.updateScrubBlocker();
};
// Prevent forward seeking and navigate to maxViewed
this._onScrubSeeking = () => {
const isSeekingAhead = this.mediaElement.currentTime > this._maxViewed + 0.25;
if (!isSeekingAhead) return;
this._suppressSeek = true;
this.mediaElement.currentTime = this._maxViewed;
this._suppressSeek = false;
this.flashBlockedOverlay(this._scrubBlocker);
this._showBlockedScrubMessage?.();
};
// Prevent keyboard forward navigation
this._onScrubKeyDown = (e) => {
const isForwardKey = FORWARD_SCRUBBING_KEYS.includes(e.code);
const isAtMaxViewed = this.mediaElement.currentTime >= this._maxViewed;
const shouldPrevent = isForwardKey && isAtMaxViewed;
if (!shouldPrevent) return;
e.preventDefault();
this.mediaElement.currentTime = this._maxViewed;
this.flashBlockedOverlay(this._scrubBlocker);
};
this._onScrubEnded = () => {
this.model.set('_isComplete', true);
this._scrubBlocker?.remove();
};
this.mediaElement.addEventListener('timeupdate', this._onScrubTimeUpdate);
this._scrubBlocker = this.createScrubBlocker($slider[0]);
this.setupScrubBlockerEvents();
// Initialize the blocker size
this.updateScrubBlocker();
}
_onBlockerPointerDown (e) {
e.preventDefault();
e.stopImmediatePropagation();
this.flashBlockedOverlay(scrubBlocker);
}
_onSliderClick (e) {
const rect = sliderElement.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const sliderWidth = rect.width;
const clickPercent = clickX / sliderWidth;
const duration = this.mediaElement.duration;
const clickTime = clickPercent * duration;
const isClickingAhead = clickTime > this._maxViewed + 0.25;
if (!isClickingAhead) return;
e.preventDefault();
e.stopImmediatePropagation();
this.mediaElement.currentTime = this._maxViewed;
this.flashBlockedOverlay(scrubBlocker);
}
createScrubBlocker(sliderElement) {
const scrubBlocker = document.createElement('span');
scrubBlocker.className = 'mejs__time-slider-blocker';
scrubBlocker.addEventListener('pointerdown', this._onBlockerPointerDown);
sliderElement.addEventListener('click', this._onSliderClick);
sliderElement.style.position = 'relative';
sliderElement.appendChild(scrubBlocker);
sliderElement.setAttribute('aria-disabled', 'true');
return scrubBlocker;
}
onScrubTimeUpdate () {
if (this._suppressSeek) return;
this._maxViewed = Math.max(this._maxViewed, this.mediaElement.currentTime);
this.model.set('_maxViewed', this._maxViewed);
this.updateScrubBlocker();
}
// Prevent forward seeking and navigate to maxViewed
onScrubSeeking () {
const isSeekingAhead = this.mediaElement.currentTime > this._maxViewed + 0.25;
if (!isSeekingAhead) return;
this._suppressSeek = true;
this.mediaElement.currentTime = this._maxViewed;
this._suppressSeek = false;
this.flashBlockedOverlay(this._scrubBlocker);
this._showBlockedScrubMessage?.();
}
// Prevent keyboard forward navigation
_onScrubKeyDown (e) {
const isForwardKey = FORWARD_SCRUBBING_KEYS.includes(e.code);
const isAtMaxViewed = this.mediaElement.currentTime >= this._maxViewed;
const shouldPrevent = isForwardKey && isAtMaxViewed;
if (!shouldPrevent) return;
e.preventDefault();
this.mediaElement.currentTime = this._maxViewed;
this.flashBlockedOverlay(this._scrubBlocker);
}
_onScrubEnded () {
this.model.set('_isComplete', true);
this._scrubBlocker?.remove();
}
setupScrubBlockerEvents() {
// Update progress and blocker size
this.mediaElement.addEventListener('timeupdate', this._onScrubTimeUpdate);

this._suppressSeek = false;

this.flashBlockedOverlay(this._scrubBlocker);
this._showBlockedScrubMessage?.();
Copy link
Member

Choose a reason for hiding this comment

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

This function doesn't exist.

      this._showBlockedScrubMessage?.();

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Needs Reviewing

Development

Successfully merging this pull request may close these issues.

Locked player bar still triggers events Backwards scrubbing is broken

4 participants