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.