New: Add forward scrubbing prevention (fix #323)#334
Open
joe-replin wants to merge 13 commits intomasterfrom
Open
New: Add forward scrubbing prevention (fix #323)#334joe-replin wants to merge 13 commits intomasterfrom
joe-replin wants to merge 13 commits intomasterfrom
Conversation
swashbuck
reviewed
Jul 30, 2025
swashbuck
reviewed
Jul 30, 2025
swashbuck
reviewed
Jul 30, 2025
swashbuck
reviewed
Jul 30, 2025
swashbuck
reviewed
Jul 30, 2025
swashbuck
reviewed
Jul 30, 2025
Contributor
Author
|
Noting here that this PR has been extensively tested and is currently a part of a large production release with no issues. |
swashbuck
reviewed
Oct 9, 2025
oliverfoster
requested changes
Oct 29, 2025
oliverfoster
requested changes
Oct 29, 2025
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.
oliverfoster
requested changes
Jan 9, 2026
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); | ||
| } |
Member
There was a problem hiding this comment.
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); |
Member
There was a problem hiding this comment.
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?.(); |
Member
There was a problem hiding this comment.
This function doesn't exist.
this._showBlockedScrubMessage?.();
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.
#320 - was not a comprehensive solution for preventing forward scrubbing. Making another attempt.
Fix
Update
_preventForwardScrubbingconfiguration option that prevents learners from skipping ahead in audio/video content until they have viewed/listened to it completely at least onceNew
Testing
_preventForwardScrubbing: truein a media component_setCompletionOn: "ended"