PageSourceSearch

https://hahmodsp.com/scripts/v2.js?v=1.2.19

js hahmodsp.com collected 2026-10-08 07:04:05 UTC 20,972 bytes, 511 lines download raw bytes

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.