Skip to content

Commit

Permalink
feat: rework set video percentage to run racked scroll and play video…
Browse files Browse the repository at this point in the history
… frames in parallel (#107)
  • Loading branch information
tarsinzer committed Jun 2, 2024
1 parent 00af278 commit 9bc3478
Show file tree
Hide file tree
Showing 6 changed files with 132 additions and 88 deletions.
18 changes: 5 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 })`

<br/>

***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
Expand Down
128 changes: 87 additions & 41 deletions src/ScrollyVideo.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import UAParser from 'ua-parser-js';
import videoDecoder from './videoDecoder';
import { debounce, isScrollPositionAtTarget } from './utils';

/**
* ____ _ _ __ ___ _
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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;
}
}

/**
Expand Down
18 changes: 7 additions & 11 deletions src/ScrollyVideo.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const ScrollyVideoComponent = forwardRef(function ScrollyVideoComponent(
sticky,
full,
trackScroll,
lockScroll,
useWebCodecs,
videoPercentage,
debug,
Expand Down Expand Up @@ -51,6 +52,7 @@ const ScrollyVideoComponent = forwardRef(function ScrollyVideoComponent(
sticky,
full,
trackScroll,
lockScroll,
useWebCodecs,
debug,
videoPercentage: videoPercentageRef.current,
Expand All @@ -67,6 +69,7 @@ const ScrollyVideoComponent = forwardRef(function ScrollyVideoComponent(
sticky,
full,
trackScroll,
lockScroll,
useWebCodecs,
debug,
]);
Expand All @@ -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(
Expand All @@ -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],
Expand Down
17 changes: 4 additions & 13 deletions src/ScrollyVideo.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 3 additions & 10 deletions src/ScrollyVideo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
@@ -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;
};

0 comments on commit 9bc3478

Please sign in to comment.