PageSourceSearch

https://cgw88.cc/client.js?v=7222a99d

js cgw88.cc collected 2026-09-26 05:36:20 UTC 16,352 bytes, 367 lines download raw bytes

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.