nrzmalik

Articulate Storyline Video Scroll Animation JavaScript

Jan 2nd, 2025 (edited)
768
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
JavaScript 16.23 KB | Source Code | 0 0
  1. initVideoScrollControl("YourRectangleAccessibilityText", "YourVideoAccessibilityText");
  2.  
  3. /*
  4. VideoScrollTrigger v1.3.0
  5. Description: Allows scrubbing through videos using scroll, touch, keyboard,
  6.              or a draggable scrubber bar. Scroll activates on hover — no click required.
  7.              Trackpad-friendly: no click needed to start scrolling.
  8. Usage: Replace "YourRectangleAccessibilityText" and "YourVideoAccessibilityText" with the
  9. accessibility text of the slide objects in Storyline. */
  10.  
  11. function initVideoScrollControl(rectangleId, videoId, options = {}) {
  12.     // Selectors
  13.     var videoContainer = document.querySelector(`[data-acc-text="${rectangleId}"] div`);
  14.     var video = document.querySelector(`[data-acc-text="${videoId}"] video`);
  15.  
  16.     // Error handling and fallbacks
  17.     if (!videoContainer) {
  18.         console.error(`VideoScrollTrigger: Container with data-acc-text="${rectangleId}" not found`);
  19.         return;
  20.     }
  21.  
  22.     if (!video) {
  23.         console.error(`VideoScrollTrigger: Video with data-acc-text="${videoId}" not found`);
  24.         return;
  25.     }
  26.  
  27.     // Merged config with user options
  28.     const config = {
  29.         scrollSensitivity: 0.06,     // fraction of video duration per 100 px of scroll
  30.         touchSensitivity: 0.08,
  31.         frameRate: 1000 / 60,
  32.         smoothingFactor: 0.8,
  33.         momentumScrolling: true,
  34.         momentumDecay: 0.80,         // lower = momentum dies faster, fewer runaway frames
  35.         maxStepSeconds: 0.5,         // hard cap: one scroll tick can move at most 0.5 s
  36.         allowHorizontalScroll: true,
  37.         keyboardSensitivity: 5,
  38.         enableKeyboard: true,
  39.         showScrubber: true,          // Show the draggable scrubber bar
  40.         scrubberHeight: 6,           // px height of the scrubber track
  41.         scrubberColor: 'rgba(255,255,255,0.85)',  // scrubber thumb color
  42.         scrubberTrackColor: 'rgba(0,0,0,0.35)',  // scrubber track color
  43.         ...options
  44.     };
  45.  
  46.     // State variables
  47.     let touchStartY = null;
  48.     let touchStartX = null;
  49.     let lastTouchY = null;
  50.     let lastTouchX = null;
  51.     let lastTime = video ? video.currentTime : 0;
  52.     let targetTime = lastTime;
  53.     let isAnimating = false;
  54.     let lastScrollTime = Date.now();
  55.     let scrollTimeout;
  56.     let momentum = 0;
  57.     let lastMomentumUpdate = Date.now();
  58.     let isMomentumActive = false;
  59.  
  60.     // ── Hover-to-scroll state ──────────────────────────────────────────────
  61.     // Tracks whether the mouse is currently hovering over the rectangle.
  62.     // Wheel events are only intercepted while hovering; this removes the
  63.     // need for users to first click the element before scrolling.
  64.     let isHovering = false;
  65.  
  66.     // ── Scrubber drag state ────────────────────────────────────────────────
  67.     let isDraggingScrubber = false;
  68.     let scrubberBar = null;     // the filled progress element
  69.     let scrubberTrack = null;   // the outer track element
  70.  
  71.     // Apply momentum decay
  72.     function applyMomentum() {
  73.         if (!config.momentumScrolling || Math.abs(momentum) < 0.001) {
  74.             isMomentumActive = false;
  75.             return;
  76.         }
  77.  
  78.         const now = Date.now();
  79.         const elapsed = now - lastMomentumUpdate;
  80.         lastMomentumUpdate = now;
  81.  
  82.         momentum *= Math.pow(config.momentumDecay, elapsed / 16);
  83.         updateVideoTime(targetTime + momentum);
  84.  
  85.         requestAnimationFrame(applyMomentum);
  86.     }
  87.  
  88.     function animateToTargetTime() {
  89.         if (!video || !isAnimating) return;
  90.         const diff = targetTime - lastTime;
  91.         if (Math.abs(diff) < 0.01) {
  92.             lastTime = targetTime;
  93.             isAnimating = false;
  94.             updateScrubber();
  95.             return;
  96.         }
  97.         lastTime += diff * config.smoothingFactor;
  98.         video.currentTime = lastTime;
  99.         updateScrubber();
  100.         requestAnimationFrame(animateToTargetTime);
  101.     }
  102.  
  103.     function updateVideoTime(newTargetTime) {
  104.         if (!video || isNaN(video.duration)) return;
  105.  
  106.         targetTime = Math.max(0, Math.min(video.duration, newTargetTime));
  107.         if (!isAnimating) {
  108.             isAnimating = true;
  109.             animateToTargetTime();
  110.         }
  111.     }
  112.  
  113.     // ── Scrubber UI ────────────────────────────────────────────────────────
  114.  
  115.     function buildScrubber() {
  116.         if (!config.showScrubber) return;
  117.  
  118.         // Ensure the container can host an absolutely-positioned child
  119.         const existingPosition = window.getComputedStyle(videoContainer).position;
  120.         if (existingPosition === 'static') {
  121.             videoContainer.style.position = 'relative';
  122.         }
  123.  
  124.         // Track (background bar)
  125.         scrubberTrack = document.createElement('div');
  126.         Object.assign(scrubberTrack.style, {
  127.             position: 'absolute',
  128.             bottom: '0',
  129.             left: '0',
  130.             width: '100%',
  131.             height: `${config.scrubberHeight}px`,
  132.             background: config.scrubberTrackColor,
  133.             borderRadius: `${config.scrubberHeight}px`,
  134.             cursor: 'pointer',
  135.             zIndex: '9999',
  136.             boxSizing: 'border-box',
  137.             userSelect: 'none',
  138.         });
  139.  
  140.         // Thumb / progress fill
  141.         scrubberBar = document.createElement('div');
  142.         Object.assign(scrubberBar.style, {
  143.             position: 'absolute',
  144.             top: '0',
  145.             left: '0',
  146.             width: '0%',
  147.             height: '100%',
  148.             background: config.scrubberColor,
  149.             borderRadius: `${config.scrubberHeight}px`,
  150.             pointerEvents: 'none',
  151.             transition: 'width 0.05s linear',
  152.         });
  153.  
  154.         scrubberTrack.appendChild(scrubberBar);
  155.         videoContainer.appendChild(scrubberTrack);
  156.  
  157.         // Drag events on the track
  158.         scrubberTrack.addEventListener('mousedown', onScrubberMouseDown);
  159.     }
  160.  
  161.     function updateScrubber() {
  162.         if (!scrubberBar || !video || isNaN(video.duration) || video.duration === 0) return;
  163.         const pct = (video.currentTime / video.duration) * 100;
  164.         scrubberBar.style.width = `${pct}%`;
  165.     }
  166.  
  167.     // Scrub to the position clicked/dragged on the track
  168.     function scrubFromMouseEvent(e) {
  169.         if (!scrubberTrack || !video || isNaN(video.duration)) return;
  170.         const rect = scrubberTrack.getBoundingClientRect();
  171.         const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
  172.         const fraction = x / rect.width;
  173.         momentum = 0;
  174.         isMomentumActive = false;
  175.         updateVideoTime(fraction * video.duration);
  176.     }
  177.  
  178.     function onScrubberMouseDown(e) {
  179.         e.preventDefault();
  180.         e.stopPropagation();  // prevent Storyline from capturing this click
  181.         isDraggingScrubber = true;
  182.         scrubFromMouseEvent(e);
  183.  
  184.         document.addEventListener('mousemove', onScrubberMouseMove);
  185.         document.addEventListener('mouseup', onScrubberMouseUp);
  186.     }
  187.  
  188.     function onScrubberMouseMove(e) {
  189.         if (!isDraggingScrubber) return;
  190.         scrubFromMouseEvent(e);
  191.     }
  192.  
  193.     function onScrubberMouseUp(e) {
  194.         isDraggingScrubber = false;
  195.         document.removeEventListener('mousemove', onScrubberMouseMove);
  196.         document.removeEventListener('mouseup', onScrubberMouseUp);
  197.     }
  198.  
  199.     // ── Scroll / Wheel handling ────────────────────────────────────────────
  200.  
  201.     function handleScroll(event) {
  202.         // Only intercept wheel events while the cursor is hovering over the
  203.         // rectangle — no click required (trackpad fix).
  204.         if (!isHovering) return;
  205.  
  206.         event.preventDefault();
  207.         if (!video || isNaN(video.duration)) return;
  208.  
  209.         const now = Date.now();
  210.         if (now - lastScrollTime < config.frameRate) return;
  211.         lastScrollTime = now;
  212.  
  213.         // ── Normalize deltaY/deltaX by deltaMode ──────────────────────────
  214.         // deltaMode 0 = pixels (trackpad), 1 = lines, 2 = pages.
  215.         // Trackpads can report deltaY in the hundreds; without normalization
  216.         // a single swipe skips huge chunks of the video.
  217.         const PIXEL_PER_LINE = 16;   // typical browser line height
  218.         const PIXEL_PER_PAGE = 200;  // reasonable page height
  219.         let normY = event.deltaY;
  220.         let normX = event.deltaX;
  221.         if (event.deltaMode === 1) { normY *= PIXEL_PER_LINE; normX *= PIXEL_PER_LINE; }
  222.         if (event.deltaMode === 2) { normY *= PIXEL_PER_PAGE; normX *= PIXEL_PER_PAGE; }
  223.  
  224.         // Pick primary scroll direction
  225.         let scrollPixels;
  226.         if (Math.abs(normY) >= Math.abs(normX) || !config.allowHorizontalScroll) {
  227.             scrollPixels = normY;
  228.         } else {
  229.             scrollPixels = normX;
  230.         }
  231.  
  232.         // Convert pixels → seconds: sensitivity is "seconds per 100 px"
  233.         let step = (scrollPixels / 100) * config.scrollSensitivity;
  234.  
  235.         // Hard cap so a single tick can never move more than maxStepSeconds
  236.         step = Math.sign(step) * Math.min(Math.abs(step), config.maxStepSeconds);
  237.  
  238.         momentum = step;
  239.         updateVideoTime(targetTime + step);
  240.  
  241.         // Start momentum scrolling
  242.         if (config.momentumScrolling && !isMomentumActive) {
  243.             isMomentumActive = true;
  244.             lastMomentumUpdate = now;
  245.             requestAnimationFrame(applyMomentum);
  246.         }
  247.  
  248.         clearTimeout(scrollTimeout);
  249.         scrollTimeout = setTimeout(() => {
  250.             isAnimating = false;
  251.         }, 150);
  252.     }
  253.  
  254.     // ── Touch handling ─────────────────────────────────────────────────────
  255.  
  256.     function handleTouchStart(event) {
  257.         if (event.touches.length !== 1) return;
  258.  
  259.         touchStartY = event.touches[0].clientY;
  260.         touchStartX = event.touches[0].clientX;
  261.         lastTouchY = touchStartY;
  262.         lastTouchX = touchStartX;
  263.         lastTime = video.currentTime;
  264.         targetTime = lastTime;
  265.         momentum = 0;
  266.         isMomentumActive = false;
  267.  
  268.         event.preventDefault();
  269.     }
  270.  
  271.     function handleTouchMove(event) {
  272.         event.preventDefault();
  273.         if (!video || isNaN(video.duration) || event.touches.length !== 1) return;
  274.  
  275.         const currentY = event.touches[0].clientY;
  276.         const currentX = event.touches[0].clientX;
  277.  
  278.         // Determine primary direction of movement
  279.         const deltaY = lastTouchY - currentY;
  280.         const deltaX = lastTouchX - currentX;
  281.  
  282.         lastTouchY = currentY;
  283.         lastTouchX = currentX;
  284.  
  285.         let delta;
  286.         if (Math.abs(deltaY) >= Math.abs(deltaX) || !config.allowHorizontalScroll) {
  287.             delta = deltaY;
  288.         } else {
  289.             delta = deltaX;
  290.         }
  291.  
  292.         const direction = Math.sign(delta);
  293.         const magnitude = Math.abs(delta);
  294.         const scaledDelta = direction * Math.pow(magnitude, 1.5) * config.touchSensitivity;
  295.  
  296.         const timeAdjustment = scaledDelta / 100;
  297.         momentum = timeAdjustment;
  298.  
  299.         const newTime = video.currentTime + timeAdjustment;
  300.         updateVideoTime(newTime);
  301.     }
  302.  
  303.     function handleTouchEnd(event) {
  304.         event.preventDefault();
  305.  
  306.         // Start momentum effect
  307.         if (config.momentumScrolling && Math.abs(momentum) > 0.001) {
  308.             isMomentumActive = true;
  309.             lastMomentumUpdate = Date.now();
  310.             requestAnimationFrame(applyMomentum);
  311.         }
  312.  
  313.         touchStartY = null;
  314.         touchStartX = null;
  315.         lastTouchY = null;
  316.         lastTouchX = null;
  317.  
  318.         setTimeout(() => {
  319.             isAnimating = false;
  320.         }, 150);
  321.     }
  322.  
  323.     // ── Keyboard handling ──────────────────────────────────────────────────
  324.  
  325.     function handleKeyDown(event) {
  326.         if (!config.enableKeyboard || !video || isNaN(video.duration)) return;
  327.  
  328.         let timeAdjustment = 0;
  329.         const step = config.keyboardSensitivity / 100;
  330.  
  331.         switch (event.key) {
  332.             case 'ArrowUp':
  333.             case 'ArrowLeft':
  334.                 timeAdjustment = -step;
  335.                 event.preventDefault();
  336.                 break;
  337.             case 'ArrowDown':
  338.             case 'ArrowRight':
  339.                 timeAdjustment = step;
  340.                 event.preventDefault();
  341.                 break;
  342.             case 'Home':
  343.                 updateVideoTime(0);
  344.                 event.preventDefault();
  345.                 return;
  346.             case 'End':
  347.                 updateVideoTime(video.duration);
  348.                 event.preventDefault();
  349.                 return;
  350.             default:
  351.                 return;
  352.         }
  353.  
  354.         updateVideoTime(video.currentTime + timeAdjustment);
  355.     }
  356.  
  357.     // ── Event listener setup ───────────────────────────────────────────────
  358.  
  359.     function setupEventListeners() {
  360.         // Remove any existing listeners first
  361.         videoContainer.removeEventListener('wheel', handleScroll, { passive: false, capture: true });
  362.         videoContainer.removeEventListener('touchstart', handleTouchStart, { passive: false });
  363.         videoContainer.removeEventListener('touchmove', handleTouchMove, { passive: false });
  364.         videoContainer.removeEventListener('touchend', handleTouchEnd, { passive: false });
  365.         document.removeEventListener('keydown', handleKeyDown);
  366.  
  367.         // ── Hover detection ────────────────────────────────────────────────
  368.         // Set isHovering flag so handleScroll knows when to intercept.
  369.         // Using mouseenter/mouseleave (which don't bubble) is reliable inside
  370.         // Storyline's iframe-based player.
  371.         videoContainer.addEventListener('mouseenter', () => { isHovering = true; });
  372.         videoContainer.addEventListener('mouseleave', () => { isHovering = false; });
  373.  
  374.         // Wheel listener on the document so we can intercept regardless of
  375.         // which child element receives the event inside the container.
  376.         // We check isHovering inside handleScroll to decide whether to act.
  377.         document.addEventListener('wheel', handleScroll, { passive: false, capture: true });
  378.  
  379.         videoContainer.addEventListener('touchstart', handleTouchStart, { passive: false });
  380.         videoContainer.addEventListener('touchmove', handleTouchMove, { passive: false });
  381.         videoContainer.addEventListener('touchend', handleTouchEnd, { passive: false });
  382.  
  383.         if (config.enableKeyboard) {
  384.             document.addEventListener('keydown', handleKeyDown);
  385.         }
  386.     }
  387.  
  388.     // ── Initialise ─────────────────────────────────────────────────────────
  389.  
  390.     video.pause();
  391.     video.preload = 'auto';
  392.  
  393.     video.addEventListener('waiting', () => {
  394.         isAnimating = false;
  395.     });
  396.  
  397.     video.addEventListener('canplay', () => {
  398.         if (targetTime !== lastTime) {
  399.             isAnimating = true;
  400.             animateToTargetTime();
  401.         }
  402.     });
  403.  
  404.     // Keep the scrubber in sync during native video time updates
  405.     video.addEventListener('timeupdate', updateScrubber);
  406.  
  407.     // Build optional scrubber overlay
  408.     buildScrubber();
  409.  
  410.     // Set up all event listeners
  411.     setupEventListeners();
  412.  
  413.     // Return API for external control
  414.     return {
  415.         updateConfig: function (newOptions) {
  416.             Object.assign(config, newOptions);
  417.             setupEventListeners();
  418.         },
  419.         seekTo: function (timePercentage) {
  420.             if (!video || isNaN(video.duration)) return;
  421.             const newTime = video.duration * Math.max(0, Math.min(1, timePercentage));
  422.             updateVideoTime(newTime);
  423.         }
  424.     };
  425. }
  426.  
Advertisement
Add Comment
Please, Sign In to add comment