1(function() { 2 'use strict'; 3 4 // ââ View Tracker ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 5 var eventId = document.body.getAttribute('data-view-event'); 6 if (eventId) { 7 var key = 'viewed_' + eventId; 8 if (!sessionStorage.getItem(key)) { 9 sessionStorage.setItem(key, '1'); 10 fetch('/api/views/' + eventId, { method: 'POST' }).catch(function() {}); 11 } 12 } 13 14 // ââ Unstick nav on event pages ââââââââââââââââââââââââââââââââââââââââââââââ 15 if (eventId) { 16 var header = document.querySelector('header.sticky'); 17 if (header) { 18 header.classList.remove('sticky'); 19 } 20 } 21 22 // ââ Nav active state ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 23 var pathname = location.pathname; 24 document.querySelectorAll('[data-nav-href]').forEach(function(el) { 25 var href = el.getAttribute('data-nav-href'); 26 var normalized = href.endsWith('/') ? href : href + '/'; 27 var active = href === '/' 28 ? pathname === '/' || /^\/page\/\d+\/?$/.test(pathname) 29 : pathname === href || pathname === normalized || pathname.startsWith(normalized); 30 if (active) { 31 // Desktop nav 32 el.classList.remove('text-zinc-400', 'hover:text-white', 'hover:bg-zinc-800'); 33 el.classList.add('bg-red-600', 'text-white'); 34 // Mobile nav 35 el.classList.remove('bg-zinc-800'); 36 } 37 }); 38 39 // ââ Pinned toggle âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 40 var toggleBtn = document.querySelector('[data-pinned-toggle]'); 41 if (toggleBtn) { 42 var content = document.querySelector('[data-pinned-content]'); 43 var svg = toggleBtn.querySelector('svg'); 44 var collapsed = false; 45 toggleBtn.addEventListener('click', function() { 46 collapsed = !collapsed; 47 if (content) content.style.display = collapsed ? 'none' : ''; 48 if (svg) svg.classList.toggle('rotate-180', !collapsed); 49 toggleBtn.title = collapsed ? 'å±å¼' : 'æ¶èµ·'; 50 }); 51 } 52 53 // ââ HLS Video âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 54 var videos = document.querySelectorAll('video[data-hls-src]'); 55 if (videos.length > 0) { 56 function initVideos(Hls) { 57 videos.forEach(function(video) { 58 var hlsSrc = video.getAttribute('data-hls-src'); 59 var fallbackSrc = video.getAttribute('data-fallback-src'); 60 61 // Legacy videos (pre-mia, never transcoded) have no HLS URL at all â mp4 is the 62 // only option they ever have, not a fallback from a failure. 63 if (!hlsSrc) { 64 video.src = fallbackSrc || ''; 65 return; 66 } 67 68 if (video.canPlayType('application/vnd.apple.mpegurl')) { 69 video.src = hlsSrc; 70 } else if (Hls && Hls.isSupported()) { 71 var hls = new Hls({ 72 manifestLoadingMaxRetry: 2, 73 levelLoadingMaxRetry: 2, 74 fragLoadingMaxRetry: 2, 75 }); 76 hls.loadSource(hlsSrc); 77 hls.attachMedia(video); 78 video._hls = hls; 79 } 80 // No else: a browser with neither native HLS nor hls.js support goes unplayed 81 // rather than falling back to mp4 â not a case we support. 82 }); 83 } 84 85 // Load hls.js dynamically 86 var script = document.createElement('script'); 87 script.src = 'https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js'; 88 script.onload = function() { initVideos(window.Hls); }; 89 // Legacy videos don't need hls.js at all â still run initVideos so they play, 90 // just without an Hls constructor for the videos that do need it. 91 script.onerror = function() { initVideos(null); }; 92 document.head.appendChild(script); 93 } 94 95 // ââ Image fallback ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 96 document.querySelectorAll('img[onerror]').forEach(function(img) { 97 // Already has inline onerror, skip 98 }); 99 100 // ââ Ads âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 101 // Two admin-managed ad types: a top-of-page banner row (falls back to a "èä½ä»¥å¾ " 102 // placeholder when none are configured) and a single popup shown once per tab session. 103 // Both are inserted here rather than server-rendered so the page HTML stays clean, and 104 // both come back empty from the API for Taiwan traffic. 105 var bannerSlot = document.getElementById('ad-banner-slot'); 106 if (bannerSlot) { 107 var adStyle = document.createElement('style'); 108 adStyle.textContent = [ 109 '.ad-banner-row { display: flex; gap: 8px; padding: 10px 16px; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; }', 110 '.ad-banner-row::-webkit-scrollbar { display: none; }', 111 '.ad-banner-item { flex-shrink: 0; display: block; border-radius: 8px; overflow: hidden; }', 112 '.ad-banner-item img { display: block; height: 64px; width: auto; }', 113 '.ad-banner-placeholder { margin: 10px 16px; padding: 14px; border: 1px dashed #3f3f46; border-radius: 8px; text-align: center; color: #52525b; font-size: 13px; }', 114 '.ad-popup-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.75); display: flex; align-items: center; justify-content: center; z-index: 200; padding: 20px; }', 115 '.ad-popup-inner { display: flex; flex-direction: column; align-items: center; max-width: 90vw; max-height: 90vh; }', 116 '.ad-popup-inner img { max-width: 100%; max-height: 75vh; border-radius: 10px; display: block; }', 117 '.ad-popup-close { margin-top: 14px; width: 36px; height: 36px; border-radius: 999px; border: none; background: rgba(255,255,255,0.9); color: #18181b; font-size: 18px; line-height: 1; cursor: pointer; }', 118 ].join('\n'); 119 document.head.appendChild(adStyle); 120 121 fetch('/api/ads/') 122 .then(function(r) { return r.json(); }) 123 .then(function(data) { 124 var banners = data.banners || []; 125 var popup = data.popup || null; 126 127 if (banners.length > 0) { 128 var row = document.createElement('div'); 129 row.className = 'ad-banner-row';
130 banners.forEach(function(b) { 131 var a = document.createElement('a'); 132 a.href = b.link; 133 a.target = '_blank'; 134 a.rel = 'noopener sponsored'; 135 a.className = 'ad-banner-item'; 136 var img = document.createElement('img'); 137 img.src = b.image; 138 img.alt = b.title || ''; 139 img.loading = 'lazy'; 140 a.appendChild(img); 141 row.appendChild(a); 142 }); 143 bannerSlot.appendChild(row); 144 } else { 145 var placeholder = document.createElement('div'); 146 placeholder.className = 'ad-banner-placeholder'; 147 placeholder.textContent = '广åä½èä½ä»¥å¾ '; 148 bannerSlot.appendChild(placeholder); 149 } 150 151 if (popup && !sessionStorage.getItem('ad_popup_shown')) { 152 sessionStorage.setItem('ad_popup_shown', '1'); 153 var backdrop = document.createElement('div'); 154 backdrop.className = 'ad-popup-backdrop'; 155 var inner = document.createElement('div'); 156 inner.className = 'ad-popup-inner'; 157 var link = document.createElement('a'); 158 link.href = popup.link; 159 link.target = '_blank'; 160 link.rel = 'noopener sponsored'; 161 var img = document.createElement('img'); 162 img.src = popup.image; 163 img.alt = ''; 164 link.appendChild(img); 165 var closeBtn = document.createElement('button'); 166 closeBtn.className = 'ad-popup-close'; 167 closeBtn.textContent = 'Ã'; 168 closeBtn.onclick = function() { backdrop.remove(); }; 169 inner.appendChild(link); 170 inner.appendChild(closeBtn); 171 backdrop.appendChild(inner); 172 backdrop.onclick = function(e) { if (e.target === backdrop) backdrop.remove(); }; 173 document.body.appendChild(backdrop); 174 } 175 }) 176 .catch(function() {}); 177 } 178 179 // ââ Rating modal ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 180 // Fires when a video finishes. Episodes run 4-6 minutes, so 'ended' is a fair proxy 181 // for "watched it" â no partial-progress heuristic needed. 182 var ratingModal = document.getElementById('rating-modal'); 183 if (ratingModal && eventId) { 184 var ratedKey = 'rated_' + eventId; 185 // Episodes of one drama live on separate event pages. Someone who already rated the 186 // drama shouldn't be asked again on every other episode of it. 187 var dramaId = ratingModal.getAttribute('data-drama'); 188 var dramaKey = dramaId ? 'rated_drama_' + dramaId : null; 189 var VISITOR_KEY = 'visitor_id'; 190 // Set once from ä¸åæç¤º and never cleared by us: someone who says they don't want to 191 // be asked shouldn't be asked again on any event, ever. 192 var OPTOUT_KEY = 'rating_optout'; 193 194 function alreadyAnswered() { 195 try { 196 return !!localStorage.getItem(OPTOUT_KEY) 197 || !!localStorage.getItem(ratedKey) 198 || (dramaKey && !!localStorage.getItem(dramaKey)); 199 } catch (e) { return false; } 200 } 201 202 function visitorId() { 203 var v = localStorage.getItem(VISITOR_KEY); 204 if (!v) { 205 v = (crypto.randomUUID ? crypto.randomUUID() 206 : String(Date.now()) + Math.random().toString(36).slice(2)); 207 localStorage.setItem(VISITOR_KEY, v); 208 } 209 return v; 210 } 211 212 // Index = half-star value (stars * 2), so LABELS[7] is 3.5. 213 var LABELS = { 214 2: 'æå·® · é¾ä»¥å ¥ç®', 3: 'å¾å·® · ç²å¶æ»¥é ', 4: 'è¾å·® · ä¹åå¯é', 215 5: '平庸 · å强çå®', 6: 'åæ ¼ · ä¸è§ä¸ç©', 7: 'å°å¯ · å¶æäº®ç¹', 216 8: '好ç · å¼å¾æ¨è', 9: 'æè³ · å¼ºçæ¨è', 10: 'ç¥ä½ · 殿å ä¹ä½' 217 }; 218 var HINT = 'æå¨æç¹å»æææå'; 219 220 var starRow = document.getElementById('rating-stars'); 221 var stars = ratingModal.querySelectorAll('.rating-star'); 222 var aspectBtns = ratingModal.querySelectorAll('.rating-aspect'); 223 var labelEl = document.getElementById('rating-label'); 224 var submitBtn = document.getElementById('rating-submit'); 225 var closeBtn = document.getElementById('rating-close'); 226 var chosen = 0; // in half-stars: 2..10 227 var shown = false;
228 var touched = false; // has the viewer interacted yet 229 230 // Paints any half-star value; each star's amber layer is clipped to 0%, 50% or 100%. 231 function paint(half) { 232 stars.forEach(function(star, i) { 233 var fill = star.querySelector('.rating-star-fill'); 234 var pct = Math.min(2, Math.max(0, half - i * 2)) / 2 * 100; 235 fill.style.width = pct + '%'; 236 }); 237 // The number leads: it's the value being submitted, and the words qualify it. 238 labelEl.textContent = half ? (half / 2).toFixed(1) + ' ' + LABELS[half] : (touched ? '' : HINT); 239 submitBtn.disabled = !half; 240 } 241 242 // Resolved against each star's own box rather than as a fraction of the row: the boxes 243 // are wider than the glyphs and separated by gaps, so a linear sweep would put the 244 // half-way point somewhere other than where the star visibly splits. 245 // Floor is 2 half-stars: 1.0 is the lowest rating that exists, and 0.5 has no label. 246 function valueFromX(clientX) { 247 for (var i = 0; i < stars.length; i++) { 248 var r = stars[i].getBoundingClientRect(); 249 if (clientX < r.left) return Math.max(2, i * 2); 250 if (clientX <= r.right) {
251 return Math.max(2, i * 2 + (clientX < r.left + r.width / 2 ? 1 : 2)); 252 } 253 } 254 return 10; 255 } 256 257 var dragging = false; 258 starRow.addEventListener('pointerdown', function(e) { 259 dragging = true; 260 touched = true; 261 // Capture keeps the drag alive if the finger leaves the row, but it throws for a 262 // pointer the browser doesn't consider active â which must not cost us the rating. 263 try { starRow.setPointerCapture(e.pointerId); } catch (err) {} 264 chosen = valueFromX(e.clientX); 265 paint(chosen); 266 }); 267 starRow.addEventListener('pointermove', function(e) { 268 if (!dragging) return; 269 chosen = valueFromX(e.clientX); 270 paint(chosen); 271 }); 272 function endDrag() { dragging = false; } 273 starRow.addEventListener('pointerup', endDrag); 274 starRow.addEventListener('pointercancel', endDrag); 275 276 aspectBtns.forEach(function(b) { 277 b.addEventListener('click', function() { b.classList.toggle('is-on'); }); 278 }); 279 280 function closeModal() { 281 ratingModal.style.display = 'none'; 282 document.body.style.overflow = ''; 283 } 284 285 // Dismissing still counts as answered for this page view â being asked twice about 286 // the same event is worse than losing the rating. 287 document.getElementById('rating-optout').addEventListener('click', function() { 288 try { localStorage.setItem(OPTOUT_KEY, '1'); } catch (e) {} 289 closeModal(); 290 }); 291 292 closeBtn.addEventListener('click', function() { 293 // Only this episode: a dismissal isn't an opinion about the drama, so another 294 // episode may still ask. 295 try { localStorage.setItem(ratedKey, '1'); } catch (e) {} 296 closeModal(); 297 }); 298 299 submitBtn.addEventListener('click', function() { 300 if (!chosen) return; 301 var aspects = []; 302 aspectBtns.forEach(function(b) { 303 if (b.classList.contains('is-on')) aspects.push(b.getAttribute('data-aspect')); 304 }); 305 submitBtn.disabled = true; 306 submitBtn.textContent = 'å·²æäº¤ï¼è°¢è°¢ï¼'; 307 try { 308 localStorage.setItem(ratedKey, '1'); 309 if (dramaKey) localStorage.setItem(dramaKey, '1'); 310 } catch (e) {} 311 fetch('/api/ratings', { 312 method: 'POST', 313 headers: { 'Content-Type': 'application/json' }, 314 body: JSON.stringify({ 315 event_id: Number(eventId), 316 stars: chosen / 2, 317 aspects: aspects, 318 visitor_id: visitorId() 319 }) 320 }).catch(function() {}); 321 setTimeout(closeModal, 900); 322 }); 323 324 function fullscreenEl() { 325 return document.fullscreenElement || document.webkitFullscreenElement || null; 326 } 327 328 function showModal() { 329 // Once per page view, however many videos the event has, and never again on an 330 // event this browser already answered for. 331 if (shown) return; 332 if (alreadyAnswered()) return; 333 334 // A fullscreen element is the only thing the browser renders, so opening now would 335 // put the modal somewhere nobody can see it â and spend the one prompt we get. 336 // Wait for them to come back out. 337 if (fullscreenEl()) { 338 var onExit = function() { 339 if (fullscreenEl()) return; 340 document.removeEventListener('fullscreenchange', onExit); 341 document.removeEventListener('webkitfullscreenchange', onExit); 342 showModal(); 343 }; 344 document.addEventListener('fullscreenchange', onExit); 345 document.addEventListener('webkitfullscreenchange', onExit); 346 return; 347 } 348 349 shown = true; 350 touched = false; 351 chosen = 0; 352 paint(0); 353 ratingModal.style.display = 'flex'; 354 document.body.style.overflow = 'hidden'; 355 356 } 357 358 document.querySelectorAll('video').forEach(function(v) { 359 v.addEventListener('ended', showModal); 360 // iOS plays fullscreen in the native player, which covers the page â wait for it 361 // to close before showing anything, or the modal opens where nobody can see it. 362 v.addEventListener('webkitendfullscreen', function() { 363 if (v.ended) showModal(); 364 }); 365 }); 366 } 367})();
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.