Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- initVideoScrollControl("YourRectangleAccessibilityText", "YourVideoAccessibilityText");
- /*
- VideoScrollTrigger v1.3.0
- Description: Allows scrubbing through videos using scroll, touch, keyboard,
- or a draggable scrubber bar. Scroll activates on hover — no click required.
- Trackpad-friendly: no click needed to start scrolling.
- Usage: Replace "YourRectangleAccessibilityText" and "YourVideoAccessibilityText" with the
- accessibility text of the slide objects in Storyline. */
- function initVideoScrollControl(rectangleId, videoId, options = {}) {
- // Selectors
- var videoContainer = document.querySelector(`[data-acc-text="${rectangleId}"] div`);
- var video = document.querySelector(`[data-acc-text="${videoId}"] video`);
- // Error handling and fallbacks
- if (!videoContainer) {
- console.error(`VideoScrollTrigger: Container with data-acc-text="${rectangleId}" not found`);
- return;
- }
- if (!video) {
- console.error(`VideoScrollTrigger: Video with data-acc-text="${videoId}" not found`);
- return;
- }
- // Merged config with user options
- const config = {
- scrollSensitivity: 0.06, // fraction of video duration per 100 px of scroll
- touchSensitivity: 0.08,
- frameRate: 1000 / 60,
- smoothingFactor: 0.8,
- momentumScrolling: true,
- momentumDecay: 0.80, // lower = momentum dies faster, fewer runaway frames
- maxStepSeconds: 0.5, // hard cap: one scroll tick can move at most 0.5 s
- allowHorizontalScroll: true,
- keyboardSensitivity: 5,
- enableKeyboard: true,
- showScrubber: true, // Show the draggable scrubber bar
- scrubberHeight: 6, // px height of the scrubber track
- scrubberColor: 'rgba(255,255,255,0.85)', // scrubber thumb color
- scrubberTrackColor: 'rgba(0,0,0,0.35)', // scrubber track color
- ...options
- };
- // State variables
- let touchStartY = null;
- let touchStartX = null;
- let lastTouchY = null;
- let lastTouchX = null;
- let lastTime = video ? video.currentTime : 0;
- let targetTime = lastTime;
- let isAnimating = false;
- let lastScrollTime = Date.now();
- let scrollTimeout;
- let momentum = 0;
- let lastMomentumUpdate = Date.now();
- let isMomentumActive = false;
- // ── Hover-to-scroll state ──────────────────────────────────────────────
- // Tracks whether the mouse is currently hovering over the rectangle.
- // Wheel events are only intercepted while hovering; this removes the
- // need for users to first click the element before scrolling.
- let isHovering = false;
- // ── Scrubber drag state ────────────────────────────────────────────────
- let isDraggingScrubber = false;
- let scrubberBar = null; // the filled progress element
- let scrubberTrack = null; // the outer track element
- // Apply momentum decay
- function applyMomentum() {
- if (!config.momentumScrolling || Math.abs(momentum) < 0.001) {
- isMomentumActive = false;
- return;
- }
- const now = Date.now();
- const elapsed = now - lastMomentumUpdate;
- lastMomentumUpdate = now;
- momentum *= Math.pow(config.momentumDecay, elapsed / 16);
- updateVideoTime(targetTime + momentum);
- requestAnimationFrame(applyMomentum);
- }
- function animateToTargetTime() {
- if (!video || !isAnimating) return;
- const diff = targetTime - lastTime;
- if (Math.abs(diff) < 0.01) {
- lastTime = targetTime;
- isAnimating = false;
- updateScrubber();
- return;
- }
- lastTime += diff * config.smoothingFactor;
- video.currentTime = lastTime;
- updateScrubber();
- requestAnimationFrame(animateToTargetTime);
- }
- function updateVideoTime(newTargetTime) {
- if (!video || isNaN(video.duration)) return;
- targetTime = Math.max(0, Math.min(video.duration, newTargetTime));
- if (!isAnimating) {
- isAnimating = true;
- animateToTargetTime();
- }
- }
- // ── Scrubber UI ────────────────────────────────────────────────────────
- function buildScrubber() {
- if (!config.showScrubber) return;
- // Ensure the container can host an absolutely-positioned child
- const existingPosition = window.getComputedStyle(videoContainer).position;
- if (existingPosition === 'static') {
- videoContainer.style.position = 'relative';
- }
- // Track (background bar)
- scrubberTrack = document.createElement('div');
- Object.assign(scrubberTrack.style, {
- position: 'absolute',
- bottom: '0',
- left: '0',
- width: '100%',
- height: `${config.scrubberHeight}px`,
- background: config.scrubberTrackColor,
- borderRadius: `${config.scrubberHeight}px`,
- cursor: 'pointer',
- zIndex: '9999',
- boxSizing: 'border-box',
- userSelect: 'none',
- });
- // Thumb / progress fill
- scrubberBar = document.createElement('div');
- Object.assign(scrubberBar.style, {
- position: 'absolute',
- top: '0',
- left: '0',
- width: '0%',
- height: '100%',
- background: config.scrubberColor,
- borderRadius: `${config.scrubberHeight}px`,
- pointerEvents: 'none',
- transition: 'width 0.05s linear',
- });
- scrubberTrack.appendChild(scrubberBar);
- videoContainer.appendChild(scrubberTrack);
- // Drag events on the track
- scrubberTrack.addEventListener('mousedown', onScrubberMouseDown);
- }
- function updateScrubber() {
- if (!scrubberBar || !video || isNaN(video.duration) || video.duration === 0) return;
- const pct = (video.currentTime / video.duration) * 100;
- scrubberBar.style.width = `${pct}%`;
- }
- // Scrub to the position clicked/dragged on the track
- function scrubFromMouseEvent(e) {
- if (!scrubberTrack || !video || isNaN(video.duration)) return;
- const rect = scrubberTrack.getBoundingClientRect();
- const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
- const fraction = x / rect.width;
- momentum = 0;
- isMomentumActive = false;
- updateVideoTime(fraction * video.duration);
- }
- function onScrubberMouseDown(e) {
- e.preventDefault();
- e.stopPropagation(); // prevent Storyline from capturing this click
- isDraggingScrubber = true;
- scrubFromMouseEvent(e);
- document.addEventListener('mousemove', onScrubberMouseMove);
- document.addEventListener('mouseup', onScrubberMouseUp);
- }
- function onScrubberMouseMove(e) {
- if (!isDraggingScrubber) return;
- scrubFromMouseEvent(e);
- }
- function onScrubberMouseUp(e) {
- isDraggingScrubber = false;
- document.removeEventListener('mousemove', onScrubberMouseMove);
- document.removeEventListener('mouseup', onScrubberMouseUp);
- }
- // ── Scroll / Wheel handling ────────────────────────────────────────────
- function handleScroll(event) {
- // Only intercept wheel events while the cursor is hovering over the
- // rectangle — no click required (trackpad fix).
- if (!isHovering) return;
- event.preventDefault();
- if (!video || isNaN(video.duration)) return;
- const now = Date.now();
- if (now - lastScrollTime < config.frameRate) return;
- lastScrollTime = now;
- // ── Normalize deltaY/deltaX by deltaMode ──────────────────────────
- // deltaMode 0 = pixels (trackpad), 1 = lines, 2 = pages.
- // Trackpads can report deltaY in the hundreds; without normalization
- // a single swipe skips huge chunks of the video.
- const PIXEL_PER_LINE = 16; // typical browser line height
- const PIXEL_PER_PAGE = 200; // reasonable page height
- let normY = event.deltaY;
- let normX = event.deltaX;
- if (event.deltaMode === 1) { normY *= PIXEL_PER_LINE; normX *= PIXEL_PER_LINE; }
- if (event.deltaMode === 2) { normY *= PIXEL_PER_PAGE; normX *= PIXEL_PER_PAGE; }
- // Pick primary scroll direction
- let scrollPixels;
- if (Math.abs(normY) >= Math.abs(normX) || !config.allowHorizontalScroll) {
- scrollPixels = normY;
- } else {
- scrollPixels = normX;
- }
- // Convert pixels → seconds: sensitivity is "seconds per 100 px"
- let step = (scrollPixels / 100) * config.scrollSensitivity;
- // Hard cap so a single tick can never move more than maxStepSeconds
- step = Math.sign(step) * Math.min(Math.abs(step), config.maxStepSeconds);
- momentum = step;
- updateVideoTime(targetTime + step);
- // Start momentum scrolling
- if (config.momentumScrolling && !isMomentumActive) {
- isMomentumActive = true;
- lastMomentumUpdate = now;
- requestAnimationFrame(applyMomentum);
- }
- clearTimeout(scrollTimeout);
- scrollTimeout = setTimeout(() => {
- isAnimating = false;
- }, 150);
- }
- // ── Touch handling ─────────────────────────────────────────────────────
- function handleTouchStart(event) {
- if (event.touches.length !== 1) return;
- touchStartY = event.touches[0].clientY;
- touchStartX = event.touches[0].clientX;
- lastTouchY = touchStartY;
- lastTouchX = touchStartX;
- lastTime = video.currentTime;
- targetTime = lastTime;
- momentum = 0;
- isMomentumActive = false;
- event.preventDefault();
- }
- function handleTouchMove(event) {
- event.preventDefault();
- if (!video || isNaN(video.duration) || event.touches.length !== 1) return;
- const currentY = event.touches[0].clientY;
- const currentX = event.touches[0].clientX;
- // Determine primary direction of movement
- const deltaY = lastTouchY - currentY;
- const deltaX = lastTouchX - currentX;
- lastTouchY = currentY;
- lastTouchX = currentX;
- let delta;
- if (Math.abs(deltaY) >= Math.abs(deltaX) || !config.allowHorizontalScroll) {
- delta = deltaY;
- } else {
- delta = deltaX;
- }
- const direction = Math.sign(delta);
- const magnitude = Math.abs(delta);
- const scaledDelta = direction * Math.pow(magnitude, 1.5) * config.touchSensitivity;
- const timeAdjustment = scaledDelta / 100;
- momentum = timeAdjustment;
- const newTime = video.currentTime + timeAdjustment;
- updateVideoTime(newTime);
- }
- function handleTouchEnd(event) {
- event.preventDefault();
- // Start momentum effect
- if (config.momentumScrolling && Math.abs(momentum) > 0.001) {
- isMomentumActive = true;
- lastMomentumUpdate = Date.now();
- requestAnimationFrame(applyMomentum);
- }
- touchStartY = null;
- touchStartX = null;
- lastTouchY = null;
- lastTouchX = null;
- setTimeout(() => {
- isAnimating = false;
- }, 150);
- }
- // ── Keyboard handling ──────────────────────────────────────────────────
- function handleKeyDown(event) {
- if (!config.enableKeyboard || !video || isNaN(video.duration)) return;
- let timeAdjustment = 0;
- const step = config.keyboardSensitivity / 100;
- switch (event.key) {
- case 'ArrowUp':
- case 'ArrowLeft':
- timeAdjustment = -step;
- event.preventDefault();
- break;
- case 'ArrowDown':
- case 'ArrowRight':
- timeAdjustment = step;
- event.preventDefault();
- break;
- case 'Home':
- updateVideoTime(0);
- event.preventDefault();
- return;
- case 'End':
- updateVideoTime(video.duration);
- event.preventDefault();
- return;
- default:
- return;
- }
- updateVideoTime(video.currentTime + timeAdjustment);
- }
- // ── Event listener setup ───────────────────────────────────────────────
- function setupEventListeners() {
- // Remove any existing listeners first
- videoContainer.removeEventListener('wheel', handleScroll, { passive: false, capture: true });
- videoContainer.removeEventListener('touchstart', handleTouchStart, { passive: false });
- videoContainer.removeEventListener('touchmove', handleTouchMove, { passive: false });
- videoContainer.removeEventListener('touchend', handleTouchEnd, { passive: false });
- document.removeEventListener('keydown', handleKeyDown);
- // ── Hover detection ────────────────────────────────────────────────
- // Set isHovering flag so handleScroll knows when to intercept.
- // Using mouseenter/mouseleave (which don't bubble) is reliable inside
- // Storyline's iframe-based player.
- videoContainer.addEventListener('mouseenter', () => { isHovering = true; });
- videoContainer.addEventListener('mouseleave', () => { isHovering = false; });
- // Wheel listener on the document so we can intercept regardless of
- // which child element receives the event inside the container.
- // We check isHovering inside handleScroll to decide whether to act.
- document.addEventListener('wheel', handleScroll, { passive: false, capture: true });
- videoContainer.addEventListener('touchstart', handleTouchStart, { passive: false });
- videoContainer.addEventListener('touchmove', handleTouchMove, { passive: false });
- videoContainer.addEventListener('touchend', handleTouchEnd, { passive: false });
- if (config.enableKeyboard) {
- document.addEventListener('keydown', handleKeyDown);
- }
- }
- // ── Initialise ─────────────────────────────────────────────────────────
- video.pause();
- video.preload = 'auto';
- video.addEventListener('waiting', () => {
- isAnimating = false;
- });
- video.addEventListener('canplay', () => {
- if (targetTime !== lastTime) {
- isAnimating = true;
- animateToTargetTime();
- }
- });
- // Keep the scrubber in sync during native video time updates
- video.addEventListener('timeupdate', updateScrubber);
- // Build optional scrubber overlay
- buildScrubber();
- // Set up all event listeners
- setupEventListeners();
- // Return API for external control
- return {
- updateConfig: function (newOptions) {
- Object.assign(config, newOptions);
- setupEventListeners();
- },
- seekTo: function (timePercentage) {
- if (!video || isNaN(video.duration)) return;
- const newTime = video.duration * Math.max(0, Math.min(1, timePercentage));
- updateVideoTime(newTime);
- }
- };
- }
Advertisement
Add Comment
Please, Sign In to add comment