1// SuperClassic v2 refresh layer â reveal-on-scroll animations. 2// Sets html.js first so the CSS hidden states only ever apply with JS running. 3document.documentElement.classList.add('js'); 4 5(function () { 6 'use strict'; 7 8 // Existing feature-card animation in main.js uses its own observer with 9 // inline styles â leave .feature-card alone to avoid double-handling. 10 // Carousels (features, testimonials) animate via their own JS transforms â 11 // keep reveals off anything inside them. 12 var AUTO_REVEAL = [ 13 '.section-title', 14 '.press-review-item', 15 '.faq-item' 16 ]; 17 18 function init() { 19 AUTO_REVEAL.forEach(function (sel) { 20 document.querySelectorAll(sel).forEach(function (el) { 21 el.classList.add('reveal'); 22 }); 23 }); 24 25 var els = document.querySelectorAll('.reveal'); 26 var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 27 28 if (reduce || !('IntersectionObserver' in window)) { 29 els.forEach(function (el) { el.classList.add('is-visible'); }); 30 return; 31 } 32 33 var io = new IntersectionObserver(function (entries) { 34 entries.forEach(function (entry) { 35 if (entry.isIntersecting) { 36 entry.target.classList.add('is-visible'); 37 io.unobserve(entry.target); 38 } 39 }); 40 }, { threshold: 0.12, rootMargin: '0px 0px -60px 0px' }); 41 42 els.forEach(function (el) { io.observe(el); }); 43 } 44 45 // Effects rack tabs (What's New section) â click + horizontal swipe. 46 // Tabbed mode (desktop) is a carousel: the panels sit in .wn-fx-track and 47 // the track translates by whole slides. Strip mode (mobile) is native 48 // scroll-snap on the stage itself. 49 function initFxTabs() { 50 var rack = document.querySelector('.wn-fx-rack'); 51 if (!rack) return; 52 53 var tabs = Array.prototype.slice.call(rack.querySelectorAll('.wn-fx-tab')); 54 var prevBtn = rack.querySelector('.wn-fx-prev'); 55 var nextBtn = rack.querySelector('.wn-fx-next'); 56 var stage = rack.querySelector('.wn-fx-stage'); 57 var track = rack.querySelector('.wn-fx-track'); 58 var panels = Array.prototype.slice.call(rack.querySelectorAll('.wn-fx-panel')); 59 60 function isStrip() { 61 return getComputedStyle(stage).display === 'flex'; 62 } 63 64 function activeIndex() { 65 return tabs.findIndex(function (t) { return t.classList.contains('is-active'); }); 66 } 67 68 // The stage hides overflow, so it needs an explicit height to follow 69 // the active slide (captions differ in length); CSS transitions it. 70 function fitStage() { 71 var i = activeIndex(); 72 if (isStrip() || i < 0) { 73 stage.style.height = ''; 74 return; 75 } 76 stage.style.height = panels[i].offsetHeight + 'px'; 77 } 78 79 function moveTrack() { 80 if (!track) return; 81 if (isStrip()) { 82 track.style.transform = ''; 83 return; 84 } 85 // Coming from strip mode (e.g. tablet rotated to landscape) the 86 // stage keeps the scrollLeft it had as a scroll container, and 87 // overflow:hidden would leave the track shifted by that much. 88 stage.scrollLeft = 0; 89 track.style.transform = 'translateX(' + (-activeIndex() * 100) + '%)'; 90 } 91 92 function activate(fxId) {
93 tabs.forEach(function (t) { 94 var active = t.dataset.fx === fxId; 95 t.classList.toggle('is-active', active); 96 t.setAttribute('aria-selected', active ? 'true' : 'false'); 97 }); 98 panels.forEach(function (p) { 99 var active = p.dataset.fx === fxId; 100 p.classList.toggle('is-active', active); 101 p.setAttribute('aria-hidden', active ? 'false' : 'true'); 102 }); 103 moveTrack(); 104 fitStage(); 105 if (prevBtn && nextBtn) { 106 var i = activeIndex(); 107 prevBtn.disabled = i <= 0; 108 nextBtn.disabled = i >= tabs.length - 1; 109 } 110 } 111 112 function step(delta) { 113 var next = activeIndex() + delta; 114 if (next < 0 || next >= tabs.length) return; 115 activate(tabs[next].dataset.fx); 116 } 117 118 if (prevBtn) prevBtn.addEventListener('click', function () { step(-1); }); 119 if (nextBtn) nextBtn.addEventListener('click', function () { step(1); }); 120 121 rack.querySelector('.wn-fx-tabs').addEventListener('click', function (e) { 122 var tab = e.target.closest('.wn-fx-tab'); 123 if (!tab) return; 124 activate(tab.dataset.fx); 125 if (isStrip()) { 126 var panel = panels.find(function (p) { return p.dataset.fx === tab.dataset.fx; }); 127 if (panel) { 128 stage.scrollTo({ 129 left: panel.offsetLeft - (stage.clientWidth - panel.clientWidth) / 2, 130 behavior: 'smooth' 131 }); 132 } 133 } 134 }); 135 136 // Strip mode: keep the tab pills in sync with native snap scrolling 137 var scrollTimer = null; 138 stage.addEventListener('scroll', function () { 139 if (!isStrip()) return; 140 clearTimeout(scrollTimer); 141 scrollTimer = setTimeout(function () { 142 var center = stage.scrollLeft + stage.clientWidth / 2; 143 var nearest = panels[0]; 144 var best = Infinity; 145 panels.forEach(function (p) { 146 var d = Math.abs(p.offsetLeft + p.clientWidth / 2 - center); 147 if (d < best) { best = d; nearest = p; } 148 }); 149 if (!nearest.classList.contains('is-active')) activate(nearest.dataset.fx); 150 }, 80); 151 }, { passive: true }); 152 153 // Initial layout, and re-fit when the viewport crosses the strip 154 // breakpoint or an image finishes loading and changes a slide's height 155 var resizeTimer = null; 156 window.addEventListener('resize', function () { 157 clearTimeout(resizeTimer); 158 resizeTimer = setTimeout(function () { moveTrack(); fitStage(); }, 100); 159 }); 160 rack.querySelectorAll('.wn-fx-panel img').forEach(function (img) { 161 if (!img.complete) img.addEventListener('load', fitStage, { once: true }); 162 }); 163 panels.forEach(function (p) { 164 p.setAttribute('aria-hidden', p.classList.contains('is-active') ? 'false' : 'true'); 165 }); 166 moveTrack(); 167 fitStage(); 168 169 // Slides parked off-screen inside the clipped stage never intersect 170 // the viewport, so their lazy images would only start loading mid-slide. 171 // Pull them in once the rack itself scrolls into view. 172 if ('IntersectionObserver' in window) { 173 var preload = new IntersectionObserver(function (entries) { 174 if (!entries.some(function (en) { return en.isIntersecting; })) return; 175 rack.querySelectorAll('.wn-fx-panel img[loading="lazy"]').forEach(function (img) { 176 img.loading = 'eager'; 177 }); 178 preload.disconnect(); 179 }, { rootMargin: '200px 0px' }); 180 preload.observe(rack); 181 } 182 } 183 184 // Scroll-scrub scene: progress drives backdrop/caption (via --scrub) and 185 // a canvas image-sequence. Seeking a <video> stutters (each frame is an 186 // on-demand decode); pre-decoded frames drawn to canvas scrub smoothly. 187 function initScrubScene() { 188 var scene = document.querySelector('.scrub-scene'); 189 if (!scene) return; 190 if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; 191 192 var FRAMES = 92; 193 var video = scene.querySelector('.scrub-video'); 194 var canvas = document.createElement('canvas'); 195 canvas.width = 1550; 196 canvas.height = 500; 197 canvas.className = 'scrub-video'; 198 canvas.setAttribute('role', 'img'); 199 canvas.setAttribute('aria-label', video.getAttribute('aria-label') || ''); 200 video.replaceWith(canvas); 201 var ctx = canvas.getContext('2d'); 202 203 var frames = new Array(FRAMES); 204 var started = false; 205 var currentFrame = -1; 206 207 function frameSrc(i) { 208 var n = String(i + 1); 209 while (n.length < 3) n = '0' + n;
210 return '/video/scrub-frames/filter-' + n + '.jpg?v=2'; 211 } 212 213 function frameReady(i) { 214 return !!frames[i]; 215 } 216 217 function draw(i) { 218 // nearest loaded frame in either direction 219 var j = -1; 220 for (var d = 0; d < FRAMES; d++) { 221 if (i - d >= 0 && frameReady(i - d)) { j = i - d; break; } 222 if (i + d < FRAMES && frameReady(i + d)) { j = i + d; break; } 223 } 224 if (j >= 0) { 225 ctx.drawImage(frames[j], 0, 0, canvas.width, canvas.height); 226 currentFrame = i; 227 } 228 } 229 230 // frames[] holds pre-decoded ImageBitmaps (decode cost paid at load 231 // time, not on the first draw), falling back to plain images 232 function load(i, onload) { 233 if (frames[i] || frames['p' + i]) return; 234 frames['p' + i] = true; 235 var im = new Image(); 236 im.onload = function () { 237 if (window.createImageBitmap) { 238 createImageBitmap(im).then(function (bmp) { 239 frames[i] = bmp; 240 if (onload) onload(); 241 }, function () { 242 frames[i] = im; 243 if (onload) onload(); 244 }); 245 } else { 246 frames[i] = im; 247 if (onload) onload(); 248 } 249 }; 250 im.src = frameSrc(i); 251 } 252 253 // first frame immediately (it is the resting poster), rest on approach 254 load(0, function () { if (currentFrame < 0) draw(0); }); 255 256 function preloadAll() { 257 if (started) return; 258 started = true; 259 // subdivision order: with any fraction loaded, coverage across the 260 // whole timeline stays evenly spaced â partial loads scrub coarse 261 // but uniform instead of sticking then leaping 262 var order = []; 263 var seen = {}; 264 function push(i) { 265 if (!seen[i]) { seen[i] = true; order.push(i); } 266 } 267 push(0); 268 push(FRAMES - 1); 269 for (var step = Math.ceil(FRAMES / 2); step >= 1; step = (step > 1 ? Math.floor(step / 2) : 0)) { 270 for (var i = step; i < FRAMES; i += step) push(i); 271 } 272 order.forEach(function (i) { 273 load(i, function () { 274 if (currentFrame >= 0) draw(currentFrame); 275 }); 276 }); 277 } 278 279 if ('IntersectionObserver' in window) { 280 var io = new IntersectionObserver(function (entries) { 281 if (entries[0].isIntersecting) { 282 preloadAll(); 283 io.disconnect(); 284 } 285 }, { rootMargin: '250% 0px' }); 286 io.observe(scene); 287 } else { 288 preloadAll(); 289 } 290 291 // Eased scrubbing: the drawn position chases the scroll position each 292 // animation frame, turning stepped wheel input into continuous motion. 293 var targetP = 0; 294 var smoothP = -1; 295 var rafId = null; 296 var lastIdx = -1; 297 298 function clamp01(x) { 299 return Math.min(1, Math.max(0, x)); 300 } 301 302 function measure() { 303 var rect = scene.getBoundingClientRect(); 304 var travel = rect.height - window.innerHeight; 305 if (travel <= 0) return; 306 targetP = clamp01(-rect.top / travel); 307 } 308 309 function animate() { 310 rafId = null; 311 if (smoothP < 0) smoothP = targetP; 312 smoothP += (targetP - smoothP) * 0.18; 313 if (Math.abs(targetP - smoothP) < 0.0005) smoothP = targetP; 314 315 scene.style.setProperty('--scrub', smoothP.toFixed(4)); 316 317 // middle 76% of the pin maps onto the frame sequence 318 var p = clamp01((smoothP - 0.12) / 0.76); 319 var idx = Math.round(p * (FRAMES - 1)); 320 if (idx !== lastIdx) { 321 lastIdx = idx; 322 draw(idx); 323 } 324 325 if (smoothP !== targetP) { 326 rafId = requestAnimationFrame(animate); 327 } 328 } 329 330 function kick() { 331 measure(); 332 if (!rafId) rafId = requestAnimationFrame(animate); 333 } 334 335 window.addEventListener('scroll', kick, { passive: true });
336 window.addEventListener('resize', kick); 337 kick(); 338 } 339 340 // Price-increase notice: shown until its data-until instant, dismissible 341 // for the session. Starts hidden in the markup so expired/dismissed 342 // states never flash. main.js handles the mobile hide-on-scroll. 343 function initPromoBanner() { 344 var bar = document.getElementById('promoBanner'); 345 if (!bar) return; 346 347 var until = Date.parse(bar.dataset.until || ''); 348 var key = 'sc_promo_dismissed_' + (bar.dataset.promo || 'promo'); 349 var dismissed = false; 350 try { dismissed = sessionStorage.getItem(key) === '1'; } catch (e) { /* storage unavailable */ } 351 352 if ((until && Date.now() >= until) || dismissed) { 353 bar.remove(); 354 return; 355 } 356 357 bar.hidden = false; 358 document.body.classList.add('has-promo'); 359 360 // Below 1024px the strip pushes the fixed header down by its own 361 // height, which depends on how the text wraps. 362 function measure() { 363 document.documentElement.style.setProperty('--promo-h', bar.offsetHeight + 'px'); 364 } 365 measure(); 366 var measureTimer = null; 367 window.addEventListener('resize', function () { 368 clearTimeout(measureTimer); 369 measureTimer = setTimeout(measure, 100); 370 }); 371 372 var close = bar.querySelector('.promo-banner-close'); 373 if (close) { 374 close.addEventListener('click', function () { 375 bar.remove(); 376 document.body.classList.remove('has-promo'); 377 document.documentElement.style.removeProperty('--promo-h'); 378 try { sessionStorage.setItem(key, '1'); } catch (e) { /* ignore */ } 379 }); 380 } 381 } 382 383 // Hero mini-players and Sound Demos cards that point at the same file are 384 // "twins": whichever one is actually playing drives the other's play 385 // state, progress and time, and a click on the idle twin acts on the 386 // active one (pause / resume / seek) instead of starting a second stream. 387 // Builds on main.js's player logic (togglePlay, setPlayButtonState, 388 // formatTime) without changing it. 389 function initTwinPlayers() { 390 if (typeof togglePlay !== 'function' || typeof setPlayButtonState !== 'function') return; 391 392 var demoPlayers = Array.prototype.slice.call(document.querySelectorAll('.audio-demos .audio-player')); 393 394 document.querySelectorAll('.hero-audio-player').forEach(function (hero) { 395 var heroAudio = hero.querySelector('audio'); 396 if (!heroAudio) return; 397 var src = heroAudio.getAttribute('src'); 398 var demo = demoPlayers.find(function (p) { 399 var a = p.querySelector('audio'); 400 return a && a.getAttribute('src') === src; 401 }); 402 if (!demo) return; 403 404 var pair = [ 405 { 406 audio: heroAudio, 407 button: hero.querySelector('.hero-play-button'), 408 fill: hero.querySelector('.hero-progress-fill'), 409 bar: hero.querySelector('.hero-progress-bar'), 410 time: null 411 }, 412 { 413 audio: demo.querySelector('audio'), 414 button: demo.querySelector('.play-button'), 415 fill: demo.querySelector('.progress-fill'), 416 bar: demo.querySelector('.progress-bar'), 417 time: demo.querySelector('.time-display') 418 } 419 ]; 420 if (pair.some(function (p) { return !p.audio || !p.button || !p.fill || !p.bar; })) return; 421 422 pair.forEach(function (self, i) { 423 var twin = pair[1 - i]; 424 425 function isLive() { 426 // Playing, or paused mid-track (the one a click should resume) 427 return !self.audio.ended && (!self.audio.paused || self.audio.currentTime > 0); 428 } 429 430 function mirror() { 431 var playing = !self.audio.paused && !self.audio.ended; 432 setPlayButtonState(twin.button, playing); 433 var pct = self.audio.duration ? (self.audio.currentTime / self.audio.duration) * 100 : 0; 434 twin.fill.style.width = pct + '%'; 435 if (twin.time && typeof formatTime === 'function') { 436 twin.time.textContent = formatTime(self.audio.currentTime) + ' / ' + formatTime(self.audio.duration); 437 } 438 } 439 440 ['play', 'pause', 'timeupdate', 'seeked', 'ended'].forEach(function (ev) { 441 // Registered after main.js's own listeners, so an 'ended' 442 // reset there is already applied when this mirrors it. 443 self.audio.addEventListener(ev, mirror); 444 }); 445 446 // Capture-phase so this runs before the button's inline onclick 447 twin.button.addEventListener('click', function (e) { 448 if (!isLive()) return; 449 e.preventDefault(); 450 e.stopImmediatePropagation(); 451 togglePlay(self.button); 452 }, true); 453 454 twin.bar.addEventListener('click', function (e) { 455 if (!isLive() || !self.audio.duration) return; 456 e.stopImmediatePropagation(); 457 var r = twin.bar.getBoundingClientRect(); 458 var pos = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)); 459 self.audio.currentTime = pos * self.audio.duration; 460 }, true); 461 }); 462 }); 463 } 464 465 // Play-once UI videos (video[data-play-once]): start when the video 466 // scrolls into view, play through a single time and rest on the last 467 // frame. No autoplay attribute, so nothing runs before it's seen. 468 function initPlayOnceVideos() { 469 var videos = document.querySelectorAll('video[data-play-once]'); 470 if (!videos.length) return; 471 if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; 472 if (!('IntersectionObserver' in window)) { 473 videos.forEach(function (v) { v.play().catch(function () {}); }); 474 return; 475 } 476 477 var io = new IntersectionObserver(function (entries) {
478 entries.forEach(function (entry) { 479 if (!entry.isIntersecting) return; 480 var v = entry.target; 481 io.unobserve(v); 482 v.play().catch(function () { /* autoplay blocked: poster stays */ }); 483 }); 484 }, { threshold: 0.4 }); 485 486 videos.forEach(function (v) { io.observe(v); }); 487 } 488 489 function boot() { 490 init(); 491 initPromoBanner(); 492 initFxTabs(); 493 initTwinPlayers(); 494 initPlayOnceVideos(); 495 initScrubScene(); 496 497 // Reduced motion: freeze the UI loop videos on their first frame 498 if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { 499 document.querySelectorAll('video.ui-loop').forEach(function (v) { 500 v.removeAttribute('autoplay'); 501 v.pause(); 502 }); 503 } 504 } 505 506 if (document.readyState === 'loading') { 507 document.addEventListener('DOMContentLoaded', boot); 508 } else { 509 boot(); 510 } 511})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.