PageSourceSearch

https://sco.com.sg/wp-content/themes/ohio-child/assets/js/custom-sco30.js?ver=1

js sco.com.sg collected 2026-10-02 05:42:05 UTC 14,821 bytes, 389 lines download raw bytes

1/* SCO30 Concert Season Page – Section 1: Scroll Story */
2
3/* ── Scroll restoration fix ──────────────────────────────────────────
4Prevent the browser from restoring a mid-scroll
5position on reload. Without this, the sticky section
6gets stuck in a broken state because ScrollTrigger
7initialises before the browser has snapped back.
8We force scroll to top, then let ScrollTrigger refresh
9once the page is fully painted.
10──────────────────────────────────────────────────────────────────── */
11if ('scrollRestoration' in history) {
12  history.scrollRestoration = 'manual';
13}
14window.scrollTo(0, 0);
15
16gsap.registerPlugin(ScrollTrigger);
17
18/* Mobile browsers fire a resize every time the URL bar collapses.
19   Without this ScrollTrigger refreshes mid-scroll and the sticky
20   section visibly jumps. */
21ScrollTrigger.config({ ignoreMobileResize: true });
22
23/* ────────────────────────────────────────────────────────────────────
24   The page ships TWO copies of the scroll story (#scroll-story for
25   desktop, #scroll-story-mobile for tablet/mobile) and Elementor hides
26   one of them per breakpoint. Everything below is therefore scoped to
27   a root element — never document.querySelector — so the script always
28   drives the copy that is actually on screen.
29──────────────────────────────────────────────────────────────────── */
30(function () {
31
32  /* Below this width the mobile widget is shown as a PLAIN BANNER — the
33     portrait film in normal document flow, no sticky scroll story, no
34     push-in, wording hidden (see custom-sco30.css). The scroll story still
35     runs on tablet, where the mobile widget is also served. */
36  var BANNER_MQ = '(max-width: 768px)';
37
38  function isBannerMode() {
39    return window.matchMedia(BANNER_MQ).matches;
40  }
41
42  /* Scene timing as a fraction of section progress: [in, holdUntil, out] */
43  var SCENES = [
44    [0.00, 0.16, 0.30],
45    [0.30, 0.46, 0.62],
46    [0.62, 0.76, 0.88]
47  ];
48
49  var ZOOM_START = 0.80;   /* begin the push-in early so it builds gradually */
50  var ZOOM_END   = 1.00;
51
52  /* ────────────────────────────────────────────────────────────────────
53     The two <video> tags carry preload="none", so a phone never downloads
54     the 23MB desktop cut and a desktop never downloads the mobile one.
55     Playback is therefore started here, for the visible copy only, once it
56     is near the viewport.
57     ──────────────────────────────────────────────────────────────────── */
58  function setupPlayback(root, video) {
59
60    /* The film element is not always a <video>: the section can also ship an
61       embedded <iframe> (Vimeo / YouTube), which exposes none of the media
62       API. Calling video.pause() on an iframe threw a TypeError that aborted
63       this whole FILE — taking the OUR ARTISTIC LEGACY block below down with
64       it. Bail out early for anything that is not a real media element; the
65       embed handles its own autoplay. */
66    if (typeof video.play !== 'function' || typeof video.pause !== 'function') {
67      return;
68    }
69
70    function start() {
71      if (!root.offsetHeight) return;          /* hidden at this breakpoint */
72      if (!video.paused) return;               /* already running */
73      video.preload = 'auto';
74      video.muted   = true;                    /* required for silent autoplay */
75      var played = video.play();
76      if (played && played.catch) played.catch(function () {});
77    }
78
79    /* Keep the copy this breakpoint is NOT using quiet and unbuffered */
80    function stopIfHidden() {
81      if (root.offsetHeight) return;
82      video.preload = 'none';
83      if (!video.paused) video.pause();
84    }
85
86    if ('IntersectionObserver' in window) {
87      new IntersectionObserver(function (entries) {
88        for (var i = 0; i < entries.length; i++) {
89          if (entries[i].isIntersecting) start();
90        }
91      }, { rootMargin: '250px' }).observe(root);
92    } else {
93      start();
94    }
95
96    window.addEventListener('resize', function () {
97      stopIfHidden();
98      start();
99    });
100
101    stopIfHidden();
102  }
103
104  function initScrollStory(root) {
105
106    var video     = root.querySelector('.ss-video');
107    var sticky    = root.querySelector('.ss-sticky');
108    var scenes    = root.querySelectorAll('.ss-scene');
109    var indicator = root.querySelector('.ss-scroll-indicator');
110
111    if (!video || !sticky) return;
112
113    setupPlayback(root, video);
114
115    /* ── Nav dots (this copy only) ── */
116    if (indicator) {
117      indicator.innerHTML = '';
118      for (var i = 0; i < scenes.length; i++) {
119        var dot = document.createElement('span');
120        dot.className     = 'ss-nav-dot';
121        dot.dataset.scene = i + 1;
122        indicator.appendChild(dot);
123      }
124    }
125
126    function setNavDot(activeIndex) {
127      if (!indicator) return;
128      indicator.querySelectorAll('.ss-nav-dot').forEach(function (dot, i) {
129        dot.classList.toggle('active', i + 1 === activeIndex);
130      });
131    }
132
133    function render(p) {
134
135      /* This copy is hidden at the current breakpoint — nothing to draw. */
136      if (!root.offsetHeight || !sticky.offsetHeight) return;
137
138      /* Phones show the film as a plain banner, so there is no scroll
139         story to drive and no inline styles to write. */
140      if (isBannerMode()) return;
141
142      var stickyW = sticky.offsetWidth;
143      var stickyH = sticky.offsetHeight;
144
145      /* offsetWidth / offsetHeight report the UNTRANSFORMED layout box, so
146         these stay correct whether CSS sized the video 55% wide with an
147         auto height (desktop) or 100% wide × 56vw tall (mobile). The old
148         code hard-coded the desktop 55%, which over-scaled mobile by 1/0.55. */
149      var boxW = video.offsetWidth;
150      var boxH = video.offsetHeight;
151
152      var TRAVEL = stickyH * 0.18;
153
154      function renderScene(sceneEl, pStart, pExitStart, pEnd) {
155        if (!sceneEl) return;
156
157        var l  = sceneEl.querySelector('.ss-left');
158        var r  = sceneEl.querySelector('.ss-right');
159        var lH = l ? l.offsetHeight : 80;
160        var rH = r ? r.offsetHeight : 80;
161
162        /* Either side of the video, on the centre line */
163        var tL = stickyH / 2 - lH / 2;
164        var tR = stickyH / 2 - rH / 2;
165
166        var opacity, offset = 0;
167
168        if (p < pStart || p >= pEnd) {
169          /* Hidden — parked at its anchor */
170          opacity = 0;
171
172        } else if (p < pExitStart) {
173          /* Quick fade-in. Scene 1 is the landing state, so it starts visible
174             rather than fading up from nothing at progress 0. */
175          opacity = pStart === 0
176            ? 1
177            : Math.min(1, (p - pStart) / (pExitStart - pStart) * 5);
178
179        } else {
180          /* Exit outward (left UP, right DOWN) + fade out */
181          var t   = Math.min(1, Math.max(0, (p - pExitStart) / (pEnd - pExitStart)));
182          opacity = 1 - t;
183          offset  = TRAVEL * t;
184        }
185
186        sceneEl.style.opacity = opacity;
187        if (l) l.style.transform = 'translateY(' + (tL - offset) + 'px)';
188        if (r) r.style.transform = 'translateY(' + (tR + offset) + 'px)';
189      }
190
191      for (var i = 0; i < scenes.length && i < SCENES.length; i++) {
192        renderScene(scenes[i], SCENES[i][0], SCENES[i][1], SCENES[i][2]);
193      }
194
195      /* ────────────────────────────────────────────────────────────
196         VIDEO EXPAND — scale from centre outward to fill the viewport.
197         Ease-in-out cubic: slow start → accelerates → settles gently.
198         object-fit is left to CSS (contain on desktop, cover on mobile);
199         setting it inline used to override the mobile rule.
200         ──────────────────────────────────────────────────────────── */
201      var fillW = stickyW;
202      /* svh can be a little shorter than the live viewport once the URL
203         bar collapses — fill to whichever is taller so there is no gap. */
204      var fillH = Math.max(stickyH, window.innerHeight);
205
206      var scaleTarget = (boxW && boxH)
207        ? Math.max(fillW / boxW, fillH / boxH)
208        : 1;
209
210      if (p < ZOOM_START) {
211        video.style.transform = 'scale(1)';
212      } else {
213        var z = Math.min(1, Math.max(0, (p - ZOOM_START) / (ZOOM_END - ZOOM_START)));
214        var e = z < 0.5
215          ? 4 * z * z * z
216          : 1 - Math.pow(-2 * z + 2, 3) / 2;
217        video.style.transform = 'scale(' + (1 + (scaleTarget - 1) * e) + ')';
218      }
219
220      /* ── NAV DOTS ── */
221      var active = 0;
222      for (var j = 0; j < SCENES.length; j++) {
223        if (p < SCENES[j][2]) { active = j + 1; break; }
224      }
225      setNavDot(active);
226      if (indicator) indicator.style.opacity = active ? 1 : 0;
227    }
228
229    ScrollTrigger.create({
230      trigger : root,
231      start   : 'top top',
232      end     : 'bottom bottom',
233      scrub   : 0.5,
234      onUpdate: function (self) { render(self.progress); }
235    });
236
237    /* Paint the landing state before the first scroll event */
238    render(0);
239  }
240
241  /* One broken copy must never stop the other copy — or any later block in
242     this file — from initialising. */
243  var roots = document.querySelectorAll('.ss-outer');
244  for (var i = 0; i < roots.length; i++) {
245    try {
246      initScrollStory(roots[i]);
247    } catch (err) {
248      if (window.console && console.error) {
249        console.error('[SCO30] scroll story init failed for', roots[i].id || roots[i], err);
250      }
251    }
252  }
253
254  /* Refresh after fonts/images are loaded so heights are correct */
255  window.addEventListener('load', function () {
256    ScrollTrigger.refresh();
257  });
258
259}());
260
261/* =============================================
262   OUR ARTISTIC LEGACY
263   Desktop (>768px):
264   – mouseenter activates, stays on mouseleave
265   – only hovering a different name changes it
266   – first item pre-activated on load
267
268   Mobile (<=768px):
269   – touchstart on name activates it (tap)
270   – name changes ONLY on tap (no scroll-driven switching)
271============================================= */
272(function () {
273
274  var nameItems   = document.querySelectorAll('.al-name-item');
275  var images      = document.querySelectorAll('.al-image');
276  var infoBlocks  = document.querySelectorAll('.al-info-block');
277  var alIndicator = document.querySelector('#al-indicator');
278
279  if (!nameItems.length) return;
280
281  var isMobile = window.matchMedia('(max-width: 768px)').matches;
282
283  /* Build nav dots (hidden on mobile via CSS) */
284  if (alIndicator) {
285    alIndicator.innerHTML = '';
286    nameItems.forEach(function () {
287      var dot = document.createElement('span');
288      dot.className = 'al-nav-dot';
289      alIndicator.appendChild(dot);
290    });
291  }
292
293  var currentIndex = -1;
294
295  function activate(index) {
296    if (index === currentIndex) return;
297    currentIndex = index;
298
299    var maestro = nameItems[index].dataset.maestro;
300
301    /* Names */
302    nameItems.forEach(function (item, i) {
303      item.classList.toggle('is-active', i === index);
304    });
305
306    /* Images – crossfade */
307    images.forEach(function (img) {
308      img.classList.toggle('is-visible', img.dataset.for === maestro);
309    });
310
311    /* Info blocks */
312    infoBlocks.forEach(function (block) {
313      block.classList.toggle('is-visible', block.dataset.for === maestro);
314    });
315
316    /* Nav dots */
317    if (alIndicator) {
318      alIndicator.querySelectorAll('.al-nav-dot').forEach(function (dot, i) {
319        dot.classList.toggle('active', i === index);
320      });
321    }
322  }
323
324  /* Default: first maestro active on load */
325  activate(0);
326
327  if (!isMobile) {
328
329    /* ── DESKTOP: hover to activate, no revert on mouseleave.
330         Click is bound too — the names read as interactive (cursor:pointer),
331         so a click must do the same thing as a hover. activate() ignores a
332         repeat of the current index, so the pair never double-fires. ── */
333    nameItems.forEach(function (item, i) {
334      item.addEventListener('mouseenter', function () {
335        activate(i);
336      });
337      item.addEventListener('click', function () {
338        activate(i);
339      });
340    });
341
342  } else {
343
344    /* ── MOBILE: tap to activate (scroll no longer changes the name) ── */
345    nameItems.forEach(function (item, i) {
346      item.addEventListener('touchstart', function (e) {
347        if (currentIndex !== i) {
348          e.preventDefault();
349          activate(i);
350        }
351      }, { passive: false });
352
353      /* Fallback for touch devices that report a click without a touchstart
354         (pen, hybrid laptops, accessibility tools). Harmless after a tap:
355         activate() ignores the index that is already current. */
356      item.addEventListener('click', function () {
357        activate(i);
358      });
359    });
360
361    /* NOTE: The previous IntersectionObserver that advanced the active
362       name while scrolling has been removed. On mobile the name now
363       changes ONLY when the user taps it. */
364  }
365
366}());
367
368/* ── Dual-timing patch for specific concerts ────────────────────────
369   Appends a second show time to cards whose title matches exactly.
370   Add more entries to the map as needed.
371──────────────────────────────────────────────────────────────────── */
372document.addEventListener('DOMContentLoaded', function () {
373  var extraTimes = {
374    'A Voyage of Voices: Moonbound': ', 7:30PM',
375    '阿德话狮城:月缘': ',晚上7:30'
376  };
377
378  document.querySelectorAll('.elementor-post.post-item').forEach(function (card) {
379    var titleEl = card.querySelector('.elementor-post__title');
380    if (!titleEl) return;
381    var title = titleEl.textContent.trim();
382    if (extraTimes[title]) {
383      var metaItem = card.querySelector('.elementor-post__custom_meta-data .custom_meta_item');
384      if (metaItem) {
385        metaItem.appendChild(document.createTextNode(extraTimes[title]));
386      }
387    }
388  });
389});

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.