297 lines
9.1 KiB
Svelte
297 lines
9.1 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy } from 'svelte';
|
|
import ScrollerVideo from './ts/ScrollerVideo';
|
|
import Debug from './Debug.svelte';
|
|
import type { Snippet } from 'svelte';
|
|
import { setContext } from 'svelte';
|
|
import { dev } from '$app/environment';
|
|
import { Tween } from 'svelte/motion';
|
|
|
|
interface Props {
|
|
/** CSS class for scroller container */
|
|
class?: string;
|
|
/** ID of the scroller container */
|
|
id?: string;
|
|
/** Bindable instance of ScrollerVideo */
|
|
scrollerVideo?: ScrollerVideo;
|
|
/** Video source URL */
|
|
src: string;
|
|
/** Bindable percentage value to control video playback. **Ranges from 0 to 1** */
|
|
videoPercentage?: number;
|
|
/** Sets the maximum playbackRate for this video */
|
|
transitionSpeed?: number;
|
|
/** When to stop the video animation, in seconds */
|
|
frameThreshold?: number;
|
|
/** How the video should be resized to fit its container */
|
|
objectFit?: string;
|
|
/** Whether the video should have position: sticky */
|
|
sticky?: boolean;
|
|
/** Whether the video should take up the entire viewport */
|
|
full?: boolean;
|
|
/** Whether this object should automatically respond to scroll. Set this to **false** while manually controlling `videoPercentage` prop. */
|
|
trackScroll?: boolean;
|
|
/** Whether it ignores human scroll while it runs setVideoPercentage with enabled trackScroll */
|
|
lockScroll?: boolean;
|
|
/** Whether the library should use the webcodecs method. For more info, visit https://scrollyvideo.js.org/ */
|
|
useWebCodecs?: boolean;
|
|
/** The callback when it's ready to scroll */
|
|
onReady?: () => void;
|
|
/** The callback for video percentage change */
|
|
onChange?: () => void;
|
|
/** Whether to log debug information. Internal library logs. */
|
|
debug?: boolean;
|
|
/** Shows debug information on page */
|
|
showDebugInfo?: boolean;
|
|
/** Height of the video container. Set it to 100lvh when using inside `ScrollerBase` */
|
|
height?: string;
|
|
/** Whether the video should autoplay */
|
|
autoplay?: boolean;
|
|
/** Variable to control component rendering on embed page */
|
|
embedded?: boolean;
|
|
/** Additional properties for embedded videos */
|
|
embeddedProps?: {
|
|
/** When to start the playback in terms of the component's position */
|
|
threshold?: number;
|
|
/** Duration of ScrollerVideo experience as a video */
|
|
duration?: number;
|
|
/** Delay before the playback */
|
|
delay?: number;
|
|
};
|
|
/** Children render function */
|
|
children?: Snippet;
|
|
}
|
|
|
|
/** Default properties for embedded videos */
|
|
const defaultEmbedProps = {
|
|
threshold: 0.5,
|
|
delay: 200,
|
|
};
|
|
|
|
/**
|
|
* Main logic for ScrollerVideo Svelte component.
|
|
* Handles instantiation, prop changes, and cleanup.
|
|
*/
|
|
let {
|
|
class: cls = '',
|
|
id = '',
|
|
src,
|
|
scrollerVideo = $bindable(),
|
|
videoPercentage,
|
|
onReady = $bindable(() => {}),
|
|
onChange = $bindable(() => {}),
|
|
height = '200lvh',
|
|
showDebugInfo = false,
|
|
embedded = false,
|
|
embeddedProps,
|
|
children,
|
|
...restProps
|
|
}: Props = $props();
|
|
|
|
// variable to hold the DOM element
|
|
/**
|
|
* Reference to the scroller video container DOM element.
|
|
* @type {HTMLDivElement | undefined}
|
|
*/
|
|
let scrollerVideoContainer = $state<HTMLDivElement | undefined>(undefined);
|
|
|
|
// Store the props so we know when things change
|
|
let lastPropsString = '';
|
|
|
|
// Concatenate default and passed embedded props
|
|
let allEmbedProps = {
|
|
...defaultEmbedProps,
|
|
...embeddedProps,
|
|
};
|
|
|
|
// Holds regular scroller video component
|
|
// and scrolls automatically for embedded version
|
|
let embeddedContainer = $state<HTMLDivElement | undefined>(undefined);
|
|
let embeddedContainerHeight = $state<number | undefined>(undefined);
|
|
let embeddedContainerScrollHeight: number = $derived.by(() => {
|
|
let scrollHeight = 1;
|
|
if (embeddedContainer && embeddedContainerHeight) {
|
|
scrollHeight = embeddedContainer.scrollHeight - embeddedContainerHeight;
|
|
}
|
|
return scrollHeight;
|
|
});
|
|
|
|
const embeddedContainerScrollY = new Tween(0, {
|
|
duration: 1000,
|
|
delay: allEmbedProps.delay,
|
|
easing: (t) => +t,
|
|
});
|
|
|
|
$effect(() => {
|
|
if (embeddedContainer) {
|
|
embeddedContainer.scrollTop = embeddedContainerScrollY.current;
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (scrollerVideoContainer) {
|
|
if (JSON.stringify(restProps) !== lastPropsString) {
|
|
// if scrollervideo already exists and any parameter is updated, destroy and recreate.
|
|
if (scrollerVideo && scrollerVideo.destroy) scrollerVideo.destroy();
|
|
|
|
scrollerVideo = new ScrollerVideo({
|
|
src,
|
|
scrollerVideoContainer,
|
|
onReady,
|
|
onChange,
|
|
...restProps,
|
|
trackScroll: embedded ? false : restProps.trackScroll, // trackScroll disabled for embedded version
|
|
autoplay: embedded ? false : restProps.autoplay, // autoplay disabled for embedded version
|
|
});
|
|
|
|
// if embedded prop is set,
|
|
// play the video when it crosses the threshold
|
|
// and reset it to zero when it crosses the threshold in opposite direction
|
|
if (embedded) {
|
|
const updatedOnReady = () => {
|
|
// add user defined onReady
|
|
onReady();
|
|
|
|
window?.addEventListener('scroll', (e: Event) => {
|
|
if (
|
|
embeddedContainer &&
|
|
embeddedContainer.getBoundingClientRect().top <
|
|
window.innerHeight * allEmbedProps.threshold
|
|
) {
|
|
if (
|
|
embeddedContainerScrollY.current == 0 &&
|
|
embeddedContainerHeight &&
|
|
scrollerVideo?.componentState
|
|
) {
|
|
const scrollDuration =
|
|
allEmbedProps.duration ||
|
|
scrollerVideo.componentState.generalData.totalTime * 1000;
|
|
embeddedContainerScrollY.set(embeddedContainerScrollHeight, {
|
|
duration: scrollDuration,
|
|
delay: allEmbedProps.delay,
|
|
});
|
|
}
|
|
} else if (
|
|
embeddedContainer &&
|
|
embeddedContainer.getBoundingClientRect().top >
|
|
window.innerHeight * allEmbedProps.threshold
|
|
) {
|
|
if (embeddedContainerScrollY.current > 0) {
|
|
embeddedContainerScrollY.set(0, { duration: 0 });
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
scrollerVideo.onReady = updatedOnReady;
|
|
}
|
|
|
|
// pass on component state to child components
|
|
// this controls fade in and out of foregrounds
|
|
setContext('scrollerVideoState', scrollerVideo.componentState);
|
|
|
|
// Save the new props
|
|
lastPropsString = JSON.stringify(restProps);
|
|
}
|
|
|
|
// If we need to update the target time percent
|
|
if (
|
|
scrollerVideo &&
|
|
videoPercentage &&
|
|
videoPercentage >= 0 &&
|
|
videoPercentage <= 1
|
|
) {
|
|
scrollerVideo.setVideoPercentage(videoPercentage);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Cleanup the component on destroy.
|
|
*/
|
|
onDestroy(() => {
|
|
if (scrollerVideo && scrollerVideo.destroy) scrollerVideo.destroy();
|
|
});
|
|
|
|
/**
|
|
* heightChange drives the height of the component when autoplay is set to true.
|
|
* @type {string}
|
|
*/
|
|
let heightChange = $derived.by(() => {
|
|
if (scrollerVideo) {
|
|
return `calc(${height} * ${1 - scrollerVideo?.componentState.autoplayProgress})`;
|
|
} else {
|
|
return height;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<!-- snippet to avoid redundancy between regular and embedded versions -->
|
|
<!-- renders Debug component and children foregrounds -->
|
|
{#snippet supportingElements()}
|
|
{#if scrollerVideo}
|
|
{#if showDebugInfo && dev}
|
|
<div class="debug-info">
|
|
<Debug componentState={scrollerVideo.componentState} />
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- renders foregrounds -->
|
|
{#if children}
|
|
{@render children()}
|
|
{/if}
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#if embedded}
|
|
<div
|
|
class="embedded-scroller-video-container"
|
|
bind:this={embeddedContainer}
|
|
bind:clientHeight={embeddedContainerHeight}
|
|
onscroll={() => {
|
|
if (scrollerVideo && embeddedContainer) {
|
|
let scrollProgress =
|
|
embeddedContainer.scrollTop / embeddedContainerScrollHeight;
|
|
scrollerVideo.setVideoPercentage(scrollProgress, {
|
|
jump: scrollProgress == 0,
|
|
easing: (t) => t,
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<div {id} class="scroller-video-container embedded {cls}">
|
|
<div bind:this={scrollerVideoContainer} data-scroller-container>
|
|
{@render supportingElements()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div
|
|
{id}
|
|
class="scroller-video-container {cls}"
|
|
style="height: {heightChange}"
|
|
>
|
|
<div bind:this={scrollerVideoContainer} data-scroller-container>
|
|
{@render supportingElements()}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style lang="scss">
|
|
.scroller-video-container {
|
|
width: 100%;
|
|
|
|
// Needs to be >= 100lvh to allow child element to scroll
|
|
// 200lvh provides smoother scrolling experience -->
|
|
&.embedded {
|
|
height: 200lvh;
|
|
}
|
|
&:not(.embedded) {
|
|
min-height: 100lvh;
|
|
}
|
|
}
|
|
|
|
.embedded-scroller-video-container {
|
|
max-height: 100lvh;
|
|
overflow: hidden;
|
|
}
|
|
</style>
|