From 9bc34784cb714dad20464b506cd6d8291a5782aa Mon Sep 17 00:00:00 2001 From: Max Tarsis <21989873+tarsinzer@users.noreply.github.com> Date: Sun, 2 Jun 2024 22:55:31 +0100 Subject: [PATCH] feat: rework set video percentage to run racked scroll and play video frames in parallel (#107) --- README.md | 18 ++---- src/ScrollyVideo.js | 128 +++++++++++++++++++++++++++------------- src/ScrollyVideo.jsx | 18 +++--- src/ScrollyVideo.svelte | 17 ++---- src/ScrollyVideo.vue | 13 +--- src/utils.js | 26 ++++++++ 6 files changed, 132 insertions(+), 88 deletions(-) create mode 100644 src/utils.js diff --git a/README.md b/README.md index bfcd5fe..0441b5f 100644 --- a/README.md +++ b/README.md @@ -83,31 +83,23 @@ Add html code to your html component: | sticky | Whether the video should have `position: sticky` | Boolean | true | | full | Whether the video should take up the entire viewport | Boolean | true | | trackScroll | Whether this object should automatically respond to scroll | Boolean | true | +| lockScroll | Whether it ignores human scroll while it runs `setVideoPercentage` with enabled `trackScroll` | Boolean | true | | useWebCodecs | Whether the library should use the webcodecs method, see below | Boolean | true | | videoPercentage | Manually specify the position of the video between 0..1, only used for react, vue, and svelte components | Number | | | onReady | The callback when it's ready to scroll | VoidFunction | | +| onChange | The callback for video percentage change | VoidFunction | | | debug | Whether to log debug information | Boolean | false | ## Additional callbacks -***setTargetTimePercent*** +***setVideoPercentage*** -Description: A way to set currentTime manually. Pass a progress in between of 0 and 1 that specifies the percentage position of the video. +Description: A way to set currentTime manually. Pass a progress in between of 0 and 1 that specifies the percentage position of the video. If `trackScroll` enabled - it performs scroll automatically. Signature: `(percentage: number, options: { transitionSpeed: number, (progress: number) => number }) => void` -Example: `scrollyVideo.setTargetTimePercent(0.5, { transitionSpeed: 12, easing: d3.easeLinear })` - -
- -***setScrollPercent*** - -Description: A way to set video currentTime manually based on `trackScroll` i.e. pass a progress in between of 0 and 1 that specifies the percentage position of the video and it will scroll smoothly. Make sure to have `trackScroll` enabled. - -Signature: `(percentage: number) => void` - -Example: `scrollyVideo.setScrollPercent(0.5)` +Example: `scrollyVideo.setVideoPercentage(0.5, { transitionSpeed: 12, easing: d3.easeLinear })` ## Technical Details and Cross Browser Differences diff --git a/src/ScrollyVideo.js b/src/ScrollyVideo.js index 4d6fd77..f2bbae1 100644 --- a/src/ScrollyVideo.js +++ b/src/ScrollyVideo.js @@ -1,5 +1,6 @@ import UAParser from 'ua-parser-js'; import videoDecoder from './videoDecoder'; +import { debounce, isScrollPositionAtTarget } from './utils'; /** * ____ _ _ __ ___ _ @@ -20,10 +21,12 @@ class ScrollyVideo { sticky = true, // Whether the video should "stick" to the top of the container full = true, // Whether the container should expand to 100vh and 100vw trackScroll = true, // Whether this object should automatically respond to scroll + lockScroll = true, // Whether it ignores human scroll while it runs `setVideoPercentage` with enabled `trackScroll` transitionSpeed = 8, // How fast the video transitions between points frameThreshold = 0.1, // When to stop the video animation, in seconds useWebCodecs = true, // Whether to try using the webcodecs approach onReady = () => {}, // A callback that invokes on video decode + onChange = () => {}, // A callback that invokes on video percentage change debug = false, // Whether to print debug stats to the console }) { // Make sure that we have a DOM @@ -65,6 +68,7 @@ class ScrollyVideo { this.sticky = sticky; this.trackScroll = trackScroll; this.onReady = onReady; + this.onChange = onChange; this.debug = debug; // Create the initial video object. Even if we are going to use webcodecs, @@ -114,6 +118,13 @@ class ScrollyVideo { this.frames = []; // The frames decoded by webCodecs this.frameRate = 0; // Calculation of frameRate so we know which frame to paint + const debouncedScroll = debounce(() => { + // eslint-disable-next-line no-undef + window.requestAnimationFrame(() => { + this.setScrollPercent(this.videoPercentage); + }); + }, 100); + // Add scroll listener for responding to scroll position this.updateScrollPercentage = (jump) => { // Used for internally setting the scroll percentage based on built-in listeners @@ -126,10 +137,18 @@ class ScrollyVideo { // eslint-disable-next-line no-undef (containerBoundingClientRect.height - window.innerHeight); - if (this.debug) console.info('ScrollyVideo scrolled to', scrollPercent); + if (this.debug) { + console.info('ScrollyVideo scrolled to', scrollPercent); + } - // Set the target time percent - this.setTargetTimePercent(scrollPercent, { jump }); + if (this.targetScrollPosition == null) { + this.setTargetTimePercent(scrollPercent, { jump }); + this.onChange(scrollPercent); + } else if (isScrollPositionAtTarget(this.targetScrollPosition)) { + this.targetScrollPosition = null; + } else if (lockScroll && this.targetScrollPosition != null) { + debouncedScroll(); + } }; // Add our event listeners for handling changes to the window or scroll @@ -168,6 +187,32 @@ class ScrollyVideo { this.decodeVideo(); } + /** + * Sets the currentTime of the video as a specified percentage of its total duration. + * + * @param percentage - The percentage of the video duration to set as the current time. + * @param options - Configuration options for adjusting the video playback. + * - jump: boolean - If true, the video currentTime will jump directly to the specified percentage. If false, the change will be animated over time. + * - transitionSpeed: number - Defines the speed of the transition when `jump` is false. Represents the duration of the transition in milliseconds. Default is 8. + * - easing: (progress: number) => number - A function that defines the easing curve for the transition. It takes the progress ratio (a number between 0 and 1) as an argument and returns the eased value, affecting the playback speed during the transition. + */ + setVideoPercentage(percentage, options = {}) { + if (this.transitioningRaf) { + // eslint-disable-next-line no-undef + window.cancelAnimationFrame(this.transitioningRaf); + } + + this.videoPercentage = percentage; + + this.onChange(percentage); + + if (this.trackScroll) { + this.setScrollPercent(percentage); + } + + this.setTargetTimePercent(percentage, options); + } + /** * Sets the style of the video or canvas to "cover" it's container * @@ -281,35 +326,32 @@ class ScrollyVideo { * @param frameNum */ paintCanvasFrame(frameNum) { - if (this.canvas) { - // Get the frame and paint it to the canvas - const currFrame = this.frames[frameNum]; - if (currFrame) { - if (this.debug) console.info('Painting frame', frameNum); - - // Make sure the canvas is scaled properly, similar to setCoverStyle - this.canvas.width = currFrame.width; - this.canvas.height = currFrame.height; - const { width, height } = this.container.getBoundingClientRect(); - - if (width / height > currFrame.width / currFrame.height) { - this.canvas.style.width = '100%'; - this.canvas.style.height = 'auto'; - } else { - this.canvas.style.height = '100%'; - this.canvas.style.width = 'auto'; - } + // Get the frame and paint it to the canvas + const currFrame = this.frames[frameNum]; - // Draw the frame to the canvas context - this.context.drawImage( - currFrame, - 0, - 0, - currFrame.width, - currFrame.height, - ); - } + if (!this.canvas || !currFrame) { + return; } + + if (this.debug) { + console.info('Painting frame', frameNum); + } + + // Make sure the canvas is scaled properly, similar to setCoverStyle + this.canvas.width = currFrame.width; + this.canvas.height = currFrame.height; + const { width, height } = this.container.getBoundingClientRect(); + + if (width / height > currFrame.width / currFrame.height) { + this.canvas.style.width = '100%'; + this.canvas.style.height = 'auto'; + } else { + this.canvas.style.height = '100%'; + this.canvas.style.width = 'auto'; + } + + // Draw the frame to the canvas context + this.context.drawImage(currFrame, 0, 0, currFrame.width, currFrame.height); } /** @@ -457,20 +499,19 @@ class ScrollyVideo { /** * Sets the currentTime of the video as a specified percentage of its total duration. * - * @param setPercentage - The percentage of the video duration to set as the current time. + * @param percentage - The percentage of the video duration to set as the current time. * @param options - Configuration options for adjusting the video playback. * - jump: boolean - If true, the video currentTime will jump directly to the specified percentage. If false, the change will be animated over time. * - transitionSpeed: number - Defines the speed of the transition when `jump` is false. Represents the duration of the transition in milliseconds. Default is 8. * - easing: (progress: number) => number - A function that defines the easing curve for the transition. It takes the progress ratio (a number between 0 and 1) as an argument and returns the eased value, affecting the playback speed during the transition. */ - setTargetTimePercent(setPercentage, options = {}) { - // eslint-disable-next-line - // The time we want to transition to - this.targetTime = - Math.max(Math.min(setPercentage, 1), 0) * - (this.frames.length && this.frameRate + setTargetTimePercent(percentage, options = {}) { + const targetDuration = + this.frames.length && this.frameRate ? this.frames.length / this.frameRate - : this.video.duration); + : this.video.duration; + // The time we want to transition to + this.targetTime = Math.max(Math.min(percentage, 1), 0) * targetDuration; // If we are close enough, return early if ( @@ -503,10 +544,15 @@ class ScrollyVideo { const startPoint = top + window.pageYOffset; // eslint-disable-next-line no-undef const containerHeightInViewport = height - window.innerHeight; - const targetPoint = startPoint + containerHeightInViewport * percentage; + const targetPosition = startPoint + containerHeightInViewport * percentage; - // eslint-disable-next-line no-undef - window.scrollTo({ top: targetPoint, behavior: 'smooth' }); + if (isScrollPositionAtTarget(targetPosition)) { + this.targetScrollPosition = null; + } else { + // eslint-disable-next-line no-undef + window.scrollTo({ top: targetPosition, behavior: 'smooth' }); + this.targetScrollPosition = targetPosition; + } } /** diff --git a/src/ScrollyVideo.jsx b/src/ScrollyVideo.jsx index 3542570..12198fd 100644 --- a/src/ScrollyVideo.jsx +++ b/src/ScrollyVideo.jsx @@ -16,6 +16,7 @@ const ScrollyVideoComponent = forwardRef(function ScrollyVideoComponent( sticky, full, trackScroll, + lockScroll, useWebCodecs, videoPercentage, debug, @@ -51,6 +52,7 @@ const ScrollyVideoComponent = forwardRef(function ScrollyVideoComponent( sticky, full, trackScroll, + lockScroll, useWebCodecs, debug, videoPercentage: videoPercentageRef.current, @@ -67,6 +69,7 @@ const ScrollyVideoComponent = forwardRef(function ScrollyVideoComponent( sticky, full, trackScroll, + lockScroll, useWebCodecs, debug, ]); @@ -80,13 +83,9 @@ const ScrollyVideoComponent = forwardRef(function ScrollyVideoComponent( videoPercentage >= 0 && videoPercentage <= 1 ) { - if (trackScroll) { - scrollyVideoRef.current.setScrollPercent(videoPercentage) - } else { - scrollyVideoRef.current.setTargetTimePercent(videoPercentage); - } + scrollyVideoRef.current.setVideoPercentage(videoPercentage); } - }, [videoPercentage, trackScroll]); + }, [videoPercentage]); // effect for unmount useEffect( @@ -101,11 +100,8 @@ const ScrollyVideoComponent = forwardRef(function ScrollyVideoComponent( useImperativeHandle( ref, () => ({ - setTargetTimePercent: scrollyVideoRef.current - ? scrollyVideoRef.current.setTargetTimePercent.bind(instance) - : () => {}, - setScrollPercent: scrollyVideoRef.current - ? scrollyVideoRef.current.setScrollPercent.bind(instance) + setVideoPercentage: scrollyVideoRef.current + ? scrollyVideoRef.current.setVideoPercentage.bind(instance) : () => {}, }), [instance], diff --git a/src/ScrollyVideo.svelte b/src/ScrollyVideo.svelte index 92b9adf..f00a11d 100644 --- a/src/ScrollyVideo.svelte +++ b/src/ScrollyVideo.svelte @@ -32,23 +32,14 @@ videoPercentage >= 0 && videoPercentage <= 1 ) { - if (restProps.trackScroll) { - scrollyVideo.setScrollPercent(videoPercentage); - } else { - scrollyVideo.setTargetTimePercent(videoPercentage); - } + scrollyVideo.setVideoPercentage(videoPercentage); } } } - // export setTargetTimePercent for use in implementations - export function setTargetTimePercent(...args) { - scrollyVideo.setTargetTimePercent(...args); - } - - // export setScrollPercent for use in implementations - export function setScrollPercent(...args) { - scrollyVideo.setScrollPercent(...args); + // export setVideoPercentage for use in implementations + export function setVideoPercentage(...args) { + scrollyVideo.setVideoPercentage(...args); } // Cleanup the component on destroy diff --git a/src/ScrollyVideo.vue b/src/ScrollyVideo.vue index bc6f1d7..409b47d 100644 --- a/src/ScrollyVideo.vue +++ b/src/ScrollyVideo.vue @@ -20,11 +20,8 @@ export default { ...props, }); }, - setTargetTimePercent(...args) { - if (this.scrollyVideo) this.scrollyVideo.setTargetTimePercent(...args); - }, - setScrollPercent(...args) { - if (this.scrollyVideo) this.scrollyVideo.setScrollPercent(...args); + setVideoPercentage(...args) { + if (this.scrollyVideo) this.scrollyVideo.setVideoPercentage(...args); }, }, watch: { @@ -47,11 +44,7 @@ export default { videoPercentage >= 0 && videoPercentage <= 1 ) { - if (restProps.trackScroll) { - this.scrollyVideo.setScrollPercent(videoPercentage); - } else { - this.scrollyVideo.setTargetTimePercent(videoPercentage); - } + this.scrollyVideo.setVideoPercentage(videoPercentage); } }, deep: true, diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..5620a96 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,26 @@ +export function debounce(func, delay = 0) { + let timeoutId; + + return function (...args) { + const context = this; + + // Clear the previous timeout if it exists + clearTimeout(timeoutId); + + // Set a new timeout to call the function later + timeoutId = setTimeout(() => { + func.apply(context, args); + }, delay); + }; +} + +export const isScrollPositionAtTarget = ( + targetScrollPosition, + threshold = 1, +) => { + // eslint-disable-next-line no-undef + const currentScrollPosition = window.pageYOffset; + const difference = Math.abs(currentScrollPosition - targetScrollPosition); + + return difference < threshold; +};